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,1098 @@
1
+ import type { Child, FC } from "hono/jsx";
2
+ import type { AuditRow, ChainAssessment } from "../../core/audit-chain";
3
+ import type { Stream } from "../../core/types";
4
+ import { formatBytes } from "../../core/verdict";
5
+ import type { AccessLogEntry } from "../../db/access-log";
6
+ import type { AccessRequest } from "../../db/access-requests";
7
+ import type {
8
+ BuildView,
9
+ Dashboard,
10
+ FaultedUser,
11
+ RecentItem,
12
+ StreamView,
13
+ UserView,
14
+ } from "../../routes/admin/read-model";
15
+ import { Post } from "./forms";
16
+ import { AdminLayout, type Chrome } from "./layout";
17
+ import { BuildTags, Dot, Lk, Tag, VerdictCell, When } from "./ui";
18
+
19
+ // The back-office list pages, quiet-instrument style: one sheet, hairline slabs, exception-only
20
+ // state, and the resolver visible everywhere — the serving map on Overview, the Next-check column on
21
+ // Users, the Serving column on Channels. Rows link to detail pages; actions live there (no
22
+ // buttons-as-data). Detail pages live in manage.tsx.
23
+
24
+ // A header cell wired for the client-side table enhancer (table-enhance.ts): `sort` makes it
25
+ // click-to-sort ("text" | "num"); `col` is the key a filter control targets. Both are omitted from the
26
+ // markup when unset (a plain <th> stays unsortable), so the enhancer only touches columns we opt in.
27
+ const Th: FC<{ col?: string; sort?: "text" | "num"; right?: boolean; children?: Child }> = ({
28
+ col,
29
+ sort,
30
+ right,
31
+ children,
32
+ }) => {
33
+ const attrs: Record<string, string> = {};
34
+ if (col) attrs["data-key"] = col;
35
+ if (sort) attrs["data-sort"] = sort;
36
+ if (right) attrs.class = "r";
37
+ return <th {...attrs}>{children}</th>;
38
+ };
39
+
40
+ // ————————————————————————————— Overview —————————————————————————————
41
+
42
+ /** One attention row: cause in prose, exactly one remedy link. */
43
+ interface AttentionItem {
44
+ dot: "warn" | "req";
45
+ title: Child;
46
+ body: Child;
47
+ fix: { href: string; label: string };
48
+ }
49
+
50
+ function faultAttention(fault: FaultedUser): AttentionItem | null {
51
+ const userHref = `/admin/users/${fault.id}`;
52
+ switch (fault.verdict.kind) {
53
+ case "stranded":
54
+ return {
55
+ dot: "warn",
56
+ title: (
57
+ <>
58
+ <a href={userHref}>{fault.email}</a> is stranded
59
+ </>
60
+ ),
61
+ body: (
62
+ <>
63
+ Installed <b class="num">#{fault.verdict.installed}</b> is above everything{" "}
64
+ {fault.streams.join(", ") || "their channels"} offer (top is{" "}
65
+ <Lk build={fault.verdict.top} dim short />) — Sparkle can't downgrade.
66
+ </>
67
+ ),
68
+ fix: { href: "/admin/upload", label: "Roll forward →" },
69
+ };
70
+ case "pin-unavailable":
71
+ return {
72
+ dot: "warn",
73
+ title: (
74
+ <>
75
+ <a href={userHref}>{fault.email}</a>'s pin serves nothing
76
+ </>
77
+ ),
78
+ body: <>The pinned build was withdrawn — their checks return an empty feed.</>,
79
+ fix: { href: userHref, label: "Review pin →" },
80
+ };
81
+ case "pin-below-installed":
82
+ return {
83
+ dot: "warn",
84
+ title: (
85
+ <>
86
+ <a href={userHref}>{fault.email}</a>'s pin serves nothing
87
+ </>
88
+ ),
89
+ body: (
90
+ <>
91
+ Pinned <Lk build={fault.verdict.pinned} dim short /> sits below their installed{" "}
92
+ <b class="num">#{fault.verdict.installed}</b> — Sparkle can't downgrade.
93
+ </>
94
+ ),
95
+ fix: { href: userHref, label: "Review pin →" },
96
+ };
97
+ default:
98
+ return null; // no-channel and empty-channel are covered by the map + channel rows
99
+ }
100
+ }
101
+
102
+ const RECENT_PHRASES: Record<string, (target: string | null) => string> = {
103
+ "client.create": (t) => `you invited ${t}`,
104
+ "client.revoke": (t) => `you revoked ${t}`,
105
+ "client.reactivate": (t) => `you reactivated ${t}`,
106
+ "client.reissue": (t) => `you reissued a link for ${t}`,
107
+ "client.pin": (t) => `you pinned ${t}`,
108
+ "client.unpin": (t) => `you unpinned ${t}`,
109
+ "client.hide": (t) => `you hid ${t}`,
110
+ "client.unhide": (t) => `you unhid ${t}`,
111
+ "stream.create": (t) => `you created the ${t} channel`,
112
+ "stream.delete": (t) => `you deleted the ${t} channel`,
113
+ "stream.assign": (t) => `you assigned ${t}`,
114
+ "stream.unassign": (t) => `you unassigned ${t}`,
115
+ "build.upload": (t) => `you published build ${t}`,
116
+ "build.register": (t) => `you published build ${t}`,
117
+ "build.withdraw": (t) => `you withdrew build ${t}`,
118
+ "build.restore": (t) => `you restored build ${t}`,
119
+ "build.critical": (t) => `you changed the critical mark on build ${t}`,
120
+ "build.rollback": (t) => `you changed the rollback target on build ${t}`,
121
+ "build.link": (t) => `you linked build ${t}`,
122
+ "build.unlink": (t) => `you unlinked build ${t}`,
123
+ "build.hide": (t) => `you hid build ${t}`,
124
+ "build.unhide": (t) => `you unhid build ${t}`,
125
+ "request.invite": (t) => `you invited ${t}`,
126
+ "request.dismiss": (t) => `you dismissed the request from ${t}`,
127
+ };
128
+
129
+ const RecentLine: FC<{ item: RecentItem }> = ({ item }) => {
130
+ if (item.kind === "admin") {
131
+ const phrase = RECENT_PHRASES[item.action ?? ""];
132
+ return <span>{phrase ? phrase(item.target) : `${item.action} ${item.target ?? ""}`}</span>;
133
+ }
134
+ const who = item.clientId ? (
135
+ <a href={`/admin/users/${item.clientId}`}>{item.email}</a>
136
+ ) : (
137
+ <span>{item.email ?? "someone"}</span>
138
+ );
139
+ const buildRef = item.buildNumber !== null ? <b class="num">#{item.buildNumber}</b> : null;
140
+ if (item.kind === "check") {
141
+ return (
142
+ <span>
143
+ {who} checked{buildRef ? <> — on {buildRef}</> : null}
144
+ </span>
145
+ );
146
+ }
147
+ if (item.kind === "download") {
148
+ return (
149
+ <span>
150
+ {who} downloaded {buildRef}
151
+ </span>
152
+ );
153
+ }
154
+ return (
155
+ <span>
156
+ {who} updated to {buildRef}
157
+ </span>
158
+ );
159
+ };
160
+
161
+ export const OverviewPage: FC<{ data: Dashboard; now: string; chrome: Chrome }> = ({
162
+ data,
163
+ now,
164
+ chrome,
165
+ }) => {
166
+ const attention: AttentionItem[] = [];
167
+ if (data.chain !== null && !data.chain.intact) {
168
+ attention.push({
169
+ dot: "warn",
170
+ title: <>Audit chain mismatch</>,
171
+ body: <>The admin audit log no longer matches its last anchor — inspect it now.</>,
172
+ fix: { href: "/admin/audit", label: "Inspect audit →" },
173
+ });
174
+ }
175
+ if (data.selfUpdate.available) {
176
+ attention.push({
177
+ dot: "warn",
178
+ title: <>Alpha Gate {data.selfUpdate.latest} is available</>,
179
+ body: (
180
+ <>
181
+ {data.selfUpdate.breaking ? "Includes breaking changes. " : ""}Update Alpha Gate (git
182
+ pull, or npx alpha-gate@latest) and re-deploy this instance.
183
+ {data.selfUpdate.notesUrl ? (
184
+ <>
185
+ {" "}
186
+ <a href={data.selfUpdate.notesUrl}>Release notes</a>.
187
+ </>
188
+ ) : null}
189
+ </>
190
+ ),
191
+ fix: { href: data.selfUpdate.notesUrl ?? "/admin/settings", label: "Details →" },
192
+ });
193
+ }
194
+ for (const fault of data.faults) {
195
+ const item = faultAttention(fault);
196
+ if (item) attention.push(item);
197
+ }
198
+ for (const s of data.servings) {
199
+ if (s.top === null && s.users > 0) {
200
+ attention.push({
201
+ dot: "warn",
202
+ title: <>{s.name} serves nothing</>,
203
+ body: (
204
+ <>
205
+ No available build is linked, so {s.users} {s.users === 1 ? "user gets" : "users get"}{" "}
206
+ an empty feed and their apps report “up to date”.
207
+ </>
208
+ ),
209
+ fix: { href: `/admin/streams/${s.streamId}`, label: "Link a build →" },
210
+ });
211
+ }
212
+ }
213
+ const firstOff = data.offMap[0];
214
+ if (data.offMap.length === 1 && firstOff) {
215
+ attention.push({
216
+ dot: "warn",
217
+ title: (
218
+ <>
219
+ <a href={`/admin/users/${firstOff.id}`}>{firstOff.email}</a> has no channel
220
+ </>
221
+ ),
222
+ body: <>Their token works but resolves to nothing — they have never received a build.</>,
223
+ fix: { href: `/admin/users/${firstOff.id}`, label: "Assign a channel →" },
224
+ });
225
+ } else if (data.offMap.length > 1) {
226
+ attention.push({
227
+ dot: "warn",
228
+ title: <>{data.offMap.length} users have no channel</>,
229
+ body: <>{data.offMap.map((u) => u.email).join(", ")} — their checks resolve to nothing.</>,
230
+ fix: { href: "/admin/users?nobuild=1", label: "Review users →" },
231
+ });
232
+ }
233
+ if (data.pendingRequests > 0) {
234
+ attention.push({
235
+ dot: "req",
236
+ title: (
237
+ <>
238
+ {data.pendingRequests} access {data.pendingRequests === 1 ? "request" : "requests"}{" "}
239
+ waiting
240
+ </>
241
+ ),
242
+ body: (
243
+ <>
244
+ {data.pendingSample.emails.join(", ")}
245
+ {data.pendingRequests > data.pendingSample.emails.length ? " and more" : ""}
246
+ {data.pendingSample.oldest ? (
247
+ <>
248
+ {" "}
249
+ — oldest from <When iso={data.pendingSample.oldest} now={now} />
250
+ </>
251
+ ) : null}
252
+ .
253
+ </>
254
+ ),
255
+ fix: { href: "/admin/pending", label: "Review requests →" },
256
+ });
257
+ }
258
+
259
+ return (
260
+ <AdminLayout
261
+ title="Overview"
262
+ chrome={chrome}
263
+ head={
264
+ <>
265
+ {data.lastPublish ? (
266
+ <p class="sub">
267
+ Last publish{" "}
268
+ <Lk
269
+ build={data.lastPublish.build}
270
+ href={`/admin/builds/${data.lastPublish.build.id}`}
271
+ />
272
+ {data.lastPublish.streams.length > 0 ? (
273
+ <> → {data.lastPublish.streams.join(", ")}</>
274
+ ) : (
275
+ <> — in no channel</>
276
+ )}
277
+ , <When iso={data.lastPublish.build.createdAt} now={now} />
278
+ </p>
279
+ ) : (
280
+ <p class="sub">Nothing published yet</p>
281
+ )}
282
+ <p class="inv">
283
+ <a href="/admin/users">
284
+ {data.users} users{data.hiddenUsers > 0 ? ` (+${data.hiddenUsers} hidden)` : ""}
285
+ </a>{" "}
286
+ · <a href="/admin/builds">{data.builds} builds</a> ·{" "}
287
+ <a href="/admin/streams">{data.streams} channels</a>
288
+ </p>
289
+ </>
290
+ }
291
+ >
292
+ <section aria-label="Serving map">
293
+ <div class="slab">
294
+ <h2>Serving now</h2>
295
+ <span class="hint">what each channel offers on the next update check</span>
296
+ </div>
297
+ <ul class="map">
298
+ {data.servings.map((s) => (
299
+ <li>
300
+ <Dot
301
+ kind={s.top === null ? "off" : s.faulted > 0 ? "warn" : "ok"}
302
+ title={
303
+ s.top === null
304
+ ? "Serving nothing"
305
+ : s.faulted > 0
306
+ ? "Serving, with faults"
307
+ : "Serving"
308
+ }
309
+ />
310
+ <a class="ch" href={`/admin/streams/${s.streamId}`}>
311
+ {s.name}
312
+ </a>
313
+ <span class={s.top === null ? "rail dash" : "rail"} />
314
+ <span class="served">
315
+ {s.top === null ? (
316
+ <span class="none">
317
+ serving nothing <i>— no build linked</i>
318
+ </span>
319
+ ) : (
320
+ <>
321
+ <Lk build={s.top} href={`/admin/builds/${s.top.id}`} />
322
+ <BuildTags build={s.top} />
323
+ </>
324
+ )}
325
+ </span>
326
+ <span class={s.top === null ? "rail dash" : "rail"} />
327
+ <a class="aud" href={`/admin/users?stream=${encodeURIComponent(s.name)}`}>
328
+ <b>
329
+ {s.users} {s.users === 1 ? "user" : "users"}
330
+ </b>
331
+ {s.users === 0 ? null : s.top === null ? (
332
+ <em class="w">empty feed</em>
333
+ ) : (
334
+ <em class={s.faulted > 0 ? "w" : ""}>
335
+ {[
336
+ s.faulted > 0 ? `${s.faulted} faulted` : null,
337
+ s.willUpdate > 0 ? `${s.willUpdate} will update` : null,
338
+ s.pinned > 0 ? `${s.pinned} pinned` : null,
339
+ ]
340
+ .filter(Boolean)
341
+ .join(" · ") || "all up to date"}
342
+ </em>
343
+ )}
344
+ </a>
345
+ </li>
346
+ ))}
347
+ {data.offMap.length > 0 ? (
348
+ <li class="offrow">
349
+ <Dot kind="off" title="Routed nowhere" />
350
+ <span class="ch">off the map</span>
351
+ <span class="rail dash" />
352
+ <span class="served">
353
+ <span class="none">
354
+ routed nowhere <i>— no channel, no pin</i>
355
+ </span>
356
+ </span>
357
+ <span class="rail dash" />
358
+ <a class="aud" href="/admin/users?nobuild=1">
359
+ <b>
360
+ {data.offMap.length} {data.offMap.length === 1 ? "user" : "users"}
361
+ </b>
362
+ <em class="w">never served</em>
363
+ </a>
364
+ </li>
365
+ ) : null}
366
+ </ul>
367
+ </section>
368
+
369
+ <div class="cols">
370
+ <section aria-label="Needs attention">
371
+ <div class="slab">
372
+ <h2>Needs attention</h2>
373
+ </div>
374
+ {attention.length === 0 ? (
375
+ <p class="allgood">
376
+ <Dot kind="ok" /> Nothing needs attention — every active user is served.
377
+ </p>
378
+ ) : (
379
+ <ul class="attn">
380
+ {attention.map((item) => (
381
+ <li>
382
+ <span class={`dot ${item.dot}`} />
383
+ <div>
384
+ <b>{item.title}</b>
385
+ <p>{item.body}</p>
386
+ </div>
387
+ <a class="fix" href={item.fix.href}>
388
+ {item.fix.label}
389
+ </a>
390
+ </li>
391
+ ))}
392
+ </ul>
393
+ )}
394
+ </section>
395
+
396
+ <section aria-label="Recent">
397
+ <div class="slab">
398
+ <h2>Recent</h2>
399
+ <a href="/admin/activity">activity →</a>
400
+ </div>
401
+ {data.recent.length === 0 ? (
402
+ <p class="empty">No activity yet — invite a user and publish a build.</p>
403
+ ) : (
404
+ <ol class="rec">
405
+ {data.recent.map((item) => (
406
+ <li>
407
+ <When iso={item.at} now={now} />
408
+ <RecentLine item={item} />
409
+ </li>
410
+ ))}
411
+ </ol>
412
+ )}
413
+ {data.chain !== null ? (
414
+ <p class={data.chain.intact ? "seal" : "seal bad"} style="margin-top:14px">
415
+ <Dot kind={data.chain.intact ? "ok" : "warn"} />
416
+ {data.chain.intact
417
+ ? `audit chain intact · ${data.chain.count} entries${data.chain.anchored ? "" : " · not yet anchored"}`
418
+ : "AUDIT CHAIN MISMATCH — inspect the audit log"}
419
+ </p>
420
+ ) : null}
421
+ </section>
422
+ </div>
423
+ </AdminLayout>
424
+ );
425
+ };
426
+
427
+ // ————————————————————————————— Requests —————————————————————————————
428
+
429
+ export const PendingPage: FC<{ requests: AccessRequest[]; now: string; chrome: Chrome }> = ({
430
+ requests,
431
+ now,
432
+ chrome,
433
+ }) => {
434
+ // Duplicates are the norm (copy-paste mode sends no confirmation) — group by email, act once.
435
+ const byEmail = new Map<string, AccessRequest[]>();
436
+ for (const r of requests) {
437
+ byEmail.set(r.email, [...(byEmail.get(r.email) ?? []), r]);
438
+ }
439
+ const grouped = [...byEmail.entries()];
440
+ return (
441
+ <AdminLayout title="Requests" chrome={chrome}>
442
+ {grouped.length === 0 ? (
443
+ <p class="empty">No pending access requests. The public request page feeds this list.</p>
444
+ ) : (
445
+ <section>
446
+ <div class="slab">
447
+ <h2>Waiting for a decision</h2>
448
+ <span class="hint">Invite creates the user and shows the link to send</span>
449
+ </div>
450
+ <ul class="rows">
451
+ {grouped.map(([email, rows]) => (
452
+ <li>
453
+ <span>
454
+ <span class="who">{email}</span>
455
+ {rows.length > 1 ? <span class="lbl">asked {rows.length} times</span> : null}
456
+ </span>
457
+ <span class="t">
458
+ <When iso={rows[0]?.createdAt ?? null} now={now} />
459
+ </span>
460
+ <Post action={`/admin/pending/${rows[0]?.id}/invite`} label="Invite" />
461
+ <Post
462
+ action={`/admin/pending/${rows[0]?.id}/dismiss`}
463
+ label="Dismiss"
464
+ hidden={{ return_to: "/admin/pending" }}
465
+ />
466
+ </li>
467
+ ))}
468
+ </ul>
469
+ </section>
470
+ )}
471
+ </AdminLayout>
472
+ );
473
+ };
474
+
475
+ // ————————————————————————————— Users —————————————————————————————
476
+
477
+ export interface UsersFilter {
478
+ status: string;
479
+ stream: string;
480
+ nobuild: boolean;
481
+ pinned: boolean;
482
+ hidden: boolean;
483
+ }
484
+
485
+ export const UsersPage: FC<{
486
+ users: UserView[];
487
+ channels: Stream[];
488
+ filter: UsersFilter;
489
+ hiddenCount: number;
490
+ now: string;
491
+ chrome: Chrome;
492
+ }> = ({ users, channels, filter, hiddenCount, now, chrome }) => {
493
+ const channelId = new Map(channels.map((s) => [s.name, s.id]));
494
+ const revoked = users.filter((u) => u.status === "revoked").length;
495
+ return (
496
+ <AdminLayout
497
+ title="Users"
498
+ chrome={chrome}
499
+ head={
500
+ <p class="sub">
501
+ {users.length} shown{revoked > 0 ? ` · ${revoked} revoked` : ""}
502
+ </p>
503
+ }
504
+ >
505
+ <form method="get" action="/admin/users" class="filters">
506
+ <label>
507
+ status
508
+ <select name="status">
509
+ <option value="">any</option>
510
+ <option value="active" selected={filter.status === "active"}>
511
+ active
512
+ </option>
513
+ <option value="revoked" selected={filter.status === "revoked"}>
514
+ revoked
515
+ </option>
516
+ </select>
517
+ </label>
518
+ <label>
519
+ channel
520
+ <select name="stream">
521
+ <option value="">any</option>
522
+ {channels.map((s) => (
523
+ <option value={s.name} selected={filter.stream === s.name}>
524
+ {s.name}
525
+ </option>
526
+ ))}
527
+ </select>
528
+ </label>
529
+ <label>
530
+ <input type="checkbox" name="nobuild" value="1" checked={filter.nobuild} /> needs
531
+ attention
532
+ </label>
533
+ <label>
534
+ <input type="checkbox" name="pinned" value="1" checked={filter.pinned} /> pinned
535
+ </label>
536
+ <label>
537
+ <input type="checkbox" name="hidden" value="1" checked={filter.hidden} /> show hidden
538
+ </label>
539
+ <button type="submit">Filter</button>
540
+ <a href="/admin/users">clear</a>
541
+ </form>
542
+
543
+ {users.length === 0 ? (
544
+ <p class="empty">No users match.</p>
545
+ ) : (
546
+ <section>
547
+ <div class="tbl">
548
+ <table data-enhance>
549
+ <thead>
550
+ <tr>
551
+ <Th sort="text">User</Th>
552
+ <Th col="channels" sort="text">
553
+ Channels
554
+ </Th>
555
+ <th>Next check</th>
556
+ <Th sort="num" right>
557
+ Installed
558
+ </Th>
559
+ <Th sort="text">Last seen</Th>
560
+ </tr>
561
+ </thead>
562
+ <tbody>
563
+ {users.map((u) => (
564
+ <tr class={u.status === "revoked" || u.hidden ? "dim" : ""}>
565
+ <td>
566
+ <a class="who" href={`/admin/users/${u.id}`}>
567
+ {u.email}
568
+ </a>
569
+ {u.label ? <span class="lbl">{u.label}</span> : null}{" "}
570
+ {u.status === "revoked" ? (
571
+ <Tag kind="mut" label="revoked" title="Not served; reactivate to restore" />
572
+ ) : null}
573
+ {u.hidden ? <Tag kind="mut" label="hidden" /> : null}
574
+ </td>
575
+ <td class="chs" data-value={u.streams.join(",")}>
576
+ {u.streams.length > 0 ? (
577
+ u.streams.map((name, i) => (
578
+ <>
579
+ {i > 0 ? " · " : ""}
580
+ <a href={`/admin/streams/${channelId.get(name) ?? ""}`}>{name}</a>
581
+ </>
582
+ ))
583
+ ) : (
584
+ <span class="mut">—</span>
585
+ )}
586
+ </td>
587
+ <td>
588
+ <VerdictCell verdict={u.verdict} />
589
+ </td>
590
+ <td class="r" data-value={String(u.currentBuild ?? "")}>
591
+ {u.currentBuild !== null ? (
592
+ <b class="num">#{u.currentBuild}</b>
593
+ ) : (
594
+ <span class="mut">—</span>
595
+ )}
596
+ </td>
597
+ <td data-value={u.lastSeen ?? ""}>
598
+ <When iso={u.lastSeen} now={now} />
599
+ </td>
600
+ </tr>
601
+ ))}
602
+ </tbody>
603
+ </table>
604
+ </div>
605
+ <p class="tfoot">
606
+ <span data-table-status />
607
+ {hiddenCount > 0 && !filter.hidden ? (
608
+ <a href="/admin/users?hidden=1">{hiddenCount} hidden not shown — show them</a>
609
+ ) : null}
610
+ </p>
611
+ <p class="empty" data-table-empty hidden>
612
+ No users match the filters.
613
+ </p>
614
+ </section>
615
+ )}
616
+
617
+ <section aria-label="Add user">
618
+ <div class="slab">
619
+ <h2>Add user</h2>
620
+ <span class="hint">the invite link appears on the next page, ready to copy</span>
621
+ </div>
622
+ <form method="post" action="/admin/clients" class="frow">
623
+ <label class="field">
624
+ <span>Email</span>
625
+ <input type="email" name="email" required placeholder="e.g. mira@studio.dev" />
626
+ </label>
627
+ <label class="field">
628
+ <span>
629
+ Label <i>· optional</i>
630
+ </span>
631
+ <input type="text" name="label" placeholder="e.g. design partner" />
632
+ </label>
633
+ <label class="field">
634
+ <span>Channel</span>
635
+ <select name="streamId">
636
+ <option value="">— none —</option>
637
+ {channels.map((s) => (
638
+ <option value={s.id}>{s.name}</option>
639
+ ))}
640
+ </select>
641
+ </label>
642
+ <button type="submit" class="btn-primary">
643
+ Create invite
644
+ </button>
645
+ </form>
646
+ <p class="fhint">
647
+ A user with no channel receives no updates until you assign one — leave it empty only if
648
+ you mean to.
649
+ </p>
650
+ </section>
651
+ </AdminLayout>
652
+ );
653
+ };
654
+
655
+ // ————————————————————————————— Builds —————————————————————————————
656
+
657
+ const FREE_TIER_BYTES = 10 * 1024 * 1024 * 1024; // R2 free tier = 10 GB
658
+
659
+ export const BuildsPage: FC<{
660
+ builds: BuildView[];
661
+ channels: Stream[];
662
+ showHidden: boolean;
663
+ hiddenCount: number;
664
+ storageBytes: number;
665
+ now: string;
666
+ chrome: Chrome;
667
+ }> = ({ builds, channels, showHidden, hiddenCount, storageBytes, now, chrome }) => {
668
+ const channelId = new Map(channels.map((s) => [s.name, s.id]));
669
+ const nearCap = storageBytes > FREE_TIER_BYTES * 0.8;
670
+ return (
671
+ <AdminLayout
672
+ title="Builds"
673
+ chrome={chrome}
674
+ head={
675
+ <>
676
+ <p class="sub" title={`${storageBytes} bytes stored in R2`}>
677
+ {nearCap ? <Tag kind="warn" label="storage" /> : null} {formatBytes(storageBytes)} of
678
+ archives{nearCap ? " — near the 10 GB free tier; purge withdrawn builds" : ""}
679
+ </p>
680
+ <p class="inv">
681
+ <a href="/admin/upload">Upload a build →</a>
682
+ </p>
683
+ </>
684
+ }
685
+ >
686
+ {builds.length === 0 ? (
687
+ <p class="empty">
688
+ {showHidden
689
+ ? "No builds published yet. Use Upload (or CI) to publish one."
690
+ : "No visible builds. Use Upload (or CI) to publish one, or show hidden builds."}
691
+ </p>
692
+ ) : (
693
+ <section>
694
+ {/* Client-side instant filters (table-enhance.ts) narrow without a reload; the show-hidden
695
+ toggle is a server round-trip because hidden rows aren't in the page at all. */}
696
+ <div class="filters">
697
+ <label>
698
+ state
699
+ <select data-filter-col="state" aria-label="Filter by state">
700
+ <option value="">any</option>
701
+ <option value="available">available</option>
702
+ <option value="withdrawn">withdrawn</option>
703
+ </select>
704
+ </label>
705
+ <label>
706
+ <input type="checkbox" data-filter-col="crit" data-filter-value="yes" /> critical only
707
+ </label>
708
+ <label>
709
+ channel
710
+ <select
711
+ data-filter-col="channels"
712
+ data-filter-match="contains"
713
+ aria-label="Filter by channel"
714
+ >
715
+ <option value="">any</option>
716
+ {channels.map((s) => (
717
+ <option value={s.name}>{s.name}</option>
718
+ ))}
719
+ </select>
720
+ </label>
721
+ <form method="get" action="/admin/builds" class="inline">
722
+ <label>
723
+ <input type="checkbox" name="hidden" value="1" checked={showHidden} /> show hidden
724
+ </label>
725
+ <button type="submit">Apply</button>
726
+ </form>
727
+ </div>
728
+
729
+ {/* Bulk bar (§13 #3). Row checkboxes bind via the HTML `form` attribute; the header
730
+ select-all checkbox + live count are progressive enhancement (table-enhance.ts). */}
731
+ <form method="post" action="/admin/builds/bulk" id="bulk" class="filters">
732
+ <span class="mut">
733
+ With selected
734
+ <span data-selected-count />:
735
+ </span>
736
+ <button type="submit" name="op" value="withdraw">
737
+ Withdraw
738
+ </button>
739
+ <button type="submit" name="op" value="critical">
740
+ Mark critical
741
+ </button>
742
+ <button type="submit" name="op" value="uncritical">
743
+ Clear critical
744
+ </button>
745
+ </form>
746
+
747
+ <div class="tbl">
748
+ <table data-enhance>
749
+ <thead>
750
+ <tr>
751
+ <th>
752
+ <input type="checkbox" data-check-all aria-label="Select all builds" />
753
+ </th>
754
+ <Th sort="num">Build</Th>
755
+ <Th col="state" sort="text">
756
+ State
757
+ </Th>
758
+ <Th col="crit">
759
+ <span class="sr-only">Critical</span>
760
+ </Th>
761
+ <Th col="channels" sort="text">
762
+ Channels
763
+ </Th>
764
+ <Th sort="num" right>
765
+ Downloads
766
+ </Th>
767
+ <Th sort="num" right>
768
+ Updates
769
+ </Th>
770
+ <Th sort="num" right>
771
+ Size
772
+ </Th>
773
+ <Th sort="text">Published</Th>
774
+ </tr>
775
+ </thead>
776
+ <tbody>
777
+ {builds.map((b) => (
778
+ <tr class={b.build.status === "withdrawn" ? "dim" : ""}>
779
+ <td>
780
+ <input
781
+ type="checkbox"
782
+ name="id"
783
+ value={b.build.id}
784
+ form="bulk"
785
+ aria-label={`Select build ${b.build.buildNumber}`}
786
+ />
787
+ </td>
788
+ <td>
789
+ <Lk build={b.build} href={`/admin/builds/${b.build.id}`} />
790
+ </td>
791
+ <td data-value={b.build.status}>
792
+ <BuildTags build={b.build} />
793
+ </td>
794
+ <td data-value={b.build.critical ? "yes" : "no"}>
795
+ <span class="sr-only">{b.build.critical ? "critical" : "not critical"}</span>
796
+ </td>
797
+ <td class="chs" data-value={b.streams.join(",")}>
798
+ {b.streams.length > 0 ? (
799
+ b.streams.map((name, i) => (
800
+ <>
801
+ {i > 0 ? " · " : ""}
802
+ <a href={`/admin/streams/${channelId.get(name) ?? ""}`}>{name}</a>
803
+ </>
804
+ ))
805
+ ) : (
806
+ <span class="mut" title="In no channel — offered to no one">
807
+
808
+ </span>
809
+ )}
810
+ </td>
811
+ <td class="r">{b.downloads}</td>
812
+ <td class="r">{b.updates}</td>
813
+ <td
814
+ class="r"
815
+ data-value={String(b.build.purgedAt !== null ? 0 : b.build.length)}
816
+ >
817
+ {b.build.purgedAt !== null ? (
818
+ <Tag kind="mut" label="purged" title="Archive bytes deleted; record kept" />
819
+ ) : (
820
+ <span title={`${b.build.length} bytes`}>{formatBytes(b.build.length)}</span>
821
+ )}
822
+ </td>
823
+ <td data-value={b.build.createdAt}>
824
+ <When iso={b.build.createdAt} now={now} />
825
+ </td>
826
+ </tr>
827
+ ))}
828
+ </tbody>
829
+ </table>
830
+ </div>
831
+ <p class="tfoot">
832
+ <span data-table-status />
833
+ {hiddenCount > 0 && !showHidden ? (
834
+ <a href="/admin/builds?hidden=1">{hiddenCount} hidden not shown — show them</a>
835
+ ) : null}
836
+ </p>
837
+ <p class="empty" data-table-empty hidden>
838
+ No builds match the filters.
839
+ </p>
840
+ </section>
841
+ )}
842
+ </AdminLayout>
843
+ );
844
+ };
845
+
846
+ // ————————————————————————————— Channels —————————————————————————————
847
+
848
+ export const StreamsPage: FC<{ streams: StreamView[]; chrome: Chrome }> = ({ streams, chrome }) => (
849
+ <AdminLayout title="Channels" chrome={chrome}>
850
+ {streams.length === 0 ? (
851
+ <p class="empty">
852
+ No channels yet. Users and builds attach to channels — create one (e.g. “stable”) to start
853
+ serving updates.
854
+ </p>
855
+ ) : (
856
+ <section>
857
+ <div class="tbl">
858
+ <table data-enhance>
859
+ <thead>
860
+ <tr>
861
+ <Th sort="text">Channel</Th>
862
+ <th>Serving</th>
863
+ <Th sort="num" right>
864
+ Builds
865
+ </Th>
866
+ <Th sort="num" right>
867
+ Users
868
+ </Th>
869
+ </tr>
870
+ </thead>
871
+ <tbody>
872
+ {streams.map((s) => (
873
+ <tr>
874
+ <td class="chs">
875
+ <a class="who" href={`/admin/streams/${s.id}`}>
876
+ {s.name}
877
+ </a>
878
+ </td>
879
+ <td>
880
+ {s.topBuild ? (
881
+ <Lk build={s.topBuild} />
882
+ ) : (
883
+ <span class="vd">
884
+ <Tag
885
+ kind="warn"
886
+ label="serving nothing"
887
+ title="No available build linked"
888
+ />
889
+ </span>
890
+ )}
891
+ </td>
892
+ <td class="r">{s.buildCount}</td>
893
+ <td class="r">{s.userCount}</td>
894
+ </tr>
895
+ ))}
896
+ </tbody>
897
+ </table>
898
+ </div>
899
+ </section>
900
+ )}
901
+
902
+ <section aria-label="Add channel">
903
+ <div class="slab">
904
+ <h2>Add channel</h2>
905
+ </div>
906
+ <form method="post" action="/admin/streams" class="frow">
907
+ <label class="field">
908
+ <span>Name</span>
909
+ <input type="text" name="name" required placeholder="e.g. stable" />
910
+ </label>
911
+ <button type="submit" class="btn-primary">
912
+ Create channel
913
+ </button>
914
+ </form>
915
+ <p class="fhint">
916
+ A channel serves its highest available linked build to every assigned user. Deleting one is
917
+ confirmed on its page.
918
+ </p>
919
+ </section>
920
+ </AdminLayout>
921
+ );
922
+
923
+ // ————————————————————————————— Activity —————————————————————————————
924
+
925
+ export interface ActivityFilterView {
926
+ email: string;
927
+ event: string;
928
+ build: string;
929
+ }
930
+
931
+ export const ActivityPage: FC<{
932
+ events: AccessLogEntry[];
933
+ filter: ActivityFilterView;
934
+ truncated: boolean;
935
+ now: string;
936
+ chrome: Chrome;
937
+ }> = ({ events, filter, truncated, now, chrome }) => (
938
+ <AdminLayout title="Activity" chrome={chrome}>
939
+ <form method="get" action="/admin/activity" class="filters">
940
+ <label>
941
+ user
942
+ <input type="text" name="email" placeholder="any part of the email" value={filter.email} />
943
+ </label>
944
+ <label>
945
+ event
946
+ <select name="event">
947
+ <option value="">any</option>
948
+ {["check", "download", "update"].map((e) => (
949
+ <option value={e} selected={filter.event === e}>
950
+ {e}
951
+ </option>
952
+ ))}
953
+ </select>
954
+ </label>
955
+ <label>
956
+ build
957
+ <input type="text" name="build" placeholder="e.g. 1500" value={filter.build} />
958
+ </label>
959
+ <button type="submit">Filter</button>
960
+ <a href="/admin/activity">clear</a>
961
+ </form>
962
+
963
+ {events.length === 0 ? (
964
+ <p class="empty">No activity matches.</p>
965
+ ) : (
966
+ <section>
967
+ <div class="tbl">
968
+ <table data-enhance>
969
+ <thead>
970
+ <tr>
971
+ <Th sort="text">When</Th>
972
+ <Th sort="text">User</Th>
973
+ <Th sort="text">Event</Th>
974
+ <Th sort="num" right>
975
+ Build
976
+ </Th>
977
+ </tr>
978
+ </thead>
979
+ <tbody>
980
+ {events.map((e) => (
981
+ <tr>
982
+ <td data-value={e.createdAt}>
983
+ <When iso={e.createdAt} now={now} />
984
+ </td>
985
+ <td>{e.email ?? <span class="mut">—</span>}</td>
986
+ <td>{e.event}</td>
987
+ <td class="r">
988
+ {e.buildNumber !== null ? (
989
+ <b class="num">#{e.buildNumber}</b>
990
+ ) : (
991
+ <span class="mut">—</span>
992
+ )}
993
+ </td>
994
+ </tr>
995
+ ))}
996
+ </tbody>
997
+ </table>
998
+ </div>
999
+ <p class="tfoot">
1000
+ {truncated ? "Showing the latest 100 events — narrow with the filters above. " : ""}
1001
+ Checks, downloads and updates older than 90 days are pruned nightly.
1002
+ </p>
1003
+ </section>
1004
+ )}
1005
+ </AdminLayout>
1006
+ );
1007
+
1008
+ // ————————————————————————————— Audit —————————————————————————————
1009
+
1010
+ export interface AuditFilterView {
1011
+ actor: string;
1012
+ action: string;
1013
+ }
1014
+
1015
+ export const AuditPage: FC<{
1016
+ rows: AuditRow[];
1017
+ filter: AuditFilterView;
1018
+ chain: ChainAssessment | null;
1019
+ truncated: boolean;
1020
+ now: string;
1021
+ chrome: Chrome;
1022
+ }> = ({ rows, filter, chain, truncated, now, chrome }) => (
1023
+ <AdminLayout
1024
+ title="Audit"
1025
+ chrome={chrome}
1026
+ head={
1027
+ chain !== null ? (
1028
+ <p class={chain.intact ? "seal" : "seal bad"}>
1029
+ <Dot kind={chain.intact ? "ok" : "warn"} />
1030
+ {chain.intact
1031
+ ? `chain intact · ${chain.count} entries${chain.anchored ? " · anchored" : " · not yet anchored"}`
1032
+ : "CHAIN MISMATCH — the log diverged from its last anchor"}
1033
+ </p>
1034
+ ) : undefined
1035
+ }
1036
+ >
1037
+ {chain !== null && !chain.intact ? (
1038
+ <p class="callout danger">
1039
+ The audit log no longer verifies against its last anchor — rows were edited, removed, or
1040
+ rebuilt. Compare with the anchored copies in R2 and your Cloudflare account audit logs.
1041
+ </p>
1042
+ ) : null}
1043
+ <form method="get" action="/admin/audit" class="filters">
1044
+ <label>
1045
+ actor
1046
+ <input type="text" name="actor" placeholder="any part of the email" value={filter.actor} />
1047
+ </label>
1048
+ <label>
1049
+ action
1050
+ <input type="text" name="action" placeholder="e.g. client.revoke" value={filter.action} />
1051
+ </label>
1052
+ <button type="submit">Filter</button>
1053
+ <a href="/admin/audit">clear</a>
1054
+ </form>
1055
+
1056
+ {rows.length === 0 ? (
1057
+ <p class="empty">No admin actions match.</p>
1058
+ ) : (
1059
+ <section>
1060
+ <div class="tbl">
1061
+ <table data-enhance>
1062
+ <thead>
1063
+ <tr>
1064
+ <Th sort="text">When</Th>
1065
+ <Th sort="text">Actor</Th>
1066
+ <Th sort="text">Action</Th>
1067
+ <Th sort="text">Target</Th>
1068
+ <Th sort="text">IP</Th>
1069
+ <Th sort="text">Ray ID</Th>
1070
+ </tr>
1071
+ </thead>
1072
+ <tbody>
1073
+ {rows.map((r) => (
1074
+ <tr>
1075
+ <td data-value={r.createdAt}>
1076
+ <When iso={r.createdAt} now={now} />
1077
+ </td>
1078
+ <td>{r.actorEmail}</td>
1079
+ <td>
1080
+ <code>{r.action}</code>
1081
+ </td>
1082
+ <td>{r.target ?? <span class="mut">—</span>}</td>
1083
+ <td class="mut">{r.ip ?? "—"}</td>
1084
+ <td class="mut">
1085
+ <code>{r.rayId ?? "—"}</code>
1086
+ </td>
1087
+ </tr>
1088
+ ))}
1089
+ </tbody>
1090
+ </table>
1091
+ </div>
1092
+ {truncated ? (
1093
+ <p class="tfoot">Showing the latest 200 actions — narrow with the filters above.</p>
1094
+ ) : null}
1095
+ </section>
1096
+ )}
1097
+ </AdminLayout>
1098
+ );