Skip to content
File

Blob: src/worker/routes/admin.ts

typescript287 lines
1import { getRepoStub, isValidOid, json } from "@/worker/common";
2import { sameOriginViolation } from "@/worker/auth/origin";
3import { safeParseJsonRequest } from "@/shared/web";
4import type { RepositoryDeleteMessage } from "@/worker/tasks/queue";
5import { resolveAdminApiRepoAccess, type AdminRepoAccess } from "./ui/helpers";
6import type { AppContext, AppRouter } from "./hono";
7import {
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.
17async 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 
28export 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}