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,34 @@
1
+ import { deleteCookie, setCookie } from "hono/cookie";
2
+ import type { AdminContext } from "./admin-context";
3
+ import { field, returnTo } from "./form";
4
+ import { requireUser } from "./middleware";
5
+
6
+ // The theme toggle (sidebar foot): light / system / dark. A plain form POST that works with JS
7
+ // disabled — the choice lives in a `theme` cookie the GET pages read (and a pre-paint script
8
+ // applies on chrome-less pages). "system" clears the cookie: following the OS is the default,
9
+ // not a stored value. A UI preference, not a domain mutation — deliberately NOT audited.
10
+
11
+ const YEAR_SECONDS = 60 * 60 * 24 * 365;
12
+
13
+ export async function setTheme(c: AdminContext): Promise<Response> {
14
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
15
+ const body = await c.req.parseBody();
16
+ const value = field(body, "value");
17
+ if (value !== "light" && value !== "dark" && value !== "system") {
18
+ return c.text("Bad request", 400);
19
+ }
20
+
21
+ const secure = new URL(c.req.url).protocol === "https:";
22
+ if (value === "system") {
23
+ deleteCookie(c, "theme", { path: "/" });
24
+ } else {
25
+ // Not HttpOnly on purpose: the pre-paint script reads it. It holds nothing sensitive.
26
+ setCookie(c, "theme", value, {
27
+ path: "/",
28
+ sameSite: "Lax",
29
+ maxAge: YEAR_SECONDS,
30
+ secure,
31
+ });
32
+ }
33
+ return c.redirect(returnTo(body) ?? "/admin", 303);
34
+ }
@@ -0,0 +1,320 @@
1
+ import * as builds from "../../db/builds";
2
+ import * as streams from "../../db/streams";
3
+ import { headObject, putArchive } from "../../r2/builds-bucket";
4
+ import { recordAudit } from "../../services/audit";
5
+ import { ResultPage } from "../../views/admin/manage-pages";
6
+ import { renderPage } from "../../views/layout";
7
+ import type { AdminContext } from "./admin-context";
8
+ import { auditFields } from "./audit-fields";
9
+ import { field, toId } from "./form";
10
+ import { wantsHtml } from "./negotiate";
11
+
12
+ // §20 / decision 0007 — the convergence point for all publish paths. Both routes accept a service
13
+ // token (CI) as well as a human (decision 0006 scopes service tokens to exactly here). The Worker
14
+ // NEVER signs; it stores bytes + the supplied EdDSA signature. A wrong length would break Sparkle
15
+ // for every client, so the register path asserts the stored object's size matches the declared one.
16
+
17
+ const MAX_UPLOAD_BYTES = 90 * 1024 * 1024; // conservative ceiling under the 100 MB Workers body cap
18
+
19
+ /**
20
+ * GET /admin/publish-info — everything a publish script needs to get a release RIGHT the first time,
21
+ * as JSON. Service-token allowed (decision 0006 scopes it here + the two publish routes): it's
22
+ * read-only and publishing-scoped, so the script can pre-check the next build number, resolve a
23
+ * channel name, and know the size ceiling BEFORE it spends minutes signing and uploading. This turns
24
+ * "full upload → 409 duplicate" into "instant local check".
25
+ */
26
+ export async function publishInfo(c: AdminContext): Promise<Response> {
27
+ const deps = c.get("deps");
28
+ const all = await builds.listAll(deps.db);
29
+ const topBuild = all.reduce((max, b) => Math.max(max, b.buildNumber), 0);
30
+ const channels = (await streams.list(deps.db)).map((s) => ({ id: s.id, name: s.name }));
31
+ return c.json({
32
+ // The highest build_number in use; the next publish must exceed it.
33
+ topBuild,
34
+ nextBuildHint: topBuild + 1,
35
+ channels,
36
+ maxUploadBytes: MAX_UPLOAD_BYTES,
37
+ });
38
+ }
39
+
40
+ interface BuildMeta {
41
+ shortVersion: string;
42
+ buildNumber: number;
43
+ edSignature: string;
44
+ minOs: string | null;
45
+ critical: boolean;
46
+ /** Resolved channel to link on publish, or null. Set from stream_id (id) OR channel (name). */
47
+ streamId: number | null;
48
+ /** A `channel=<name>` request awaiting resolution to an id (mutually exclusive with stream_id). */
49
+ channelName: string | null;
50
+ }
51
+
52
+ // STRICT digits only — parseInt would quietly accept "1.2.3" as 1 and "1500abc" as 1500, minting a
53
+ // wrong (and permanent: build_number is unique and monotonic) build number from a swapped argument.
54
+ function intField(body: Record<string, unknown>, name: string): number | null {
55
+ const raw = field(body, name);
56
+ if (raw === null || !/^\d+$/.test(raw.trim())) return null;
57
+ const n = Number.parseInt(raw, 10);
58
+ return Number.isSafeInteger(n) ? n : null;
59
+ }
60
+
61
+ function parseBuildMeta(
62
+ body: Record<string, unknown>,
63
+ ): { ok: true; value: BuildMeta } | { ok: false; error: string } {
64
+ const shortVersion = field(body, "short_version");
65
+ const edSignature = field(body, "ed_signature");
66
+ const buildNumber = intField(body, "build_number");
67
+ if (shortVersion === null || edSignature === null) {
68
+ return { ok: false, error: "short_version and ed_signature are required" };
69
+ }
70
+ if (buildNumber === null || buildNumber <= 0) {
71
+ return { ok: false, error: "build_number must be a positive integer" };
72
+ }
73
+ // Channel destination two ways: stream_id (the browser select; a DB id) OR channel=<name> (the
74
+ // publish CLI — human-friendly, per the "never a DB row id on operator surfaces" principle). The
75
+ // name is resolved to an id in rejectPublish (needs the db); giving both is a conflict.
76
+ const streamId = toId(field(body, "stream_id"));
77
+ const channelName = field(body, "channel");
78
+ if (streamId !== null && channelName !== null) {
79
+ return { ok: false, error: "give either stream_id or channel, not both" };
80
+ }
81
+ return {
82
+ ok: true,
83
+ value: {
84
+ shortVersion,
85
+ buildNumber,
86
+ edSignature,
87
+ minOs: field(body, "min_os"),
88
+ critical: field(body, "critical") === "true",
89
+ streamId,
90
+ channelName,
91
+ },
92
+ };
93
+ }
94
+
95
+ // Content-negotiated responses (decision 0006 — both routes serve a human form AND a CI service token).
96
+ // A browser gets a page; CI keeps the machine JSON/text contract. See ./negotiate.
97
+
98
+ function fail(c: AdminContext, status: 400 | 409 | 413 | 507, message: string): Response {
99
+ if (!wantsHtml(c)) return c.text(message, status);
100
+ return c.html(
101
+ renderPage(
102
+ <ResultPage
103
+ title="Upload failed"
104
+ intent="error"
105
+ back={{ href: "/admin/upload", label: "← Back to upload" }}
106
+ >
107
+ <p>{message}</p>
108
+ </ResultPage>,
109
+ ),
110
+ status,
111
+ );
112
+ }
113
+
114
+ // build_number is UNIQUE (Sparkle's monotonic key). A re-upload of an existing one would hit the DB
115
+ // constraint and surface as a bare 500; pre-check so the admin gets an actionable message instead. In
116
+ // uploadBuild this runs BEFORE the R2 PUT, so a rejected duplicate never leaves an orphan archive.
117
+ async function duplicateBuild(c: AdminContext, buildNumber: number): Promise<Response | null> {
118
+ const existing = await builds.getByBuildNumber(c.get("deps").db, buildNumber);
119
+ if (existing === null) return null;
120
+ return fail(
121
+ c,
122
+ 409,
123
+ `Build number ${buildNumber} already exists (published as ${existing.shortVersion}). Each ` +
124
+ `build number is unique — to publish a corrected build, give it a higher number (a roll-forward).`,
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Pre-write validation shared by both publish endpoints, run BEFORE any R2 or D1 write so a rejected
130
+ * publish never half-registers (the old failure mode: build row inserted, then the channel link threw
131
+ * a raw foreign-key 500 with the archive already stored). Also RESOLVES a channel name into
132
+ * `meta.streamId` in place, so the insert path only ever deals with an id.
133
+ */
134
+ async function rejectPublish(
135
+ c: AdminContext,
136
+ body: Record<string, unknown>,
137
+ meta: BuildMeta,
138
+ ): Promise<Response | null> {
139
+ const deps = c.get("deps");
140
+
141
+ const duplicate = await duplicateBuild(c, meta.buildNumber);
142
+ if (duplicate !== null) return duplicate;
143
+
144
+ // Resolve a channel NAME → id (the publish CLI's --channel). Unknown name is a clear 400 that
145
+ // lists what exists, not a foreign-key 500.
146
+ if (meta.channelName !== null) {
147
+ const stream = await streams.getByName(deps.db, meta.channelName);
148
+ if (stream === null) {
149
+ const names = (await streams.list(deps.db)).map((s) => s.name);
150
+ return fail(
151
+ c,
152
+ 400,
153
+ `Channel "${meta.channelName}" not found. ` +
154
+ (names.length > 0
155
+ ? `Existing channels: ${names.join(", ")}.`
156
+ : "No channels exist yet.") +
157
+ " Nothing was published.",
158
+ );
159
+ }
160
+ meta.streamId = stream.id;
161
+ }
162
+
163
+ // The selected channel must still exist (a stale form otherwise FK-500s AFTER the insert).
164
+ if (meta.streamId !== null && (await streams.getById(deps.db, meta.streamId)) === null) {
165
+ return fail(
166
+ c,
167
+ 400,
168
+ `Channel ${meta.streamId} not found — it may have been deleted. Nothing was published; ` +
169
+ `pick another channel (or none) and retry.`,
170
+ );
171
+ }
172
+
173
+ // Rollback mode's whole point is a build number ABOVE the current highest (Sparkle can't
174
+ // downgrade); enforce the floor the form only explains.
175
+ if (field(body, "mode") === "rollback") {
176
+ const all = await builds.listAll(deps.db);
177
+ const top = all.reduce((max, b) => Math.max(max, b.buildNumber), 0);
178
+ if (meta.buildNumber <= top) {
179
+ return fail(
180
+ c,
181
+ 400,
182
+ `A rollback build must exceed the current highest build number (${top}) — Sparkle never ` +
183
+ `offers a lower build. Rebuild the previous good code with a number above ${top}.`,
184
+ );
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function published(
191
+ c: AdminContext,
192
+ build: { id: number; buildNumber: number; shortVersion: string },
193
+ streamName: string | null,
194
+ ): Response {
195
+ return c.html(
196
+ renderPage(
197
+ <ResultPage
198
+ title="Build published"
199
+ back={{ href: `/admin/builds/${build.id}`, label: `Open build ${build.buildNumber} →` }}
200
+ >
201
+ <p>
202
+ Build <strong>{build.buildNumber}</strong> ({build.shortVersion}) is now available.
203
+ </p>
204
+ {streamName !== null ? (
205
+ <p class="muted">
206
+ It's in the <strong>{streamName}</strong> channel — users assigned there are offered it
207
+ on their next update check (unless a higher build already serves them).
208
+ </p>
209
+ ) : (
210
+ <p class="callout warn">
211
+ It isn't in any channel yet, so <strong>no one receives it</strong> until you link a
212
+ channel from the <a href={`/admin/builds/${build.id}`}>build page</a>.
213
+ </p>
214
+ )}
215
+ </ResultPage>,
216
+ ),
217
+ 201,
218
+ );
219
+ }
220
+
221
+ /** Full upload: streams the archive body to R2 (under the size ceiling), then registers the build. */
222
+ export async function uploadBuild(c: AdminContext): Promise<Response> {
223
+ const deps = c.get("deps");
224
+
225
+ const declaredLength = Number.parseInt(c.req.header("content-length") ?? "", 10);
226
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BYTES) {
227
+ return fail(c, 413, "Archive exceeds the upload ceiling; use /admin/builds/register");
228
+ }
229
+
230
+ const body = await c.req.parseBody();
231
+ const archive = body.archive;
232
+ if (!(archive instanceof File)) return fail(c, 400, "an archive file is required");
233
+ if (archive.size > MAX_UPLOAD_BYTES) {
234
+ return fail(c, 413, "Archive exceeds the upload ceiling; use /admin/builds/register");
235
+ }
236
+
237
+ const meta = parseBuildMeta(body);
238
+ if (!meta.ok) return fail(c, 400, meta.error);
239
+
240
+ const rejected = await rejectPublish(c, body, meta.value);
241
+ if (rejected !== null) return rejected;
242
+
243
+ let objectKey: string;
244
+ try {
245
+ objectKey = await putArchive(
246
+ deps.r2,
247
+ meta.value.buildNumber,
248
+ archive.name || "App.zip",
249
+ await archive.arrayBuffer(),
250
+ );
251
+ } catch (e) {
252
+ // A quota/write failure otherwise surfaces as a bare 500 with nothing published. Say what's
253
+ // wrong and the remedy (the Builds page shows the total and offers per-build archive purge).
254
+ console.error("[upload] R2 put failed:", e);
255
+ return fail(
256
+ c,
257
+ 507,
258
+ "Storage write failed — the R2 bucket may be at the free-tier limit. Purge withdrawn builds' " +
259
+ "archives from the Builds page to reclaim space, then retry. Nothing was published.",
260
+ );
261
+ }
262
+ const build = await builds.insert(deps.db, {
263
+ shortVersion: meta.value.shortVersion,
264
+ buildNumber: meta.value.buildNumber,
265
+ objectKey,
266
+ edSignature: meta.value.edSignature,
267
+ length: archive.size,
268
+ minOs: meta.value.minOs,
269
+ critical: meta.value.critical,
270
+ });
271
+ const streamName =
272
+ meta.value.streamId === null
273
+ ? null
274
+ : ((await streams.getById(deps.db, meta.value.streamId))?.name ?? null);
275
+ if (meta.value.streamId !== null) await builds.linkStream(deps.db, build.id, meta.value.streamId);
276
+ await recordAudit(deps, auditFields(c, "build.upload", String(meta.value.buildNumber)));
277
+
278
+ if (wantsHtml(c)) return published(c, build, streamName);
279
+ return c.json({ ok: true, buildNumber: meta.value.buildNumber, objectKey }, 201);
280
+ }
281
+
282
+ /** Metadata-only register for an archive already PUT to R2 out of band (large builds, §20). */
283
+ export async function registerBuild(c: AdminContext): Promise<Response> {
284
+ const deps = c.get("deps");
285
+ const body = await c.req.parseBody();
286
+
287
+ const objectKey = field(body, "object_key");
288
+ const size = intField(body, "size");
289
+ if (objectKey === null || size === null || size < 0) {
290
+ return fail(c, 400, "object_key and a non-negative size are required");
291
+ }
292
+ const meta = parseBuildMeta(body);
293
+ if (!meta.ok) return fail(c, 400, meta.error);
294
+
295
+ const rejected = await rejectPublish(c, body, meta.value);
296
+ if (rejected !== null) return rejected;
297
+
298
+ const head = await headObject(deps.r2, objectKey);
299
+ if (head === null) return fail(c, 400, "object not found in R2");
300
+ if (head.size !== size) return fail(c, 400, "declared size does not match the stored object");
301
+
302
+ const build = await builds.insert(deps.db, {
303
+ shortVersion: meta.value.shortVersion,
304
+ buildNumber: meta.value.buildNumber,
305
+ objectKey,
306
+ edSignature: meta.value.edSignature,
307
+ length: size,
308
+ minOs: meta.value.minOs,
309
+ critical: meta.value.critical,
310
+ });
311
+ const streamName =
312
+ meta.value.streamId === null
313
+ ? null
314
+ : ((await streams.getById(deps.db, meta.value.streamId))?.name ?? null);
315
+ if (meta.value.streamId !== null) await builds.linkStream(deps.db, build.id, meta.value.streamId);
316
+ await recordAudit(deps, auditFields(c, "build.register", String(meta.value.buildNumber)));
317
+
318
+ if (wantsHtml(c)) return published(c, build, streamName);
319
+ return c.json({ ok: true, buildNumber: meta.value.buildNumber }, 201);
320
+ }
@@ -0,0 +1,293 @@
1
+ import { getCookie } from "hono/cookie";
2
+ import type { AccessEvent } from "../../core/types";
3
+ import { totalArchiveBytes } from "../../db/builds";
4
+ import { adminToAppOrigin, inviteUrl } from "../../lib/hosts";
5
+ import { emailStatus } from "../../services/email";
6
+ import { CiPage } from "../../views/admin/ci-page";
7
+ import type { Chrome } from "../../views/admin/layout";
8
+ import {
9
+ BuildManagePage,
10
+ SettingsPage,
11
+ StreamManagePage,
12
+ UploadPage,
13
+ UserManagePage,
14
+ } from "../../views/admin/manage";
15
+ import {
16
+ ActivityPage,
17
+ AuditPage,
18
+ BuildsPage,
19
+ OverviewPage,
20
+ PendingPage,
21
+ StreamsPage,
22
+ UsersPage,
23
+ } from "../../views/admin/read-pages";
24
+ import { SetupPage } from "../../views/admin/setup-page";
25
+ import { renderPage } from "../../views/layout";
26
+ import type { AdminContext } from "./admin-context";
27
+ import { flashMessage } from "./flash";
28
+ import { toId } from "./form";
29
+ import {
30
+ loadActivity,
31
+ loadAudit,
32
+ loadBuildDetail,
33
+ loadBuilds,
34
+ loadChainStatus,
35
+ loadChannels,
36
+ loadDashboard,
37
+ loadPending,
38
+ loadPendingCount,
39
+ loadSettings,
40
+ loadStreamDetail,
41
+ loadStreams,
42
+ loadUser,
43
+ loadUsersPage,
44
+ selfUpdateView,
45
+ } from "./read-model";
46
+
47
+ // §13 — the admin GET pages. Each loads its read-model and renders the matching pure view, threading
48
+ // the shared chrome (flash notice, instance slug, pending-requests chip) and the clock's now.
49
+
50
+ async function chromeFor(c: AdminContext): Promise<Chrome> {
51
+ const theme = getCookie(c, "theme");
52
+ return {
53
+ notice: flashMessage(c),
54
+ instance: c.env?.INSTANCE,
55
+ pending: await loadPendingCount(c.get("deps")),
56
+ theme: theme === "light" || theme === "dark" ? theme : undefined,
57
+ path: c.req.path,
58
+ };
59
+ }
60
+
61
+ export async function dashboardView(c: AdminContext): Promise<Response> {
62
+ const deps = c.get("deps");
63
+ return c.html(
64
+ renderPage(
65
+ <OverviewPage
66
+ data={await loadDashboard(deps)}
67
+ now={deps.clock()}
68
+ chrome={await chromeFor(c)}
69
+ />,
70
+ ),
71
+ );
72
+ }
73
+
74
+ export async function usersView(c: AdminContext): Promise<Response> {
75
+ const deps = c.get("deps");
76
+ const { users, channels } = await loadUsersPage(deps);
77
+ const filter = {
78
+ status: c.req.query("status") ?? "",
79
+ stream: c.req.query("stream") ?? "",
80
+ nobuild: c.req.query("nobuild") === "1",
81
+ pinned: c.req.query("pinned") === "1",
82
+ hidden: c.req.query("hidden") === "1", // show hidden too (default: visible only)
83
+ };
84
+ const hiddenCount = users.filter((u) => u.hidden).length;
85
+ let rows = users;
86
+ if (!filter.hidden) rows = rows.filter((u) => !u.hidden);
87
+ if (filter.status) rows = rows.filter((u) => u.status === filter.status);
88
+ if (filter.nobuild) rows = rows.filter((u) => u.noBuild !== "servable");
89
+ if (filter.pinned) rows = rows.filter((u) => u.pinnedBuildId !== null);
90
+ if (filter.stream) rows = rows.filter((u) => u.streams.includes(filter.stream));
91
+ return c.html(
92
+ renderPage(
93
+ <UsersPage
94
+ users={rows}
95
+ channels={channels}
96
+ filter={filter}
97
+ hiddenCount={hiddenCount}
98
+ now={deps.clock()}
99
+ chrome={await chromeFor(c)}
100
+ />,
101
+ ),
102
+ );
103
+ }
104
+
105
+ export async function userManageView(c: AdminContext): Promise<Response> {
106
+ const deps = c.get("deps");
107
+ const id = toId(c.req.param("id"));
108
+ const detail = id === null ? null : await loadUser(deps, id);
109
+ if (detail === null) return c.text("Not found", 404);
110
+ return c.html(
111
+ renderPage(
112
+ <UserManagePage
113
+ detail={detail}
114
+ inviteLink={inviteUrl(c.req.url, detail.client.token)}
115
+ linkDerived={adminToAppOrigin(new URL(c.req.url).origin) !== null}
116
+ now={deps.clock()}
117
+ chrome={await chromeFor(c)}
118
+ />,
119
+ ),
120
+ );
121
+ }
122
+
123
+ export async function buildsView(c: AdminContext): Promise<Response> {
124
+ const deps = c.get("deps");
125
+ const [builds, channels, storageBytes] = await Promise.all([
126
+ loadBuilds(deps),
127
+ loadChannels(deps),
128
+ totalArchiveBytes(deps.db),
129
+ ]);
130
+ const showHidden = c.req.query("hidden") === "1"; // default: visible only
131
+ const hiddenCount = builds.filter((b) => b.build.hidden).length;
132
+ const rows = showHidden ? builds : builds.filter((b) => !b.build.hidden);
133
+ return c.html(
134
+ renderPage(
135
+ <BuildsPage
136
+ builds={rows}
137
+ channels={channels}
138
+ showHidden={showHidden}
139
+ hiddenCount={hiddenCount}
140
+ storageBytes={storageBytes}
141
+ now={deps.clock()}
142
+ chrome={await chromeFor(c)}
143
+ />,
144
+ ),
145
+ );
146
+ }
147
+
148
+ export async function buildManageView(c: AdminContext): Promise<Response> {
149
+ const deps = c.get("deps");
150
+ const id = toId(c.req.param("id"));
151
+ const detail = id === null ? null : await loadBuildDetail(deps, id);
152
+ if (detail === null) return c.text("Not found", 404);
153
+ return c.html(
154
+ renderPage(<BuildManagePage detail={detail} now={deps.clock()} chrome={await chromeFor(c)} />),
155
+ );
156
+ }
157
+
158
+ export async function streamsView(c: AdminContext): Promise<Response> {
159
+ const deps = c.get("deps");
160
+ return c.html(
161
+ renderPage(<StreamsPage streams={await loadStreams(deps)} chrome={await chromeFor(c)} />),
162
+ );
163
+ }
164
+
165
+ export async function streamManageView(c: AdminContext): Promise<Response> {
166
+ const deps = c.get("deps");
167
+ const id = toId(c.req.param("id"));
168
+ const detail = id === null ? null : await loadStreamDetail(deps, id);
169
+ if (detail === null) return c.text("Not found", 404);
170
+ return c.html(
171
+ renderPage(<StreamManagePage detail={detail} now={deps.clock()} chrome={await chromeFor(c)} />),
172
+ );
173
+ }
174
+
175
+ export async function uploadView(c: AdminContext): Promise<Response> {
176
+ const deps = c.get("deps");
177
+ const [channels, builds] = await Promise.all([loadChannels(deps), loadBuilds(deps)]);
178
+ // The rollback guidance needs the floor (current highest build number) + a few recents for reference.
179
+ const recentBuilds = builds
180
+ .map((b) => ({ buildNumber: b.build.buildNumber, shortVersion: b.build.shortVersion }))
181
+ .sort((a, b) => b.buildNumber - a.buildNumber)
182
+ .slice(0, 5);
183
+ return c.html(
184
+ renderPage(
185
+ <UploadPage channels={channels} recentBuilds={recentBuilds} chrome={await chromeFor(c)} />,
186
+ ),
187
+ );
188
+ }
189
+
190
+ export async function ciView(c: AdminContext): Promise<Response> {
191
+ return c.html(
192
+ renderPage(<CiPage adminOrigin={new URL(c.req.url).origin} chrome={await chromeFor(c)} />),
193
+ );
194
+ }
195
+
196
+ export async function setupView(c: AdminContext): Promise<Response> {
197
+ const meta = await loadSettings(c.get("deps"));
198
+ const info = {
199
+ appName: meta.app_name || "Your App",
200
+ activateScheme: meta.activate_scheme || "myapp",
201
+ publicKey: meta.sparkle_public_key || null,
202
+ appOrigin: adminToAppOrigin(new URL(c.req.url).origin) ?? "<your App Worker URL>",
203
+ };
204
+ return c.html(renderPage(<SetupPage info={info} chrome={await chromeFor(c)} />));
205
+ }
206
+
207
+ export async function settingsView(c: AdminContext): Promise<Response> {
208
+ const deps = c.get("deps");
209
+ const settings = await loadSettings(deps);
210
+ const env = c.env;
211
+ const info = {
212
+ instance: env.INSTANCE,
213
+ toolVersion: env.TOOL_VERSION,
214
+ email: emailStatus(env),
215
+ accessTeam: env.ACCESS_TEAM_DOMAIN ?? null,
216
+ accessAud: env.ACCESS_AUD ?? null,
217
+ selfUpdate: selfUpdateView(settings),
218
+ appOrigin: adminToAppOrigin(new URL(c.req.url).origin),
219
+ };
220
+ return c.html(
221
+ renderPage(
222
+ <SettingsPage
223
+ settings={settings}
224
+ info={info}
225
+ now={deps.clock()}
226
+ chrome={await chromeFor(c)}
227
+ />,
228
+ ),
229
+ );
230
+ }
231
+
232
+ export async function pendingView(c: AdminContext): Promise<Response> {
233
+ const deps = c.get("deps");
234
+ return c.html(
235
+ renderPage(
236
+ <PendingPage
237
+ requests={await loadPending(deps)}
238
+ now={deps.clock()}
239
+ chrome={await chromeFor(c)}
240
+ />,
241
+ ),
242
+ );
243
+ }
244
+
245
+ export async function activityView(c: AdminContext): Promise<Response> {
246
+ const deps = c.get("deps");
247
+ const filter = {
248
+ email: c.req.query("email") ?? "",
249
+ event: c.req.query("event") ?? "",
250
+ build: c.req.query("build") ?? "",
251
+ };
252
+ const buildNumber = /^\d+$/.test(filter.build) ? Number.parseInt(filter.build, 10) : undefined;
253
+ const events = await loadActivity(deps, {
254
+ email: filter.email || undefined,
255
+ event: filter.event ? (filter.event as AccessEvent) : undefined,
256
+ buildNumber,
257
+ });
258
+ return c.html(
259
+ renderPage(
260
+ <ActivityPage
261
+ events={events}
262
+ filter={filter}
263
+ truncated={events.length >= 100}
264
+ now={deps.clock()}
265
+ chrome={await chromeFor(c)}
266
+ />,
267
+ ),
268
+ );
269
+ }
270
+
271
+ export async function auditView(c: AdminContext): Promise<Response> {
272
+ const deps = c.get("deps");
273
+ const filter = {
274
+ actor: c.req.query("actor") ?? "",
275
+ action: c.req.query("action") ?? "",
276
+ };
277
+ const rows = await loadAudit(deps, {
278
+ actor: filter.actor || undefined,
279
+ action: filter.action || undefined,
280
+ });
281
+ return c.html(
282
+ renderPage(
283
+ <AuditPage
284
+ rows={rows}
285
+ filter={filter}
286
+ chain={await loadChainStatus(deps)}
287
+ truncated={rows.length >= 200}
288
+ now={deps.clock()}
289
+ chrome={await chromeFor(c)}
290
+ />,
291
+ ),
292
+ );
293
+ }
@@ -0,0 +1,38 @@
1
+ import * as accessRequests from "../../db/access-requests";
2
+ import { isEmail } from "../../lib/text";
3
+ import { loadBranding } from "../../services/branding";
4
+ import { AccessPage, RequestReceivedPage } from "../../views/access-page";
5
+ import { renderPage } from "../../views/layout";
6
+ import type { AppContext } from "./app-context";
7
+
8
+ // §13 — the public "request access" page (GET) and its submission (POST → a pending request the
9
+ // admin reviews). No token required.
10
+
11
+ export async function accessRoute(c: AppContext): Promise<Response> {
12
+ const branding = await loadBranding(c.get("deps"));
13
+ return c.html(renderPage(<AccessPage appName={branding.appName} accent={branding.accent} />));
14
+ }
15
+
16
+ export async function postAccessRoute(c: AppContext): Promise<Response> {
17
+ const deps = c.get("deps");
18
+ const body = await c.req.parseBody();
19
+ const email = typeof body.email === "string" ? body.email.trim() : "";
20
+ const branding = await loadBranding(deps);
21
+
22
+ if (!isEmail(email)) {
23
+ return c.html(
24
+ renderPage(<AccessPage appName={branding.appName} accent={branding.accent} />),
25
+ 400,
26
+ );
27
+ }
28
+
29
+ await accessRequests.insert(deps.db, {
30
+ email,
31
+ ip: c.req.header("cf-connecting-ip") ?? null,
32
+ userAgent: c.req.header("user-agent") ?? null,
33
+ createdAt: deps.clock(),
34
+ });
35
+ return c.html(
36
+ renderPage(<RequestReceivedPage appName={branding.appName} accent={branding.accent} />),
37
+ );
38
+ }
@@ -0,0 +1,8 @@
1
+ import type { Context } from "hono";
2
+ import type { Deps } from "../../deps";
3
+ import type { Env } from "../../env";
4
+
5
+ // The Hono realization of the Deps rule: a middleware puts Deps on the context, handlers read it via
6
+ // c.get("deps") and never touch bindings directly. One type shared by every app-side handler.
7
+ export type AppEnv = { Bindings: Env; Variables: { deps: Deps } };
8
+ export type AppContext = Context<AppEnv>;