| 1 | import { Hono } from "hono"; |
| 2 | import type { AppEnv } from "../env"; |
| 3 | import { toPackageDTO } from "../types"; |
| 4 | import { repos } from "../db"; |
| 5 | import { requireAdmin } from "../http/auth"; |
| 6 | import { writeRateLimit } from "../http/ratelimit"; |
| 7 | import { ApiError } from "../http/errors"; |
| 8 | import { z } from "zod"; |
| 9 | |
| 10 | const admin = new Hono<AppEnv>(); |
| 11 | |
| 12 | const now = () => new Date().toISOString(); |
| 13 | |
| 14 | const ApprovalRevisionSchema = z.object({ |
| 15 | expectedVersion: z.string().min(1).max(64), |
| 16 | expectedUpdatedAt: z.string().min(1).max(64), |
| 17 | expectedStatus: z.enum(["pending", "hidden", "rejected"]), |
| 18 | }); |
| 19 | |
| 20 | admin.use("*", requireAdmin); |
| 21 | |
| 22 | // Review queue. ?status defaults to pending; also accepts rejected/hidden/active. |
| 23 | admin.get("/packages", async (c) => { |
| 24 | const status = new URL(c.req.url).searchParams.get("status") || "pending"; |
| 25 | const rows = await repos(c.env).packages.listByStatus(status, 200); |
| 26 | return c.json({ packages: rows.map(toPackageDTO) }); |
| 27 | }); |
| 28 | |
| 29 | // Approve a pending package → live. Its publish event is emitted here (on first |
| 30 | // approval), so the activity feed only ever announces public packages. |
| 31 | admin.post("/packages/:handle/:name/approve", writeRateLimit, async (c) => { |
| 32 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 33 | const { packages: repo, events } = repos(c.env); |
| 34 | const revision = ApprovalRevisionSchema.safeParse(await c.req.json().catch(() => null)); |
| 35 | if (!revision.success) { |
| 36 | throw new ApiError(400, "invalid_review_revision", "Approval requires the reviewed package revision."); |
| 37 | } |
| 38 | const approvedAt = now(); |
| 39 | const row = await repo.setStatusIfCurrent( |
| 40 | slug, |
| 41 | "active", |
| 42 | revision.data.expectedVersion, |
| 43 | revision.data.expectedUpdatedAt, |
| 44 | revision.data.expectedStatus, |
| 45 | approvedAt, |
| 46 | ); |
| 47 | if (!row) { |
| 48 | const current = await repo.bySlug(slug); |
| 49 | if (!current) throw new ApiError(404, "not_found", "No such package."); |
| 50 | throw new ApiError( |
| 51 | 409, |
| 52 | "stale_review", |
| 53 | "Package changed since it was reviewed. Refresh and review the latest version.", |
| 54 | ); |
| 55 | } |
| 56 | await events.log({ |
| 57 | type: "publish", |
| 58 | packageId: row.id, |
| 59 | actorHandle: row.scope_handle, |
| 60 | summary: `published ${row.slug}@${row.latest_version}`, |
| 61 | now: approvedAt, |
| 62 | }); |
| 63 | return c.json({ package: toPackageDTO(row) }); |
| 64 | }); |
| 65 | |
| 66 | admin.post("/packages/:handle/:name/reject", writeRateLimit, async (c) => { |
| 67 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 68 | const row = await repos(c.env).packages.setStatus(slug, "rejected", now()); |
| 69 | if (!row) throw new ApiError(404, "not_found", "No such package."); |
| 70 | return c.json({ package: toPackageDTO(row) }); |
| 71 | }); |
| 72 | |
| 73 | // Take a previously-approved package back down. |
| 74 | admin.post("/packages/:handle/:name/hide", writeRateLimit, async (c) => { |
| 75 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 76 | const row = await repos(c.env).packages.setStatus(slug, "hidden", now()); |
| 77 | if (!row) throw new ApiError(404, "not_found", "No such package."); |
| 78 | return c.json({ package: toPackageDTO(row) }); |
| 79 | }); |
| 80 | |
| 81 | // Grant or revoke the verified badge. Body {verified:false} revokes; default grants. |
| 82 | admin.post("/packages/:handle/:name/verify", writeRateLimit, async (c) => { |
| 83 | const slug = `${c.req.param("handle")}/${c.req.param("name")}`; |
| 84 | const body = (await c.req.json().catch(() => ({}))) as { verified?: boolean }; |
| 85 | const row = await repos(c.env).packages.setVerified(slug, body.verified !== false, now()); |
| 86 | if (!row) throw new ApiError(404, "not_found", "No such package."); |
| 87 | return c.json({ package: toPackageDTO(row) }); |
| 88 | }); |
| 89 | |
| 90 | export default admin; |
| 91 |