alpha-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/bin/alpha-gate.mjs +75 -0
  5. package/deploy/backup.sh +45 -0
  6. package/deploy/deploy.sh +21 -0
  7. package/deploy/dev.sh +56 -0
  8. package/deploy/lib/statedir.sh +11 -0
  9. package/deploy/teardown.sh +17 -0
  10. package/docs/ONBOARDING.md +16 -0
  11. package/docs/PRINCIPLES.md +195 -0
  12. package/docs/README.md +54 -0
  13. package/docs/UPLOADING.md +14 -0
  14. package/docs/integrate/activation.md +56 -0
  15. package/docs/integrate/sparkle-go.md +106 -0
  16. package/docs/integrate/sparkle-swift.md +63 -0
  17. package/docs/maintain/backup.md +52 -0
  18. package/docs/maintain/migrate-account.md +95 -0
  19. package/docs/maintain/teardown.md +46 -0
  20. package/docs/maintain/troubleshooting.md +102 -0
  21. package/docs/maintain/updating.md +92 -0
  22. package/docs/operate/add-users.md +55 -0
  23. package/docs/operate/channels.md +54 -0
  24. package/docs/operate/email.md +51 -0
  25. package/docs/operate/monitoring.md +56 -0
  26. package/docs/operate/publish.md +90 -0
  27. package/docs/operate/remove-users.md +47 -0
  28. package/docs/setup/cloudflare-account.md +41 -0
  29. package/docs/setup/deploy.md +84 -0
  30. package/docs/setup/install.md +65 -0
  31. package/migrations/0001_clients.sql +12 -0
  32. package/migrations/0002_builds_streams.sql +30 -0
  33. package/migrations/0003_access_log.sql +14 -0
  34. package/migrations/0004_meta.sql +5 -0
  35. package/migrations/0005_admin_audit.sql +14 -0
  36. package/migrations/0006_build_dmg.sql +6 -0
  37. package/migrations/0007_access_requests.sql +12 -0
  38. package/migrations/0008_build_rollback_target.sql +7 -0
  39. package/migrations/0009_hidden.sql +6 -0
  40. package/migrations/0010_purged_at.sql +5 -0
  41. package/package.json +65 -0
  42. package/publish.sh +302 -0
  43. package/release.json +7 -0
  44. package/src/auth/access-jwt.ts +210 -0
  45. package/src/auth/token-gate.ts +27 -0
  46. package/src/core/appcast.ts +107 -0
  47. package/src/core/audit-chain.ts +145 -0
  48. package/src/core/invite-template.ts +127 -0
  49. package/src/core/no-build.ts +160 -0
  50. package/src/core/resolver.ts +60 -0
  51. package/src/core/tokens.ts +51 -0
  52. package/src/core/types.ts +76 -0
  53. package/src/core/validation.ts +60 -0
  54. package/src/core/verdict.ts +195 -0
  55. package/src/core/version.ts +113 -0
  56. package/src/cron.ts +37 -0
  57. package/src/db/access-log.ts +168 -0
  58. package/src/db/access-requests.ts +90 -0
  59. package/src/db/admin-audit.ts +101 -0
  60. package/src/db/builds.ts +169 -0
  61. package/src/db/client.ts +80 -0
  62. package/src/db/clients.ts +103 -0
  63. package/src/db/meta.ts +30 -0
  64. package/src/db/streams.ts +85 -0
  65. package/src/deploy/cli.ts +181 -0
  66. package/src/deploy/commands/deploy.ts +392 -0
  67. package/src/deploy/commands/dev.ts +190 -0
  68. package/src/deploy/commands/teardown.ts +159 -0
  69. package/src/deploy/core/args.ts +205 -0
  70. package/src/deploy/core/colors.ts +49 -0
  71. package/src/deploy/core/config.ts +65 -0
  72. package/src/deploy/core/parse.ts +51 -0
  73. package/src/deploy/core/paths.ts +27 -0
  74. package/src/deploy/core/plan.ts +138 -0
  75. package/src/deploy/core/result.ts +13 -0
  76. package/src/deploy/core/state.ts +64 -0
  77. package/src/deploy/core/table.ts +49 -0
  78. package/src/deploy/core/types.ts +39 -0
  79. package/src/deploy/core/ui.ts +107 -0
  80. package/src/deploy/seams/clock.ts +10 -0
  81. package/src/deploy/seams/files.ts +52 -0
  82. package/src/deploy/seams/io.ts +56 -0
  83. package/src/deploy/seams/wrangler.ts +100 -0
  84. package/src/deploy/ui-preview.ts +112 -0
  85. package/src/deps.ts +41 -0
  86. package/src/dev/admin-entry.ts +104 -0
  87. package/src/env.ts +47 -0
  88. package/src/lib/clock.ts +17 -0
  89. package/src/lib/hosts.ts +27 -0
  90. package/src/lib/text.ts +6 -0
  91. package/src/r2/builds-bucket.ts +50 -0
  92. package/src/r2/keys.ts +27 -0
  93. package/src/routes/admin/admin-context.ts +9 -0
  94. package/src/routes/admin/audit-fields.ts +22 -0
  95. package/src/routes/admin/branding.tsx +161 -0
  96. package/src/routes/admin/builds.tsx +388 -0
  97. package/src/routes/admin/clients.tsx +396 -0
  98. package/src/routes/admin/confirm.tsx +80 -0
  99. package/src/routes/admin/flash.ts +72 -0
  100. package/src/routes/admin/form.ts +43 -0
  101. package/src/routes/admin/index.ts +146 -0
  102. package/src/routes/admin/invite.ts +57 -0
  103. package/src/routes/admin/middleware.ts +52 -0
  104. package/src/routes/admin/negotiate.ts +13 -0
  105. package/src/routes/admin/pending.tsx +73 -0
  106. package/src/routes/admin/read-model.ts +518 -0
  107. package/src/routes/admin/streams.tsx +182 -0
  108. package/src/routes/admin/theme.ts +34 -0
  109. package/src/routes/admin/upload.tsx +320 -0
  110. package/src/routes/admin/views.tsx +293 -0
  111. package/src/routes/app/access.tsx +38 -0
  112. package/src/routes/app/app-context.ts +8 -0
  113. package/src/routes/app/appcast.ts +68 -0
  114. package/src/routes/app/assets.ts +25 -0
  115. package/src/routes/app/download.ts +45 -0
  116. package/src/routes/app/get.tsx +35 -0
  117. package/src/routes/app/index.ts +31 -0
  118. package/src/routes/app/resolve.ts +16 -0
  119. package/src/services/anchor.ts +54 -0
  120. package/src/services/audit.ts +19 -0
  121. package/src/services/branding.ts +51 -0
  122. package/src/services/email-cloudflare.ts +80 -0
  123. package/src/services/email.ts +68 -0
  124. package/src/services/self-update.ts +62 -0
  125. package/src/views/access-page.tsx +39 -0
  126. package/src/views/admin/ci-page.tsx +76 -0
  127. package/src/views/admin/combobox.tsx +191 -0
  128. package/src/views/admin/forms.tsx +37 -0
  129. package/src/views/admin/layout.tsx +496 -0
  130. package/src/views/admin/manage-pages.tsx +223 -0
  131. package/src/views/admin/manage.tsx +1120 -0
  132. package/src/views/admin/plist-extract.ts +233 -0
  133. package/src/views/admin/read-pages.tsx +1098 -0
  134. package/src/views/admin/setup-page.tsx +108 -0
  135. package/src/views/admin/table-enhance.ts +176 -0
  136. package/src/views/admin/ui.tsx +159 -0
  137. package/src/views/get-page.tsx +39 -0
  138. package/src/views/layout.tsx +57 -0
  139. package/src/worker.ts +23 -0
  140. package/tsconfig.json +35 -0
@@ -0,0 +1,388 @@
1
+ import type { AdminAction } from "../../core/no-build";
2
+ import * as builds from "../../db/builds";
3
+ import * as streams from "../../db/streams";
4
+ import { deleteArchivePrefix } from "../../r2/builds-bucket";
5
+ import { buildPrefix } from "../../r2/keys";
6
+ import { recordAudit } from "../../services/audit";
7
+ import { ConfirmActionPage, ResultPage } from "../../views/admin/manage-pages";
8
+ import { renderPage } from "../../views/layout";
9
+ import type { AdminContext } from "./admin-context";
10
+ import { auditFields } from "./audit-fields";
11
+ import { guardStranding, guardStrandingBatch } from "./confirm";
12
+ import { buildSubject, doneRedirect } from "./flash";
13
+ import { field, idList, returnTo, toId } from "./form";
14
+ import { requireUser } from "./middleware";
15
+
16
+ // §9/§10/§11 — build mutations. Withdraw and remove-from-stream can strand users, so they run the
17
+ // §11 confirm flow (not blocked, confirmed). Restore, mark-critical, and link-to-stream only ever
18
+ // add capability, so they never strand. Each requires a human actor and records an audit row.
19
+ // Successes 303 back to the page the operator acted from (return_to) with a flash notice; a re-post
20
+ // that would change nothing (double submit, stale tab) is a flash no-op, not a phantom audit row.
21
+
22
+ async function loadBuild(c: AdminContext) {
23
+ const id = toId(c.req.param("id"));
24
+ if (id === null) return { id: null, build: null };
25
+ const build = await builds.getById(c.get("deps").db, id);
26
+ return { id, build };
27
+ }
28
+
29
+ function staleChannelPage(c: AdminContext, back: string): Response {
30
+ return c.html(
31
+ renderPage(
32
+ <ResultPage
33
+ title="That channel no longer exists"
34
+ intent="error"
35
+ back={{ href: back, label: "← Back" }}
36
+ >
37
+ <p>Nothing was changed — the channel has been deleted. Reload the page and pick another.</p>
38
+ </ResultPage>,
39
+ ),
40
+ 400,
41
+ );
42
+ }
43
+
44
+ export async function withdrawBuild(c: AdminContext): Promise<Response> {
45
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
46
+ const deps = c.get("deps");
47
+ const body = await c.req.parseBody();
48
+ const { id, build } = await loadBuild(c);
49
+ if (id === null) return c.text("Bad request", 400);
50
+ if (build === null) return c.text("Not found", 404);
51
+ if (build.status === "withdrawn") {
52
+ return doneRedirect(
53
+ c,
54
+ body,
55
+ "/admin/builds",
56
+ "noop",
57
+ `${buildSubject(build)} is already withdrawn.`,
58
+ );
59
+ }
60
+ const back = returnTo(body) ?? "/admin/builds";
61
+
62
+ const action: AdminAction = { type: "withdraw-build", buildId: id };
63
+ const blocked = await guardStranding(
64
+ c,
65
+ action,
66
+ field(body, "confirm") === "true",
67
+ `/admin/builds/${id}/withdraw`,
68
+ { return_to: back },
69
+ { subject: `Withdraw ${buildSubject(build)}`, cancelTo: back },
70
+ );
71
+ if (blocked !== null) return blocked;
72
+
73
+ await builds.setStatus(deps.db, id, "withdrawn");
74
+ await recordAudit(deps, auditFields(c, "build.withdraw", String(build.buildNumber)));
75
+ return doneRedirect(c, body, "/admin/builds", "build.withdrawn", buildSubject(build));
76
+ }
77
+
78
+ export async function restoreBuild(c: AdminContext): Promise<Response> {
79
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
80
+ const deps = c.get("deps");
81
+ const body = await c.req.parseBody();
82
+ const { id, build } = await loadBuild(c);
83
+ if (id === null) return c.text("Bad request", 400);
84
+ if (build === null) return c.text("Not found", 404);
85
+ if (build.status === "available") {
86
+ return doneRedirect(
87
+ c,
88
+ body,
89
+ "/admin/builds",
90
+ "noop",
91
+ `${buildSubject(build)} is already available.`,
92
+ );
93
+ }
94
+ // A purged build has no archive to serve — restoring would offer a 404 to Sparkle. Block it until
95
+ // the operator re-uploads (a fresh publish with a higher number is the real path).
96
+ if (build.purgedAt !== null) {
97
+ return c.html(
98
+ renderPage(
99
+ <ResultPage
100
+ title="Archive was purged"
101
+ intent="error"
102
+ back={{ href: `/admin/builds/${id}`, label: "← Back to build" }}
103
+ >
104
+ <p>
105
+ {buildSubject(build)} can't be restored — its archive bytes were purged to reclaim
106
+ space. Re-publish the code with a higher build number instead (a roll-forward).
107
+ </p>
108
+ </ResultPage>,
109
+ ),
110
+ 409,
111
+ );
112
+ }
113
+
114
+ await builds.setStatus(deps.db, id, "available"); // restoring never strands
115
+ await recordAudit(deps, auditFields(c, "build.restore", String(build.buildNumber)));
116
+ return doneRedirect(c, body, "/admin/builds", "build.restored", buildSubject(build));
117
+ }
118
+
119
+ // Storage lifecycle: delete a WITHDRAWN build's archive bytes from R2 to reclaim free-tier space.
120
+ // The D1 row is kept (build_number uniqueness, counts, and the audit chain stay intact) and stamped
121
+ // purged_at. Safe by construction: the resolver only ever serves status='available' builds, so a
122
+ // withdrawn build's bytes are already unreachable. Always confirmed (destructive + irreversible).
123
+ export async function purgeArchive(c: AdminContext): Promise<Response> {
124
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
125
+ const deps = c.get("deps");
126
+ const body = await c.req.parseBody();
127
+ const { id, build } = await loadBuild(c);
128
+ if (id === null) return c.text("Bad request", 400);
129
+ if (build === null) return c.text("Not found", 404);
130
+ const here = `/admin/builds/${id}`;
131
+
132
+ if (build.status !== "withdrawn") {
133
+ return c.html(
134
+ renderPage(
135
+ <ResultPage
136
+ title="Withdraw it first"
137
+ intent="error"
138
+ back={{ href: here, label: "← Back to build" }}
139
+ >
140
+ <p>
141
+ Only a withdrawn build's archive can be purged — an available build is still served.
142
+ Withdraw {buildSubject(build)} first, then purge.
143
+ </p>
144
+ </ResultPage>,
145
+ ),
146
+ 409,
147
+ );
148
+ }
149
+ if (build.purgedAt !== null) {
150
+ return doneRedirect(
151
+ c,
152
+ body,
153
+ here,
154
+ "noop",
155
+ `${buildSubject(build)}'s archive is already purged.`,
156
+ );
157
+ }
158
+
159
+ if (field(body, "confirm") !== "true") {
160
+ return c.html(
161
+ renderPage(
162
+ <ConfirmActionPage
163
+ subject={`Purge the archive for ${buildSubject(build)}`}
164
+ confirmLabel="Purge archive"
165
+ postTo={`${here}/purge-archive`}
166
+ hidden={{ confirm: "true", return_to: here }}
167
+ cancelTo={here}
168
+ >
169
+ <p class="muted">
170
+ Deletes the stored bytes from R2 to reclaim space. The build stays in the history with
171
+ its download counts and audit trail, but it can no longer be restored — re-publish with
172
+ a higher build number if you need it back.
173
+ </p>
174
+ </ConfirmActionPage>,
175
+ ),
176
+ );
177
+ }
178
+
179
+ const deleted = await deleteArchivePrefix(deps.r2, buildPrefix(build.buildNumber));
180
+ await builds.markPurged(deps.db, id, deps.clock());
181
+ await recordAudit(
182
+ deps,
183
+ auditFields(c, "build.purge", String(build.buildNumber), JSON.stringify({ objects: deleted })),
184
+ );
185
+ return doneRedirect(c, body, here, "build.purged", buildSubject(build));
186
+ }
187
+
188
+ export async function markCritical(c: AdminContext): Promise<Response> {
189
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
190
+ const deps = c.get("deps");
191
+ const body = await c.req.parseBody();
192
+ const { id, build } = await loadBuild(c);
193
+ if (id === null) return c.text("Bad request", 400);
194
+ if (build === null) return c.text("Not found", 404);
195
+
196
+ const critical = field(body, "critical") === "true";
197
+ if (build.critical === critical) {
198
+ return doneRedirect(c, body, "/admin/builds", "noop");
199
+ }
200
+ await builds.setCritical(deps.db, id, critical);
201
+ await recordAudit(
202
+ deps,
203
+ auditFields(c, "build.critical", String(build.buildNumber), JSON.stringify({ critical })),
204
+ );
205
+ return doneRedirect(
206
+ c,
207
+ body,
208
+ "/admin/builds",
209
+ critical ? "build.critical" : "build.uncritical",
210
+ buildSubject(build),
211
+ );
212
+ }
213
+
214
+ // Admin-list visibility: hide/unhide declutters the Builds list — it never changes whether the build
215
+ // serves (that's withdraw). Toggle via a hidden field, like critical/rollback.
216
+ export async function setBuildHidden(c: AdminContext): Promise<Response> {
217
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
218
+ const deps = c.get("deps");
219
+ const body = await c.req.parseBody();
220
+ const hidden = field(body, "hidden") === "true";
221
+ const { id, build } = await loadBuild(c);
222
+ if (id === null) return c.text("Bad request", 400);
223
+ if (build === null) return c.text("Not found", 404);
224
+
225
+ await builds.setHidden(deps.db, id, hidden);
226
+ await recordAudit(
227
+ deps,
228
+ auditFields(c, hidden ? "build.hide" : "build.unhide", String(build.buildNumber)),
229
+ );
230
+ return doneRedirect(
231
+ c,
232
+ body,
233
+ "/admin/builds",
234
+ hidden ? "build.hidden" : "build.unhidden",
235
+ buildSubject(build),
236
+ );
237
+ }
238
+
239
+ // §9/§13 #7 — toggle the rollback-target marker. A label only (Sparkle can't downgrade; real rollback
240
+ // is a roll-forward, §9), so it never strands and needs no §11 gate.
241
+ export async function markRollbackTarget(c: AdminContext): Promise<Response> {
242
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
243
+ const deps = c.get("deps");
244
+ const body = await c.req.parseBody();
245
+ const { id, build } = await loadBuild(c);
246
+ if (id === null) return c.text("Bad request", 400);
247
+ if (build === null) return c.text("Not found", 404);
248
+
249
+ const rollbackTarget = field(body, "rollback") === "true";
250
+ if (build.rollbackTarget === rollbackTarget) {
251
+ return doneRedirect(c, body, "/admin/builds", "noop");
252
+ }
253
+ await builds.setRollbackTarget(deps.db, id, rollbackTarget);
254
+ await recordAudit(
255
+ deps,
256
+ auditFields(c, "build.rollback", String(build.buildNumber), JSON.stringify({ rollbackTarget })),
257
+ );
258
+ return doneRedirect(
259
+ c,
260
+ body,
261
+ "/admin/builds",
262
+ rollbackTarget ? "build.rollback" : "build.unrollback",
263
+ buildSubject(build),
264
+ );
265
+ }
266
+
267
+ export async function linkBuildStream(c: AdminContext): Promise<Response> {
268
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
269
+ const deps = c.get("deps");
270
+ const body = await c.req.parseBody();
271
+ const { id, build } = await loadBuild(c);
272
+ const streamId = toId(field(body, "streamId"));
273
+ if (id === null || streamId === null) return c.text("Bad request", 400);
274
+ if (build === null) return c.text("Not found", 404);
275
+ const back = returnTo(body) ?? "/admin/builds";
276
+
277
+ // Both ends must exist and the link must be new — a stale form otherwise turns into a raw
278
+ // foreign-key or unique-constraint 500.
279
+ if ((await streams.getById(deps.db, streamId)) === null) return staleChannelPage(c, back);
280
+ const links = await builds.listBuildStreams(deps.db);
281
+ if (links.some((l) => l.buildId === id && l.streamId === streamId)) {
282
+ return doneRedirect(
283
+ c,
284
+ body,
285
+ "/admin/builds",
286
+ "noop",
287
+ `${buildSubject(build)} is already in that channel.`,
288
+ );
289
+ }
290
+
291
+ await builds.linkStream(deps.db, id, streamId); // adding to a channel never strands
292
+ await recordAudit(
293
+ deps,
294
+ auditFields(c, "build.link", String(build.buildNumber), JSON.stringify({ streamId })),
295
+ );
296
+ return doneRedirect(c, body, "/admin/builds", "build.linked", buildSubject(build));
297
+ }
298
+
299
+ export async function unlinkBuildStream(c: AdminContext): Promise<Response> {
300
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
301
+ const deps = c.get("deps");
302
+ const body = await c.req.parseBody();
303
+ const { id, build } = await loadBuild(c);
304
+ const streamId = toId(field(body, "streamId"));
305
+ if (id === null || streamId === null) return c.text("Bad request", 400);
306
+ if (build === null) return c.text("Not found", 404);
307
+ const back = returnTo(body) ?? "/admin/builds";
308
+ const stream = await streams.getById(deps.db, streamId);
309
+
310
+ const action: AdminAction = { type: "remove-build-from-stream", buildId: id, streamId };
311
+ const blocked = await guardStranding(
312
+ c,
313
+ action,
314
+ field(body, "confirm") === "true",
315
+ `/admin/builds/${id}/streams/unlink`,
316
+ { streamId: String(streamId), return_to: back },
317
+ {
318
+ subject: `Unlink ${buildSubject(build)} from ${stream?.name ?? "the channel"}`,
319
+ cancelTo: back,
320
+ },
321
+ );
322
+ if (blocked !== null) return blocked;
323
+
324
+ await builds.unlinkStream(deps.db, id, streamId);
325
+ await recordAudit(
326
+ deps,
327
+ auditFields(c, "build.unlink", String(build.buildNumber), JSON.stringify({ streamId })),
328
+ );
329
+ return doneRedirect(c, body, "/admin/builds", "build.unlinked", buildSubject(build));
330
+ }
331
+
332
+ // §13 #3 — apply one operation to several selected builds. Bulk withdraw can strand users, so it runs
333
+ // the §11 confirm over the COMBINED effect (one confirmation for the whole selection); mark/clear
334
+ // critical only adds capability and never strands. Repeated `id` fields → parseBody({ all: true }).
335
+ export async function bulkBuilds(c: AdminContext): Promise<Response> {
336
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
337
+ const deps = c.get("deps");
338
+ const body = await c.req.parseBody({ all: true });
339
+ const op = field(body, "op");
340
+ const ids = idList(body, "id");
341
+ if (op === null) return c.text("A bulk operation is required", 400);
342
+ if (ids.length === 0) {
343
+ return doneRedirect(c, body, "/admin/builds", "bulk.none"); // nothing selected → say so
344
+ }
345
+ const subject = `${ids.length} ${ids.length === 1 ? "build" : "builds"}`;
346
+
347
+ if (op === "withdraw") {
348
+ const actions: AdminAction[] = ids.map((buildId) => ({ type: "withdraw-build", buildId }));
349
+ const blocked = await guardStrandingBatch(
350
+ c,
351
+ actions,
352
+ field(body, "confirm") === "true",
353
+ "/admin/builds/bulk",
354
+ "withdraw",
355
+ ids,
356
+ );
357
+ if (blocked !== null) return blocked;
358
+ for (const id of ids) {
359
+ const build = await builds.getById(deps.db, id);
360
+ if (build === null || build.status === "withdrawn") continue; // no phantom audit rows
361
+ await builds.setStatus(deps.db, id, "withdrawn");
362
+ await recordAudit(deps, auditFields(c, "build.withdraw", String(build.buildNumber)));
363
+ }
364
+ return doneRedirect(c, body, "/admin/builds", "bulk.withdrawn", subject);
365
+ }
366
+
367
+ if (op === "critical" || op === "uncritical") {
368
+ const critical = op === "critical";
369
+ for (const id of ids) {
370
+ const build = await builds.getById(deps.db, id);
371
+ if (build === null || build.critical === critical) continue; // no phantom audit rows
372
+ await builds.setCritical(deps.db, id, critical);
373
+ await recordAudit(
374
+ deps,
375
+ auditFields(c, "build.critical", String(build.buildNumber), JSON.stringify({ critical })),
376
+ );
377
+ }
378
+ return doneRedirect(
379
+ c,
380
+ body,
381
+ "/admin/builds",
382
+ critical ? "bulk.critical" : "bulk.uncritical",
383
+ subject,
384
+ );
385
+ }
386
+
387
+ return c.text("Unknown bulk operation", 400);
388
+ }