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
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ Notable changes to Alpha Gate (the tool). The deployed instance's daily self-update check polls the
4
+ npm registry's `latest` for this package and compares it against its `TOOL_VERSION`; the
5
+ dashboard/Settings link here for notes. `release.json` is only the static-manifest fallback for
6
+ `$UPDATE_MANIFEST_URL` overrides — keep its `latest` in sync with `package.json`'s `version`.
7
+
8
+ ## 0.1.0
9
+
10
+ - **Back office redesign** ("quiet instrument"): the serving map makes the resolver visible; the
11
+ Users list answers "what does each tester get next?"; confirm-name-and-return feedback loop;
12
+ reversible revoke (Reactivate); searchable comboboxes; light/system/dark theme toggle.
13
+ - **Publishing simplified to one command**: `./publish.sh <artifact>` handles `.dmg` and signed
14
+ `.app.zip`, links channels by name, auto-picks the instance, pre-checks the build number, and
15
+ handles the >90 MB register path itself. (`publish-dmg.sh` and `ci-publish.sh` removed.)
16
+ - **Storage lifecycle**: per-build size + bucket total on the Builds page; purge a withdrawn build's
17
+ archive to reclaim R2 space (the record is kept).
18
+ - **Deploy**: remembers email/Access inputs across re-runs (no silent revert); derives the Access
19
+ team domain from the enablement redirect; reason-bearing admin 403; real `--help`.
20
+ - **Security**: service tokens are scoped to the publish surface only (decision 0006 enforced).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandr Ivanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # Alpha Gate
2
+
3
+ A lightweight, self-hosted distribution gate for a notarized macOS app updated via **Sparkle**. It gates
4
+ downloads and the per-user Sparkle update feed behind a token, manages clients / builds / release
5
+ channels / pins / rollbacks from an admin back office behind **Cloudflare Access**, and runs entirely on
6
+ **Cloudflare (Workers + D1 + R2)** within the free tier — no custom domain, deployable to any account from
7
+ one script, with many isolated instances per account.
8
+
9
+ > Status: feature-complete and tested — 376 worker + 93 deploy-CLI tests, all offline; `tsc` (both
10
+ > configs) and Biome clean.
11
+
12
+ ## Features
13
+
14
+ - **One private link per user** (`/get?token=`) → download + an `myapp://activate` deep link + the
15
+ pasteable key. The token is the only credential; users never log in.
16
+ - **Per-request Sparkle appcast** generated from the database — gating, re-download, channels, pinning,
17
+ rollback, and revocation notices all fall out of one resolver.
18
+ - **Release channels, pinning, and rollback** (roll-forward, since Sparkle can't downgrade).
19
+ - **Publish a signed `.dmg` or `.zip`** from a one-command script, CI, or the browser — all converging on
20
+ one upload endpoint; the Worker never signs and never holds a signing key.
21
+ - **Admin behind Cloudflare Access** (one-time-PIN email allowlist) with defense-in-depth JWT
22
+ verification, and a **tamper-evident, hash-chained audit log**.
23
+ - **Free-tier**: D1 + R2 + Workers, no custom domain. Copy-paste invites by default; Cloudflare email is
24
+ an optional, paid add-on (it needs a real domain).
25
+
26
+ ## How it works
27
+
28
+ The same code deploys **twice**, switched by a `ROLE` var, onto two `workers.dev` hostnames that share one
29
+ D1 database and one R2 bucket:
30
+
31
+ ```
32
+ macOS app ──► /appcast?token= ┌─────────────────────────┐
33
+ (Sparkle) /download?token= │ App Worker (public) │──┐
34
+ │ • token gate, resolver │ │ ┌── D1 (clients, builds,
35
+ User ──────► /get?token= │ • binary stream from R2 │ ├──►│ channels, access_log, audit)
36
+ (browser) landing page └─────────────────────────┘ │ └── R2 (build archives, branding)
37
+
38
+ Admin ─────► /admin ┌─────────────────────────┐ │
39
+ (Cloudflare [Cloudflare Access]│ Admin Worker (gated) │──┘
40
+ Access) ───────────────────│ • clients/builds/channels, validates the Access JWT
41
+ └─────────────────────────┘
42
+ ```
43
+
44
+ Why two Workers: on `workers.dev`, Cloudflare Access protects an entire hostname, so the public routes
45
+ (which Sparkle must reach) and the gated admin must live on separate hostnames. Both are host-agnostic
46
+ (they derive their origin from the request), so they run on bare `*.workers.dev` URLs with no config. Full
47
+ rationale and the invariants behind the design: [`docs/PRINCIPLES.md`](docs/PRINCIPLES.md).
48
+
49
+ ## Quick start
50
+
51
+ From npm — no clone, a pinned versioned release:
52
+
53
+ ```bash
54
+ npx wrangler login # once, interactive
55
+ npx alpha-gate deploy --instance myalpha # provision D1 + R2, deploy both Workers (idempotent)
56
+ npx alpha-gate publish MyApp.dmg --channel beta
57
+ ```
58
+
59
+ Or from a git clone (contributors, or to run unreleased `main`):
60
+
61
+ ```bash
62
+ git clone <your-fork> alpha-gate && cd alpha-gate && npm install
63
+ ./deploy/deploy.sh --instance myalpha # the deploy/*.sh wrappers = the same CLI
64
+ ```
65
+
66
+ Then lock the admin behind Cloudflare Access and re-run deploy with your team domain + AUD. The full,
67
+ step-by-step path — install, prepare the account, deploy, Access, verify — starts at
68
+ **[docs/setup/install.md](docs/setup/install.md)**.
69
+
70
+ To ship builds: wire Sparkle into your app ([Swift](docs/integrate/sparkle-swift.md) or
71
+ [Go](docs/integrate/sparkle-go.md)), [add users](docs/operate/add-users.md), and
72
+ [publish](docs/operate/publish.md). Once set up, publishing is one command:
73
+
74
+ ```bash
75
+ ./publish.sh MyApp.dmg --channel beta
76
+ ```
77
+
78
+ It reads the version from the app, signs with Sparkle's `sign_update`, links the channel by name, and
79
+ picks the instance automatically when only one is deployed.
80
+
81
+ ## Develop
82
+
83
+ Everything runs **offline** — no Cloudflare account, no network — inside the Workers runtime via
84
+ `@cloudflare/vitest-pool-workers` (Miniflare simulates D1/R2/cron with isolated per-test storage).
85
+
86
+ ```bash
87
+ npm run check # the full gate: biome + tsc (both configs) + tests
88
+ npm test # tests only (offline)
89
+ ./deploy/dev.sh # run BOTH Workers locally, seeded (app :8787, admin :8788); --role app|admin for one
90
+ ```
91
+
92
+ Conventions, architecture, and how to add a feature: [`CONTRIBUTING.md`](CONTRIBUTING.md).
93
+
94
+ ## Documentation
95
+
96
+ | Doc | What |
97
+ |---|---|
98
+ | [`docs/README.md`](docs/README.md) | The documentation index: setup, integrate, operate, maintain. |
99
+ | [`docs/PRINCIPLES.md`](docs/PRINCIPLES.md) | The durable architecture & product principles and hard constraints. |
100
+ | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Developer guide: conventions, architecture, testing, adding a feature. |
101
+ | [`CLAUDE.md`](CLAUDE.md) | Working guidance for AI assistants in this repo. |
102
+
103
+ ## Project structure
104
+
105
+ ```
106
+ src/
107
+ worker.ts # entrypoint — reads env.ROLE, mounts app|admin, exports fetch + scheduled
108
+ core/ # PURE logic (no I/O): resolver, no-build, appcast XML, audit hash-chain, tokens, version
109
+ views/ # pure hono/jsx pages (public + admin/, incl. injected client scripts)
110
+ db/ # raw D1 prepared statements, one module per table (no ORM)
111
+ r2/ # R2 archive + branding access (never presigns)
112
+ auth/ # token gate + Cloudflare Access JWT verifier
113
+ services/ # audit, email, self-update, anchor, branding (orchestration over db/r2/core)
114
+ routes/{app,admin} # Hono handlers; deps injected, never bindings directly
115
+ deploy/ # deploy/teardown/dev CLI: core/ (pure) + seams/ (wrangler,fs,io,clock) + commands/
116
+ migrations/ # 0001–0010 D1 schema (SQL)
117
+ deploy/ # thin bash wrappers (deploy.sh, teardown.sh, dev.sh) → the TS CLI; backup.sh (D1 dump)
118
+ publish.sh # ONE publish command (dmg | .app.zip | CI); .github/workflows/ has a sample
119
+ bin/alpha-gate.mjs # the npm entrypoint: npx alpha-gate deploy|dev|publish|backup|teardown
120
+ test/ # unit/ integration/ cuj/ + support/ (offline vitest-pool-workers)
121
+ docs/ # the docs: setup/ integrate/ operate/ maintain/ + PRINCIPLES (index: docs/README.md)
122
+ site/ # the marketing page (static HTML + screenshots; see site/README.md)
123
+ ```
124
+
125
+ ## Security notes
126
+
127
+ - The token travels in URLs, so it appears in Cloudflare's own request logs (accepted for a private
128
+ alpha); `/get` sets `Referrer-Policy: no-referrer` and bad tokens get a generic 404 (no existence leak).
129
+ - The Sparkle feed is **not** signed (`SURequireSignedFeed` off) — incompatible with per-user dynamic
130
+ feeds; the per-archive EdDSA signature still blocks tampered binaries.
131
+ - The admin JWT verifier is **fail-closed** (RS256-pinned, `aud`/`iss` + expiry checked); service tokens
132
+ are accepted only on the upload/register routes.
133
+ - Admin actions are recorded in a **hash-chained, daily-anchored** audit log; pair it with Cloudflare's
134
+ Access and Audit logs for full who/when.
135
+ - Account-level caveat: anyone with Cloudflare dashboard access to the account can read D1/R2 directly;
136
+ install into a dedicated account if that isolation matters.
137
+
138
+ More on the security model and the invariants: [`docs/PRINCIPLES.md`](docs/PRINCIPLES.md).
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // The `alpha-gate` command (npm bin). One entry point over the two implementation styles:
3
+ // deploy | dev | teardown → the TypeScript CLI (src/deploy/cli.ts), run via tsx.
4
+ // publish | backup → the bash scripts (publish.sh, deploy/backup.sh) — macOS publish tools.
5
+ // Everything resolves from the package root (this file's dir), so it works from an npx cache install.
6
+ import { spawn } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const [command, ...rest] = process.argv.slice(2);
13
+
14
+ const HELP = `alpha-gate — self-hosted Cloudflare distribution gate for a Sparkle-updated macOS app
15
+
16
+ usage: alpha-gate <command> [options]
17
+
18
+ deploy provision + deploy an instance (D1 + R2 + both Workers); re-run to update
19
+ dev run locally on Miniflare (both Workers), no Cloudflare account
20
+ publish publish a signed build (.dmg | .app.zip) — macOS
21
+ backup dump an instance's D1 database to a .sql file
22
+ teardown archive + destroy an instance
23
+
24
+ Run \`alpha-gate <command> --help\` for a command's options.
25
+ State lives in ~/.alpha-gate (override with $ALPHA_GATE_HOME).`;
26
+
27
+ if (command === undefined || command === "--help" || command === "-h") {
28
+ console.log(HELP);
29
+ process.exit(command === undefined ? 1 : 0);
30
+ }
31
+
32
+ // tsx is a dependency; resolve its bin from the package's node_modules so we don't rely on `npx`.
33
+ function runTsxCli(args) {
34
+ const tsxBin = path.join(
35
+ ROOT,
36
+ "node_modules",
37
+ ".bin",
38
+ process.platform === "win32" ? "tsx.cmd" : "tsx",
39
+ );
40
+ const cli = path.join(ROOT, "src", "deploy", "cli.ts");
41
+ const bin = existsSync(tsxBin) ? tsxBin : "tsx";
42
+ return spawn(bin, [cli, ...args], { stdio: "inherit", cwd: ROOT });
43
+ }
44
+
45
+ function runScript(scriptRelPath, args) {
46
+ return spawn("bash", [path.join(ROOT, scriptRelPath), ...args], {
47
+ stdio: "inherit",
48
+ cwd: process.cwd(),
49
+ });
50
+ }
51
+
52
+ let child;
53
+ switch (command) {
54
+ case "deploy":
55
+ case "dev":
56
+ case "teardown":
57
+ child = runTsxCli([command, ...rest]);
58
+ break;
59
+ case "publish":
60
+ child = runScript("publish.sh", rest);
61
+ break;
62
+ case "backup":
63
+ child = runScript("deploy/backup.sh", rest);
64
+ break;
65
+ default:
66
+ console.error(`unknown command: ${command}\n`);
67
+ console.error(HELP);
68
+ process.exit(1);
69
+ }
70
+
71
+ child.on("exit", (code) => process.exit(code ?? 0));
72
+ child.on("error", (err) => {
73
+ console.error(`failed to run '${command}': ${err.message}`);
74
+ process.exit(1);
75
+ });
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Back up an Alpha Gate instance's D1 database to a timestamped .sql dump — the same export teardown
3
+ # takes before destroying, minus the destruction. Run it on a schedule (cron/launchd) or before a
4
+ # risky change. R2 archives are NOT included: they're large and re-uploadable by re-publishing; this
5
+ # captures the irreplaceable state (clients + their tokens, builds, channels, logs, audit chain).
6
+ #
7
+ # ./deploy/backup.sh --instance myalpha [--out ./backups]
8
+ #
9
+ # The dump contains LIVE per-user tokens — store it somewhere private (NOT the repo) and delete old
10
+ # copies. Restore into a fresh instance: wrangler d1 execute <db> --remote --file <dump>.sql
11
+ # (see docs/maintain/backup.md → Backup & recovery).
12
+ set -euo pipefail
13
+
14
+ INSTANCE=""; OUT=""
15
+ need() { [ "$#" -ge 2 ] || { echo "missing value for $1" >&2; exit 1; }; }
16
+ while [ "$#" -gt 0 ]; do
17
+ case "$1" in
18
+ --instance) need "$@"; INSTANCE="$2"; shift 2 ;;
19
+ --out) need "$@"; OUT="$2"; shift 2 ;;
20
+ -h|--help) sed -n '2,12p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
21
+ *) echo "unknown flag: $1" >&2; exit 1 ;;
22
+ esac
23
+ done
24
+ [ -n "${INSTANCE}" ] || { echo "usage: ./deploy/backup.sh --instance <slug> [--out <dir>]" >&2; exit 1; }
25
+
26
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
27
+ # shellcheck source=deploy/lib/statedir.sh
28
+ . "${ROOT}/deploy/lib/statedir.sh"
29
+ RES="alpha-gate-${INSTANCE}"
30
+ OUT="${OUT:-$(alpha_gate_state_dir "${ROOT}")}"
31
+ mkdir -p "${OUT}"
32
+ STAMP="$(date -u +%Y%m%d-%H%M%S)"
33
+ FILE="${OUT}/${INSTANCE}-${STAMP}.sql"
34
+
35
+ echo "Exporting ${RES} → ${FILE} (this reads the REMOTE database)…" >&2
36
+ if npx wrangler d1 export "${RES}" --remote --output "${FILE}"; then
37
+ BYTES="$(wc -c < "${FILE}" | tr -d ' ')"
38
+ echo "✓ backed up ${BYTES} bytes to ${FILE}" >&2
39
+ echo " This dump contains live tokens — keep it private and prune old copies." >&2
40
+ echo "${FILE}"
41
+ else
42
+ echo "backup failed — is wrangler logged in (npx wrangler login) and is '${INSTANCE}' deployed?" >&2
43
+ rm -f "${FILE}"
44
+ exit 1
45
+ fi
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # Provision and deploy one Alpha Gate instance (§19). Thin wrapper → the TypeScript deploy CLI
3
+ # (src/deploy/cli.ts), which replaced the hand-rolled bash orchestration: it shells out to wrangler
4
+ # with typed argv (no scraping/quoting surface), validates every command's output, is idempotent and
5
+ # resumable, and is unit-tested. The flags and UX are unchanged:
6
+ #
7
+ # ./deploy/deploy.sh --instance <slug> [--app-name … --activate-scheme … --blurb … --accent …]
8
+ # [--access-team-domain … --access-aud …]
9
+ # [--email-provider none|cloudflare --email-from …] [--yes] [--dry-run]
10
+ #
11
+ # First init is guided (prompts for app config when interactive); Access is two-phase (it shows the
12
+ # dashboard step, waits, then collects the AUD). Requires `npm install` once (provides tsx + wrangler).
13
+ # To roll back to the previous pure-bash implementation: `git log -- deploy/deploy.sh`.
14
+ set -euo pipefail
15
+
16
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17
+ # Run the local tsx binary directly, not via `npx` (avoids a registry round-trip and a nested
18
+ # npm-exec deadlock when the CLI shells out to wrangler — see deploy/dev.sh). npx tsx is the fallback.
19
+ TSX="${ROOT}/node_modules/.bin/tsx"
20
+ [ -x "${TSX}" ] && exec "${TSX}" "${ROOT}/src/deploy/cli.ts" deploy "$@"
21
+ exec npx tsx "${ROOT}/src/deploy/cli.ts" deploy "$@"
package/deploy/dev.sh ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # Run Alpha Gate LOCALLY (§23 local surface) — Miniflare-backed D1 + R2, no Cloudflare account. Thin
3
+ # wrapper → the TypeScript CLI (src/deploy/cli.ts). By default it starts BOTH Workers so the whole
4
+ # system is live from one command: the App on :8787 and the gated admin on :8788 (the dev-only auth
5
+ # shim, src/dev/admin-entry.ts; DEV_ADMIN=1, never deployable).
6
+ #
7
+ # ./deploy/dev.sh # both Workers (app :8787, admin :8788)
8
+ # ./deploy/dev.sh --role app # just the App Worker
9
+ # ./deploy/dev.sh --role admin --port 8788 # just the admin
10
+ # ./deploy/dev.sh --reset --no-seed # flags pass through
11
+ set -euo pipefail
12
+
13
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14
+ # Run the local tsx binary DIRECTLY, not via `npx`: `npx` can stall on a registry round-trip, and
15
+ # running under npx/npm-exec makes the child `npx wrangler` a NESTED npm-exec that can deadlock on
16
+ # npm's lock — a silent hang where `wrangler dev` never binds. Fall back to `npx tsx` only when the
17
+ # local binary is missing (run `npm install`).
18
+ TSX="${ROOT}/node_modules/.bin/tsx"
19
+ CLI=(npx tsx "${ROOT}/src/deploy/cli.ts")
20
+ [ -x "${TSX}" ] && CLI=("${TSX}" "${ROOT}/src/deploy/cli.ts")
21
+
22
+ # A picked role (or --help) is a single-Worker run — hand straight to the CLI, unchanged behavior.
23
+ for arg in "$@"; do
24
+ case "$arg" in
25
+ --role|-h|--help) exec "${CLI[@]}" dev "$@" ;;
26
+ esac
27
+ done
28
+
29
+ # Default: BOTH Workers. The app runs in the background (it shares Miniflare state via .wrangler/state),
30
+ # the admin in the foreground so Ctrl-C stops the session. Derive the admin port as app_port+1.
31
+ APP_PORT=8787
32
+ for ((i=1; i<=$#; i++)); do
33
+ if [ "${!i}" = "--port" ]; then j=$((i+1)); APP_PORT="${!j:-8787}"; fi
34
+ done
35
+ ADMIN_PORT=$((APP_PORT + 1))
36
+
37
+ echo "Starting BOTH Workers — App :${APP_PORT}, Admin :${ADMIN_PORT}. Ctrl-C stops both." >&2
38
+
39
+ # Shutdown: kill the background app CLI, then whatever is listening on our two ports (precisely our
40
+ # workerds — they re-parent to init so a process-tree walk can't reach them, but they hold the port).
41
+ # Best-effort: like the single-role path, wrangler can leave a workerd briefly orphaned; the CLI's
42
+ # start-time port-in-use guard catches any residual on the next run and tells you to `pkill -f workerd`.
43
+ "${CLI[@]}" dev "$@" &
44
+ APP_PID=$!
45
+ cleanup() {
46
+ kill "${APP_PID}" 2>/dev/null || true
47
+ for p in "${APP_PORT}" "${ADMIN_PORT}"; do
48
+ for pid in $(lsof -ti tcp:"${p}" 2>/dev/null); do kill -9 "${pid}" 2>/dev/null || true; done
49
+ done
50
+ }
51
+ trap cleanup EXIT INT TERM
52
+
53
+ # Give the app a moment to apply migrations/seed before the admin migrates the same DB (avoids a race
54
+ # on the shared SQLite file). Then the admin in the foreground — no --reset/--no-seed (already done).
55
+ sleep 4
56
+ "${CLI[@]}" dev --role admin --port "${ADMIN_PORT}"
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ # Resolve where per-instance deploy state lives — the bash mirror of src/deploy/core/paths.ts, so the
3
+ # publish/backup/dev scripts read the same `.deploy` the CLI wrote. Source it, then call
4
+ # alpha_gate_state_dir <package-root>. Order: $ALPHA_GATE_HOME > (<root>/.git present ? <root>/.deploy
5
+ # : ~/.alpha-gate).
6
+ alpha_gate_state_dir() {
7
+ local root="$1"
8
+ if [ -n "${ALPHA_GATE_HOME:-}" ]; then printf '%s' "${ALPHA_GATE_HOME}"; return; fi
9
+ if [ -d "${root}/.git" ]; then printf '%s/.deploy' "${root}"; return; fi
10
+ printf '%s/.alpha-gate' "${HOME:-.}"
11
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # Destroy one Alpha Gate instance (§21). Thin wrapper → the TypeScript CLI (src/deploy/cli.ts). By
3
+ # default it ARCHIVES the database first (wrangler d1 export to .deploy/<slug>-<ts>.sql); pass
4
+ # --no-archive to skip. Confirmed by typing the instance name (or --yes). Same flags/UX as before:
5
+ #
6
+ # ./deploy/teardown.sh --instance <slug> [--no-archive] [--archive-dir <dir>] [--yes] [--dry-run]
7
+ #
8
+ # The R2 bucket (if non-empty) and the Cloudflare Access app are finished by hand — pure wrangler can't
9
+ # remove them; the CLI prints exactly what's left. Roll back: `git log -- deploy/teardown.sh`.
10
+ set -euo pipefail
11
+
12
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
13
+ # Run the local tsx binary directly, not via `npx` (avoids a registry round-trip and a nested
14
+ # npm-exec deadlock when the CLI shells out to wrangler — see deploy/dev.sh). npx tsx is the fallback.
15
+ TSX="${ROOT}/node_modules/.bin/tsx"
16
+ [ -x "${TSX}" ] && exec "${TSX}" "${ROOT}/src/deploy/cli.ts" teardown "$@"
17
+ exec npx tsx "${ROOT}/src/deploy/cli.ts" teardown "$@"
@@ -0,0 +1,16 @@
1
+ # Onboarding
2
+
3
+ This guide was split into task pages. Start at the [documentation index](README.md).
4
+
5
+ Where its sections went:
6
+
7
+ | Was | Now |
8
+ |---|---|
9
+ | Prerequisites, two ways to run the tooling | [setup/install.md](setup/install.md) |
10
+ | Prepare your Cloudflare account | [setup/cloudflare-account.md](setup/cloudflare-account.md) |
11
+ | Deploy, Access, service token, verify | [setup/deploy.md](setup/deploy.md) |
12
+ | Email | [operate/email.md](operate/email.md) |
13
+ | Backup & recovery | [maintain/backup.md](maintain/backup.md) |
14
+ | Multiple instances, updating in place | [setup/deploy.md](setup/deploy.md), [maintain/updating.md](maintain/updating.md) |
15
+ | Teardown | [maintain/teardown.md](maintain/teardown.md) |
16
+ | Troubleshooting | [maintain/troubleshooting.md](maintain/troubleshooting.md) |
@@ -0,0 +1,195 @@
1
+ # Alpha Gate — principles & constraints
2
+
3
+ The durable ideas behind Alpha Gate: the invariants that must keep holding, and the hard constraints
4
+ that shaped the design. This replaces the original `design/` spec (now in git history). It is the
5
+ reference to read before changing core behavior — operational how-to lives in the task pages
6
+ (index: [README](README.md)); the developer workflow in [CONTRIBUTING](../CONTRIBUTING.md).
7
+
8
+ > Legacy note: many code comments cite `§N` (e.g. `§8`, `§11`). Those referred to sections of the old
9
+ > `design/DESIGN.md`; the durable content is distilled here. Treat the markers as historical anchors.
10
+
11
+ ## What it is
12
+
13
+ A self-hosted distribution gate for a notarized macOS app updated via **Sparkle**. It gates downloads
14
+ and the per-user Sparkle update feed behind a token, with an admin back office for clients, builds,
15
+ release channels, pins, and rollbacks. It runs entirely on **Cloudflare (Workers + D1 + R2)** within the
16
+ free tier, deployable to any account from one script, with many isolated instances per account.
17
+
18
+ ## Architecture
19
+
20
+ **Two Workers, one dataset.** The same code deploys twice, switched by a `ROLE` var, onto two
21
+ `workers.dev` hostnames that share one D1 database and one R2 bucket:
22
+
23
+ - **App Worker (public)** — token gate, the per-request resolver, the appcast, binary streaming, the
24
+ `/get` landing page. Sparkle and users reach it freely.
25
+ - **Admin Worker (gated)** — the back office and all mutations; its **whole hostname** sits behind
26
+ Cloudflare Access.
27
+
28
+ **Why split.** On `workers.dev`, Cloudflare Access protects an entire hostname — there is no reliable
29
+ per-path gating. The public routes must stay open for Sparkle, so the admin cannot share that hostname.
30
+ Splitting gives a fully public app and a fully gated admin over one shared dataset.
31
+
32
+ **Host-agnostic.** Both Workers derive their origin from the incoming request, so they run on bare
33
+ `*.workers.dev` URLs with **no custom domain, DNS, or zone**. The admin/app host pair follows a fixed
34
+ naming contract (`alpha-gate-<instance>` and `alpha-gate-<instance>-admin`), which is how the admin
35
+ derives the public `/get` link host (`src/lib/hosts.ts`).
36
+
37
+ **The pivotal decision:** the appcast is **generated per request** from D1. Gating, re-download,
38
+ channels, pinning, rollback, and revocation notices all fall out of one resolver, so there is a single
39
+ source of truth (`src/core/resolver.ts`) the runtime, the download target, and the admin previews share.
40
+
41
+ ## Hard constraints (the ones that bite repeatedly)
42
+
43
+ - **Sparkle cannot downgrade.** An item below the installed version is never offered. So "rollback" is
44
+ always a **roll-forward**: rebuild the previous good code with a *higher* `build_number`. The same
45
+ no-downgrade rule means pinning below, or moving a user to a channel whose top build is lower than
46
+ what they run, won't take effect until a higher build exists.
47
+
48
+ - **`build_number` is the machine-comparable monotonic key** (Sparkle's `sparkle:version`);
49
+ `short_version` is the human string (`sparkle:shortVersionString`). They diverge during a rollback.
50
+ `build_number` must be a **positive integer** and must increase on every publish. Build metadata lives
51
+ in D1 (the resolver queries by channel/status/version); R2 holds only the archive bytes.
52
+
53
+ - **Workers never sign and never hold a signing key.** All signatures — Developer ID + notarization,
54
+ Sparkle EdDSA, any feed signature — are produced on **macOS** at publish time. The Worker only stores
55
+ the bytes plus the fixed EdDSA string per archive. `SURequireSignedFeed` is **off**: it is incompatible
56
+ with per-user dynamic feeds (it would put the signing key on the edge); the per-archive EdDSA still
57
+ blocks tampered binaries.
58
+
59
+ - **The token is never embedded in the binary.** Embedding would break the notarization seal. The build
60
+ is generic and signed once; the token reaches the app out-of-band via deep link
61
+ (`myapp://activate?token=`) or paste on first launch.
62
+
63
+ - **Never hand out raw R2 or pre-signed URLs.** Every download routes through `/download?token=` so
64
+ access logging and instant revocation hold.
65
+
66
+ - **The "no-build" state is surfaced, never silently blocked.** A user with no servable build (no
67
+ available build in their channels, no channel at all, or pinned to a withdrawn build) gets an
68
+ **empty appcast** — Sparkle stays "up to date". A user *stranded under no-downgrade* (their
69
+ channel's top is below what they run) still receives that top item — the resolver never reads the
70
+ installed build; Sparkle itself discards the lower item, with the same quiet "up to date" result.
71
+ Admin actions that would create either state (withdraw, unlink, unassign, pin/unpin) are not
72
+ blocked; they show which users would be affected and proceed on explicit confirmation. The remedy
73
+ is roll-forward, reassignment, or unpin.
74
+
75
+ ## The artifact model
76
+
77
+ The update enclosure is **format-agnostic**. Storage and the appcast assume nothing about the file: the
78
+ enclosure `type` is `application/octet-stream`, the URL is the extension-less `/download?…&via=update`,
79
+ and `/download` sends a `Content-Disposition` filename from the object key. So either a signed **`.zip`**
80
+ or a signed **`.dmg`** can be the Sparkle update artifact — Sparkle verifies the EdDSA, sees the filename,
81
+ and picks the right installer. A single signed DMG can serve both first-install and update.
82
+
83
+ R2 keys are built in exactly one place (`src/r2/keys.ts`): archives at
84
+ `build/<build_number>/<sanitized-filename>` (append-only; `build_number` is unique), branding at
85
+ `branding/icon` / `branding/header`, and an append-only audit anchor head.
86
+
87
+ ## Security model
88
+
89
+ - **Admin auth is fail-closed.** The admin Worker verifies the Cloudflare Access JWT itself
90
+ (RS256-pinned, `aud`/`iss` + expiry checked) as defense-in-depth on top of edge Access. Any error
91
+ rejects. With the Access secrets unset, every admin request is denied. Service tokens are accepted on
92
+ **only** the publish surface — the build upload/register routes plus the read-only
93
+ `/admin/publish-info` pre-check — so a leaked CI credential is bounded to publishing.
94
+
95
+ - **Token-in-URL trade-off.** The token travels in URLs, so it appears in Cloudflare's own request logs
96
+ (accepted for a private alpha). Mitigations: `/get` sets `Referrer-Policy: no-referrer`, and unknown
97
+ tokens get a generic 404 (no existence leak).
98
+
99
+ - **Tamper-evident audit.** Every admin mutation is recorded in a **hash-chained** log, anchored daily to
100
+ an append-only R2 object (and emailed when email is configured). A divergence from the last anchor —
101
+ edit, truncation, rebuild — is flagged. Pair it with Cloudflare's Access auth logs and account Audit
102
+ logs, which an attacker with account access cannot rewrite.
103
+
104
+ - **Account-level caveat.** Anyone with Cloudflare dashboard access to the account can read D1/R2
105
+ directly. Install into a dedicated account if that isolation matters.
106
+
107
+ - **Branding inputs that reach a raw sink are validated.** The accent colour is interpolated raw into a
108
+ public `<style>`, so it is constrained to a hex value (rejected on write, coerced on read). Uploaded
109
+ branding images are raster-only (SVG is a stored-XSS vector when served from the app origin).
110
+
111
+ ## The back office
112
+
113
+ The admin UI follows a few durable rules ("quiet instrument"), so changes should preserve them:
114
+
115
+ - **The resolver is visible.** The Overview serving map (channel → build → audience, plus an
116
+ exhaustive *off the map* row), the Users list's **Next check** column, and every detail page's
117
+ verdict strip are all computed by the same pure core the runtime uses (`core/verdict.ts` over
118
+ `core/resolver.ts`) — the UI can never disagree with what Sparkle actually receives. "Which build
119
+ does this user get, and why not?" must always be answerable on the page where you'd act on it.
120
+ - **Exception-only state.** Healthy states render as silence. `critical` is the single filled tag;
121
+ withdrawn/revoked/pinned/hidden are quiet outlined tags; every fault is amber and carries its
122
+ cause and a remedy. Builds are written exactly one way everywhere: the mono lockup
123
+ `#1500 · v1.2.1` — never a DB row id.
124
+ - **The feedback loop closes.** Mutations 303 back to the page the operator acted from (validated
125
+ `return_to`) with a flash slug the target page renders; free text never rides in the URL.
126
+ Confirmation pages name their subject in operator words and Cancel returns to the origin.
127
+ Destructive actions (revoke, reissue, delete channel) are always confirmed; revoke is reversible
128
+ (**Reactivate** restores the same link). Stale forms are clear 400s *before* any write.
129
+ - **The audit chain means "something changed".** No-op re-posts (double submit, stale tab) are
130
+ flash no-ops, not phantom audit rows; audit targets are human identifiers (emails, build
131
+ numbers). The Audit page and Overview show the chain's live integrity via the same
132
+ `assessChain` judgment the daily anchor records.
133
+ - **One vocabulary on operator surfaces:** user, channel, request. (URLs and DB keep `stream` /
134
+ `pending` as stable contracts; only copy changed.)
135
+ - **Entity pickers are comboboxes over native selects.** Wherever a user or build is picked
136
+ (pin, link, assign), the markup is a plain `<select>` that works with JavaScript disabled; the
137
+ self-contained enhancer (`views/admin/combobox.tsx`) turns it into a type-to-filter combobox —
138
+ multi-select (chips) on the channel page, whose batch routes accept repeated ids. New pickers
139
+ should reuse it, not grow bare selects.
140
+ - **Theme follows the OS by default**, with a light/system/dark override (sidebar toggle → `theme`
141
+ cookie → `data-theme` on `<html>`). The dark tokens exist once (`darkRules` in the admin layout)
142
+ and apply both via `prefers-color-scheme` and via the forced attribute — never fork them.
143
+
144
+ ## Email
145
+
146
+ Copy-paste invites are the **free-tier default**: with no email configured, every invite/notice is a
147
+ link the admin sends manually (the invite page has a one-click copy). Automated delivery uses Cloudflare
148
+ Email Service via the admin Worker's `send_email` binding — which requires **Workers Paid + a real,
149
+ onboarded sending domain** (SPF/DKIM DNS). **A `*.workers.dev` hostname cannot be that domain** (you
150
+ don't control its DNS), so a no-custom-domain instance must use copy-paste. Delivery sits behind an
151
+ `EmailSender` seam, so another provider (e.g. a single-sender transactional service that needs no domain)
152
+ can be added without touching the call sites. A misconfigured email setup falls back to copy-paste rather
153
+ than erroring, and a failed send never 500s — the page surfaces the reason and the link.
154
+
155
+ ## Design for testability
156
+
157
+ I/O lives at the edges; the logic is **pure**. The resolver, the no-build computation, validation, the
158
+ audit hash-chain, and appcast XML generation are plain functions over plain data (no bindings), so most
159
+ logic is testable with zero runtime. Handlers and services receive a `Deps` object (db, r2, clock,
160
+ access, email, fetch) and never import bindings directly, so every seam is swappable in tests. `Date` /
161
+ `Date.now()` are forbidden outside the two clock seams — `lib/clock.ts` for the Workers,
162
+ `deploy/seams/clock.ts` for the deploy CLI (a lint rule enforces it) — so time-dependent behavior takes
163
+ a seeded clock. The suites run **offline**: the Worker suite in the Workers runtime
164
+ (`@cloudflare/vitest-pool-workers` / Miniflare), the deploy-CLI suite in plain Node. What those can't
165
+ simulate — the real Access JWT, Cloudflare email delivery, bucket-lock, an end-to-end Sparkle client —
166
+ sits behind injectable seams and is the only thing needing a throwaway deploy.
167
+
168
+ **Client-side scripts gotcha.** The admin's small browser scripts (table sort/filter, upload autofill)
169
+ serialize **pure** functions into the page via `Function.prototype.toString()` so the browser runs the
170
+ exact tested code. A serialized function must reference **nothing** at module scope: a module-level
171
+ constant, or an esbuild runtime helper (`__name`, `__spreadValues`, `__async`, …), would be `undefined`
172
+ in the browser and throw. There is an identity shim for `__name`; a unit-test regex fails the build if
173
+ any other helper leaks in. Keep those functions self-contained (no inner-closure references, no spread/
174
+ async/`**`) and the contract holds.
175
+
176
+ ## Operating principles
177
+
178
+ - **One idempotent script.** `deploy.sh` provisions D1 + R2, applies migrations, and deploys both
179
+ Workers; re-running updates in place with data preserved. Deployment is **pure wrangler** — no API
180
+ token, DNS, or zone. The two things a script can't do (enable Access on the admin hostname; create a
181
+ service token) are printed as a one-time checklist.
182
+ - **Everything is namespaced by the instance slug**, so one account holds many independent instances
183
+ (separate D1, R2, Workers, Access app, and per-slug state).
184
+ - **Two install channels, one CLI.** The same commands run from an npm install (`npx alpha-gate …`,
185
+ the `bin`) or a git clone (`./deploy/*.sh`). The only difference is where per-instance state lives:
186
+ `~/.alpha-gate` for npm (the package files sit in the ephemeral npm cache), `<repo>/.deploy` for a
187
+ clone; `$ALPHA_GATE_HOME` overrides. So the rendered wrangler config uses **absolute** paths into the
188
+ package for `main` and `migrations_dir` (`core/paths.ts` resolves the state dir; keep the two — code
189
+ root vs state dir — distinct).
190
+ - **The self-update banner checks the npm registry.** The deployed Worker's daily cron polls
191
+ `registry.npmjs.org/<name>/latest` and compares against its baked-in `TOOL_VERSION` (= the deploying
192
+ package's version); the upgrade signals travel in `package.json`'s `alphaGate` field. It only
193
+ **notifies** — a Worker never deploys itself (that would need a privileged Cloudflare API token on
194
+ the edge, which the "no API token / hold no privileged creds" posture forbids). Updating stays an
195
+ operator-run, human-in-the-loop `deploy` (safe for migrations).