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,518 @@
1
+ import { type AuditRow, assessChain, type ChainAssessment } from "../../core/audit-chain";
2
+ import { type NoBuildState, noBuildState, type World } from "../../core/no-build";
3
+ import type { Build, Client, Stream } from "../../core/types";
4
+ import {
5
+ type ChannelServing,
6
+ channelServings,
7
+ offTheMap,
8
+ type Verdict,
9
+ verdictFor,
10
+ } from "../../core/verdict";
11
+ import {
12
+ type AccessLogEntry,
13
+ type ActivityFilter,
14
+ countByBuild,
15
+ currentBuild,
16
+ lastEventAt,
17
+ lastSeenAt,
18
+ recent,
19
+ } from "../../db/access-log";
20
+ import { type AccessRequest, countPending, listByStatus } from "../../db/access-requests";
21
+ import { type AuditFilter, listForDisplay, listInOrder } from "../../db/admin-audit";
22
+ import { getById as getBuildById, listAll, listAvailable, listBuildStreams } from "../../db/builds";
23
+ import { getById as getClientById, list as listClients } from "../../db/clients";
24
+ import { getAll as getAllMeta, get as getMeta } from "../../db/meta";
25
+ import {
26
+ getById as getStreamById,
27
+ list as listStreams,
28
+ listUserStreams,
29
+ streamIdsForClient,
30
+ } from "../../db/streams";
31
+ import type { Deps } from "../../deps";
32
+
33
+ // Impure read-model assembly: queries the db and shapes view-ready data so the admin views stay pure.
34
+ // N+1 queries (per-build counts, per-user installed build) are fine at single-admin/alpha scale.
35
+
36
+ export interface UserView {
37
+ id: number;
38
+ email: string;
39
+ label: string | null;
40
+ status: string;
41
+ streams: string[];
42
+ pinnedBuildId: number | null;
43
+ currentBuild: number | null;
44
+ /** Most recent event of any kind — the "last seen" column. */
45
+ lastSeen: string | null;
46
+ /** What this user's next update check actually does, by cause (core/verdict). */
47
+ verdict: Verdict;
48
+ noBuild: NoBuildState;
49
+ hidden: boolean;
50
+ }
51
+
52
+ export interface BuildView {
53
+ build: Build;
54
+ streams: string[];
55
+ downloads: number;
56
+ updates: number;
57
+ }
58
+
59
+ export interface StreamView {
60
+ id: number;
61
+ name: string;
62
+ buildCount: number;
63
+ userCount: number;
64
+ /** Highest available linked build — what the channel serves right now — or null. */
65
+ topBuild: Build | null;
66
+ }
67
+
68
+ export interface SelfUpdateView {
69
+ available: boolean;
70
+ latest: string | null;
71
+ breaking: boolean;
72
+ notesUrl: string | null;
73
+ /** When the daily cron last polled the manifest, or null if it never has (cron not firing). */
74
+ checkedAt: string | null;
75
+ }
76
+
77
+ /** A user whose next check is faulted — the dashboard's attention material, with its cause. */
78
+ export interface FaultedUser {
79
+ id: number;
80
+ email: string;
81
+ verdict: Verdict;
82
+ /** Channel names the user is assigned to (for remedy links). */
83
+ streams: string[];
84
+ }
85
+
86
+ /** One line of the merged recent feed (tester activity + admin actions), newest first. */
87
+ export interface RecentItem {
88
+ at: string;
89
+ /** Pre-composed operator sentence fragments; the view renders links from the refs. */
90
+ kind: "check" | "download" | "update" | "admin";
91
+ email: string | null;
92
+ clientId: number | null;
93
+ buildNumber: number | null;
94
+ /** For admin rows: the audit action slug (e.g. "build.withdraw") and its target. */
95
+ action: string | null;
96
+ target: string | null;
97
+ }
98
+
99
+ export interface Dashboard {
100
+ users: number;
101
+ hiddenUsers: number;
102
+ builds: number;
103
+ streams: number;
104
+ pendingRequests: number;
105
+ /** Oldest pending request time + a couple of addresses for the attention row. */
106
+ pendingSample: { emails: string[]; oldest: string | null };
107
+ selfUpdate: SelfUpdateView;
108
+ /** The serving map rows (per channel), computed from the same World the resolver uses. */
109
+ servings: ChannelServing[];
110
+ /** Active users routed nowhere (no channel, no pin) — the map's "off the map" row. */
111
+ offMap: { id: number; email: string }[];
112
+ /** Visible (non-hidden) active users whose next check is faulted, with causes. */
113
+ faults: FaultedUser[];
114
+ /** Merged recent feed: tester activity + admin audit rows, newest first. */
115
+ recent: RecentItem[];
116
+ /** The newest build and where it went — the header's "last publish" line. */
117
+ lastPublish: { build: Build; streams: string[] } | null;
118
+ /** Audit-chain integrity (same judgment as the daily anchor). Null when no audit rows exist. */
119
+ chain: ChainAssessment | null;
120
+ }
121
+
122
+ /** Reads the §22 self-update status the daily cron persisted into `meta`. */
123
+ export function selfUpdateView(m: Record<string, string>): SelfUpdateView {
124
+ return {
125
+ available: m.selfupdate_available === "1",
126
+ latest: m.selfupdate_latest || null,
127
+ breaking: m.selfupdate_breaking === "1",
128
+ notesUrl: m.selfupdate_notes_url || null,
129
+ checkedAt: m.selfupdate_checked_at || null,
130
+ };
131
+ }
132
+
133
+ async function loadWorld(deps: Deps) {
134
+ const clients = await listClients(deps.db);
135
+ const builds = await listAll(deps.db);
136
+ const buildStreams = await listBuildStreams(deps.db);
137
+ const userStreams = await listUserStreams(deps.db);
138
+ const streams = await listStreams(deps.db);
139
+ const world: World = { clients, builds, buildStreams, userStreams };
140
+ const streamName = new Map(streams.map((s) => [s.id, s.name]));
141
+ return { world, streams, streamName };
142
+ }
143
+
144
+ async function loadUsers(deps: Deps): Promise<UserView[]> {
145
+ const { world, streamName } = await loadWorld(deps);
146
+ const users: UserView[] = [];
147
+ for (const client of world.clients) {
148
+ const installed = await currentBuild(deps.db, client.id);
149
+ const streamIds = world.userStreams
150
+ .filter((link) => link.clientId === client.id)
151
+ .map((link) => link.streamId);
152
+ users.push({
153
+ id: client.id,
154
+ email: client.email,
155
+ label: client.label,
156
+ status: client.status,
157
+ streams: streamIds.map((id) => streamName.get(id) ?? `#${id}`),
158
+ pinnedBuildId: client.pinnedBuildId,
159
+ currentBuild: installed,
160
+ lastSeen: await lastSeenAt(deps.db, client.id),
161
+ verdict: verdictFor(world, client, installed),
162
+ noBuild: noBuildState(world, client, installed),
163
+ hidden: client.hidden,
164
+ });
165
+ }
166
+ return users;
167
+ }
168
+
169
+ export async function loadBuilds(deps: Deps): Promise<BuildView[]> {
170
+ const { world, streamName } = await loadWorld(deps);
171
+ const views: BuildView[] = [];
172
+ // Newest first — the monotonic build_number is the release order, and the newest release is what
173
+ // the operator came to check.
174
+ const ordered = [...world.builds].sort((a, b) => b.buildNumber - a.buildNumber);
175
+ for (const build of ordered) {
176
+ const streamIds = world.buildStreams
177
+ .filter((link) => link.buildId === build.id)
178
+ .map((link) => link.streamId);
179
+ views.push({
180
+ build,
181
+ streams: streamIds.map((id) => streamName.get(id) ?? `#${id}`),
182
+ downloads: await countByBuild(deps.db, build.buildNumber, "download"),
183
+ updates: await countByBuild(deps.db, build.buildNumber, "update"),
184
+ });
185
+ }
186
+ return views;
187
+ }
188
+
189
+ export async function loadStreams(deps: Deps): Promise<StreamView[]> {
190
+ const { world, streams } = await loadWorld(deps);
191
+ return streams.map((stream) => {
192
+ const linked = new Set(
193
+ world.buildStreams.filter((link) => link.streamId === stream.id).map((l) => l.buildId),
194
+ );
195
+ const topBuild = world.builds
196
+ .filter((b) => linked.has(b.id) && b.status === "available")
197
+ .reduce<Build | null>(
198
+ (top, b) => (top === null || b.buildNumber > top.buildNumber ? b : top),
199
+ null,
200
+ );
201
+ return {
202
+ id: stream.id,
203
+ name: stream.name,
204
+ buildCount: linked.size,
205
+ userCount: world.userStreams.filter((link) => link.streamId === stream.id).length,
206
+ topBuild,
207
+ };
208
+ });
209
+ }
210
+
211
+ export async function loadDashboard(deps: Deps): Promise<Dashboard> {
212
+ const { world, streams, streamName } = await loadWorld(deps);
213
+ const m = await getAllMeta(deps.db);
214
+
215
+ const installed = new Map<number, number>();
216
+ for (const client of world.clients) {
217
+ const current = await currentBuild(deps.db, client.id);
218
+ if (current !== null) installed.set(client.id, current);
219
+ }
220
+
221
+ // Faulted users: visible + active only — hiding a user is the operator saying "stop counting them".
222
+ const faults: FaultedUser[] = [];
223
+ for (const client of world.clients) {
224
+ if (client.hidden || client.status !== "active") continue;
225
+ const verdict = verdictFor(world, client, installed.get(client.id) ?? null);
226
+ if (verdict.kind === "offered" || verdict.kind === "up-to-date") continue;
227
+ const streamIds = world.userStreams
228
+ .filter((link) => link.clientId === client.id)
229
+ .map((link) => link.streamId);
230
+ faults.push({
231
+ id: client.id,
232
+ email: client.email,
233
+ verdict,
234
+ streams: streamIds.map((id) => streamName.get(id) ?? `#${id}`),
235
+ });
236
+ }
237
+
238
+ // Merged recent feed: tester activity + admin audit rows, by time, newest first.
239
+ const emailToId = new Map(world.clients.map((c) => [c.email, c.id]));
240
+ const activity = await recent(deps.db, { limit: 8 });
241
+ const audit = await listForDisplay(deps.db, {});
242
+ const recentItems: RecentItem[] = [
243
+ ...activity.map(
244
+ (e): RecentItem => ({
245
+ at: e.createdAt,
246
+ kind: e.event,
247
+ email: e.email,
248
+ clientId: e.email === null ? null : (emailToId.get(e.email) ?? null),
249
+ buildNumber: e.buildNumber,
250
+ action: null,
251
+ target: null,
252
+ }),
253
+ ),
254
+ ...audit.slice(0, 8).map(
255
+ (r): RecentItem => ({
256
+ at: r.createdAt,
257
+ kind: "admin",
258
+ email: null,
259
+ clientId: r.target === null ? (null as number | null) : (emailToId.get(r.target) ?? null),
260
+ buildNumber: null,
261
+ action: r.action,
262
+ target: r.target,
263
+ }),
264
+ ),
265
+ ]
266
+ .sort((a, b) => (a.at < b.at ? 1 : -1))
267
+ .slice(0, 8);
268
+
269
+ const newest = world.builds.reduce<Build | null>(
270
+ (top, b) => (top === null || b.buildNumber > top.buildNumber ? b : top),
271
+ null,
272
+ );
273
+ const lastPublish =
274
+ newest === null
275
+ ? null
276
+ : {
277
+ build: newest,
278
+ streams: world.buildStreams
279
+ .filter((l) => l.buildId === newest.id)
280
+ .map((l) => streamName.get(l.streamId) ?? `#${l.streamId}`),
281
+ };
282
+
283
+ const pending = await listByStatus(deps.db, "pending");
284
+ const auditRows = await listInOrder(deps.db);
285
+ const chain =
286
+ auditRows.length === 0
287
+ ? null
288
+ : await assessChain(auditRows, await getMeta(deps.db, "audit_anchor_head"));
289
+
290
+ return {
291
+ users: world.clients.filter((c) => !c.hidden).length,
292
+ hiddenUsers: world.clients.filter((c) => c.hidden).length,
293
+ builds: world.builds.filter((b) => !b.hidden).length,
294
+ streams: streams.length,
295
+ pendingRequests: pending.length,
296
+ pendingSample: {
297
+ emails: pending.slice(0, 2).map((r) => r.email),
298
+ oldest: pending.at(-1)?.createdAt ?? null,
299
+ },
300
+ selfUpdate: selfUpdateView(m),
301
+ servings: channelServings(world, streams, installed),
302
+ offMap: offTheMap(world).map((c) => ({ id: c.id, email: c.email })),
303
+ faults,
304
+ recent: recentItems,
305
+ lastPublish,
306
+ chain,
307
+ };
308
+ }
309
+
310
+ export function loadPending(deps: Deps): Promise<AccessRequest[]> {
311
+ return listByStatus(deps.db, "pending");
312
+ }
313
+
314
+ /** Pending-request count for the nav chip every admin page shows. */
315
+ export function loadPendingCount(deps: Deps): Promise<number> {
316
+ return countPending(deps.db);
317
+ }
318
+
319
+ /** The World + per-client installed-build map that §11 validation (core/validation) operates on. */
320
+ export async function loadValidationWorld(
321
+ deps: Deps,
322
+ ): Promise<{ world: World; installed: Map<number, number> }> {
323
+ const { world } = await loadWorld(deps);
324
+ const installed = new Map<number, number>();
325
+ for (const client of world.clients) {
326
+ const build = await currentBuild(deps.db, client.id);
327
+ if (build !== null) installed.set(client.id, build);
328
+ }
329
+ return { world, installed };
330
+ }
331
+
332
+ /** Users list + the channels the "Add user" form / assign controls need. */
333
+ export async function loadUsersPage(
334
+ deps: Deps,
335
+ ): Promise<{ users: UserView[]; channels: Stream[] }> {
336
+ return { users: await loadUsers(deps), channels: await listStreams(deps.db) };
337
+ }
338
+
339
+ export interface UserDetail {
340
+ client: Client;
341
+ channels: Stream[];
342
+ assignedStreamIds: number[];
343
+ availableBuilds: Build[];
344
+ currentBuild: number | null;
345
+ /** What this user's next check does, by cause — the detail page's verdict strip. */
346
+ verdict: Verdict;
347
+ pinnedBuild: Build | null;
348
+ lastCheck: string | null;
349
+ lastInstalled: string | null;
350
+ lastUpdated: string | null;
351
+ }
352
+
353
+ /** One user with everything the manage page needs (channels to assign, builds to pin). */
354
+ export async function loadUser(deps: Deps, id: number): Promise<UserDetail | null> {
355
+ const client = await getClientById(deps.db, id);
356
+ if (client === null) return null;
357
+ const { world } = await loadWorld(deps);
358
+ const installed = await currentBuild(deps.db, id);
359
+ return {
360
+ client,
361
+ channels: await listStreams(deps.db),
362
+ assignedStreamIds: await streamIdsForClient(deps.db, id),
363
+ availableBuilds: await listAvailable(deps.db),
364
+ currentBuild: installed,
365
+ verdict: verdictFor(world, client, installed),
366
+ pinnedBuild:
367
+ client.pinnedBuildId === null
368
+ ? null
369
+ : (world.builds.find((b) => b.id === client.pinnedBuildId) ?? null),
370
+ lastCheck: await lastEventAt(deps.db, id, "check"),
371
+ lastInstalled: await lastEventAt(deps.db, id, "download"),
372
+ lastUpdated: await lastEventAt(deps.db, id, "update"),
373
+ };
374
+ }
375
+
376
+ export interface BuildDetail {
377
+ build: Build;
378
+ channels: Stream[];
379
+ linkedStreamIds: number[];
380
+ downloads: number;
381
+ updates: number;
382
+ /** How many active users this build is the resolver's answer for right now. */
383
+ audience: { offeredTo: number; currentFor: number };
384
+ }
385
+
386
+ /** One build with all channels + which it's linked to (for the link/unlink controls). */
387
+ export async function loadBuildDetail(deps: Deps, id: number): Promise<BuildDetail | null> {
388
+ const build = await getBuildById(deps.db, id);
389
+ if (build === null) return null;
390
+ const { world } = await loadWorld(deps);
391
+ const links = world.buildStreams;
392
+
393
+ let offeredTo = 0;
394
+ let currentFor = 0;
395
+ for (const client of world.clients) {
396
+ if (client.status !== "active") continue;
397
+ const installed = await currentBuild(deps.db, client.id);
398
+ const verdict = verdictFor(world, client, installed);
399
+ if (verdict.kind === "offered" && verdict.build.id === id) offeredTo++;
400
+ if (verdict.kind === "up-to-date" && verdict.build.id === id) currentFor++;
401
+ }
402
+
403
+ return {
404
+ build,
405
+ channels: await listStreams(deps.db),
406
+ linkedStreamIds: links.filter((link) => link.buildId === id).map((link) => link.streamId),
407
+ downloads: await countByBuild(deps.db, build.buildNumber, "download"),
408
+ updates: await countByBuild(deps.db, build.buildNumber, "update"),
409
+ audience: { offeredTo, currentFor },
410
+ };
411
+ }
412
+
413
+ export interface StreamDetail {
414
+ stream: Stream;
415
+ /** Builds linked to this channel (any status), newest first. */
416
+ linkedBuilds: Build[];
417
+ /** Available builds not yet linked — the link control's options. */
418
+ unlinkedBuilds: Build[];
419
+ /** Highest-numbered available linked build — what this channel currently serves (§8). */
420
+ topBuild: Build | null;
421
+ /** Clients assigned to this channel, each with their next-check verdict. */
422
+ assignedUsers: (Client & { verdict: Verdict })[];
423
+ /** Active clients not assigned — the assign control's options. */
424
+ unassignedUsers: Client[];
425
+ /** The channel's audience math (same row the dashboard map shows). */
426
+ serving: ChannelServing;
427
+ }
428
+
429
+ /** One channel with the builds it carries and the users it serves (the §13 channel manage page). */
430
+ export async function loadStreamDetail(deps: Deps, id: number): Promise<StreamDetail | null> {
431
+ const stream = await getStreamById(deps.db, id);
432
+ if (stream === null) return null;
433
+ const { world } = await loadWorld(deps);
434
+
435
+ const linkedBuildIds = new Set(
436
+ world.buildStreams.filter((link) => link.streamId === id).map((link) => link.buildId),
437
+ );
438
+ const linkedBuilds = world.builds
439
+ .filter((build) => linkedBuildIds.has(build.id))
440
+ .sort((a, b) => b.buildNumber - a.buildNumber);
441
+ const unlinkedBuilds = world.builds
442
+ .filter((build) => !linkedBuildIds.has(build.id) && build.status === "available")
443
+ .sort((a, b) => b.buildNumber - a.buildNumber);
444
+ const topBuild = linkedBuilds
445
+ .filter((build) => build.status === "available")
446
+ .reduce<Build | null>(
447
+ (top, build) => (top === null || build.buildNumber > top.buildNumber ? build : top),
448
+ null,
449
+ );
450
+
451
+ const installed = new Map<number, number>();
452
+ for (const client of world.clients) {
453
+ const current = await currentBuild(deps.db, client.id);
454
+ if (current !== null) installed.set(client.id, current);
455
+ }
456
+
457
+ const assignedClientIds = new Set(
458
+ world.userStreams.filter((link) => link.streamId === id).map((link) => link.clientId),
459
+ );
460
+ const assignedUsers = world.clients
461
+ .filter((client) => assignedClientIds.has(client.id))
462
+ .map((client) => ({
463
+ ...client,
464
+ verdict: verdictFor(world, client, installed.get(client.id) ?? null),
465
+ }));
466
+ const unassignedUsers = world.clients.filter(
467
+ (client) => !assignedClientIds.has(client.id) && client.status === "active",
468
+ );
469
+
470
+ const [serving] = channelServings(world, [stream], installed);
471
+ return {
472
+ stream,
473
+ linkedBuilds,
474
+ unlinkedBuilds,
475
+ topBuild,
476
+ assignedUsers,
477
+ unassignedUsers,
478
+ serving: serving ?? {
479
+ streamId: id,
480
+ name: stream.name,
481
+ top: null,
482
+ users: 0,
483
+ willUpdate: 0,
484
+ upToDate: 0,
485
+ faulted: 0,
486
+ pinned: 0,
487
+ },
488
+ };
489
+ }
490
+
491
+ /** Channels for the upload form's channel select. */
492
+ export function loadChannels(deps: Deps): Promise<Stream[]> {
493
+ return listStreams(deps.db);
494
+ }
495
+
496
+ /** Current meta values to prefill the branding/settings form. */
497
+ export function loadSettings(deps: Deps): Promise<Record<string, string>> {
498
+ return getAllMeta(deps.db);
499
+ }
500
+
501
+ export function loadActivity(deps: Deps, filter: ActivityFilter = {}): Promise<AccessLogEntry[]> {
502
+ return recent(deps.db, { limit: 100, ...filter });
503
+ }
504
+
505
+ export function loadAudit(deps: Deps, filter: AuditFilter = {}): Promise<AuditRow[]> {
506
+ return listForDisplay(deps.db, filter);
507
+ }
508
+
509
+ /**
510
+ * Live audit-chain integrity for the Audit page — the same judgment the daily anchor records
511
+ * (core/audit-chain assessChain), so the page and the cron can never disagree. Null when the log
512
+ * is still empty.
513
+ */
514
+ export async function loadChainStatus(deps: Deps): Promise<ChainAssessment | null> {
515
+ const rows = await listInOrder(deps.db);
516
+ if (rows.length === 0) return null;
517
+ return assessChain(rows, await getMeta(deps.db, "audit_anchor_head"));
518
+ }
@@ -0,0 +1,182 @@
1
+ import type { AdminAction } from "../../core/no-build";
2
+ import * as builds from "../../db/builds";
3
+ import * as clients from "../../db/clients";
4
+ import { assignUser, create, getById, getByName, listUserStreams, remove } from "../../db/streams";
5
+ import { recordAudit } from "../../services/audit";
6
+ import { ConfirmActionPage, ResultPage } from "../../views/admin/manage-pages";
7
+ import { renderPage } from "../../views/layout";
8
+ import type { AdminContext } from "./admin-context";
9
+ import { auditFields } from "./audit-fields";
10
+ import { strandingPreview } from "./confirm";
11
+ import { doneRedirect } from "./flash";
12
+ import { field, idList, returnTo, toId } from "./form";
13
+ import { requireUser } from "./middleware";
14
+
15
+ // §13 — channel (stream) create/delete plus the channel page's batch attach routes (link several
16
+ // builds / assign several users in one POST — the multi-select combobox posts repeated ids; the
17
+ // no-JS fallback posts one). Both are purely additive, so they never strand and need no §11 gate.
18
+ // Deleting a channel silently unassigns its users and unlinks its builds — destructive even when
19
+ // nobody is stranded — so it is ALWAYS confirmed, with the §11 stranded-users list embedded.
20
+
21
+ export async function createStream(c: AdminContext): Promise<Response> {
22
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
23
+ const deps = c.get("deps");
24
+ const body = await c.req.parseBody();
25
+ const raw = field(body, "name");
26
+ const name = raw === null ? null : raw.trim();
27
+ if (name === null || name.length === 0) return c.text("A channel name is required", 400);
28
+
29
+ // name is UNIQUE; pre-check so a duplicate is a clear 409, not the DB constraint's bare 500
30
+ // (mirrors createClient / build upload).
31
+ if ((await getByName(deps.db, name)) !== null) {
32
+ return c.html(
33
+ renderPage(
34
+ <ResultPage
35
+ title="Channel already exists"
36
+ intent="error"
37
+ back={{ href: "/admin/streams", label: "← Channels" }}
38
+ >
39
+ <p>
40
+ A channel named <strong>{name}</strong> already exists.
41
+ </p>
42
+ </ResultPage>,
43
+ ),
44
+ 409,
45
+ );
46
+ }
47
+
48
+ const stream = await create(deps.db, name);
49
+ await recordAudit(deps, auditFields(c, "stream.create", name, JSON.stringify({ id: stream.id })));
50
+ return doneRedirect(c, body, "/admin/streams", "channel.created", name);
51
+ }
52
+
53
+ /** POST /admin/streams/:id/link — link the selected build(s) to this channel (additive, no §11). */
54
+ export async function linkBuildsToStream(c: AdminContext): Promise<Response> {
55
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
56
+ const deps = c.get("deps");
57
+ const id = toId(c.req.param("id"));
58
+ if (id === null) return c.text("Bad request", 400);
59
+ const stream = await getById(deps.db, id);
60
+ if (stream === null) return c.text("Not found", 404);
61
+
62
+ const body = await c.req.parseBody({ all: true });
63
+ const ids = idList(body, "buildId");
64
+ if (ids.length === 0) {
65
+ return doneRedirect(c, body, `/admin/streams/${id}`, "noop", "No builds were selected.");
66
+ }
67
+
68
+ const links = await builds.listBuildStreams(deps.db);
69
+ let linked = 0;
70
+ for (const buildId of ids) {
71
+ const build = await builds.getById(deps.db, buildId);
72
+ if (build === null) continue; // vanished under a stale form — skip, count honestly
73
+ if (links.some((l) => l.buildId === buildId && l.streamId === id)) continue; // already here
74
+ await builds.linkStream(deps.db, buildId, id);
75
+ await recordAudit(
76
+ deps,
77
+ auditFields(c, "build.link", String(build.buildNumber), JSON.stringify({ streamId: id })),
78
+ );
79
+ linked++;
80
+ }
81
+ if (linked === 0) {
82
+ return doneRedirect(
83
+ c,
84
+ body,
85
+ `/admin/streams/${id}`,
86
+ "noop",
87
+ "Nothing to link — already in the channel or no longer available.",
88
+ );
89
+ }
90
+ return doneRedirect(
91
+ c,
92
+ body,
93
+ `/admin/streams/${id}`,
94
+ "channel.builds-linked",
95
+ `${linked} ${linked === 1 ? "build" : "builds"} into ${stream.name}`,
96
+ );
97
+ }
98
+
99
+ /** POST /admin/streams/:id/assign — assign the selected user(s) to this channel (additive, no §11). */
100
+ export async function assignUsersToStream(c: AdminContext): Promise<Response> {
101
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
102
+ const deps = c.get("deps");
103
+ const id = toId(c.req.param("id"));
104
+ if (id === null) return c.text("Bad request", 400);
105
+ const stream = await getById(deps.db, id);
106
+ if (stream === null) return c.text("Not found", 404);
107
+
108
+ const body = await c.req.parseBody({ all: true });
109
+ const ids = idList(body, "clientId");
110
+ if (ids.length === 0) {
111
+ return doneRedirect(c, body, `/admin/streams/${id}`, "noop", "No users were selected.");
112
+ }
113
+
114
+ const memberships = await listUserStreams(deps.db);
115
+ let assigned = 0;
116
+ for (const clientId of ids) {
117
+ const client = await clients.getById(deps.db, clientId);
118
+ if (client === null) continue; // vanished under a stale form — skip, count honestly
119
+ if (memberships.some((m) => m.clientId === clientId && m.streamId === id)) continue;
120
+ await assignUser(deps.db, clientId, id);
121
+ await recordAudit(
122
+ deps,
123
+ auditFields(c, "stream.assign", client.email, JSON.stringify({ streamId: id })),
124
+ );
125
+ assigned++;
126
+ }
127
+ if (assigned === 0) {
128
+ return doneRedirect(
129
+ c,
130
+ body,
131
+ `/admin/streams/${id}`,
132
+ "noop",
133
+ "Nothing to assign — already in the channel or no longer exist.",
134
+ );
135
+ }
136
+ return doneRedirect(
137
+ c,
138
+ body,
139
+ `/admin/streams/${id}`,
140
+ "channel.users-assigned",
141
+ `${assigned} ${assigned === 1 ? "user" : "users"} to ${stream.name}`,
142
+ );
143
+ }
144
+
145
+ export async function deleteStream(c: AdminContext): Promise<Response> {
146
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
147
+ const deps = c.get("deps");
148
+ const body = await c.req.parseBody();
149
+ const id = toId(c.req.param("id"));
150
+ if (id === null) return c.text("Bad request", 400);
151
+
152
+ const stream = await getById(deps.db, id);
153
+ if (stream === null) return c.text("Not found", 404);
154
+ const back = returnTo(body) ?? "/admin/streams";
155
+
156
+ if (field(body, "confirm") !== "true") {
157
+ const action: AdminAction = { type: "delete-stream", streamId: id };
158
+ const preview = await strandingPreview(c, action);
159
+ if (!preview.ok) return preview.response;
160
+ return c.html(
161
+ renderPage(
162
+ <ConfirmActionPage
163
+ subject={`Delete the ${stream.name} channel`}
164
+ confirmLabel="Delete channel"
165
+ postTo={`/admin/streams/${id}/delete`}
166
+ hidden={{ confirm: "true", return_to: back }}
167
+ cancelTo={back}
168
+ affected={preview.affected}
169
+ >
170
+ <p class="muted">
171
+ Every user assigned to it is unassigned and every build linked to it is unlinked. The
172
+ users and builds themselves are kept.
173
+ </p>
174
+ </ConfirmActionPage>,
175
+ ),
176
+ );
177
+ }
178
+
179
+ await remove(deps.db, id);
180
+ await recordAudit(deps, auditFields(c, "stream.delete", stream.name));
181
+ return doneRedirect(c, body, "/admin/streams", "channel.deleted", stream.name);
182
+ }