File
Blob: src/worker/routes/admin.ts
| 1 | import { getRepoStub, isValidOid, json } from "@/worker/common"; |
| 2 | import { sameOriginViolation } from "@/worker/auth/origin"; |
| 3 | import { safeParseJsonRequest } from "@/shared/web"; |
| 4 | import type { RepositoryDeleteMessage } from "@/worker/tasks/queue"; |
| 5 | import { resolveAdminApiRepoAccess, type AdminRepoAccess } from "./ui/helpers"; |
| 6 | import type { AppContext, AppRouter } from "./hono"; |
| 7 | import { |
| 8 | AdminCompactionRequestSchema, |
| 9 | AdminHeadPayloadSchema, |
| 10 | AdminPurgeRequestSchema, |
| 11 | AdminRefsPayloadSchema, |
| 12 | } from "./requestSchemas"; |
| 13 | |
| 14 | // Centralizes the auth + non-disclosure + CSRF policy for repo-scoped admin |
| 15 | // endpoints. Git credentials are deliberately ignored: admin is |
| 16 | // browser-session-only. |
| 17 | async function requireRepoAdmin(c: AppContext): Promise<AdminRepoAccess> { |
| 18 | // CSRF check first so a missing/cross-origin mutating request fails before |
| 19 | // we read D1. `sameOriginViolation` short-circuits safe methods (GET, HEAD, |
| 20 | // OPTIONS), so debug/list endpoints stay reachable from background tabs. |
| 21 | const violation = sameOriginViolation(c); |
| 22 | if (violation) { |
| 23 | return { kind: "response", response: violation }; |
| 24 | } |
| 25 | return await resolveAdminApiRepoAccess(c); |
| 26 | } |
| 27 | |
| 28 | export function registerAdminRoutes(router: AppRouter) { |
| 29 | async function handleCompactionPost(c: AppContext<"/:owner/:repo/admin/compact">) { |
| 30 | const gate = await requireRepoAdmin(c); |
| 31 | if (gate.kind === "response") return gate.response; |
| 32 | const { route, limiter } = gate; |
| 33 | const env = c.env; |
| 34 | const rawBody = await safeParseJsonRequest(c.req.raw); |
| 35 | const parsedBody = AdminCompactionRequestSchema.safeParse(rawBody); |
| 36 | const body = parsedBody.success ? parsedBody.data : { dryRun: undefined }; |
| 37 | const dryRun = body.dryRun !== false; |
| 38 | const stub = getRepoStub(env, route.doName); |
| 39 | const log = c.var.logFor({ |
| 40 | service: "AdminRoutes", |
| 41 | repoId: route.doName, |
| 42 | }); |
| 43 | try { |
| 44 | const res = dryRun |
| 45 | ? await limiter.run("do:admin-preview-compaction", () => stub.previewCompaction()) |
| 46 | : await limiter.run("do:admin-request-compaction", () => stub.requestCompaction()); |
| 47 | if (!dryRun && res.status === "queued" && res.shouldEnqueue) { |
| 48 | const queueTask = env.REPO_TASKS_QUEUE.send({ |
| 49 | kind: "compaction", |
| 50 | doId: stub.id.toString(), |
| 51 | repoId: route.doName, |
| 52 | }) |
| 53 | .then(() => { |
| 54 | log.info("admin:compaction-enqueue-requested", { |
| 55 | doId: stub.id.toString(), |
| 56 | }); |
| 57 | }) |
| 58 | .catch((error) => { |
| 59 | log.warn("admin:compaction-enqueue-failed", { |
| 60 | doId: stub.id.toString(), |
| 61 | error: String(error), |
| 62 | }); |
| 63 | }); |
| 64 | c.executionCtx.waitUntil(queueTask); |
| 65 | } |
| 66 | |
| 67 | const status = dryRun || res.status !== "queued" ? 200 : 202; |
| 68 | return json(res, status, { "Cache-Control": "no-cache" }); |
| 69 | } catch (e) { |
| 70 | return json({ error: String(e) }, 500); |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | async function handleCompactionDelete(c: AppContext<"/:owner/:repo/admin/compact">) { |
| 75 | const gate = await requireRepoAdmin(c); |
| 76 | if (gate.kind === "response") return gate.response; |
| 77 | const { route, limiter } = gate; |
| 78 | const stub = getRepoStub(c.env, route.doName); |
| 79 | try { |
| 80 | const res = await limiter.run("do:admin-clear-compaction", () => |
| 81 | stub.clearCompactionRequest() |
| 82 | ); |
| 83 | return json({ ok: true, ...res }, 200, { "Cache-Control": "no-cache" }); |
| 84 | } catch (e) { |
| 85 | return json({ ok: false, error: String(e) }, 500); |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | router.delete(`/:owner/:repo/admin/compact`, handleCompactionDelete); |
| 90 | router.post(`/:owner/:repo/admin/compact`, handleCompactionPost); |
| 91 | |
| 92 | // ------------------------------------------------------------------------- |
| 93 | // Repo-scoped admin endpoints. Auth model: tessera session + namespace |
| 94 | // membership, plus same-origin for mutating verbs (`requireRepoAdmin` |
| 95 | // calls `sameOriginViolation` itself). Git credentials must NOT authorize |
| 96 | // these routes. |
| 97 | |
| 98 | router.get(`/:owner/:repo/admin/refs`, async (c) => { |
| 99 | const gate = await requireRepoAdmin(c); |
| 100 | if (gate.kind === "response") return gate.response; |
| 101 | const { route, limiter } = gate; |
| 102 | const stub = getRepoStub(c.env, route.doName); |
| 103 | try { |
| 104 | const refs = await limiter.run("do:admin-list-refs", () => stub.listRefs()); |
| 105 | return json(refs); |
| 106 | } catch { |
| 107 | return json([]); |
| 108 | } |
| 109 | }); |
| 110 | |
| 111 | router.put(`/:owner/:repo/admin/refs`, async (c) => { |
| 112 | const gate = await requireRepoAdmin(c); |
| 113 | if (gate.kind === "response") return gate.response; |
| 114 | const { route, limiter } = gate; |
| 115 | const stub = getRepoStub(c.env, route.doName); |
| 116 | const body = await safeParseJsonRequest(c.req.raw); |
| 117 | const refs = AdminRefsPayloadSchema.safeParse(body); |
| 118 | if (!refs.success) { |
| 119 | return new Response("Invalid refs payload\n", { status: 400 }); |
| 120 | } |
| 121 | await limiter.run("do:admin-set-refs", () => stub.setRefs(refs.data)); |
| 122 | return new Response("OK\n"); |
| 123 | }); |
| 124 | |
| 125 | router.get(`/:owner/:repo/admin/head`, async (c) => { |
| 126 | const gate = await requireRepoAdmin(c); |
| 127 | if (gate.kind === "response") return gate.response; |
| 128 | const { route, limiter } = gate; |
| 129 | const stub = getRepoStub(c.env, route.doName); |
| 130 | try { |
| 131 | const head = await limiter.run("do:admin-get-head", () => stub.getHead()); |
| 132 | return json(head); |
| 133 | } catch { |
| 134 | return new Response("Not found\n", { status: 404 }); |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | router.put(`/:owner/:repo/admin/head`, async (c) => { |
| 139 | const gate = await requireRepoAdmin(c); |
| 140 | if (gate.kind === "response") return gate.response; |
| 141 | const { route, limiter } = gate; |
| 142 | const stub = getRepoStub(c.env, route.doName); |
| 143 | const body = await safeParseJsonRequest(c.req.raw); |
| 144 | const head = AdminHeadPayloadSchema.safeParse(body); |
| 145 | if (!head.success) { |
| 146 | return new Response("Invalid head payload\n", { status: 400 }); |
| 147 | } |
| 148 | await limiter.run("do:admin-set-head", () => stub.setHead(head.data)); |
| 149 | return new Response("OK\n"); |
| 150 | }); |
| 151 | |
| 152 | router.get(`/:owner/:repo/admin/debug-state`, async (c) => { |
| 153 | const gate = await requireRepoAdmin(c); |
| 154 | if (gate.kind === "response") return gate.response; |
| 155 | const { route, limiter } = gate; |
| 156 | const stub = getRepoStub(c.env, route.doName); |
| 157 | try { |
| 158 | const state = await limiter.run("do:admin-debug-state", () => stub.debugState()); |
| 159 | return json(state); |
| 160 | } catch { |
| 161 | return json({}); |
| 162 | } |
| 163 | }); |
| 164 | |
| 165 | router.get(`/:owner/:repo/admin/debug-commit/:commit`, async (c) => { |
| 166 | const gate = await requireRepoAdmin(c); |
| 167 | if (gate.kind === "response") return gate.response; |
| 168 | const { route, limiter } = gate; |
| 169 | const commit = c.req.param("commit"); |
| 170 | if (!isValidOid(commit)) { |
| 171 | return new Response("Invalid commit\n", { status: 400 }); |
| 172 | } |
| 173 | const stub = getRepoStub(c.env, route.doName); |
| 174 | try { |
| 175 | const result = await limiter.run("do:admin-debug-commit", () => |
| 176 | stub.debugCheckCommit(commit) |
| 177 | ); |
| 178 | return json(result); |
| 179 | } catch (e) { |
| 180 | return json({ error: String(e) }, 500); |
| 181 | } |
| 182 | }); |
| 183 | |
| 184 | router.get(`/:owner/:repo/admin/debug-oid/:oid`, async (c) => { |
| 185 | const gate = await requireRepoAdmin(c); |
| 186 | if (gate.kind === "response") return gate.response; |
| 187 | const { route, limiter } = gate; |
| 188 | const oid = c.req.param("oid"); |
| 189 | if (!isValidOid(oid)) { |
| 190 | return new Response("Invalid OID\n", { status: 400 }); |
| 191 | } |
| 192 | const stub = getRepoStub(c.env, route.doName); |
| 193 | try { |
| 194 | const result = await limiter.run("do:admin-debug-oid", () => stub.debugCheckOid(oid)); |
| 195 | return json(result); |
| 196 | } catch (e) { |
| 197 | return json({ error: String(e) }, 500); |
| 198 | } |
| 199 | }); |
| 200 | |
| 201 | router.delete(`/:owner/:repo/admin/pack/:packKey`, async (c) => { |
| 202 | const gate = await requireRepoAdmin(c); |
| 203 | if (gate.kind === "response") return gate.response; |
| 204 | const { route, limiter } = gate; |
| 205 | const packKey = c.req.param("packKey"); |
| 206 | if (!packKey) { |
| 207 | return json({ error: "Pack key is required" }, 400); |
| 208 | } |
| 209 | const stub = getRepoStub(c.env, route.doName); |
| 210 | try { |
| 211 | const result = await limiter.run("do:admin-remove-pack", () => stub.removePack(packKey)); |
| 212 | if (result.rejected) { |
| 213 | const error = |
| 214 | result.rejected === "active-pack" |
| 215 | ? "Active packs cannot be deleted until they are superseded" |
| 216 | : "Only superseded packs can be deleted through this endpoint"; |
| 217 | return json( |
| 218 | { |
| 219 | ok: false, |
| 220 | error, |
| 221 | ...result, |
| 222 | }, |
| 223 | 409 |
| 224 | ); |
| 225 | } |
| 226 | return json({ ok: result.removed, ...result }); |
| 227 | } catch (e) { |
| 228 | return json({ ok: false, error: String(e) }, 500); |
| 229 | } |
| 230 | }); |
| 231 | |
| 232 | // DANGEROUS: completely delete the repository - D1 row, ROUTES KV, R2 |
| 233 | // objects, and DO storage. The request handler authorizes via |
| 234 | // `requireRepoAdmin` (session + membership + same-origin) and confirms |
| 235 | // the danger-zone payload, then enqueues a `repository-delete` task. The |
| 236 | // queue consumer owns every side-effecting step so a queue-send failure |
| 237 | // cannot leave D1 partially mutated and R2/DO orphaned. |
| 238 | router.delete(`/:owner/:repo/admin/purge`, async (c) => { |
| 239 | const gate = await requireRepoAdmin(c); |
| 240 | if (gate.kind === "response") return gate.response; |
| 241 | const { route, viewer } = gate; |
| 242 | const owner = c.req.param("owner"); |
| 243 | const repo = c.req.param("repo"); |
| 244 | const log = c.var.logFor({ |
| 245 | service: "AdminPurge", |
| 246 | repoId: route.doName, |
| 247 | }); |
| 248 | const rawBody = await safeParseJsonRequest(c.req.raw); |
| 249 | const parsedBody = AdminPurgeRequestSchema.safeParse(rawBody); |
| 250 | const body = parsedBody.success ? parsedBody.data : { confirm: "" }; |
| 251 | const confirm = body.confirm; |
| 252 | if (confirm !== `purge-${owner}/${repo}`) { |
| 253 | return json( |
| 254 | { |
| 255 | error: "Confirmation required", |
| 256 | hint: `Set confirm to "purge-${owner}/${repo}"`, |
| 257 | }, |
| 258 | 400 |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | const message: RepositoryDeleteMessage = { |
| 263 | kind: "repository-delete", |
| 264 | repositoryId: route.repositoryId, |
| 265 | namespaceId: route.namespaceId, |
| 266 | namespaceSlug: route.routeNamespaceSlug, |
| 267 | repoSlug: route.routeRepoSlug, |
| 268 | doName: route.doName, |
| 269 | actor: viewer.userId, |
| 270 | requestedAt: Date.now(), |
| 271 | }; |
| 272 | try { |
| 273 | // Required: a failed enqueue must not mutate D1/KV/R2/DO. Returning |
| 274 | // 503 lets the operator retry without leaving partial state behind. |
| 275 | await c.env.REPO_TASKS_QUEUE.send(message); |
| 276 | } catch (error) { |
| 277 | log.error("admin-purge:enqueue-failed", { error: String(error) }); |
| 278 | return json({ ok: false, error: "Failed to enqueue delete; please retry" }, 503); |
| 279 | } |
| 280 | log.info("admin-purge:enqueued", { |
| 281 | actor: viewer.userId, |
| 282 | requestedAt: message.requestedAt, |
| 283 | }); |
| 284 | return json({ ok: true, queued: true }, 202); |
| 285 | }); |
| 286 | } |