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,112 @@
1
+ import { selectPalette, shouldColor } from "./core/colors";
2
+ import type { ApplyStep, Finding, InspectStep, PreflightItem } from "./core/types";
3
+ import {
4
+ renderApply,
5
+ renderFindings,
6
+ renderHeader,
7
+ renderInspect,
8
+ renderManualStep,
9
+ renderPreflight,
10
+ } from "./core/ui";
11
+
12
+ // Dev preview of the deploy CLI's UI — `npm run ui:preview` (add FORCE_COLOR=1 to see colors when
13
+ // piped). Renders the scenarios that matter: fresh install, idempotent re-run, and a manual step.
14
+
15
+ const RES = "alpha-gate-myalpha";
16
+ const palette = selectPalette(shouldColor(process.env, process.stdout.isTTY === true));
17
+
18
+ const PREFLIGHT: PreflightItem[] = [
19
+ { name: "node", ok: true, detail: "node 20.11" },
20
+ { name: "wrangler", ok: true, detail: "wrangler 4.100" },
21
+ { name: "auth", ok: true, detail: "login jane@acme" },
22
+ ];
23
+
24
+ const INSPECT: InspectStep[] = [
25
+ { why: "account & login", command: "wrangler whoami" },
26
+ { why: "database exists?", command: "wrangler d1 list --json" },
27
+ { why: "bucket exists?", command: `wrangler r2 bucket info ${RES}` },
28
+ { why: "Access wired?", command: "wrangler secret list -c admin.toml --format json" },
29
+ ];
30
+
31
+ function show(title: string, findings: Finding[], apply: ApplyStep[]): void {
32
+ console.log(
33
+ [
34
+ `\n\n══════════ ${title} ══════════\n`,
35
+ renderHeader("myalpha", palette),
36
+ "",
37
+ renderPreflight(PREFLIGHT, palette),
38
+ "",
39
+ renderInspect(INSPECT, palette),
40
+ " ↳ run these 4 read-only checks? [Y/n]",
41
+ "",
42
+ renderFindings(findings, palette),
43
+ "",
44
+ renderApply(apply, palette),
45
+ " ↳ apply changes? [y/N]",
46
+ ].join("\n"),
47
+ );
48
+ }
49
+
50
+ show(
51
+ "FRESH INSTALL",
52
+ [
53
+ { label: "database", value: "not found" },
54
+ { label: "bucket", value: "not found" },
55
+ { label: "Access", value: "not configured" },
56
+ ],
57
+ [
58
+ { kind: "create", what: "database", why: "", command: `wrangler d1 create ${RES}` },
59
+ { kind: "create", what: "bucket", why: "", command: `wrangler r2 bucket create ${RES}` },
60
+ {
61
+ kind: "create",
62
+ what: "migrations",
63
+ why: "",
64
+ command: "wrangler d1 migrations apply --remote",
65
+ },
66
+ {
67
+ kind: "create",
68
+ what: "seed config",
69
+ why: "",
70
+ command: "wrangler d1 execute (app_name, scheme)",
71
+ },
72
+ { kind: "create", what: "deploy app", why: "", command: "wrangler deploy -c app.toml" },
73
+ { kind: "create", what: "deploy admin", why: "", command: "wrangler deploy -c admin.toml" },
74
+ ],
75
+ );
76
+
77
+ show(
78
+ "RE-RUN (idempotent)",
79
+ [
80
+ { label: "database", value: "exists (a1b2…)" },
81
+ { label: "bucket", value: "exists" },
82
+ { label: "Access", value: "configured" },
83
+ ],
84
+ [
85
+ { kind: "skip", what: "database", why: "exists — skipping", command: "" },
86
+ { kind: "skip", what: "bucket", why: "exists — skipping", command: "" },
87
+ {
88
+ kind: "update",
89
+ what: "migrations",
90
+ why: "",
91
+ command: "wrangler d1 migrations apply --remote",
92
+ },
93
+ { kind: "skip", what: "seed config", why: "not a first init — skipping", command: "" },
94
+ { kind: "update", what: "deploy app", why: "", command: "wrangler deploy -c app.toml" },
95
+ { kind: "update", what: "deploy admin", why: "", command: "wrangler deploy -c admin.toml" },
96
+ ],
97
+ );
98
+
99
+ console.log(`\n\n══════════ MANUAL STEP (CLI waits for you) ══════════\n`);
100
+ console.log(
101
+ renderManualStep(
102
+ "Enable Cloudflare Access on the admin Worker, then come back:",
103
+ [
104
+ "Zero Trust → Access → Applications → Add (Self-hosted)",
105
+ `Hostname: ${RES}-admin.workers.dev`,
106
+ "Add a policy allowing your email (one-time PIN)",
107
+ "Copy the Application Audience (AUD) tag",
108
+ ],
109
+ palette,
110
+ ),
111
+ );
112
+ console.log(" ↳ Press Enter once that's done and you have the AUD …");
package/src/deps.ts ADDED
@@ -0,0 +1,41 @@
1
+ import {
2
+ type AccessVerifier,
3
+ createAccessVerifier,
4
+ createCachedJwksFetcher,
5
+ } from "./auth/access-jwt";
6
+ import type { Env } from "./env";
7
+ import { type Clock, emailDate, nowSeconds, systemClock } from "./lib/clock";
8
+ import { type EmailSender, selectEmailSender } from "./services/email";
9
+
10
+ // The dependency-injection container (the Deps DI rule; see CONTRIBUTING.md). Handlers and services
11
+ // receive `Deps` and never import bindings or seams directly, so tests swap each seam.
12
+
13
+ // decision 0006 — ONE JWKS cache for the whole isolate (module scope, not per-request), so Access
14
+ // verification reuses fetched keys across requests and only re-fetches on TTL expiry or an unknown kid.
15
+ const cachedFetchJwks = createCachedJwksFetcher({ now: nowSeconds });
16
+
17
+ export interface Deps {
18
+ db: D1Database;
19
+ r2: R2Bucket;
20
+ clock: Clock;
21
+ access: AccessVerifier;
22
+ email: EmailSender;
23
+ fetch: typeof fetch; // outbound HTTP for the self-update manifest (§22); mocked in tests
24
+ }
25
+
26
+ /** Production wiring, built once at the worker entry from the runtime env. */
27
+ export function buildDeps(env: Env): Deps {
28
+ return {
29
+ db: env.DB,
30
+ r2: env.BUILDS,
31
+ clock: systemClock,
32
+ access: createAccessVerifier({
33
+ teamDomain: env.ACCESS_TEAM_DOMAIN,
34
+ aud: env.ACCESS_AUD,
35
+ fetchJwks: cachedFetchJwks,
36
+ now: nowSeconds,
37
+ }),
38
+ email: selectEmailSender(env, emailDate),
39
+ fetch: globalThis.fetch,
40
+ };
41
+ }
@@ -0,0 +1,104 @@
1
+ import { createAccessVerifier, type Jwk } from "../auth/access-jwt";
2
+ import { buildDeps } from "../deps";
3
+ import type { Env } from "../env";
4
+ import { nowSeconds } from "../lib/clock";
5
+ import { createAdminApp } from "../routes/admin";
6
+
7
+ // LOCAL-DEV ONLY (§23). A throwaway Worker entrypoint that lets the gated Admin Worker be opened in a
8
+ // browser on localhost WITHOUT Cloudflare Access in front of it. It runs the REAL admin app and the
9
+ // REAL Access verifier (decision 0006) — only the trust anchor is swapped: a throwaway RSA keypair is
10
+ // minted in-process, and a dev-signed assertion is auto-injected on requests that lack the header
11
+ // (a browser cannot send Cf-Access-Jwt-Assertion; the Cloudflare edge normally adds it).
12
+ //
13
+ // SAFETY: this file is NEVER imported by src/worker.ts and NEVER referenced by the deploy template
14
+ // (deploy.sh fixes main=../src/worker.ts), so a normal deploy cannot ship it. As belt-and-suspenders it
15
+ // also refuses to serve unless env.DEV_ADMIN === "1", which only deploy/dev.sh sets. Effect while
16
+ // running: anyone who can reach the local port is admin "dev@local" — localhost only. Never deploy.
17
+
18
+ const DEV_TEAM_DOMAIN = "dev.localhost.cloudflareaccess.test";
19
+ const DEV_AUD = "alpha-gate-local-dev";
20
+ const DEV_EMAIL = "dev@local";
21
+ const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
22
+
23
+ function b64url(input: string): string {
24
+ return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ }
26
+
27
+ function b64urlBytes(bytes: Uint8Array): string {
28
+ let binary = "";
29
+ for (const byte of bytes) binary += String.fromCharCode(byte);
30
+ return b64url(binary);
31
+ }
32
+
33
+ // Mint the dev keypair + a long-lived assertion once per isolate, and build the admin app against a
34
+ // verifier pointed at that key. Memoized so the keygen cost is paid once, not per request.
35
+ let initPromise: Promise<{ app: ReturnType<typeof createAdminApp>; token: string }> | null = null;
36
+
37
+ function init(): Promise<{ app: ReturnType<typeof createAdminApp>; token: string }> {
38
+ if (initPromise === null) {
39
+ initPromise = (async () => {
40
+ const pair = (await crypto.subtle.generateKey(
41
+ {
42
+ name: "RSASSA-PKCS1-v1_5",
43
+ modulusLength: 2048,
44
+ publicExponent: new Uint8Array([1, 0, 1]),
45
+ hash: "SHA-256",
46
+ },
47
+ true,
48
+ ["sign", "verify"],
49
+ )) as CryptoKeyPair;
50
+ const jwk = (await crypto.subtle.exportKey("jwk", pair.publicKey)) as Jwk;
51
+ jwk.kid = "dev-kid";
52
+ jwk.alg = "RS256";
53
+ jwk.use = "sig";
54
+
55
+ const now = nowSeconds();
56
+ const header = b64url(JSON.stringify({ alg: "RS256", kid: "dev-kid", typ: "JWT" }));
57
+ const payload = b64url(
58
+ JSON.stringify({
59
+ iss: `https://${DEV_TEAM_DOMAIN}`,
60
+ aud: DEV_AUD,
61
+ email: DEV_EMAIL,
62
+ iat: now,
63
+ nbf: now - 5,
64
+ exp: now + ONE_YEAR_SECONDS,
65
+ }),
66
+ );
67
+ const signature = await crypto.subtle.sign(
68
+ "RSASSA-PKCS1-v1_5",
69
+ pair.privateKey,
70
+ new TextEncoder().encode(`${header}.${payload}`),
71
+ );
72
+ const token = `${header}.${payload}.${b64urlBytes(new Uint8Array(signature))}`;
73
+
74
+ const verifier = createAccessVerifier({
75
+ teamDomain: DEV_TEAM_DOMAIN,
76
+ aud: DEV_AUD,
77
+ fetchJwks: async () => [jwk],
78
+ now: nowSeconds,
79
+ });
80
+ const app = createAdminApp((env) => ({ ...buildDeps(env), access: verifier }));
81
+ return { app, token };
82
+ })();
83
+ }
84
+ return initPromise;
85
+ }
86
+
87
+ export default {
88
+ async fetch(
89
+ request: Request,
90
+ env: Env & { DEV_ADMIN?: string },
91
+ ctx: ExecutionContext,
92
+ ): Promise<Response> {
93
+ if (env.DEV_ADMIN !== "1") {
94
+ return new Response(
95
+ "Refused: src/dev/admin-entry.ts is a local-dev-only entrypoint (DEV_ADMIN!=1). Never deploy it.",
96
+ { status: 500 },
97
+ );
98
+ }
99
+ const { app, token } = await init();
100
+ const headers = new Headers(request.headers);
101
+ if (!headers.has("Cf-Access-Jwt-Assertion")) headers.set("Cf-Access-Jwt-Assertion", token);
102
+ return app.fetch(new Request(request, { headers }), env, ctx);
103
+ },
104
+ };
package/src/env.ts ADDED
@@ -0,0 +1,47 @@
1
+ // The typed contract for the §18 wrangler `[vars]` and bindings. Both Workers (app + admin) share
2
+ // this shape, switched at runtime by `ROLE`. Declared on the global `Cloudflare.Env` so the
3
+ // `cloudflare:test` runtime `env` is typed identically to production.
4
+ //
5
+ // `readEnv()` below is the defensive guard: it fails fast on a misconfigured `ROLE`.
6
+
7
+ export type Role = "app" | "admin";
8
+ export type EmailProvider = "none" | "cloudflare";
9
+
10
+ declare global {
11
+ namespace Cloudflare {
12
+ interface Env {
13
+ // Bindings (§18)
14
+ DB: D1Database;
15
+ BUILDS: R2Bucket;
16
+
17
+ // Vars (§18)
18
+ INSTANCE: string;
19
+ ROLE: Role;
20
+ EMAIL_PROVIDER: EmailProvider;
21
+ EMAIL_FROM: string;
22
+ TOOL_VERSION: string;
23
+ UPDATE_MANIFEST_URL: string;
24
+
25
+ // Admin-only, set after Cloudflare Access is enabled (§19). Absent on the app Worker.
26
+ ACCESS_TEAM_DOMAIN?: string;
27
+ ACCESS_AUD?: string;
28
+
29
+ // Cloudflare Email Service binding (§24). Rendered onto the admin Worker only, and only when
30
+ // EMAIL_PROVIDER="cloudflare"; absent otherwise (delivery falls back to copy-paste).
31
+ EMAIL?: SendEmail;
32
+ }
33
+ }
34
+ }
35
+
36
+ export type Env = Cloudflare.Env;
37
+
38
+ /**
39
+ * Defensive read of the runtime env: fails fast on a misconfigured Worker (e.g. a ROLE that isn't
40
+ * "app"/"admin") rather than mis-routing silently. Returns the same object, narrowed.
41
+ */
42
+ export function readEnv(env: Env): Env {
43
+ if (env.ROLE !== "app" && env.ROLE !== "admin") {
44
+ throw new Error(`Invalid ROLE: ${String(env.ROLE)} (expected "app" or "admin")`);
45
+ }
46
+ return env;
47
+ }
@@ -0,0 +1,17 @@
1
+ // The single sanctioned source of wall-clock time. Everything that records or compares time takes a
2
+ // Clock so tests can seed it; no other module may call `new Date()` / `Date.now()` (Biome enforces
3
+ // this — see biome.json). D1's own `datetime('now')` defaults are fine for columns no test asserts on.
4
+
5
+ export type Clock = () => string; // ISO-8601 UTC, e.g. "2026-06-13T12:00:00.000Z"
6
+
7
+ export const systemClock: Clock = () => new Date().toISOString();
8
+
9
+ /** Current Unix time in whole seconds — for JWT exp/nbf checks. The only other sanctioned Date use. */
10
+ export const nowSeconds = (): number => Math.floor(Date.now() / 1000);
11
+
12
+ /** ISO-8601 UTC for `days` ago — the §16 log-prune cutoff. */
13
+ export const isoDaysAgo = (days: number): string =>
14
+ new Date(Date.now() - days * 86_400_000).toISOString();
15
+
16
+ /** RFC 5322 / IMF date for an email `Date:` header (e.g. "Fri, 13 Jun 2026 12:00:00 GMT"). */
17
+ export const emailDate = (): string => new Date().toUTCString();
@@ -0,0 +1,27 @@
1
+ // Derive the public App Worker origin from the admin Worker's own URL. deploy.sh names the pair
2
+ // `alpha-gate-<inst>` (app) and `alpha-gate-<inst>-admin` (admin), both on *.workers.dev — so the app
3
+ // origin is the admin origin with the `-admin` name suffix dropped. Returns null when it can't know
4
+ // (a custom admin domain), so callers show a placeholder rather than a wrong URL.
5
+
6
+ export function adminToAppOrigin(adminUrl: string): string | null {
7
+ let url: URL;
8
+ try {
9
+ url = new URL(adminUrl);
10
+ } catch {
11
+ return null;
12
+ }
13
+ if (!url.hostname.endsWith(".workers.dev")) return null;
14
+ const labels = url.hostname.split(".");
15
+ const first = labels[0];
16
+ if (first === undefined || !first.endsWith("-admin")) return null;
17
+ labels[0] = first.slice(0, -"-admin".length);
18
+ return `${url.protocol}//${labels.join(".")}`;
19
+ }
20
+
21
+ // The public `/get?token=` invite link a user follows. It must resolve on the *App* host — `/get` only
22
+ // exists there — so derive it from the admin URL. When the app host can't be known (custom domain, local
23
+ // dev), fall back to the admin request origin: no worse than before, and the only option without it.
24
+ export function inviteUrl(adminUrl: string, token: string): string {
25
+ const origin = adminToAppOrigin(adminUrl) ?? new URL(adminUrl).origin;
26
+ return `${origin}/get?token=${encodeURIComponent(token)}`;
27
+ }
@@ -0,0 +1,6 @@
1
+ // Small format helpers shared across layers (no bindings, no I/O).
2
+
3
+ /** Conservative email shape check: a single @, non-empty whitespace-free parts, a dotted domain. */
4
+ export function isEmail(value: string): boolean {
5
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
6
+ }
@@ -0,0 +1,50 @@
1
+ import { archiveKey } from "./keys";
2
+
3
+ // The single seam over R2 (decision §16): build archives, branding assets, and audit anchors all flow
4
+ // through here. It NEVER produces a presigned URL — everything is read back through the Worker so the
5
+ // /download token gate, logging, and instant revocation always hold.
6
+
7
+ type PutBody = ReadableStream | ArrayBuffer | ArrayBufferView | string;
8
+
9
+ /** Stores a build archive and returns the key it was written under. */
10
+ export async function putArchive(
11
+ r2: R2Bucket,
12
+ buildNumber: number,
13
+ filename: string,
14
+ body: PutBody,
15
+ ): Promise<string> {
16
+ const key = archiveKey(buildNumber, filename);
17
+ await r2.put(key, body);
18
+ return key;
19
+ }
20
+
21
+ /** Reads an object's body for streaming through /download or /assets. */
22
+ export function getObject(r2: R2Bucket, key: string): Promise<R2ObjectBody | null> {
23
+ return r2.get(key);
24
+ }
25
+
26
+ /** Delete every object under a build's prefix (the zip and any DMG) — the archive-purge action. */
27
+ export async function deleteArchivePrefix(r2: R2Bucket, prefix: string): Promise<number> {
28
+ const listed = await r2.list({ prefix });
29
+ const keys = listed.objects.map((o) => o.key);
30
+ if (keys.length > 0) await r2.delete(keys);
31
+ return keys.length;
32
+ }
33
+
34
+ /** Metadata-only existence/size check (the §20 register path asserts size == declared). */
35
+ export function headObject(r2: R2Bucket, key: string): Promise<R2Object | null> {
36
+ return r2.head(key);
37
+ }
38
+
39
+ export async function putBranding(
40
+ r2: R2Bucket,
41
+ key: string,
42
+ body: PutBody,
43
+ contentType: string,
44
+ ): Promise<void> {
45
+ await r2.put(key, body, { httpMetadata: { contentType } });
46
+ }
47
+
48
+ export async function putAuditAnchor(r2: R2Bucket, key: string, json: string): Promise<void> {
49
+ await r2.put(key, json, { httpMetadata: { contentType: "application/json" } });
50
+ }
package/src/r2/keys.ts ADDED
@@ -0,0 +1,27 @@
1
+ // The ONLY place R2 object keys are constructed (decision 0003), so the layout is single-sourced and
2
+ // testable. Build archives are append-only (build_number is UNIQUE); DMG + zip for one build coexist
3
+ // under one prefix; branding lives at fixed keys; audit anchors are append-only head objects (§16).
4
+
5
+ /** Reduce a user-supplied filename to one safe key segment (strips any path, restricts the charset). */
6
+ export function sanitizeFilename(filename: string): string {
7
+ const base = filename.replace(/^.*[/\\]/, "");
8
+ const safe = base.replace(/[^A-Za-z0-9._-]/g, "_");
9
+ return safe.length > 0 ? safe : "artifact";
10
+ }
11
+
12
+ export function archiveKey(buildNumber: number, filename: string): string {
13
+ return `build/${buildNumber}/${sanitizeFilename(filename)}`;
14
+ }
15
+
16
+ /** The prefix holding a build's archive object(s) — the unit the purge action deletes. */
17
+ export function buildPrefix(buildNumber: number): string {
18
+ return `build/${buildNumber}/`;
19
+ }
20
+
21
+ export const BRANDING_ICON_KEY = "branding/icon";
22
+ export const BRANDING_HEADER_KEY = "branding/header";
23
+
24
+ /** One append-only head object per anchoring run (§16). */
25
+ export function auditAnchorKey(iso: string): string {
26
+ return `audit/anchor/${iso}.json`;
27
+ }
@@ -0,0 +1,9 @@
1
+ import type { Context } from "hono";
2
+ import type { AccessIdentity } from "../../auth/access-jwt";
3
+ import type { Deps } from "../../deps";
4
+ import type { Env } from "../../env";
5
+
6
+ // The admin-side context: Deps plus the verified actor (set by the auth middleware). Handlers read
7
+ // the actor for authorization decisions and audit attribution — never from a raw header.
8
+ export type AdminEnv = { Bindings: Env; Variables: { deps: Deps; actor: AccessIdentity } };
9
+ export type AdminContext = Context<AdminEnv>;
@@ -0,0 +1,22 @@
1
+ import type { AuditFields } from "../../core/audit-chain";
2
+ import type { AdminContext } from "./admin-context";
3
+
4
+ // Builds the audited content of a mutation from the verified actor (never a raw header) plus request
5
+ // metadata. The route layer owns this (it knows the Hono context); services/audit stays Hono-free.
6
+ export function auditFields(
7
+ c: AdminContext,
8
+ action: string,
9
+ target: string | null = null,
10
+ detail: string | null = null,
11
+ ): AuditFields {
12
+ const actor = c.get("actor");
13
+ return {
14
+ actorEmail: actor.kind === "user" ? actor.email : actor.commonName,
15
+ action,
16
+ target,
17
+ detail,
18
+ ip: c.req.header("cf-connecting-ip") ?? null,
19
+ rayId: c.req.header("cf-ray") ?? null,
20
+ createdAt: c.get("deps").clock(),
21
+ };
22
+ }
@@ -0,0 +1,161 @@
1
+ import { isValidAccent } from "../../core/invite-template";
2
+ import * as meta from "../../db/meta";
3
+ import type { Deps } from "../../deps";
4
+ import { putBranding } from "../../r2/builds-bucket";
5
+ import { BRANDING_HEADER_KEY, BRANDING_ICON_KEY } from "../../r2/keys";
6
+ import { recordAudit } from "../../services/audit";
7
+ import { emailStatus } from "../../services/email";
8
+ import { ResultPage } from "../../views/admin/manage-pages";
9
+ import { renderPage } from "../../views/layout";
10
+ import type { AdminContext } from "./admin-context";
11
+ import { auditFields } from "./audit-fields";
12
+ import { doneRedirect } from "./flash";
13
+ import { field, isEmail } from "./form";
14
+ import { requireUser } from "./middleware";
15
+
16
+ // §13 — download-page branding + invite template. Human-only. Text config goes to `meta`; images go
17
+ // to R2 under the fixed branding keys after a content-type + size check (branding attack surface).
18
+
19
+ const TEXT_FIELDS = [
20
+ "app_name",
21
+ "blurb",
22
+ "accent",
23
+ "activate_scheme",
24
+ "sparkle_public_key",
25
+ "invite_subject",
26
+ "invite_body",
27
+ "notice_title",
28
+ "notice_message",
29
+ ] as const;
30
+ // Raster only — SVG served from the app origin is a stored-XSS vector (scriptable when opened
31
+ // directly at /assets/icon), so it is intentionally excluded.
32
+ const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
33
+ const MAX_IMAGE_BYTES = 512 * 1024;
34
+
35
+ type Body = Record<string, unknown>;
36
+
37
+ async function saveImage(
38
+ deps: Deps,
39
+ body: Body,
40
+ fileField: string,
41
+ key: string,
42
+ metaFlag: string,
43
+ ): Promise<string | null> {
44
+ const file = body[fileField];
45
+ if (!(file instanceof File)) return null;
46
+ if (!ALLOWED_IMAGE_TYPES.has(file.type)) return "unsupported image type";
47
+ if (file.size > MAX_IMAGE_BYTES) return "image too large";
48
+ await putBranding(deps.r2, key, await file.arrayBuffer(), file.type);
49
+ await meta.set(deps.db, metaFlag, "1");
50
+ return null;
51
+ }
52
+
53
+ export async function saveBranding(c: AdminContext): Promise<Response> {
54
+ if (requireUser(c) === null) return c.text("Forbidden", 403);
55
+ const deps = c.get("deps");
56
+ const body = await c.req.parseBody();
57
+
58
+ // accent is interpolated raw into a public <style> — reject anything that isn't a hex colour so it
59
+ // can never break out of the style block (loadBranding also coerces stored values defensively).
60
+ const accent = field(body, "accent");
61
+ if (accent !== null && accent.trim() !== "" && !isValidAccent(accent)) {
62
+ return c.text("Accent colour must be a hex value like #0A84FF", 400);
63
+ }
64
+
65
+ for (const name of TEXT_FIELDS) {
66
+ const value = field(body, name);
67
+ if (value !== null) await meta.set(deps.db, name, value);
68
+ }
69
+
70
+ for (const [fileField, key, flag] of [
71
+ ["icon", BRANDING_ICON_KEY, "icon"],
72
+ ["header", BRANDING_HEADER_KEY, "header"],
73
+ ] as const) {
74
+ const error = await saveImage(deps, body, fileField, key, flag);
75
+ if (error !== null) return c.text(error, 400);
76
+ }
77
+
78
+ await recordAudit(deps, auditFields(c, "branding.update"));
79
+ // Close the feedback loop: land back on Settings with the "Settings saved." flash.
80
+ return doneRedirect(c, body, "/admin/settings", "settings.saved");
81
+ }
82
+
83
+ const back = { href: "/admin/settings", label: "← Settings" } as const;
84
+
85
+ // Send a one-off test email so the admin can debug delivery without creating users. Sends to a given
86
+ // address (defaults to the admin's own), reuses the real send path, and reports the exact outcome —
87
+ // success, or the provider's error (also logged for `wrangler tail`). Reproduces the create-user failure.
88
+ export async function sendTestEmail(c: AdminContext): Promise<Response> {
89
+ const actor = requireUser(c);
90
+ if (actor === null) return c.text("Forbidden", 403);
91
+ const deps = c.get("deps");
92
+
93
+ const status = emailStatus(c.env);
94
+ if (status.mode !== "active") {
95
+ return c.html(
96
+ renderPage(
97
+ <ResultPage title="Email not configured" intent="error" back={back}>
98
+ <p>
99
+ Email delivery isn't active ({status.mode}); there's nothing to test. Turn it on first —
100
+ see <a href="/admin/settings">Settings</a>.
101
+ </p>
102
+ </ResultPage>,
103
+ ),
104
+ 400,
105
+ );
106
+ }
107
+
108
+ const requested = field(await c.req.parseBody(), "to");
109
+ const to = requested && requested.trim().length > 0 ? requested.trim() : actor.email;
110
+ if (!isEmail(to)) {
111
+ return c.html(
112
+ renderPage(
113
+ <ResultPage title="Test email failed" intent="error" back={back}>
114
+ <p>
115
+ <code>{to}</code> isn't a valid email address.
116
+ </p>
117
+ </ResultPage>,
118
+ ),
119
+ 400,
120
+ );
121
+ }
122
+
123
+ try {
124
+ await deps.email.send({
125
+ to,
126
+ subject: "Alpha Gate — test email",
127
+ body: `This is a test from your Alpha Gate admin (${status.from}). If you received it, invite delivery works.`,
128
+ });
129
+ await recordAudit(deps, auditFields(c, "email.test", to));
130
+ return c.html(
131
+ renderPage(
132
+ <ResultPage title="Test email sent" back={back}>
133
+ <p>
134
+ Handed to Cloudflare for delivery to <strong>{to}</strong>. Check the inbox (and spam).
135
+ If it never arrives, the provider accepted it but couldn't deliver — verify the
136
+ recipient and sending domain in Cloudflare Email Routing.
137
+ </p>
138
+ </ResultPage>,
139
+ ),
140
+ );
141
+ } catch (e) {
142
+ const detail = e instanceof Error ? e.message : String(e);
143
+ console.error("[email.test] send to", to, "failed:", e); // full error → `wrangler tail`
144
+ return c.html(
145
+ renderPage(
146
+ <ResultPage title="Test email failed" intent="error" back={back}>
147
+ <p>
148
+ Sending to <strong>{to}</strong> failed: <code>{detail}</code>
149
+ </p>
150
+ <p class="muted">
151
+ For the full error, run <code>wrangler tail alpha-gate-&lt;instance&gt;-admin</code> and
152
+ resend. Common causes: the sending domain (<code>{status.from}</code>) isn't fully
153
+ onboarded for sending in Cloudflare Email Routing (SPF/DKIM/DMARC), or the recipient
154
+ isn't allowed.
155
+ </p>
156
+ </ResultPage>,
157
+ ),
158
+ 502,
159
+ );
160
+ }
161
+ }