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,396 @@
1
+ import type { AdminAction } from "../../core/no-build";
2
+ import { generateToken } from "../../core/tokens";
3
+ import * as builds from "../../db/builds";
4
+ import * as clients from "../../db/clients";
5
+ import * as streams from "../../db/streams";
6
+ import { recordAudit } from "../../services/audit";
7
+ import { ConfirmActionPage, InvitePage, 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 } from "./confirm";
12
+ import { buildSubject, doneRedirect } from "./flash";
13
+ import { field, isEmail, returnTo, toId } from "./form";
14
+ import { sendInvite } from "./invite";
15
+ import { requireUser } from "./middleware";
16
+
17
+ // §10/§13 — client mutations. Every handler requires a human actor (service tokens are refused),
18
+ // validates its inputs defensively, runs the §11 confirm flow for stranding actions, and records an
19
+ // audit row. Destructive-but-not-stranding actions (revoke, reissue) are confirmed too: both are
20
+ // irreversible from the tester's point of view (a dead app, a dead link) and were one-click before.
21
+ // Success 303s back to the page the operator acted from (return_to) with a flash notice.
22
+
23
+ function errorPage(
24
+ c: AdminContext,
25
+ status: 400 | 404 | 409,
26
+ title: string,
27
+ body: string,
28
+ back: { href: string; label: string },
29
+ ): Response {
30
+ return c.html(
31
+ renderPage(
32
+ <ResultPage title={title} intent="error" back={back}>
33
+ <p>{body}</p>
34
+ </ResultPage>,
35
+ ),
36
+ status,
37
+ );
38
+ }
39
+
40
+ export async function createClient(c: AdminContext): Promise<Response> {
41
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
42
+ const deps = c.get("deps");
43
+ const body = await c.req.parseBody();
44
+
45
+ const emailRaw = field(body, "email");
46
+ const email = emailRaw === null ? null : emailRaw.trim();
47
+ if (email === null || !isEmail(email)) {
48
+ return errorPage(
49
+ c,
50
+ 400,
51
+ "That doesn't look like an email address",
52
+ `“${email ?? ""}” isn't a valid email — check for typos and try again.`,
53
+ { href: "/admin/users", label: "← Back to users" },
54
+ );
55
+ }
56
+ const label = field(body, "label");
57
+ const streamId = toId(field(body, "streamId"));
58
+
59
+ // The channel must still exist BEFORE the insert — a stale form (channel deleted in another tab)
60
+ // otherwise creates the user, then throws on assignment: a raw 500 that loses the invite link and
61
+ // skips the audit row.
62
+ if (streamId !== null && (await streams.getById(deps.db, streamId)) === null) {
63
+ return errorPage(
64
+ c,
65
+ 400,
66
+ "That channel no longer exists",
67
+ `The user was NOT created. The selected channel has been deleted — reload the Users page and pick another (the address you typed was ${email}).`,
68
+ { href: "/admin/users", label: "← Back to users" },
69
+ );
70
+ }
71
+
72
+ // email is UNIQUE; a re-add would otherwise hit the DB constraint and surface as a bare 500. Tell the
73
+ // admin the user already exists and point at the right next step for the user's status — §12.
74
+ const existing = await clients.findByEmail(deps.db, email);
75
+ if (existing !== null) {
76
+ return c.html(
77
+ renderPage(
78
+ <ResultPage
79
+ title="User already exists"
80
+ intent="error"
81
+ back={{ href: "/admin/users", label: "← Back to users" }}
82
+ >
83
+ <p>
84
+ A user with <strong>{email}</strong> already exists. Open their{" "}
85
+ <a href={`/admin/users/${existing.id}`}>user page</a> —{" "}
86
+ {existing.status === "revoked" ? (
87
+ <>
88
+ they are currently <strong>revoked</strong>, so use <strong>Reactivate</strong> to
89
+ restore access (Reissue alone would mint a link that doesn't work).
90
+ </>
91
+ ) : (
92
+ <>
93
+ use <strong>Reissue</strong> to send a fresh invite link, or <strong>Revoke</strong>{" "}
94
+ to disable access.
95
+ </>
96
+ )}
97
+ </p>
98
+ </ResultPage>,
99
+ ),
100
+ 409,
101
+ );
102
+ }
103
+
104
+ const token = generateToken();
105
+ const client = await clients.insert(deps.db, { email, token, label });
106
+ if (streamId !== null) await streams.assignUser(deps.db, client.id, streamId);
107
+ await recordAudit(deps, auditFields(c, "client.create", email, JSON.stringify({ streamId })));
108
+
109
+ // Delivery never throws: a failed send still returns the link (the copy-paste fallback) with a notice.
110
+ const { url, delivery, message } = await sendInvite(c, email, token);
111
+ return c.html(
112
+ renderPage(<InvitePage email={email} getUrl={url} delivery={delivery} message={message} />),
113
+ );
114
+ }
115
+
116
+ export async function revokeClient(c: AdminContext): Promise<Response> {
117
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
118
+ const deps = c.get("deps");
119
+ const body = await c.req.parseBody();
120
+ const id = toId(c.req.param("id"));
121
+ if (id === null) return c.text("Bad request", 400);
122
+
123
+ const client = await clients.getById(deps.db, id);
124
+ if (client === null) return c.text("Not found", 404);
125
+ const back = returnTo(body) ?? "/admin/users";
126
+
127
+ if (client.status === "revoked") {
128
+ return doneRedirect(c, body, "/admin/users", "noop", `${client.email} is already revoked.`);
129
+ }
130
+
131
+ // Irreversible for the tester (their app drops to the reactivation notice) → always confirmed.
132
+ if (field(body, "confirm") !== "true") {
133
+ return c.html(
134
+ renderPage(
135
+ <ConfirmActionPage
136
+ subject={`Revoke ${client.email}`}
137
+ confirmLabel="Revoke access"
138
+ postTo={`/admin/clients/${id}/revoke`}
139
+ hidden={{ confirm: "true", return_to: back }}
140
+ cancelTo={back}
141
+ >
142
+ <p class="muted">
143
+ Their installed app stops receiving updates and shows a reactivation notice; their
144
+ private download link stops working. You can <strong>Reactivate</strong> them later from
145
+ their user page — the same link starts working again.
146
+ </p>
147
+ </ConfirmActionPage>,
148
+ ),
149
+ );
150
+ }
151
+
152
+ await clients.setStatus(deps.db, id, "revoked");
153
+ await recordAudit(deps, auditFields(c, "client.revoke", client.email));
154
+ return doneRedirect(c, body, "/admin/users", "user.revoked", client.email);
155
+ }
156
+
157
+ /** The inverse of revoke: the stored token becomes valid again, so the old /get link revives. */
158
+ export async function reactivateClient(c: AdminContext): Promise<Response> {
159
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
160
+ const deps = c.get("deps");
161
+ const body = await c.req.parseBody();
162
+ const id = toId(c.req.param("id"));
163
+ if (id === null) return c.text("Bad request", 400);
164
+
165
+ const client = await clients.getById(deps.db, id);
166
+ if (client === null) return c.text("Not found", 404);
167
+ if (client.status === "active") {
168
+ return doneRedirect(c, body, "/admin/users", "noop", `${client.email} is already active.`);
169
+ }
170
+
171
+ await clients.setStatus(deps.db, id, "active");
172
+ await recordAudit(deps, auditFields(c, "client.reactivate", client.email));
173
+ return doneRedirect(c, body, "/admin/users", "user.reactivated", client.email);
174
+ }
175
+
176
+ export async function reissueClient(c: AdminContext): Promise<Response> {
177
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
178
+ const deps = c.get("deps");
179
+ const body = await c.req.parseBody();
180
+ const id = toId(c.req.param("id"));
181
+ if (id === null) return c.text("Bad request", 400);
182
+
183
+ const client = await clients.getById(deps.db, id);
184
+ if (client === null) return c.text("Not found", 404);
185
+ const back = returnTo(body) ?? "/admin/users";
186
+ const revoked = client.status === "revoked";
187
+
188
+ // Rotating the token kills the tester's working app session and their old link → always confirmed.
189
+ // For a revoked user the reissue also reactivates: a fresh link for a still-revoked user would be
190
+ // dead on arrival (every public route gates on status), which was this flow's nastiest trap.
191
+ if (field(body, "confirm") !== "true") {
192
+ return c.html(
193
+ renderPage(
194
+ <ConfirmActionPage
195
+ subject={
196
+ revoked
197
+ ? `Reactivate ${client.email} with a fresh link`
198
+ : `Reissue a fresh link for ${client.email}`
199
+ }
200
+ confirmLabel={revoked ? "Reactivate and reissue" : "Reissue link"}
201
+ postTo={`/admin/clients/${id}/reissue`}
202
+ hidden={{ confirm: "true", return_to: back }}
203
+ cancelTo={back}
204
+ >
205
+ {revoked ? (
206
+ <p class="muted">
207
+ This user is currently revoked. Confirming restores their access with a brand-new
208
+ private link (the old link and any installed app token stay dead).
209
+ </p>
210
+ ) : (
211
+ <p class="muted">
212
+ A new private link replaces the current one immediately: the old link stops working
213
+ and their installed app asks to re-activate. Do this when a link leaked or was lost.
214
+ </p>
215
+ )}
216
+ </ConfirmActionPage>,
217
+ ),
218
+ );
219
+ }
220
+
221
+ const token = generateToken();
222
+ await clients.setToken(deps.db, id, token);
223
+ if (revoked) {
224
+ await clients.setStatus(deps.db, id, "active");
225
+ await recordAudit(deps, auditFields(c, "client.reactivate", client.email));
226
+ }
227
+ await recordAudit(deps, auditFields(c, "client.reissue", client.email));
228
+ // Deliver like create does (email when configured; message for copy-paste otherwise).
229
+ const { url, delivery, message } = await sendInvite(c, client.email, token);
230
+ return c.html(
231
+ renderPage(
232
+ <InvitePage
233
+ email={client.email}
234
+ getUrl={url}
235
+ delivery={delivery}
236
+ message={message}
237
+ restored={revoked}
238
+ />,
239
+ ),
240
+ );
241
+ }
242
+
243
+ // Admin-list visibility: hide/unhide declutters the Users list — it never revokes access (that's
244
+ // revoke). Toggle via a hidden field.
245
+ export async function setClientHidden(c: AdminContext): Promise<Response> {
246
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
247
+ const deps = c.get("deps");
248
+ const id = toId(c.req.param("id"));
249
+ if (id === null) return c.text("Bad request", 400);
250
+ const body = await c.req.parseBody();
251
+ const hidden = field(body, "hidden") === "true";
252
+
253
+ const client = await clients.getById(deps.db, id);
254
+ if (client === null) return c.text("Not found", 404);
255
+ await clients.setHidden(deps.db, id, hidden);
256
+ await recordAudit(deps, auditFields(c, hidden ? "client.hide" : "client.unhide", client.email));
257
+ return doneRedirect(
258
+ c,
259
+ body,
260
+ "/admin/users",
261
+ hidden ? "user.hidden" : "user.unhidden",
262
+ client.email,
263
+ );
264
+ }
265
+
266
+ export async function assignStream(c: AdminContext): Promise<Response> {
267
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
268
+ const deps = c.get("deps");
269
+ const body = await c.req.parseBody();
270
+ const id = toId(c.req.param("id"));
271
+ const streamId = toId(field(body, "streamId"));
272
+ if (id === null || streamId === null) return c.text("Bad request", 400);
273
+
274
+ // Both ends must exist — a stale form otherwise turns into a foreign-key 500.
275
+ const client = await clients.getById(deps.db, id);
276
+ if (client === null) return c.text("Not found", 404);
277
+ const stream = await streams.getById(deps.db, streamId);
278
+ if (stream === null) {
279
+ return errorPage(
280
+ c,
281
+ 400,
282
+ "That channel no longer exists",
283
+ "Nothing was changed — the channel has been deleted. Reload the page and pick another.",
284
+ { href: returnTo(body) ?? `/admin/users/${id}`, label: "← Back" },
285
+ );
286
+ }
287
+
288
+ await streams.assignUser(deps.db, id, streamId); // adding access never strands
289
+ await recordAudit(
290
+ deps,
291
+ auditFields(c, "stream.assign", client.email, JSON.stringify({ streamId })),
292
+ );
293
+ return doneRedirect(c, body, `/admin/users/${id}`, "user.assigned", client.email);
294
+ }
295
+
296
+ export async function unassignStream(c: AdminContext): Promise<Response> {
297
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
298
+ const deps = c.get("deps");
299
+ const body = await c.req.parseBody();
300
+ const id = toId(c.req.param("id"));
301
+ const streamId = toId(field(body, "streamId"));
302
+ if (id === null || streamId === null) return c.text("Bad request", 400);
303
+
304
+ const client = await clients.getById(deps.db, id);
305
+ if (client === null) return c.text("Not found", 404);
306
+ const stream = await streams.getById(deps.db, streamId);
307
+ const back = returnTo(body) ?? `/admin/users/${id}`;
308
+
309
+ const action: AdminAction = { type: "unassign-user-stream", clientId: id, streamId };
310
+ const blocked = await guardStranding(
311
+ c,
312
+ action,
313
+ field(body, "confirm") === "true",
314
+ `/admin/clients/${id}/streams/unassign`,
315
+ { streamId: String(streamId), return_to: back },
316
+ {
317
+ subject: `Remove ${client.email} from ${stream?.name ?? "the channel"}`,
318
+ cancelTo: back,
319
+ },
320
+ );
321
+ if (blocked !== null) return blocked;
322
+
323
+ await streams.unassignUser(deps.db, id, streamId);
324
+ await recordAudit(
325
+ deps,
326
+ auditFields(c, "stream.unassign", client.email, JSON.stringify({ streamId })),
327
+ );
328
+ return doneRedirect(c, body, `/admin/users/${id}`, "user.unassigned", client.email);
329
+ }
330
+
331
+ export async function pinClient(c: AdminContext): Promise<Response> {
332
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
333
+ const deps = c.get("deps");
334
+ const body = await c.req.parseBody();
335
+ const id = toId(c.req.param("id"));
336
+ const buildId = toId(field(body, "buildId"));
337
+ if (id === null || buildId === null) return c.text("Bad request", 400);
338
+
339
+ const client = await clients.getById(deps.db, id);
340
+ if (client === null) return c.text("Not found", 404);
341
+ // The build must exist — a dangling pin is accepted by the DB (no FK) and silently strands.
342
+ const build = await builds.getById(deps.db, buildId);
343
+ if (build === null) {
344
+ return errorPage(
345
+ c,
346
+ 400,
347
+ "That build no longer exists",
348
+ "Nothing was changed — reload the page and pick a build from the list.",
349
+ { href: returnTo(body) ?? `/admin/users/${id}`, label: "← Back" },
350
+ );
351
+ }
352
+ const back = returnTo(body) ?? `/admin/users/${id}`;
353
+
354
+ const action: AdminAction = { type: "pin-client", clientId: id, buildId };
355
+ const blocked = await guardStranding(
356
+ c,
357
+ action,
358
+ field(body, "confirm") === "true",
359
+ `/admin/clients/${id}/pin`,
360
+ { buildId: String(buildId), return_to: back },
361
+ { subject: `Pin ${client.email} to ${buildSubject(build)}`, cancelTo: back },
362
+ );
363
+ if (blocked !== null) return blocked;
364
+
365
+ await clients.setPinnedBuild(deps.db, id, buildId);
366
+ await recordAudit(deps, auditFields(c, "client.pin", client.email, JSON.stringify({ buildId })));
367
+ return doneRedirect(c, body, `/admin/users/${id}`, "user.pinned", client.email);
368
+ }
369
+
370
+ export async function unpinClient(c: AdminContext): Promise<Response> {
371
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
372
+ const deps = c.get("deps");
373
+ const body = await c.req.parseBody();
374
+ const id = toId(c.req.param("id"));
375
+ if (id === null) return c.text("Bad request", 400);
376
+
377
+ const client = await clients.getById(deps.db, id);
378
+ if (client === null) return c.text("Not found", 404);
379
+ const back = returnTo(body) ?? `/admin/users/${id}`;
380
+
381
+ // Unpinning can strand a user whose pinned build was their only servable target → §11 confirm.
382
+ const action: AdminAction = { type: "unpin-client", clientId: id };
383
+ const blocked = await guardStranding(
384
+ c,
385
+ action,
386
+ field(body, "confirm") === "true",
387
+ `/admin/clients/${id}/unpin`,
388
+ { return_to: back },
389
+ { subject: `Unpin ${client.email}`, cancelTo: back },
390
+ );
391
+ if (blocked !== null) return blocked;
392
+
393
+ await clients.setPinnedBuild(deps.db, id, null);
394
+ await recordAudit(deps, auditFields(c, "client.unpin", client.email));
395
+ return doneRedirect(c, body, `/admin/users/${id}`, "user.unpinned", client.email);
396
+ }
@@ -0,0 +1,80 @@
1
+ import type { AdminAction } from "../../core/no-build";
2
+ import { validateAction, validateActions } from "../../core/validation";
3
+ import { BulkConfirmPage, ConfirmPage } from "../../views/admin/manage-pages";
4
+ import { renderPage } from "../../views/layout";
5
+ import type { AdminContext } from "./admin-context";
6
+ import { loadValidationWorld } from "./read-model";
7
+
8
+ // The shared §11 gate for any potentially-stranding mutation (client or build). Returns a Response to
9
+ // short-circuit the handler — a 400 (malformed) or the confirm page (needs confirmation, not yet
10
+ // given) — or null to proceed. The affected set comes from the SAME pure core as runtime resolution.
11
+
12
+ export interface StrandingGuardOptions {
13
+ /** The action in operator words for the confirm page, e.g. "Withdraw build 1400 (1.3.1-beta)". */
14
+ subject: string;
15
+ /** Where Cancel returns to — the page the operator acted from. */
16
+ cancelTo: string;
17
+ }
18
+
19
+ export async function guardStranding(
20
+ c: AdminContext,
21
+ action: AdminAction,
22
+ confirmed: boolean,
23
+ postTo: string,
24
+ hidden: Record<string, string>,
25
+ opts: StrandingGuardOptions,
26
+ ): Promise<Response | null> {
27
+ const { world, installed } = await loadValidationWorld(c.get("deps"));
28
+ const result = validateAction(world, action, installed);
29
+ if (!result.ok) return c.text(result.error, 400);
30
+ if (result.needsConfirm && !confirmed) {
31
+ return c.html(
32
+ renderPage(
33
+ <ConfirmPage
34
+ subject={opts.subject}
35
+ affected={result.affectedEmails}
36
+ postTo={postTo}
37
+ hidden={{ ...hidden, confirm: "true" }}
38
+ cancelTo={opts.cancelTo}
39
+ />,
40
+ ),
41
+ );
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** §11 gate for a batch applied together (the §13 #3 bulk withdraw). Same contract as guardStranding. */
47
+ export async function guardStrandingBatch(
48
+ c: AdminContext,
49
+ actions: AdminAction[],
50
+ confirmed: boolean,
51
+ postTo: string,
52
+ op: string,
53
+ ids: number[],
54
+ ): Promise<Response | null> {
55
+ const { world, installed } = await loadValidationWorld(c.get("deps"));
56
+ const result = validateActions(world, actions, installed);
57
+ if (!result.ok) return c.text(result.error, 400);
58
+ if (result.needsConfirm && !confirmed) {
59
+ return c.html(
60
+ renderPage(
61
+ <BulkConfirmPage op={op} ids={ids} affected={result.affectedEmails} postTo={postTo} />,
62
+ ),
63
+ );
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * The affected-users preview for an action that is ALWAYS confirmed (channel delete): the §11 list to
70
+ * embed in the confirm page, or a 400 short-circuit when the action is malformed.
71
+ */
72
+ export async function strandingPreview(
73
+ c: AdminContext,
74
+ action: AdminAction,
75
+ ): Promise<{ ok: true; affected: string[] } | { ok: false; response: Response }> {
76
+ const { world, installed } = await loadValidationWorld(c.get("deps"));
77
+ const result = validateAction(world, action, installed);
78
+ if (!result.ok) return { ok: false, response: c.text(result.error, 400) };
79
+ return { ok: true, affected: result.affectedEmails };
80
+ }
@@ -0,0 +1,72 @@
1
+ import type { AdminContext } from "./admin-context";
2
+ import { returnTo } from "./form";
3
+
4
+ // Post-action feedback with no JavaScript and no session state: a mutation 303s back to the page the
5
+ // operator acted from with `?done=<slug>&s=<subject>`, and the GET view resolves the slug here into a
6
+ // sentence rendered as a notice. Slugs are a fixed registry — the query string is user-visible input,
7
+ // and free-text messages in it would let a crafted link put arbitrary words in the admin's mouth.
8
+
9
+ const MESSAGES: Record<string, (subject: string | null) => string> = {
10
+ "user.revoked": (s) => `Revoked ${s ?? "the user"} — their app now sees the reactivation notice.`,
11
+ "user.reactivated": (s) => `Reactivated ${s ?? "the user"} — their existing link works again.`,
12
+ "user.hidden": (s) => `Hid ${s ?? "the user"} from this list. Access is unchanged.`,
13
+ "user.unhidden": (s) => `${s ?? "The user"} is visible in this list again.`,
14
+ "user.assigned": (s) => `Assigned ${s ?? "the user"} to the channel.`,
15
+ "user.unassigned": (s) => `Removed ${s ?? "the user"} from the channel.`,
16
+ "user.pinned": (s) =>
17
+ `Pinned ${s ?? "the user"} — channels are ignored while the pin is in place.`,
18
+ "user.unpinned": (s) => `Unpinned ${s ?? "the user"} — channel resolution applies again.`,
19
+ "build.withdrawn": (s) => `Withdrew ${s ?? "the build"} — it is no longer offered to anyone.`,
20
+ "build.restored": (s) => `Restored ${s ?? "the build"} — it can be offered again.`,
21
+ "build.critical": (s) => `Marked ${s ?? "the build"} critical — updates to it are mandatory.`,
22
+ "build.uncritical": (s) => `Cleared the critical mark on ${s ?? "the build"}.`,
23
+ "build.rollback": (s) => `${s ?? "The build"} is now the designated rollback target.`,
24
+ "build.unrollback": (s) => `${s ?? "The build"} is no longer the rollback target.`,
25
+ "build.hidden": (s) => `Hid ${s ?? "the build"} from this list. Serving is unchanged.`,
26
+ "build.unhidden": (s) => `${s ?? "The build"} is visible in this list again.`,
27
+ "build.purged": (s) => `Purged the archive for ${s ?? "the build"}. The record is kept.`,
28
+ "build.linked": (s) => `Linked ${s ?? "the build"} to the channel.`,
29
+ "build.unlinked": (s) => `Unlinked ${s ?? "the build"} from the channel.`,
30
+ "channel.created": (s) => `Created the ${s ?? "new"} channel.`,
31
+ "channel.builds-linked": (s) => `Linked ${s ?? "the selected builds"}.`,
32
+ "channel.users-assigned": (s) => `Assigned ${s ?? "the selected users"}.`,
33
+ "channel.deleted": (s) => `Deleted the ${s ?? ""} channel and its assignments.`,
34
+ "request.dismissed": (s) => `Dismissed the request from ${s ?? "the requester"}.`,
35
+ "bulk.withdrawn": (s) => `Withdrew ${s ?? "the selected builds"}.`,
36
+ "bulk.critical": (s) => `Marked ${s ?? "the selected builds"} critical.`,
37
+ "bulk.uncritical": (s) => `Cleared the critical mark on ${s ?? "the selected builds"}.`,
38
+ "bulk.none": () => "Nothing was selected — tick some builds first.",
39
+ "settings.saved": () => "Settings saved.",
40
+ noop: (s) => s ?? "Nothing to change.",
41
+ };
42
+
43
+ /** The notice for the current GET, or null when the page wasn't reached via a done-redirect. */
44
+ export function flashMessage(c: AdminContext): string | null {
45
+ const slug = c.req.query("done");
46
+ if (!slug) return null;
47
+ const render = MESSAGES[slug];
48
+ return render ? render(c.req.query("s") ?? null) : "Done.";
49
+ }
50
+
51
+ /**
52
+ * The mutation-success redirect: back to the validated `return_to` (the page the operator acted
53
+ * from) or the section fallback, carrying the flash slug + subject for the target view to render.
54
+ */
55
+ export function doneRedirect(
56
+ c: AdminContext,
57
+ body: Record<string, unknown>,
58
+ fallback: string,
59
+ slug: string,
60
+ subject?: string,
61
+ ): Response {
62
+ const target = returnTo(body) ?? fallback;
63
+ const url = new URL(target, "http://internal");
64
+ url.searchParams.set("done", slug);
65
+ if (subject !== undefined) url.searchParams.set("s", subject);
66
+ return c.redirect(url.pathname + url.search, 303);
67
+ }
68
+
69
+ /** The canonical human name for a build in copy: its number plus the human version string. */
70
+ export function buildSubject(build: { buildNumber: number; shortVersion: string }): string {
71
+ return `build ${build.buildNumber} (${build.shortVersion})`;
72
+ }
@@ -0,0 +1,43 @@
1
+ // Defensive readers for admin form POSTs (untrusted input). Shared by every mutation handler.
2
+
3
+ export function field(body: Record<string, unknown>, name: string): string | null {
4
+ const value = body[name];
5
+ return typeof value === "string" && value.length > 0 ? value : null;
6
+ }
7
+
8
+ /** Parses a positive-integer id, or null for anything malformed/absent. */
9
+ export function toId(raw: string | null | undefined): number | null {
10
+ if (raw === null || raw === undefined) return null;
11
+ const n = Number.parseInt(raw, 10);
12
+ return Number.isInteger(n) && n > 0 ? n : null;
13
+ }
14
+
15
+ /**
16
+ * Collects repeated form values for `name` into deduped positive-int ids — the bulk checkboxes post
17
+ * the same field name N times, so the handler must parse with parseBody({ all: true }) (arrays).
18
+ */
19
+ export function idList(body: Record<string, unknown>, name: string): number[] {
20
+ const raw = body[name];
21
+ const values = Array.isArray(raw) ? raw : raw === undefined ? [] : [raw];
22
+ const ids = new Set<number>();
23
+ for (const value of values) {
24
+ const id = toId(typeof value === "string" ? value : undefined);
25
+ if (id !== null) ids.add(id);
26
+ }
27
+ return [...ids];
28
+ }
29
+
30
+ /**
31
+ * The validated `return_to` form field: the internal admin path the handler may 303 back to so the
32
+ * operator lands where they acted (list, detail page, filtered view). Anything that isn't a plain
33
+ * same-origin `/admin…` path — a scheme, an authority, control characters — is rejected, so a
34
+ * tampered form can't turn the redirect into an open redirect.
35
+ */
36
+ export function returnTo(body: Record<string, unknown>): string | null {
37
+ const raw = field(body, "return_to");
38
+ if (raw === null) return null;
39
+ const ok = /^\/admin(?:\/[A-Za-z0-9._~/-]*)?(?:\?[A-Za-z0-9=&%._~-]*)?$/.test(raw);
40
+ return ok && !raw.startsWith("/admin//") ? raw : null;
41
+ }
42
+
43
+ export { isEmail } from "../../lib/text";