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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- 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
|
+
});
|
package/deploy/backup.sh
ADDED
|
@@ -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
|
package/deploy/deploy.sh
ADDED
|
@@ -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).
|