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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- 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
|
+
}
|