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,68 @@
1
+ import { gateToken } from "../../auth/token-gate";
2
+ import { renderAppcast, renderInformationalItem, renderUpdateItem } from "../../core/appcast";
3
+ import { insertEvent } from "../../db/access-log";
4
+ import { loadAccessNotice, loadBranding } from "../../services/branding";
5
+ import type { AppContext } from "./app-context";
6
+ import { resolveServed } from "./resolve";
7
+
8
+ // §8/§15 — the per-user appcast. Gate → resolve → one of three feeds: a target item (a servable
9
+ // build), an EMPTY feed (active but no-build, §11 — Sparkle stays "up to date"), or the reactivation
10
+ // notice (revoked/unknown only). Active checks log a `check` with the installed build the app reports.
11
+ // Never a 403: an unknown token still gets the notice so background checks surface it (decision 0010).
12
+ export async function appcastRoute(c: AppContext): Promise<Response> {
13
+ const deps = c.get("deps");
14
+ const origin = new URL(c.req.url).origin;
15
+ const accessUrl = `${origin}/access`;
16
+ const title = (await loadBranding(deps)).appName;
17
+
18
+ const gate = await gateToken(deps, c.req.query("token"));
19
+
20
+ let items: readonly string[];
21
+ if (gate.kind !== "active") {
22
+ // Revoked AND unknown take the identical path — same notice, same (no) resolve/log work — so the
23
+ // response can't reveal whether a token exists (§6/§16): no DB write is gated on token existence.
24
+ // (loadAccessNotice reads only instance-wide meta, never anything token-specific.)
25
+ const notice = await loadAccessNotice(deps);
26
+ items = [renderInformationalItem({ accessUrl, title: notice.title, message: notice.message })];
27
+ } else {
28
+ const client = gate.client;
29
+ const result = await resolveServed(deps, client);
30
+ // Active client: the resolved build, or an EMPTY feed when nothing is servable (the §11 no-build
31
+ // state) so Sparkle reports "up to date" rather than prompting. The reactivation notice is for
32
+ // revoked/unknown tokens only (§8/§15, decision 0010) — a valid user is never told to reactivate.
33
+ items =
34
+ result.kind === "target"
35
+ ? [
36
+ renderUpdateItem(
37
+ result.build,
38
+ `${origin}/download?token=${encodeURIComponent(client.token)}&via=update`,
39
+ ),
40
+ ]
41
+ : [];
42
+
43
+ await insertEvent(deps.db, {
44
+ clientId: client.id,
45
+ email: client.email,
46
+ event: "check",
47
+ buildNumber: parseInstalled(c.req.query("installed"), c.req.query("appVersion")),
48
+ ip: c.req.header("cf-connecting-ip") ?? null,
49
+ userAgent: c.req.header("user-agent") ?? null,
50
+ createdAt: deps.clock(),
51
+ });
52
+ }
53
+
54
+ return c.body(renderAppcast({ title, items }), 200, {
55
+ "Content-Type": "application/rss+xml; charset=utf-8",
56
+ });
57
+ }
58
+
59
+ // decision 0004: the app sends &installed=<build_number>; Sparkle's appVersion is an opportunistic
60
+ // fallback. Parse defensively — only an all-digit value counts, else NULL (never throws).
61
+ function parseInstalled(...candidates: (string | undefined)[]): number | null {
62
+ for (const raw of candidates) {
63
+ if (raw !== undefined && /^\d+$/.test(raw.trim())) {
64
+ return Number.parseInt(raw.trim(), 10);
65
+ }
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1,25 @@
1
+ import { getObject } from "../../r2/builds-bucket";
2
+ import { BRANDING_HEADER_KEY, BRANDING_ICON_KEY } from "../../r2/keys";
3
+ import type { AppContext } from "./app-context";
4
+
5
+ // §6/§13 — public branding assets. Only the known names map to keys (no arbitrary R2 access), and
6
+ // responses set an explicit Content-Type plus X-Content-Type-Options:nosniff so user-uploaded bytes
7
+ // are never sniffed as HTML (branding attack-surface mitigation).
8
+ const ASSET_KEYS: Record<string, string> = {
9
+ icon: BRANDING_ICON_KEY,
10
+ header: BRANDING_HEADER_KEY,
11
+ };
12
+
13
+ export async function assetsRoute(c: AppContext): Promise<Response> {
14
+ const deps = c.get("deps");
15
+ const name = c.req.param("name");
16
+ const key = name === undefined ? undefined : ASSET_KEYS[name];
17
+ if (key === undefined) return c.text("Not found", 404);
18
+
19
+ const object = await getObject(deps.r2, key);
20
+ if (object === null) return c.text("Not found", 404);
21
+
22
+ c.header("Content-Type", object.httpMetadata?.contentType ?? "application/octet-stream");
23
+ c.header("X-Content-Type-Options", "nosniff");
24
+ return c.body(object.body);
25
+ }
@@ -0,0 +1,45 @@
1
+ import { gateToken } from "../../auth/token-gate";
2
+ import { insertEvent } from "../../db/access-log";
3
+ import { getObject } from "../../r2/builds-bucket";
4
+ import type { AppContext } from "./app-context";
5
+ import { resolveServed } from "./resolve";
6
+
7
+ // §8/§16 — streams the archive from R2 behind the token gate (never a presigned URL). via=install
8
+ // serves the first-install DMG when present (else the zip); via=update serves the EdDSA-signed zip
9
+ // Sparkle installs. Logs a download (install) or update (update) event. An invalid token is denied
10
+ // with no R2 read.
11
+ export async function downloadRoute(c: AppContext): Promise<Response> {
12
+ const deps = c.get("deps");
13
+ const gate = await gateToken(deps, c.req.query("token"));
14
+ if (gate.kind !== "active") return c.text("Not found", 404);
15
+ const client = gate.client;
16
+
17
+ const result = await resolveServed(deps, client);
18
+ if (result.kind !== "target") return c.text("Not found", 404);
19
+ const build = result.build;
20
+
21
+ const via = c.req.query("via") === "update" ? "update" : "install";
22
+ const key =
23
+ via === "install" && build.dmgObjectKey !== null ? build.dmgObjectKey : build.objectKey;
24
+ const object = await getObject(deps.r2, key);
25
+ if (object === null) return c.text("Not found", 404);
26
+
27
+ await insertEvent(deps.db, {
28
+ clientId: client.id,
29
+ email: client.email,
30
+ event: via === "update" ? "update" : "download",
31
+ shortVersion: build.shortVersion,
32
+ buildNumber: build.buildNumber,
33
+ ip: c.req.header("cf-connecting-ip") ?? null,
34
+ userAgent: c.req.header("user-agent") ?? null,
35
+ createdAt: deps.clock(),
36
+ });
37
+
38
+ const filename = key.slice(key.lastIndexOf("/") + 1);
39
+ return new Response(object.body, {
40
+ headers: {
41
+ "Content-Type": "application/octet-stream",
42
+ "Content-Disposition": `attachment; filename="${filename}"`,
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,35 @@
1
+ import { gateToken } from "../../auth/token-gate";
2
+ import { loadActivateScheme, loadBranding } from "../../services/branding";
3
+ import { NotFoundPage } from "../../views/access-page";
4
+ import { GetPage } from "../../views/get-page";
5
+ import { renderPage } from "../../views/layout";
6
+ import type { AppContext } from "./app-context";
7
+
8
+ // §6 — the token-gated landing page. Invalid/revoked tokens get the generic 404 (never the get page),
9
+ // so existence isn't confirmed. Referrer-Policy:no-referrer keeps the token out of Referer (§16).
10
+
11
+ export async function getRoute(c: AppContext): Promise<Response> {
12
+ const deps = c.get("deps");
13
+ const gate = await gateToken(deps, c.req.query("token"));
14
+ if (gate.kind !== "active") {
15
+ return c.html(renderPage(<NotFoundPage />), 404);
16
+ }
17
+
18
+ const { token } = gate.client;
19
+ const branding = await loadBranding(deps);
20
+ const scheme = await loadActivateScheme(deps); // §7 deep-link scheme (admin-configurable in Settings)
21
+ const downloadUrl = `/download?token=${encodeURIComponent(token)}&via=install`;
22
+ const activateUrl = `${scheme}://activate?token=${encodeURIComponent(token)}`;
23
+
24
+ c.header("Referrer-Policy", "no-referrer");
25
+ return c.html(
26
+ renderPage(
27
+ <GetPage
28
+ branding={branding}
29
+ token={token}
30
+ downloadUrl={downloadUrl}
31
+ activateUrl={activateUrl}
32
+ />,
33
+ ),
34
+ );
35
+ }
@@ -0,0 +1,31 @@
1
+ import { Hono } from "hono";
2
+ import { buildDeps, type Deps } from "../../deps";
3
+ import type { Env } from "../../env";
4
+ import { accessRoute, postAccessRoute } from "./access";
5
+ import type { AppEnv } from "./app-context";
6
+ import { appcastRoute } from "./appcast";
7
+ import { assetsRoute } from "./assets";
8
+ import { downloadRoute } from "./download";
9
+ import { getRoute } from "./get";
10
+
11
+ // The public App Worker surface (ROLE=app). /appcast is added in M10. There are no admin routes here,
12
+ // so any /admin/* path falls through to the generic 404 — there is no ungated admin surface.
13
+ // depsFor is injectable so tests swap seams (clock, and later access/email).
14
+ export function createAppApp(depsFor: (env: Env) => Deps = buildDeps) {
15
+ const app = new Hono<AppEnv>();
16
+
17
+ app.use("*", async (c, next) => {
18
+ c.set("deps", depsFor(c.env));
19
+ await next();
20
+ });
21
+
22
+ app.get("/get", getRoute);
23
+ app.get("/appcast", appcastRoute);
24
+ app.get("/download", downloadRoute);
25
+ app.get("/assets/:name", assetsRoute);
26
+ app.get("/access", accessRoute);
27
+ app.post("/access", postAccessRoute);
28
+
29
+ app.notFound((c) => c.text("Not found", 404));
30
+ return app;
31
+ }
@@ -0,0 +1,16 @@
1
+ import { type ResolverResult, resolve } from "../../core/resolver";
2
+ import type { Client } from "../../core/types";
3
+ import { listAvailable, listBuildStreams } from "../../db/builds";
4
+ import { streamIdsForClient } from "../../db/streams";
5
+ import type { Deps } from "../../deps";
6
+
7
+ // Loads the slice the §8 resolver needs for one client and runs it. Shared by /appcast and /download
8
+ // so both serve exactly the same target. Passing only available builds is sufficient: a pin to a
9
+ // withdrawn build is then absent → resolver yields none (the no-build state), which is correct.
10
+ // Queries run sequentially — D1 dislikes concurrent statements on one session.
11
+ export async function resolveServed(deps: Deps, client: Client): Promise<ResolverResult> {
12
+ const builds = await listAvailable(deps.db);
13
+ const buildStreams = await listBuildStreams(deps.db);
14
+ const clientStreamIds = await streamIdsForClient(deps.db, client.id);
15
+ return resolve({ client, builds, buildStreams, clientStreamIds });
16
+ }
@@ -0,0 +1,54 @@
1
+ import { assessChain, buildHead } from "../core/audit-chain";
2
+ import { listInOrder } from "../db/admin-audit";
3
+ import * as meta from "../db/meta";
4
+ import type { Deps } from "../deps";
5
+ import { putAuditAnchor } from "../r2/builds-bucket";
6
+ import { auditAnchorKey } from "../r2/keys";
7
+
8
+ // §16 — the daily audit anchor. Records the current chain head where the running admin can't silently
9
+ // rewrite it (an append-only R2 object + an owner email), and detects tampering since the last anchor:
10
+ // a chain that no longer verifies, is shorter (truncation), or whose old head hash no longer matches
11
+ // the row at that position (divergence/rebuild) is flagged. The judgment itself (assessChain) is
12
+ // shared with the admin Audit page, so the cron and the UI can never disagree.
13
+
14
+ export interface AnchorResult {
15
+ hash: string;
16
+ count: number;
17
+ intact: boolean;
18
+ }
19
+
20
+ export async function anchorAudit(
21
+ deps: Deps,
22
+ opts: { now: string; ownerEmail: string | null },
23
+ ): Promise<AnchorResult> {
24
+ const rows = await listInOrder(deps.db);
25
+ const head = buildHead(rows);
26
+ const priorRaw = await meta.get(deps.db, "audit_anchor_head");
27
+ const { intact, anchored: prior } = await assessChain(rows, priorRaw);
28
+
29
+ const anchor = { hash: head.hash, count: head.count, at: opts.now, intact };
30
+ await putAuditAnchor(deps.r2, auditAnchorKey(opts.now), JSON.stringify(anchor));
31
+
32
+ // Ratchet: only advance the trusted head when the chain is intact and no shorter than the last
33
+ // anchor. On any mismatch the prior head stays sticky, so a truncate-then-regrow can't launder
34
+ // itself back to "intact" on a later run.
35
+ if (intact && (prior === null || head.count >= prior.count)) {
36
+ await meta.set(
37
+ deps.db,
38
+ "audit_anchor_head",
39
+ JSON.stringify({ hash: head.hash, count: head.count }),
40
+ );
41
+ }
42
+
43
+ if (opts.ownerEmail !== null) {
44
+ await deps.email.send({
45
+ to: opts.ownerEmail,
46
+ subject: intact
47
+ ? `Alpha Gate audit anchor (${head.count} rows)`
48
+ : "Alpha Gate audit ANCHOR MISMATCH",
49
+ body: JSON.stringify(anchor),
50
+ });
51
+ }
52
+
53
+ return { hash: head.hash, count: head.count, intact };
54
+ }
@@ -0,0 +1,19 @@
1
+ import { type AuditFields, linkRow } from "../core/audit-chain";
2
+ import { appendIfHead, getHead } from "../db/admin-audit";
3
+ import type { Deps } from "../deps";
4
+
5
+ // §16 — the single entry point admin mutations call to record a tamper-evident audit row. Reads the
6
+ // head, links + hashes the new row (core/audit-chain), and appends it guarded on the head being
7
+ // unchanged; on contention it retries, so concurrent mutations can never fork the chain.
8
+
9
+ const MAX_ATTEMPTS = 5;
10
+
11
+ export async function recordAudit(deps: Deps, fields: AuditFields): Promise<void> {
12
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
13
+ const head = await getHead(deps.db);
14
+ const prevHash = head?.hash ?? null;
15
+ const row = await linkRow(prevHash, fields);
16
+ if (await appendIfHead(deps.db, row, prevHash)) return;
17
+ }
18
+ throw new Error("audit chain write contention");
19
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ type AccessNotice,
3
+ type Branding,
4
+ DEFAULT_ACCESS_NOTICE,
5
+ DEFAULT_BRANDING,
6
+ DEFAULT_INVITE_TEMPLATE,
7
+ fillAppName,
8
+ type InviteTemplate,
9
+ resolveActivateScheme,
10
+ safeAccent,
11
+ } from "../core/invite-template";
12
+ import { getAll, get as getMeta } from "../db/meta";
13
+ import type { Deps } from "../deps";
14
+
15
+ // §6/§13 — resolves the branded /get page model and the invite template from the `meta` table,
16
+ // falling back to the clean defaults. The icon/header are served at /assets/icon and /assets/header
17
+ // when one has been uploaded (tracked by meta.icon / meta.header = "1").
18
+
19
+ export async function loadBranding(deps: Deps): Promise<Branding> {
20
+ const all = await getAll(deps.db);
21
+ return {
22
+ appName: all.app_name ?? DEFAULT_BRANDING.appName,
23
+ blurb: all.blurb ?? DEFAULT_BRANDING.blurb,
24
+ accent: safeAccent(all.accent), // coerce to a safe hex value — this is interpolated raw into CSS
25
+ iconUrl: all.icon === "1" ? "/assets/icon" : DEFAULT_BRANDING.iconUrl,
26
+ headerUrl: all.header === "1" ? "/assets/header" : DEFAULT_BRANDING.headerUrl,
27
+ };
28
+ }
29
+
30
+ /** The §7 activation deep-link scheme (meta.activate_scheme), validated, falling back to the default. */
31
+ export async function loadActivateScheme(deps: Deps): Promise<string> {
32
+ return resolveActivateScheme(await getMeta(deps.db, "activate_scheme"));
33
+ }
34
+
35
+ export async function loadInviteTemplate(deps: Deps): Promise<InviteTemplate> {
36
+ const all = await getAll(deps.db);
37
+ return {
38
+ subject: all.invite_subject ?? DEFAULT_INVITE_TEMPLATE.subject,
39
+ body: all.invite_body ?? DEFAULT_INVITE_TEMPLATE.body,
40
+ };
41
+ }
42
+
43
+ /** §15 — the reactivation notice text (meta.notice_title / notice_message), {app_name}-filled. */
44
+ export async function loadAccessNotice(deps: Deps): Promise<AccessNotice> {
45
+ const all = await getAll(deps.db);
46
+ const appName = all.app_name ?? DEFAULT_BRANDING.appName;
47
+ return {
48
+ title: fillAppName(all.notice_title ?? DEFAULT_ACCESS_NOTICE.title, appName),
49
+ message: fillAppName(all.notice_message ?? DEFAULT_ACCESS_NOTICE.message, appName),
50
+ };
51
+ }
@@ -0,0 +1,80 @@
1
+ import type { ComposedEmail, EmailSender } from "./email";
2
+
3
+ // §24 — the Cloudflare Email Service adapter (EMAIL_PROVIDER="cloudflare"). It composes an RFC 5322
4
+ // message from the purely-composed invite (core/invite-template) and hands it to the `send_email`
5
+ // binding via cloudflare:email's EmailMessage. ACTUAL DELIVERY is real-infra-only — it needs Workers
6
+ // Paid + an onboarded sending domain, so it is exercised against live Cloudflare, never in the offline
7
+ // test runtime (§23). The MIME composition below is pure and unit-tested; cloudflare:email is imported
8
+ // lazily inside send() so the test module graph never loads a binding it can't satisfy.
9
+
10
+ export interface CloudflareEmailDeps {
11
+ /** The `EMAIL` send_email binding (admin Worker only). */
12
+ binding: SendEmail;
13
+ /** The verified From address on the onboarded sending domain (EMAIL_FROM). */
14
+ from: string;
15
+ /** RFC 5322 date for the `Date:` header — the clock seam (lib/clock emailDate). */
16
+ emailDate: () => string;
17
+ }
18
+
19
+ export function createCloudflareEmailSender(deps: CloudflareEmailDeps): EmailSender {
20
+ return {
21
+ async send(email: ComposedEmail): Promise<void> {
22
+ // Lazy: only the real send path touches cloudflare:email (unavailable/irrelevant in tests).
23
+ const { EmailMessage } = await import("cloudflare:email");
24
+ const date = deps.emailDate();
25
+ const raw = composeMimeMessage({
26
+ from: deps.from,
27
+ to: email.to,
28
+ subject: email.subject,
29
+ body: email.body,
30
+ date,
31
+ messageId: makeMessageId(deps.from, email.to, date),
32
+ });
33
+ await deps.binding.send(new EmailMessage(deps.from, email.to, raw));
34
+ },
35
+ };
36
+ }
37
+
38
+ export interface MimeParts {
39
+ from: string;
40
+ to: string;
41
+ subject: string;
42
+ body: string;
43
+ date: string;
44
+ messageId: string;
45
+ }
46
+
47
+ /**
48
+ * Pure RFC 5322 plain-text message builder. Header values are stripped of CR/LF to prevent header
49
+ * injection (a hostile app name in the subject must not be able to forge headers); the body is
50
+ * normalised to CRLF line endings and separated from the headers by a blank line.
51
+ */
52
+ export function composeMimeMessage(parts: MimeParts): string {
53
+ const headers = [
54
+ `From: ${sanitizeHeader(parts.from)}`,
55
+ `To: ${sanitizeHeader(parts.to)}`,
56
+ `Subject: ${sanitizeHeader(parts.subject)}`,
57
+ `Message-ID: ${sanitizeHeader(parts.messageId)}`,
58
+ `Date: ${sanitizeHeader(parts.date)}`,
59
+ "MIME-Version: 1.0",
60
+ "Content-Type: text/plain; charset=utf-8",
61
+ ];
62
+ const body = parts.body.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n");
63
+ return `${headers.join("\r\n")}\r\n\r\n${body}`;
64
+ }
65
+
66
+ /** A deterministic, header-safe Message-ID derived from the date + recipient + sender domain. */
67
+ export function makeMessageId(from: string, to: string, date: string): string {
68
+ const at = from.lastIndexOf("@");
69
+ const domain = at >= 0 ? from.slice(at + 1) : "alpha-gate.local";
70
+ const local = idSafe(`${date}.${to}`);
71
+ return `<${local}@${idSafe(domain)}>`;
72
+ }
73
+
74
+ function sanitizeHeader(value: string): string {
75
+ return value.replace(/[\r\n]+/g, " ").trim();
76
+ }
77
+
78
+ function idSafe(value: string): string {
79
+ return value.replace(/[^A-Za-z0-9.-]/g, "-");
80
+ }
@@ -0,0 +1,68 @@
1
+ import type { Env } from "../env";
2
+ import { createCloudflareEmailSender } from "./email-cloudflare";
3
+
4
+ // §13/§24 — the email seam. Invites are composed purely (core/invite-template); this only delivers.
5
+ // The free-tier default is copy-paste: nothing is sent and the admin UI surfaces the /get link. The
6
+ // Cloudflare Email Service adapter (EMAIL_PROVIDER="cloudflare", Workers Paid + an onboarded sending
7
+ // domain + the `EMAIL` send_email binding) delivers for real; its actual send is real-infra-only (§23).
8
+ // Anything misconfigured falls back to copy-paste so a missing binding never throws at request time.
9
+
10
+ export interface ComposedEmail {
11
+ to: string;
12
+ subject: string;
13
+ body: string;
14
+ }
15
+
16
+ export interface EmailSender {
17
+ send(email: ComposedEmail): Promise<void>;
18
+ }
19
+
20
+ /** Copy-paste mode: nothing leaves the Worker; the invite link is shown in the back office. */
21
+ export const noopEmailSender: EmailSender = {
22
+ async send() {
23
+ // intentionally no-op
24
+ },
25
+ };
26
+
27
+ export type EmailMode =
28
+ | "active" // provider=cloudflare, fully wired — invites are actually emailed
29
+ | "incomplete" // provider=cloudflare but a prerequisite is missing — silently falls back to copy-paste
30
+ | "copy-paste"; // provider=none — intentional free-tier default; the back office shows the link
31
+
32
+ export interface EmailStatus {
33
+ mode: EmailMode;
34
+ /** EMAIL_FROM as configured (may be empty). */
35
+ from: string;
36
+ /** For "incomplete": the human-readable prerequisites the cloudflare provider is still missing. */
37
+ missing: string[];
38
+ }
39
+
40
+ /**
41
+ * The true email-delivery state for this Worker — the single source of truth shared by delivery
42
+ * (selectEmailSender) and the admin UI, so what the Settings page reports can't drift from what actually
43
+ * sends. Cloudflare delivery needs ALL of: provider="cloudflare", the `EMAIL` binding (admin Worker only,
44
+ * added by deploy when email is on), and a non-empty From. Any gap with provider="cloudflare" is
45
+ * "incomplete" — delivery quietly falls back to copy-paste, which the UI must call out rather than hide.
46
+ */
47
+ export function emailStatus(env: Env): EmailStatus {
48
+ const from = env.EMAIL_FROM;
49
+ if (env.EMAIL_PROVIDER !== "cloudflare") return { mode: "copy-paste", from, missing: [] };
50
+ const missing: string[] = [];
51
+ if (env.EMAIL === undefined) missing.push("the EMAIL send_email binding");
52
+ if (from.length === 0) missing.push("a From address (EMAIL_FROM)");
53
+ return missing.length > 0
54
+ ? { mode: "incomplete", from, missing }
55
+ : { mode: "active", from, missing };
56
+ }
57
+
58
+ /**
59
+ * Picks the delivery adapter from the runtime env, off the same {@link emailStatus} the UI reports. Only
60
+ * "active" delivers for real; everything else falls back to copy-paste rather than fail. `emailDate` is
61
+ * the clock seam for the Date header.
62
+ */
63
+ export function selectEmailSender(env: Env, emailDate: () => string): EmailSender {
64
+ if (emailStatus(env).mode === "active" && env.EMAIL !== undefined) {
65
+ return createCloudflareEmailSender({ binding: env.EMAIL, from: env.EMAIL_FROM, emailDate });
66
+ }
67
+ return noopEmailSender;
68
+ }
@@ -0,0 +1,62 @@
1
+ import { isUpdateAvailable, type UpdateManifest } from "../core/version";
2
+ import * as meta from "../db/meta";
3
+ import type { Deps } from "../deps";
4
+
5
+ // §22 — checks the upstream manifest for a newer version of the TOOL ITSELF, stores the result in
6
+ // meta (the dashboard banner reads it), and emails the operator once per new version. Defensive: a
7
+ // failed/garbage fetch is swallowed (the cron just retries next time); never throws.
8
+
9
+ export interface SelfUpdateOptions {
10
+ toolVersion: string;
11
+ manifestUrl: string;
12
+ ownerEmail: string | null;
13
+ }
14
+
15
+ export async function checkSelfUpdate(deps: Deps, opts: SelfUpdateOptions): Promise<void> {
16
+ // Record the attempt time up front (before the fetch that may fail), so Settings can honestly show
17
+ // "last checked" — and distinguish "checked, up to date" from "never checked / cron not firing".
18
+ await meta.set(deps.db, "selfupdate_checked_at", deps.clock());
19
+
20
+ let manifest: UpdateManifest;
21
+ try {
22
+ const res = await deps.fetch(opts.manifestUrl);
23
+ if (!res.ok) return;
24
+ manifest = (await res.json()) as UpdateManifest;
25
+ } catch {
26
+ return;
27
+ }
28
+
29
+ const status = isUpdateAvailable(opts.toolVersion, manifest);
30
+ await meta.set(deps.db, "selfupdate_latest", status.latest ?? "");
31
+ await meta.set(deps.db, "selfupdate_available", status.available ? "1" : "0");
32
+ await meta.set(deps.db, "selfupdate_breaking", status.breaking ? "1" : "0");
33
+ await meta.set(deps.db, "selfupdate_below_min", status.belowMinSupported ? "1" : "0");
34
+ // notes_url is untrusted upstream input — only persist a safe http(s) link (the dashboard renders it).
35
+ await meta.set(deps.db, "selfupdate_notes_url", safeHttpUrl(status.notesUrl) ?? "");
36
+
37
+ if (status.available && status.latest !== null && opts.ownerEmail !== null) {
38
+ const lastNotified = await meta.get(deps.db, "last_notified_version");
39
+ if (lastNotified !== status.latest) {
40
+ const notes = safeHttpUrl(status.notesUrl);
41
+ await deps.email.send({
42
+ to: opts.ownerEmail,
43
+ subject: `Alpha Gate ${status.latest} is available`,
44
+ body: `A newer Alpha Gate (${status.latest}) is available${
45
+ status.breaking ? " — note: breaking changes" : ""
46
+ }.${notes !== null ? ` Release notes: ${notes}.` : ""} Update Alpha Gate (git pull or npx alpha-gate@latest) and re-run deploy.`,
47
+ });
48
+ await meta.set(deps.db, "last_notified_version", status.latest);
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Accept only absolute http(s) URLs; reject javascript:/data: and anything unparseable (defense). */
54
+ function safeHttpUrl(url: string | null): string | null {
55
+ if (url === null) return null;
56
+ try {
57
+ const parsed = new URL(url);
58
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? url : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,39 @@
1
+ import type { FC } from "hono/jsx";
2
+ import { DEFAULT_BRANDING } from "../core/invite-template";
3
+ import { Layout } from "./layout";
4
+
5
+ // §13 — the public "request access" page (target of the revoked-user notice link) and the generic
6
+ // 404 surface returned for an invalid/revoked token on /get, which must NOT confirm token existence.
7
+
8
+ export const AccessPage: FC<{ appName: string; accent: string }> = ({ appName, accent }) => (
9
+ <Layout title={appName} accent={accent}>
10
+ <h1>Request access</h1>
11
+ <p class="blurb">Enter your email to request access to the {appName} alpha.</p>
12
+ <form method="post" action="/access">
13
+ <input class="input" type="email" name="email" placeholder="you@example.com" required />
14
+ <button class="btn primary" type="submit">
15
+ Request access
16
+ </button>
17
+ </form>
18
+ </Layout>
19
+ );
20
+
21
+ export const RequestReceivedPage: FC<{ appName: string; accent: string }> = ({
22
+ appName,
23
+ accent,
24
+ }) => (
25
+ <Layout title={appName} accent={accent}>
26
+ <h1>Request received</h1>
27
+ <p class="blurb">
28
+ Thanks — your request for {appName} access has been recorded. The admin will follow up by
29
+ email.
30
+ </p>
31
+ </Layout>
32
+ );
33
+
34
+ export const NotFoundPage: FC<{ accent?: string }> = ({ accent = DEFAULT_BRANDING.accent }) => (
35
+ <Layout title="Not found" accent={accent}>
36
+ <h1>Not found</h1>
37
+ <p class="muted">This page isn’t available.</p>
38
+ </Layout>
39
+ );