diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 9ec3222..61df235 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -41,6 +41,20 @@ jobs: - name: Types run: npm run check + - name: Unit tests + # PLAN.md §8 — the beta signup's decision path: honeypot, form token, timing, rate + # limit, cap, validation, duplicate, removal. + # + # The first thing in this repository that the other checks cannot see. They all read + # the built output, and none of this appears there: a honeypot that has stopped + # working produces a build that is identical in every way to one where it works. + # + # The test file is NAMED rather than the directory passed. `node --test test/` fails + # on Node 22 with MODULE_NOT_FOUND — directory mode is not portable across the + # versions this org runs, and this workflow pins 22 while developers are on 24, so + # the shorter form would pass locally and break only here. + run: npm test + - name: Production build run: npm run build diff --git a/PLAN.md b/PLAN.md index dfe8cdc..faf7bf7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -243,6 +243,7 @@ somewhere other than the thing it decided. The count of record is **twenty-five* | D14–D16 | §7, "How phase 2 actually built it" | The branding pipeline: one raster in, brand text applied at boot, the mark is the real emblem | | D17–D19 | §10, "How phase 3 built the homepage" | The data-path diagram, all five groups on the homepage, the emblem-led hero | | D20–D25 | §10, "How phase 4 built the marketing pages" | `/features/` as the same list with detail, `/architecture/` as reasons not reference, the absences as data, the two absorbed scope items, `needsModule`, the demo deep links | +| D26–D29 | §8, "How phase 5 built the app and the beta" | The screenshot slot reserved for phase 9, the demo as the tester target, `/beta` handling its own POST, equal billing for the APK and the beta | --- @@ -257,6 +258,9 @@ POST and write it somewhere (§8), and branding must be overridable by dropping mount **without rebuilding the image** (§7) — which means the bytes cannot be fingerprinted into the build output. +*Amended 2026-08-24 by D28: the signup is `/beta` itself rather than a `POST /api/beta-signup` +endpoint. The dynamic surface is still two routes and the reasoning above is unchanged; see §8.* + Why not a separate API service: one container is one thing to deploy, one thing to patch, and one log to read. The dynamic surface is three endpoints. @@ -266,7 +270,7 @@ log to read. The dynamic surface is three endpoints. │ Astro (Node adapter) │ │ ├── prerendered pages ......... marketing, docs, legal — plain HTML │ │ ├── GET /brand/* ............ reads the bind mount, falls back to defaults │ -│ └── POST /api/beta-signup ..... writes SQLite on the bind mount │ +│ └── GET + POST /beta/ ......... renders the form; writes SQLite on the mount │ │ │ │ /app/brand-default ..... baked into the image (stock logo, tokens, brand.json) │ └─────────┬──────────────────────────────────────────────┬───────────────────────────┘ @@ -435,6 +439,17 @@ identify. The salt lives in the container environment, so rotating it destroys t deliberately. `consent_text` stores the wording itself rather than a version number, so a record can always answer "what exactly did this person agree to" without archaeology. +Phase 5 added the second table this section describes in prose but does not draw — `attempts` +(`ip_hash`, `at`, `outcome`), which is the token bucket below, persisted as the events themselves +rather than as a counter that would need a decay schedule and a clock it trusts. It is pruned on +write, so nothing has to remember to run. + +One consequence of `remove` that follows from the promise rather than from a separate choice: the +address is **overwritten**, not flagged, so afterwards the store cannot tell a removed address from +one it has never seen. Somebody who left and signs up again is an ordinary new row. Keeping a hash +so the form could say "you were removed" would mean retaining a derived identifier for the one +person who explicitly asked not to be retained. + ### Abuse resistance without a third party D9 and §6's CSP forbid external requests, so no captcha service. Instead: @@ -457,6 +472,11 @@ docker compose exec site node scripts/beta.mjs remove # deletion reques docker compose exec site node scripts/beta.mjs stats ``` +Phase 5 note: `export` writes **two** files, not one. The `.csv` is the record (id, address, date, +status, the consent wording); the `.txt` beside it is one address per line, which is what Play's +tester list actually wants pasted. Producing only the CSV would mean hand-editing it before every +paste, which is where a mistake would come from. + Deliberately not an admin page. An authenticated HTTP surface on a marketing site is a login form, a session, a password to rotate and a thing to patch — for an operation performed by the one person who already has shell on the host, against a file already on their disk. The CSV lands in the bind @@ -464,6 +484,74 @@ mount and is opened locally. `remove` exists because §9 promises deletion on request and a promise needs a mechanism. +### How phase 5 built the app and the beta + +Four decisions taken before the pages were written (org lead, 2026-08-24), plus what the +repositories said when the plan above was checked against them. + +**Three things this section had assumed that turned out not to hold.** §10 promised `/app/` "the 14 +existing screenshots"; they exist and are the wrong fourteen (D26). §8 never said what a tester +would point the app at, and the app points at nothing by default (D27). And `/app/` can offer a +download today, because every `Android-app` release attaches a signed APK — which §8 and §10 both +omitted, having been written as though Play were the only delivery path. + +**D26 — the screenshots are reserved for phase 9, and the slot ships empty.** `docs/android/screenshots/` +is a trusted-device and recovery-code smoke test from 2026-07-22: captured against a development +instance with no seeded content, before the theming work changed how every screen looks, and five +of the fourteen are two-factor prompts. Shipping them would break D4 and would show an app that no +longer looks like that. Phase 9 already stands up the review stack and seeds content for the web +screenshots, so it gains an emulator pass and the phone shots then show the same deployment on the +same day. `src/components/app/Screenshots.astro` exists now, rendering nothing, so filling it is a +data change rather than a design task. Rejected: shipping the fourteen, and pulling phase 9's rig +forward into phase 5. + +**D27 — the public demo is the tester target, so the beta waits for it.** `ConnectScreen.kt` on +`Android-app` `main` is unambiguous — nothing in the app runs until a valid Runic Gateway site has +been entered and validated — so an installed app with no deployment behind it is a text field. The +alternative considered and declined was naming UOMysticmoon, which would have opened the beta to +players immediately at the cost of publishing a private shard's address on a public page. The +consequence is accepted rather than hidden: **the beta cannot start until §15's demo VM exists**, +which is the second of the two gates `/beta` states outright. It costs nothing today, because D28's +other gate — no closed test track — is open anyway. + +**D28 — `/beta` handles its own POST; there is no `/api/beta-signup`.** §6 specified an endpoint, +and an endpoint cannot report a validation error without JavaScript: it answers with JSON, which +makes the form script-only, or with a redirect, which returns a person who mistyped an address to a +blank form with no explanation. Both are poor on a page whose job is conversion, and the first is +worse on a site with no analytics — a form that silently does nothing for a reader with scripts off +has no way of telling anyone it is broken. Handling the POST in the page costs one on-demand route +and buys a form that works with JavaScript disabled, renders every outcome in the real layout, and +needs no client-side code, so nothing on it argues with the CSP. + +**D29 — the APK and the beta get equal billing, and the APK link is currently off.** Two panels of +the same weight: sideload today, or join the closed test for Play delivery and automatic updates. +The beta argues for itself on convenience rather than on being the only door. But the org lead +reports that the published `v0.5.0` build does not work, so `platform.json`'s `androidApk.serviceable` +is `false` and the panel renders a plain statement that the build is being replaced rather than a +link. That flag is the one value in `platform.json` with no authority to check it against, and +deliberately so — no fetch can tell whether an APK runs. `checkFacts.mjs` asserts the two assets +still exist and that `minSdk` still says what "Android 10 or newer" claims, so the link is correct +the moment a working build flips the boolean. The panel is not removed while the link is off: a page +that omitted sideloading would read, to somebody who knows the APK exists, as a page hiding it. + +**Three mechanisms this phase added that the plan did not anticipate.** + +- **`liveBrand()`, because a server-rendered page cannot use the boot rewrite.** §7's mechanism + rewrites files in `dist/client`; an on-demand route's HTML never was a file, so `/beta` reading + `brand` would show stock values forever. It reads the mounted `brand.json` itself, guarded by an + mtime check. That is strictly better where it applies — pasting the opt-in URL into the mount + takes effect on the **next request**, with no restart. +- **`checkLinks.mjs` learned what an on-demand route is.** `/beta` is the first on-demand *page*, + and rule 1 resolves links against the build, where it has no file. The fix is not a + `PLANNED_ROUTES` entry — that list's reverse check fires when a route has been *built*, and an + on-demand route never produces a file, so the entry could never rot out and would become the + permanent exemption the two-way check exists to prevent. Instead the routes are derived from the + source: a page exporting `prerender = false` is one. Delete `beta.astro` and the links fail again. +- **A test suite, for the first time in this repository.** The five checks of §12 all read built + output, and none of this phase's logic appears there — a honeypot can stop working entirely and + produce a build identical to one where it works. `node --test`, named file rather than directory + (`node --test test/` fails on Node 22, which is what CI runs). + --- ## 9. Legal pages @@ -521,8 +609,8 @@ Organised by what a reader is trying to do. A reader should never need to know t | `/architecture/` | The system explained visually, for a technical evaluator deciding whether to run it | | `/modules/` | What a module is, `module-uo` as the worked example, writing your own, the Integration Kit (draft-badged per D8) | | `/integrations/` | Discord, mobile + ntfy push, SSO — with an explicit "not built" list | -| `/app/` | The Android app: what it does, the 14 existing screenshots, and the beta CTA | -| `/beta/` | The closed-beta signup (§8) | +| `/app/` | The Android app: what it does, the signed-APK download beside the beta CTA, and a screenshot slot **phase 9 fills** (D26 — the 14 existing screenshots are the wrong fourteen) | +| `/beta/` | The closed-beta signup (§8). The one page that handles its own POST (D28) | | `/community/` | Discord (`discord.gg/t2Jav8yT4g`) as the front door, the Gitea org for code and contributions, the `brand.json` contact address for vulnerabilities (D13) — the split in §14 N3 | | `/privacy/`, `/terms/` | §9 | @@ -819,11 +907,11 @@ a mechanism rather than diligence: | **2** | Branding pipeline (§7): `/brand/*` resolution, `brand-default` contents, the emblem's web derivatives and lockup, `brand.json` wiring | | **3** | Homepage: hero, the data-path diagram as inline SVG, grouped capability sections, CTA, the reserved demo slot | | **4** | Marketing: `/features/`, `/architecture/`, `/modules/`, `/integrations/`, **and `/community/`** — plus `checkLinks.mjs`, the capability `detail` lines, `notBuilt.mjs` and the demo deep links. See D20–D25 | -| **5** | The app and the beta: `/app/`, `/beta/`, the signup endpoint, the SQLite store, rate limiting, the export CLI (§8) | +| **5** | The app and the beta: `/app/`, `/beta/`, the signup handler, the SQLite store, rate limiting, the export CLI (§8). **Also the repository's first `node --test` suite**, and phase 9 inherits an emulator pass (D26) | | **6** | Legal: `/privacy/`, `/terms/`, footer links, and the Play Data Safety notes (§9) | | **7** | Docs — the journey: Getting started (7) + Administration (12). **The installation path is the priority of the whole project** | | **8** | Docs — builder and reference: Modules (8) + Architecture (5) + Reference (7) | -| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components | +| **9** | Screenshots (D4): stand up the local review stack, seed presentable content, capture the admin panel, Teams, forums, marketplace, spawn atlas and shard console; build the screenshot components. **Plus an emulator pass against the same seeded stack** to fill `/app/`'s reserved slot (D26) | | **10** | Polish: responsive, accessibility, SEO/OpenGraph/sitemap/robots, full-text search, CSP headers | | **11** | Validation: `astro check`, production build, **all five check scripts** (tokens, brand, links, facts, types), mobile layout verified in a real browser, a signup walked end to end | | **12** | Delivery: Dockerfile, `docker-compose.yml` with both bind mounts documented, Gitea Actions workflow publishing to the registry, README, CONTRIBUTING with the AI-disclosure requirement, and an operator note covering DNS, TLS and the reverse proxy (D6) | diff --git a/README.md b/README.md index 3834447..82115f1 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,11 @@ closed beta: **players**, who want the app. platform state, the org lead's decisions, the information architecture, and the build phases. Read it before changing anything here. -**Status: phase 1 of 12 — the foundation.** The scaffold, the token file, the typography, the layout -shell and the two build-time checks are in place. The homepage is phase 3, the marketing pages -phase 4, and the documentation — the installation path, which is the priority of the whole project — -phase 7. +**Status: phase 5 of 12 — the app and the beta.** The foundation, the branding pipeline, the +homepage and the five marketing pages are built, and `/app/` and `/beta/` now join them: a signed +APK beside the closed-test signup, backed by a SQLite store on a bind mount and an export CLI. Next +are the legal pages (phase 6) and then the documentation — the installation path, which is the +priority of the whole project — in phases 7 and 8. --- @@ -39,9 +40,12 @@ makes that would otherwise decay quietly. ```bash npm run check:tokens # no colour literal outside the token file -GITEA_TOKEN= npm run check:facts # every version agrees with its authority +npm run check:brand # the branding pipeline's two quiet failures npm run check # astro check -npm run verify # all of the above, then a production build +npm test # the beta signup's decision path +npm run build && npm run check:links # every internal link resolves (reads the build) +GITEA_TOKEN= npm run check:facts # every version agrees with its authority +npm run verify # all of the above, in that order ``` **`checkFacts.mjs`** re-reads every version, protocol number and bundle tag in @@ -71,6 +75,44 @@ every literal `/brand/...` URL in the source through the route's own classifier, asking for a size that is not on the allowlist fails the build rather than 404ing in a browser; and it refuses a brand string short enough that replacing it blindly at boot could corrupt a page. +**`checkLinks.mjs`** reads `dist/client` rather than `src/`, because half the links these pages +carry are assembled from data files and template literals and a source scan sees an expression. It +also refuses a commit permalink into any org repository — those stop tracking the document they name +without ever 404ing, which is the failure a link checker would otherwise call healthy. + +**`npm test`** is the one check that reads none of the above. Everything else inspects built output, +and the beta signup's logic does not appear there: a honeypot can stop working entirely and produce +a build identical to one where it works. It covers the honeypot, the signed form token, the timing +window, the per-connection rate limit, the global cap, address validation, idempotent duplicates and +removal. Run the file by name — `node --test test/` fails on Node 22, which is what CI uses. + +## The closed-beta signup + +`/beta` is the only page that renders per request and the only one that writes anything. It handles +its own POST, so the form works with JavaScript disabled and every outcome renders in the real +layout. The store is SQLite on the `data/` bind mount; **the raw IP address is never recorded**, +only a salted hash used to rate-limit. + +There is no admin page, by design — the tester list is managed from a shell: + +```bash +npm run beta -- stats # counts, and where the store lives +npm run beta -- export # a CSV record + a .txt to paste into Play; marks rows exported +npm run beta -- export -- --all # everything, including already-exported rows +npm run beta -- remove someone@example.com +``` + +| Variable | Default | What it does | +|---|---|---| +| `DATA_DIR` | `./data` | The bind mount holding `beta.sqlite` and `exports/` | +| `BETA_IP_SALT` | random per process | Salts `ip_hash`. Unset means rate limits reset on restart | +| `BETA_FORM_KEY` | random per process | Signs the form token, so a script must fetch the page before posting | +| `BETA_TOTAL_CAP` | `500` | Rows above which the form closes and says so | +| `BETA_PER_HOUR` / `BETA_PER_DAY` | `3` / `24` | Attempts one connection may make | + +Neither random default is a placeholder to be replaced by a constant: a hard-coded salt would make +every deployment's hashes identical and therefore reversible by anyone holding this repository. + ## Branding is bind-mounted data `brand-default/` is baked into the image and always complete. `brand/` is the bind mount and may be @@ -114,14 +156,20 @@ src/ styles/starlight.css Restates our tokens as Starlight's, so the docs cannot drift. layouts/, components/ The marketing chrome. pages/ Marketing routes. + pages/beta.astro The signup. Renders AND handles its own POST — runs per request. content/docs/docs/ Documentation. The extra level mounts Starlight at /docs. pages/brand/ GET /brand/* — the mount, resolved and derived. Runs per request. - lib/brand.mjs The single accessor for brand text. + lib/brand.mjs The single accessor for brand text, plus liveBrand() for the two + routes that render per request and so miss the boot rewrite. lib/brandAssets.mjs Mount-first resolution and on-demand derivation. + lib/betaStore.mjs The SQLite store: schema, dedupe, rate-limit window, cap, removal. + lib/betaSignup.mjs Everything between a POST body and a row. Never throws. lib/tokens.mjs Reads tokens.css at build time, for the few values that leave CSS. config/sidebar.mjs The documentation journey, and the planned tree behind it. brand-default/ The stock brand, baked into the image and always complete. -scripts/ The build-time checks, plus applyBrand (boot) and brand:assets (manual). +scripts/ The build-time checks, plus applyBrand (boot), brand:assets (manual) + and beta.mjs (the tester-list CLI). +test/ node --test. The logic the other checks cannot see. ``` Two directories are bind mounts at runtime and are **not** in the repository: `brand/` overrides diff --git a/brand-default/brand.json b/brand-default/brand.json index a350b36..a44f89e 100644 --- a/brand-default/brand.json +++ b/brand-default/brand.json @@ -28,5 +28,20 @@ "is a non-empty URL, so the site gains a working demo by way of one line in a mounted", "file — no rebuild, consistent with §7." ], - "demoUrl": "" + "demoUrl": "", + + "$comment_beta": [ + "PLAN.md §8 / D27. The Google Play closed-test opt-in URL. Empty until the track", + "exists, and /beta renders a waiting state rather than a broken link while it is.", + "", + "It is safe to publish once it is filled in, and that is the whole reason the beta can", + "work with a site that sends no email (D7): the opt-in link only works for addresses", + "already on the tester list, so anyone else who opens it is refused. Google does not", + "notify testers on the email-list path either — Discord carries the announcement.", + "", + "/beta is one of the two routes that render per request, so unlike every other field", + "here this one is read from the mounted copy on the NEXT REQUEST rather than at the", + "next restart. Paste the URL in and reload the page." + ], + "betaOptInUrl": "" } diff --git a/package-lock.json b/package-lock.json index eb339d9..5aea845 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fontsource-variable/cinzel": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "astro": "^7.2.4", + "better-sqlite3": "^12.11.1", "sharp": "^0.35.3" }, "devDependencies": { @@ -3123,6 +3124,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcp-47": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", @@ -3148,12 +3169,70 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3235,6 +3314,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3508,6 +3593,30 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/defu": { "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", @@ -3703,6 +3812,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -3928,6 +4046,15 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expressive-code": { "version": "0.44.1", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz", @@ -4011,6 +4138,12 @@ } } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/find-process": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz", @@ -4064,6 +4197,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4116,6 +4255,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4593,12 +4738,38 @@ } } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -6164,6 +6335,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6204,6 +6402,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/neotraverse": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", @@ -6226,6 +6430,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -6301,6 +6517,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -6547,6 +6772,33 @@ "node": ">=4" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prettier": { "version": "3.9.6", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", @@ -6591,6 +6843,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -6610,6 +6872,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", @@ -7044,6 +7335,26 @@ "@rolldown/binding-win32-x64-msvc": "1.2.5" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/satteri": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", @@ -7194,6 +7505,51 @@ "node": ">=20" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7274,6 +7630,15 @@ "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", @@ -7321,6 +7686,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7385,6 +7759,34 @@ "node": ">=16" } }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -7461,6 +7863,18 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typesafe-path": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", @@ -8285,6 +8699,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/package.json b/package.json index e06f697..9cdf129 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "check:tokens": "node scripts/checkTokens.mjs", "check:brand": "node scripts/checkBrand.mjs", "check:links": "node scripts/checkLinks.mjs", + "beta": "node scripts/beta.mjs", + "test": "node --test test/beta.test.mjs", "brand:assets": "node scripts/buildBrandAssets.mjs", - "verify": "npm run check:tokens && npm run check:brand && npm run check && npm run build && npm run check:links && npm run check:facts" + "verify": "npm run check:tokens && npm run check:brand && npm run check && npm test && npm run build && npm run check:links && npm run check:facts" }, "dependencies": { "@astrojs/node": "^11.1.4", @@ -27,6 +29,7 @@ "@fontsource-variable/cinzel": "^5.3.0", "@fontsource-variable/inter": "^5.3.0", "astro": "^7.2.4", + "better-sqlite3": "^12.11.1", "sharp": "^0.35.3" }, "devDependencies": { diff --git a/scripts/beta.mjs b/scripts/beta.mjs new file mode 100644 index 0000000..8de4d1e --- /dev/null +++ b/scripts/beta.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * beta.mjs — the closed-beta tester list, from the shell. PLAN.md §8, phase 5. + * + * node scripts/beta.mjs export → data/exports/.csv, marks rows exported + * node scripts/beta.mjs export --all → everything, including already-exported rows + * node scripts/beta.mjs remove → a deletion request + * node scripts/beta.mjs stats + * + * In the container, with the compose file of §6: + * + * docker compose exec site node scripts/beta.mjs export + * + * --------------------------------------------------------------------------------------- + * WHY THIS IS A CLI AND NOT AN ADMIN PAGE + * --------------------------------------------------------------------------------------- + * §8 is explicit, and the argument is worth restating where somebody might be tempted to + * "improve" it. An authenticated HTTP surface on a marketing site is a login form, a + * session, a password to rotate, a lockout policy and a thing to patch — brought into + * existence for an operation performed by the one person who already has shell on the host, + * against a file already on their disk. Adding it would mean this site had an attack + * surface where it currently has none, and the only thing gained is not having to type a + * command. + * + * The CSV lands in the bind mount and is opened locally. Google Play has no API for adding + * an individual tester — every route into a closed test ends with a human pasting a list — + * so the last step is manual no matter how this is built. + * + * `remove` exists because §9 promises deletion on request, and a promise with no mechanism + * behind it is a sentence. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { + EXPORT_DIR, + close, + markExported, + pending, + removeSignup, + stats, +} from '../src/lib/betaStore.mjs'; + +const [command, ...rest] = process.argv.slice(2); + +const USAGE = ` + node scripts/beta.mjs export [--all] write a CSV of the tester list + node scripts/beta.mjs remove honour a deletion request + node scripts/beta.mjs stats counts, and where the store lives +`; + +/** + * RFC 4180 quoting. Overkill for addresses that have already been validated against a + * regex that admits no commas or quotes — and worth having anyway, because the day this + * function is wrong is the day somebody pastes a corrupted list into a system that emails + * strangers, and nothing about that failure would be visible in the CSV. + */ +const csvCell = (value) => { + const text = value === null || value === undefined ? '' : String(value); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +}; + +function doExport(all) { + const rows = pending({ all }); + + if (!rows.length) { + console.log( + all + ? 'Nothing to export — the list is empty.' + : 'Nothing new to export. Use --all to re-export rows already marked exported.' + ); + return; + } + + fs.mkdirSync(EXPORT_DIR, { recursive: true }); + + // Dated rather than sequential, and suffixed only if a second export happens the same + // day: the file name should say when the list was taken, because that is the question + // being asked when somebody finds three of these in a directory next year. + const day = new Date().toISOString().slice(0, 10); + let file = path.join(EXPORT_DIR, `${day}.csv`); + for (let n = 2; fs.existsSync(file); n += 1) { + file = path.join(EXPORT_DIR, `${day}-${n}.csv`); + } + + // Two files, deliberately. Play's tester list wants addresses and nothing else — one per + // line, ready to paste — while the CSV is the record: when they signed up, what they + // agreed to, what state the row is in. Producing only the CSV would mean hand-editing it + // before every paste, which is where a mistake would come from. + const csv = [ + ['id', 'email', 'created_at', 'status', 'consent_text'].join(','), + ...rows.map((row) => + [row.id, row.email, row.created_at, row.status, row.consent_text].map(csvCell).join(',') + ), + ].join('\r\n'); + + const listFile = file.replace(/\.csv$/, '.txt'); + fs.writeFileSync(file, `${csv}\r\n`, 'utf8'); + fs.writeFileSync(listFile, `${rows.map((row) => row.email).join('\n')}\n`, 'utf8'); + + const marked = markExported(rows.map((row) => row.id)); + + console.log(`Wrote ${rows.length} row(s):`); + console.log(` ${file} the record`); + console.log(` ${listFile} paste this into Play`); + console.log(`Marked ${marked} row(s) exported.`); + console.log( + '\nPlay Console → Testing → Closed testing → your track → Testers → paste the list.\n' + + 'Testers still have to open the opt-in link themselves; being on the list is not enough.' + ); +} + +function doRemove(email) { + if (!email) { + console.error('remove needs an address: node scripts/beta.mjs remove someone@example.com'); + process.exitCode = 2; + return; + } + + const result = removeSignup(email.trim().toLowerCase()); + + if (result.removed) { + console.log(`Removed #${result.id}. The address is overwritten, not just flagged.`); + console.log( + 'If that row was already exported, remove the address from the Play tester list too — ' + + 'this store is not the only copy once a CSV has been pasted.' + ); + } else if (result.alreadyRemoved) { + console.log(`#${result.id} was already removed. Nothing to do.`); + } else { + console.log('No such address on the list. Nothing to do.'); + } +} + +function doStats() { + const s = stats(); + const rows = [ + ['store', s.path], + ['total rows', s.total], + ['new (not yet exported)', s.new], + ['exported', s.exported], + ['removed', s.removed], + ['counting toward the cap', `${s.live} / ${s.cap}`], + ['attempts, last 24h', s.attemptsLastDay], + ]; + + const width = Math.max(...rows.map(([label]) => label.length)); + for (const [label, value] of rows) console.log(` ${String(label).padEnd(width)} ${value}`); +} + +try { + switch (command) { + case 'export': + doExport(rest.includes('--all')); + break; + case 'remove': + doRemove(rest[0]); + break; + case 'stats': + doStats(); + break; + default: + console.log(USAGE); + process.exitCode = command ? 2 : 0; + } +} finally { + close(); +} diff --git a/scripts/checkBrand.mjs b/scripts/checkBrand.mjs index 9201229..726dfad 100644 --- a/scripts/checkBrand.mjs +++ b/scripts/checkBrand.mjs @@ -148,6 +148,7 @@ if (brand) { 'discordInvite', 'giteaOrg', 'demoUrl', + 'betaOptInUrl', ]; for (const field of REQUIRED_FIELDS) { @@ -213,6 +214,17 @@ if (brand) { ' be string-replaced. It is handled by the data-attribute gate instead.' ); } + + // betaOptInUrl is out for the same arithmetic reason and a second, stronger one: the + // only page that reads it renders per request, so it never passes through the boot + // rewrite at all. `liveBrand()` in src/lib/brand.mjs reads the mount directly. Putting + // it in TEXT_FIELDS would not make it work — it would be a rewrite that never matches. + if (rewritable.includes('betaOptInUrl')) { + fail( + 'betaOptInUrl must not be in TEXT_FIELDS: its default is the empty string, and\n' + + ' /beta is server-rendered, so it reads the mounted brand.json via liveBrand().' + ); + } } } diff --git a/scripts/checkFacts.mjs b/scripts/checkFacts.mjs index da76f98..8bf8fd2 100644 --- a/scripts/checkFacts.mjs +++ b/scripts/checkFacts.mjs @@ -187,7 +187,48 @@ async function checkWebsiteHasNoReleases() { } // --------------------------------------------------------------------------- -// 8. D13 — the contact address lives in exactly one file +// 8. The Android APK `/app/` offers, and the Android version it claims to need +// +// The app is on no store, so the download block on `/app/` links straight at a release +// asset — the one kind of link on this site that 404s the moment a filename changes, +// because the filename carries the version. Both asset names are asserted against +// releases/latest, so a release that renames or drops either turns this repo red before a +// visitor finds a dead link. +// +// `minSdk` is checked for a different reason. "Android 10 or newer" is prose derived from a +// number, and it is exactly the kind of derived claim §12 exists to stop rotting: raising +// the minimum in the app would otherwise leave this site telling people with Android 10 +// that it works for them. The mapping from API level to the marketing version is a fixed +// table, so checking the number is enough to protect the sentence. +// +// What is NOT checked is `serviceable` — see the comment beside it in platform.json. No +// fetch can tell whether a build works, so that value is a person's word, and the site +// treats it as the gate on the link rather than the link as the gate on itself. +// --------------------------------------------------------------------------- +async function checkAndroidApk() { + const authority = 'Android-app releases/latest assets'; + const release = await json('Android-app/releases/latest'); + const names = new Set((release.assets || []).map((asset) => asset.name)); + + const apk = platform.androidApk; + record(`apk asset`, true, names.has(apk.asset), `${authority} → ${apk.asset}`); + record(`apk checksums`, true, names.has(apk.checksums), `${authority} → ${apk.checksums}`); + + // The asset name carries the version, so it has to agree with the release this site + // already quotes — a mismatch here means one of the two was updated alone. + const tag = String(release.tag_name || '').replace(/^v/, ''); + record('apk names the release', true, apk.asset.includes(tag), `${authority} → ${release.tag_name}`); + + const gradleAuthority = 'Android-app main:app/build.gradle.kts'; + const gradle = await raw('Android-app', 'app/build.gradle.kts', 'main'); + const minSdk = Number( + extract(gradle, /minSdk\s*=\s*(\d+)/, 'minSdk', gradleAuthority) + ); + record('android minSdk', apk.minSdk, minSdk, gradleAuthority); +} + +// --------------------------------------------------------------------------- +// 9. D13 — the contact address lives in exactly one file // --------------------------------------------------------------------------- const CONTACT_CHECK = 'contact address (D13)'; @@ -201,6 +242,25 @@ const SCAN_EXT = new Set([ // every commit, and the noreply Gitea uses for the bot identity. const ALLOWED_ADDRESSES = new Set(['noreply@anthropic.com', 'claude@whitlocktech.net']); +/** + * Domains reserved by RFC 2606 and RFC 6761 for documentation and examples. + * + * Phase 5 is what needed this, and the exemption is principled rather than a concession. + * The rule being enforced is that no CONTACT address appears outside `brand.json` (D13), so + * that changing the published address stays a file copy. An `example.com` address cannot be + * a contact address — the domain is reserved precisely so that documentation can use it and + * it can never route to anybody — so exempting these weakens nothing. + * + * Without it the rule would have forbidden the signup form's `placeholder="you@example.com"` + * and the CLI's usage line, which is the check telling somebody to write a worse page in + * order to satisfy a rule about a different problem. A check people have to work around is + * one they eventually switch off. + * + * Matched on the domain, not on the exact address, because these appear with whatever local + * part reads best in context. + */ +const RESERVED_DOMAINS = /@(?:[a-z0-9-]+\.)*(?:example\.(?:com|net|org)|example|invalid|test|localhost)$/i; + async function* walk(dir) { let entries; try { @@ -226,6 +286,7 @@ async function checkContactAddressIsIsolated() { const text = readFileSync(file, 'utf8'); for (const match of text.matchAll(EMAIL_RE)) { if (ALLOWED_ADDRESSES.has(match[0].toLowerCase())) continue; + if (RESERVED_DOMAINS.test(match[0])) continue; const line = text.slice(0, match.index).split('\n').length; offenders.push(`${path.relative(ROOT, file)}:${line} — ${match[0]}`); } @@ -270,6 +331,7 @@ async function main() { checkBundle, checkReleases, checkWebsiteHasNoReleases, + checkAndroidApk, ]; for (const check of network) { diff --git a/scripts/checkLinks.mjs b/scripts/checkLinks.mjs index 2b0d7d6..bbe289e 100644 --- a/scripts/checkLinks.mjs +++ b/scripts/checkLinks.mjs @@ -65,6 +65,50 @@ const GITEA_HOST = new URL(platform.gitea.base).host; */ const RUNTIME_PREFIXES = ['/brand/']; +/** + * Pages that render per request, and therefore have no file in `dist/client` to resolve + * against — discovered from the source rather than listed here. + * + * Phase 5 is what made this necessary. Until then the only on-demand route was `/brand/*`, + * which is an asset route with its own checker and is skipped by prefix above; `/beta/` is + * the first on-demand PAGE, and it is linked from `/app/`, the header and the footer like + * any other. Rule 1 read `dist/client`, saw nothing at `beta/index.html`, and failed a link + * that is perfectly good. + * + * The tempting fix — an entry in `PLANNED_ROUTES` — would be wrong, and wrong in the exact + * way that list's own comment warns about. Its reverse check fires when a route HAS been + * built, and an on-demand route never produces a file, so the entry could never rot out. It + * would become the permanent exemption the two-way check exists to prevent. + * + * So the route is derived instead: a file under `src/pages/` that exports `prerender = + * false` IS an on-demand route, and its path maps to a URL by Astro's own file-routing + * rules. That is a fact about the source, checkable at the same moment, and it cannot go + * stale — delete `beta.astro` and the links to `/beta/` start failing again immediately, + * which is the behaviour rule 1 is there to provide. + * + * Dynamic segments (`[...file].ts`) are deliberately not handled: the only one is the brand + * route, already covered by prefix, and inventing a matcher for a case that does not exist + * would be guessing at a shape nobody has written yet. + */ +async function findOnDemandRoutes() { + const pagesDir = path.join(ROOT, 'src', 'pages'); + const routes = new Set(); + + for await (const file of walk(pagesDir, ['.astro', '.ts', '.js'])) { + const source = readFileSync(file, 'utf8'); + if (!/export\s+const\s+prerender\s*=\s*false/.test(source)) continue; + + const relative = path.relative(pagesDir, file).split(path.sep).join('/'); + if (relative.includes('[')) continue; + + const withoutExt = relative.replace(/\.(astro|ts|js)$/, ''); + const name = withoutExt.replace(/(^|\/)index$/, ''); + routes.add(name ? `/${name}/` : '/'); + } + + return routes; +} + /** * Routes the site links today that a later phase builds. * @@ -86,8 +130,6 @@ const RUNTIME_PREFIXES = ['/brand/']; * Adding to it is a deliberate act. If a route is not in §10, it does not belong here. */ const PLANNED_ROUTES = new Map([ - ['/app/', 'phase 5 — the Android app page'], - ['/beta/', 'phase 5 — the closed-beta signup'], ['/privacy/', 'phase 6 — the privacy policy'], ['/terms/', 'phase 6 — the terms'], ]); @@ -103,7 +145,7 @@ function fail(file, line, message) { failures.push({ file, line, message }); } -async function* walk(dir) { +async function* walk(dir, extensions = ['.html']) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); @@ -112,8 +154,8 @@ async function* walk(dir) { } for (const entry of entries) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) yield* walk(full); - else if (path.extname(entry.name) === '.html') yield full; + if (entry.isDirectory()) yield* walk(full, extensions); + else if (extensions.includes(path.extname(entry.name))) yield full; } } @@ -154,6 +196,8 @@ if (!existsSync(DIST)) { process.exit(1); } +const onDemandRoutes = await findOnDemandRoutes(); + /* ======================================================================================= 1. Internal links resolve ======================================================================================= */ @@ -195,6 +239,10 @@ for await (const file of walk(DIST)) { if (resolvesInBuild(value)) continue; + // A page that renders per request has no file to find. Checked here rather than as a + // prefix skip, so an on-demand route still has to EXIST — see findOnDemandRoutes. + if (onDemandRoutes.has(value.replace(/[?#].*$/, ''))) continue; + const planned = PLANNED_ROUTES.get(value.replace(/[?#].*$/, '')); if (planned) { plannedSeen.add(value.replace(/[?#].*$/, '')); diff --git a/src/components/app/Screenshots.astro b/src/components/app/Screenshots.astro new file mode 100644 index 0000000..160370f --- /dev/null +++ b/src/components/app/Screenshots.astro @@ -0,0 +1,124 @@ +--- +/** + * The app's screenshot strip — defined now, empty until phase 9 (D26). + * + * --------------------------------------------------------------------------------------- + * WHY A COMPONENT THAT RENDERS NOTHING IS WORTH COMMITTING + * --------------------------------------------------------------------------------------- + * PLAN.md §10 says `/app/` shows "the 14 existing screenshots". They exist — + * `docs/android/screenshots/` on the `docs` repository — and they are the wrong fourteen: + * a trusted-device and recovery-code smoke test from 2026-07-22, captured against a + * development instance with no seeded content, before the theming work that changed how + * every screen looks. Five of them are two-factor prompts. The home shot is an empty page. + * + * Shipping them would break two things at once: D4, which says real screenshots from the + * review stack rather than placeholders, and §1, because they would show an app that no + * longer looks like that. D26 records the decision — the slot is reserved, phase 9 fills + * it, and phase 9 is already the phase that stands up the review stack and seeds the + * content the web screenshots need. Adding an emulator pass to a rig that is being built + * anyway is most of the work already done, and it has the property that the phone shots + * and the browser shots then show the same deployment on the same day. + * + * The component exists rather than the page carrying a `TODO` because a defined shape is + * what makes phase 9 a data change instead of a design task: fill `shots`, and the section + * appears with a heading, a caption line and a grid. Nothing else has to be decided then. + * + * --------------------------------------------------------------------------------------- + * WHAT PHASE 9 SHOULD PUT HERE + * --------------------------------------------------------------------------------------- + * Portrait captures at the device's own pixel size, from an API 36 emulator pointed at the + * seeded review stack, one per idea rather than one per screen: the shard hub with live + * data, the marketplace, a character sheet, the news list, the notification settings, and + * the drawer showing a deployment's own navigation. Six is plenty. Fourteen was never a + * target — it was the number that happened to exist. + * + * They belong in `public/`, not `brand-default/`: these are editorial content shipped with + * the image, not branding an operator overrides (§7). + */ + +/** + * One capture. `width` and `height` are the real pixel dimensions and are required rather + * than optional: without them the page reflows as each image decodes, and a strip of six + * phone screenshots is the worst possible place for that. + * + * Frontmatter is TypeScript, so this is an interface rather than the JSDoc typedef the + * `.mjs` data files use — and it has to be typed explicitly, because an empty array + * annotated by inference is `any[]` and `astro check` is right to refuse it. + */ +interface Shot { + /** Site-absolute path under `/screens/`. */ + src: string; + /** What the screen shows, for somebody who cannot see it. */ + alt: string; + caption: string; + width: number; + height: number; +} + +const shots: Shot[] = []; +--- + +{ + shots.length > 0 && ( +
+

What it looks like

+

+ Captured against a real deployment with real content, not mocked up. The app takes + its colours, type and navigation from the site it is connected to, so these show one + community's app rather than a neutral one. +

+ +
    + {shots.map((shot) => ( +
  • + {shot.alt} +

    {shot.caption}

    +
  • + ))} +
+
+ ) +} + + diff --git a/src/data/app.mjs b/src/data/app.mjs new file mode 100644 index 0000000..167545f --- /dev/null +++ b/src/data/app.mjs @@ -0,0 +1,183 @@ +/** + * app.mjs — what the Android client does, as data. PLAN.md §10 `/app/`, phase 5. + * + * --------------------------------------------------------------------------------------- + * WHY THIS IS NOT capabilities.mjs + * --------------------------------------------------------------------------------------- + * `capabilities.mjs` describes what a DEPLOYMENT does, and it is checked against + * `module-uo`'s manifest because the module declares its capabilities in a machine-readable + * file. The app declares nothing of the kind: what it does is a set of screens in + * `Routes.kt`, and there is no manifest to diff against. So these are written from that + * file and carry the route names, which is the closest thing to a citation available — a + * reader who wants to check a claim here has a file to open. + * + * That is also why the app's list is shorter than the platform's rather than a mirror of + * it. The app is a client for the parts of a deployment a person uses on a phone; the parts + * it does not have are not missing, they are the parts nobody wants on a phone. + * + * --------------------------------------------------------------------------------------- + * "GATED BY WHAT THE DEPLOYMENT PUBLISHES" IS LOAD-BEARING, NOT A DISCLAIMER + * --------------------------------------------------------------------------------------- + * Almost everything here is conditional on the site the app is pointed at: shard screens + * appear only when the deployment runs a game module and has that visibility feature + * turned on, staff screens only for a staff account, push only where the operator runs an + * ntfy. A features list that omitted that would be describing an app nobody will see, + * because there is no default deployment — see `requiresDeployment` below. + */ + +/** + * The single most misunderstood thing about this app, stated once and rendered prominently. + * + * `ConnectScreen.kt` on `Android-app` `main`: "First-run 'Connect to your shard's website' + * screen. Nothing else in the app runs until a valid Runic Gateway site is entered and + * validated." Not a soft default that can be changed later in settings — the gate on + * everything else. An install with no deployment behind it is a text field, and a page that + * let somebody find that out after downloading would have wasted their time on purpose. + */ +export const requiresDeployment = { + title: 'It is a client. It ships pointed at nothing.', + body: + 'The first screen asks for the web address of a site running Runic Gateway and checks ' + + 'it before anything else in the app will open. There is no default server, no ' + + 'directory of servers, and no account with us — the app talks to the deployment you ' + + 'name and to nothing else. If you do not run one and are not a member of a community ' + + 'that does, the app has nothing to show you yet.', +}; + +/** + * @typedef {object} AppFeature + * @property {string} title + * @property {string} body + * @property {string} [gate] What the deployment must provide for this to appear at all. + */ + +/** @type {{ heading: string, blurb: string, items: AppFeature[] }[]} */ +export const appFeatures = [ + { + heading: 'The shard, live', + blurb: + 'The same feed the website shows, on a phone. Every one of these appears only when ' + + 'the deployment runs a game module and the operator has published that surface — ' + + 'the visibility settings are per-feature and default to off.', + items: [ + { + title: 'Status, and the boards', + body: + 'Whether the game server is up, plus champion spawns, guilds, city governors and ' + + 'houses as the shard reports them.', + gate: 'A game module, and the matching visibility feature', + }, + { + title: 'The player marketplace', + body: + 'Player-run vendors and what is on their shelves, down to a single vendor. Item ' + + "names arrive as the game's own string ids and are resolved against its string " + + 'table, so they read the way they read in the client.', + gate: 'A game module publishing the market', + }, + { + title: 'Rules, leaderboards and the spawn atlas', + body: + "The deployment's published ruleset, its points and loyalty boards, and the " + + 'creature atlas — what spawns where, and what it drops.', + gate: 'A game module, per feature', + }, + ], + }, + { + heading: "The site's content", + blurb: 'News, wiki and pages, read natively rather than in a browser frame.', + items: [ + { + title: 'News, by category', + body: + "Announcements and posts in the categories the site defines, and a link from " + + 'anywhere on the web opens the matching tab rather than the top of the list.', + }, + { + title: 'The wiki and the site pages', + body: + 'Wiki articles and whatever pages the operator has written in the admin panel, ' + + 'including the ones they added to the navigation themselves.', + }, + ], + }, + { + heading: 'Your account', + blurb: + 'Staff are players too — the self-service screens are role-agnostic, and an ' + + 'administrator sees their own characters on the same screen everybody else does.', + items: [ + { + title: 'Signing in, including two-factor', + body: + 'A native sign-in with an authenticator code or a single-use recovery code, or ' + + "single sign-on handed off to the deployment's own provider in a browser tab. " + + 'Trusting a device skips the code for thirty days, and that trust is revocable ' + + 'per device from the app.', + }, + { + title: 'Your characters, vendors and houses', + body: + 'Character sheets, the vendors you run and the houses you own, visible to the ' + + 'account they belong to and to nobody else.', + gate: 'A game module', + }, + ], + }, + { + heading: 'Notifications, on infrastructure the operator owns', + blurb: + 'Opt-in, per stream, and delivered without a third party — which is unusual enough ' + + 'to be worth spelling out.', + items: [ + { + title: 'Self-hosted push', + body: + "Push arrives over the deployment's own ntfy server, not Firebase. The app has " + + 'no Google messaging dependency at all, which is why it works on a device with ' + + 'no Play Services and why no notification passes through anyone else on its way ' + + 'to the phone.', + gate: 'An ntfy server the operator runs', + }, + { + title: 'The message carries no content', + body: + 'What is pushed is which stream fired and an opaque reference — never the ' + + 'subject, the sender or the text. The app opens the right screen and fetches the ' + + 'actual content over the authenticated API, so a notification sitting on a lock ' + + 'screen discloses nothing.', + }, + ], + }, + { + heading: 'Staff work, if you are staff', + blurb: + 'Gated by role in the menu and re-checked against the database on every request, so ' + + 'a demoted account loses the screens immediately rather than at next sign-in.', + items: [ + { + title: 'Moderation, support and content', + body: + 'The dashboard, the moderation queue, support requests and content editing — ' + + 'enough to answer a report from a phone. The surfaces that would let somebody ' + + 'reconfigure the deployment stay on the web.', + gate: 'A staff role on the deployment', + }, + ], + }, + { + heading: 'It looks like the deployment it is pointed at', + blurb: '', + items: [ + { + title: 'The theme comes down the wire', + body: + "The palette, the type, the corner radii, the logo, the hero image and even the " + + "navigation order are read from the site's own appearance settings. Two " + + 'communities running this app do not see the same app, and neither of them had ' + + 'to build one.', + }, + ], + }, +]; diff --git a/src/data/beta.mjs b/src/data/beta.mjs new file mode 100644 index 0000000..22dea4e --- /dev/null +++ b/src/data/beta.mjs @@ -0,0 +1,179 @@ +/** + * beta.mjs — the closed beta as data: the limits, the consent wording, and the two gates + * that are not open yet. PLAN.md §8, built in phase 5. + * + * --------------------------------------------------------------------------------------- + * WHY THE CONSENT TEXT LIVES HERE AND NOT IN THE MARKUP + * --------------------------------------------------------------------------------------- + * §8's schema stores `consent_text` — the exact wording somebody agreed to — rather than a + * version number, so a row can always answer "what did this person actually consent to" + * without going back through the git history of a template. That only works if the string + * the page renders and the string the row records are the same object. One export, read by + * the label on the checkbox and by the insert. + * + * Editing it is therefore a real act: every row written from that moment carries the new + * wording, and the old rows keep the old one, which is the behaviour that makes the column + * worth having. `CONSENT_VERSION` is not what the row stores — it exists so an operator + * reading a CSV can group rows without diffing prose. + * + * --------------------------------------------------------------------------------------- + * THE TWO GATES (D26, D27) + * --------------------------------------------------------------------------------------- + * The page collects signups today and cannot invite anybody yet, for two independent + * reasons, and it says both plainly rather than implying a queue that is moving: + * + * 1. THE PLAY TRACK. A closed test has an opt-in URL, and that URL only works for + * addresses already on the tester list — which is the whole reason §8 can publish it + * and still send no email (D7). The developer account exists; the track does not, so + * there is no URL yet. It is a `brand.json` field for the same reason `demoUrl` is: + * the day the track opens, the confirmation screen gains a working link for the cost + * of a file copy and a restart (§7). + * + * 2. SOMEWHERE TO POINT IT. `ConnectScreen.kt` on `Android-app` `main` is blunt about + * this — "nothing else in the app runs until a valid Runic Gateway site is entered and + * validated". An installed app with no deployment behind it is a text field. D27 makes + * the public demo (§15) the tester target rather than naming a private shard, which + * means the beta opens when the demo VM does. `notBuilt.mjs` already carries that + * absence; this page renders the demo link from `demoUrl` when there is one. + * + * Neither gate is a reason not to collect addresses now — the list is what makes the first + * batch possible on day one — but a page that hid them would be advertising a beta that + * cannot start, which is exactly what §1 forbids. + */ + +/** + * Google Play's closed-testing rules, as verified in the Play Console documentation on + * **2026-08-24**. + * + * These are the one class of fact on this site that `checkFacts.mjs` cannot police: there + * is no API to fetch them from and Play's testing requirements have changed more than once + * (§8 says so in as many words). So they carry a date, they live in data rather than prose + * like every other fact here, and the date is rendered on the page next to them. A reader + * can tell how old the claim is, and so can whoever re-checks it before launch. + */ +export const playPolicy = { + verifiedOn: '2026-08-24', + /** Opted-in testers required, continuously, before production access can be requested. */ + testersRequired: 12, + /** Consecutive days those testers must stay opted in. */ + testerDays: 14, + /** Addresses one pasted email list holds. Context for the cap below, not a target. */ + addressesPerList: 2000, +}; + +/** + * The signup limits (§8's "abuse resistance without a third party"). + * + * Every one of these is overridable by environment variable, and that is deliberate: the + * numbers are guesses about a form nobody has attacked yet, and the alternative to tuning + * them from the compose file is rebuilding an image to change an integer. + * + * `TOTAL_CAP` is the org lead's choice of 500 — far above any plausible demand for a beta + * that needs twelve people, and far below one list's 2,000, so a run that beats both the + * honeypot and the bucket still cannot fill the box before the form closes and says so. + */ +export const limits = { + /** Rows, across all time, above which the form closes. */ + totalCap: readInt('BETA_TOTAL_CAP', 500), + /** Signups one `ip_hash` may make in a rolling hour. */ + perHour: readInt('BETA_PER_HOUR', 3), + /** Signups one `ip_hash` may make in a rolling day. */ + perDay: readInt('BETA_PER_DAY', 24), + /** + * Seconds a human plausibly needs between the page rendering and the form posting. + * + * Two is §8's number and it is generous in the right direction: a person who has already + * decided still has to type an address and tick a box. A script does not. + */ + minSeconds: readInt('BETA_MIN_SECONDS', 2), + /** + * Seconds after which a rendered form is stale. + * + * Not an abuse control — a page left open overnight has a timestamp that says nothing, + * and re-rendering the form is a better answer than trusting it. Twelve hours. + */ + maxSeconds: readInt('BETA_MAX_SECONDS', 12 * 60 * 60), +}; + +function readInt(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value <= 0) { + // Loud, and then carry on with the default. A typo in a compose file should not stop + // the site from booting, and it must not silently become an unlimited form either. + console.warn(`[beta] ignoring ${name}="${raw}" — expected a positive integer.`); + return fallback; + } + return value; +} + +/** + * A label for the batch a row was written in. Stored in no column — see the header. + */ +export const CONSENT_VERSION = '2026-08-24'; + +/** + * The exact sentence beside the checkbox, and the exact sentence written to `consent_text`. + * + * Written to be true of what the code does, not of what a privacy policy template says: + * the address is kept until the beta ends or removal is asked for, it is pasted into Play + * because that is the only way Play accepts testers, and nothing is mailed to it because + * the site cannot send mail at all (D7). + */ +export const CONSENT_TEXT = + 'I understand my email address will be stored so it can be added to the Google Play ' + + 'closed test, that it will be shared with Google Play for that purpose only, that ' + + 'Runic Gateway sends no email of any kind, and that I can ask for it to be deleted at ' + + 'any time.'; + +/** + * What a tester needs, rendered as the page's eligibility list. + * + * The third item is the one that matters and the one a beta page usually omits. It is + * phrased as a dependency rather than a warning because it is one: the app is a client for + * a deployment, and a client with no server is not a product with a missing feature. + */ +export const requirements = [ + { + title: 'An Android device on 10 or newer', + body: + // No backticks. These strings render as text, not as Markdown, so a reader sees the + // punctuation rather than code formatting — caught by looking at the built page. + 'API level 29 is the minimum the app is built against. Phones and tablets both; ' + + 'there is no TV or Wear build and none is planned.', + }, + { + title: 'A Google account, and the willingness to stay opted in', + body: + 'Play counts testers who are opted in continuously. Leaving the test and rejoining ' + + 'resets that count for everybody, which is the one thing a tester can do that ' + + 'actually costs something.', + }, + { + title: 'A Runic Gateway deployment to connect to', + body: + 'The app ships pointed at nothing. Its first screen asks for the address of a site ' + + 'running this platform and validates it before anything else in the app will run — ' + + 'so a tester needs either their own deployment or the public demo, which is the ' + + 'second of the two things this beta is waiting on.', + }, +]; + +/** + * The form's field names, in one place because three files need to agree about them: the + * markup that renders the inputs, the handler that reads the body, and the test that posts + * one. A honeypot whose name drifts is a honeypot that catches nothing, and nothing about + * a passing build would say so. + * + * `HONEYPOT` is named for something a form plausibly has and a bot will want to fill. + * Naming it `honeypot` would be a note to the bot. + */ +export const fields = { + EMAIL: 'email', + CONSENT: 'consent', + HONEYPOT: 'website', + /** When the form was rendered — signed, see `betaSignup.mjs`. */ + ISSUED: 'ts', +}; diff --git a/src/data/notBuilt.mjs b/src/data/notBuilt.mjs index 545b363..f9ff466 100644 --- a/src/data/notBuilt.mjs +++ b/src/data/notBuilt.mjs @@ -127,7 +127,11 @@ export const notBuilt = [ }, { id: 'public-demo', - scope: ['features'], + // Phase 5 added `app` and `beta`, and that is not tidying. D27 makes the demo the + // deployment a beta tester connects to, so on those two pages this stopped being a + // thing the site lacks and became the thing the beta is waiting for. An absence that + // blocks a call to action has to be on the page carrying that call to action. + scope: ['features', 'app', 'beta'], title: 'A public demo you can click through', body: 'Planned and out of scope today: a virtual machine running the whole stack including ' + @@ -138,6 +142,46 @@ export const notBuilt = [ 'The machine being stood up. The site is already built to gain it by way of one line ' + 'in a configuration file, rather than a rebuild.', }, + + /* --------------------------------------------------------------------------------------- + THE ANDROID CLIENT (phase 5) + + These are about the app rather than the platform, and they live here rather than in a + second list on `/app/` for the reason this file exists at all: two lists of absences + drift, and the one that drifts is always the one nobody is looking at. The `scope` tag + is what keeps them off the pages they would be noise on. + --------------------------------------------------------------------------------------- */ + { + id: 'ios-app', + scope: ['app'], + title: 'An iOS app', + body: + 'Android only. There is no iOS build, no cross-platform layer waiting to grow one, ' + + 'and no work in progress — the app is native Kotlin and Compose, so a second ' + + 'platform would be a second app rather than another build target.', + resolvedBy: 'Nothing planned. A deployment is a website first, and that works on any phone.', + }, + { + id: 'play-listing', + scope: ['app', 'beta'], + title: 'A listing on Google Play', + body: + 'The app is not published. A developer account exists; the closed test is the next ' + + 'step, and production access cannot even be requested until a run of testers has ' + + 'been opted in continuously — which is what the beta is for, and why the beta is not ' + + 'a formality.', + resolvedBy: 'The closed test running its course, and then a production review.', + link: { href: '/beta/', label: 'The closed beta' }, + }, + { + id: 'app-offline', + scope: ['app'], + title: 'Reading anything offline', + body: + 'Every screen is a live read against the deployment. Nothing is cached for offline ' + + 'use, so the app with no signal is an app with no content.', + resolvedBy: 'Somebody asking for it. Nobody has.', + }, ]; /** The entries a given page renders, in file order. */ diff --git a/src/data/platform.json b/src/data/platform.json index 6c61e3e..2683f81 100644 --- a/src/data/platform.json +++ b/src/data/platform.json @@ -53,6 +53,28 @@ "androidApplicationId": "com.runicgateway.app", + "$comment_androidApk": [ + "Phase 5 / `/app/`. The app is not on any store, so the only way to install it is the", + "signed APK attached to each Android-app release. `asset` and `checksums` are the two", + "assets checkFacts.mjs asserts exist on releases/latest, and `minSdk` is re-read from", + "the app's build.gradle.kts — so the download block on /app/ cannot outlive the file it", + "points at, and 'Android 10 or newer' cannot outlive the number that makes it true.", + "", + "`serviceable` is the one value here with no authority to check it against, and it is", + "deliberately manual. It answers a question no API can: does the published build", + "actually work. The org lead reports v0.5.0's does not, so the download block renders a", + "'being replaced' state instead of a link, and flipping this to true is the single edit", + "that turns the link back on once a working build is released. A check cannot run an", + "APK; a person can, and this is where they record that they did." + ], + "androidApk": { + "asset": "runic-gateway-0.5.0.apk", + "checksums": "SHA256SUMS", + "minSdk": 29, + "minAndroid": "10", + "serviceable": false + }, + "gitea": { "base": "https://gitea.whitlocktech.com", "org": "RunicGateway" diff --git a/src/lib/betaSignup.mjs b/src/lib/betaSignup.mjs new file mode 100644 index 0000000..378c1e1 --- /dev/null +++ b/src/lib/betaSignup.mjs @@ -0,0 +1,183 @@ +/** + * betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5. + * + * Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this + * is about not trusting a request. The split is what lets the tests drive the whole + * decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch + * database without a server, which is the only way this logic gets exercised at all. + * + * --------------------------------------------------------------------------------------- + * THE ORDER OF THE CHECKS IS PART OF THE DESIGN + * --------------------------------------------------------------------------------------- + * Cheap and silent first, expensive and honest last: + * + * 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately. + * A bot that is told it failed learns which field to leave alone next time; a bot that + * is told it succeeded goes away. Nothing is written. + * 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it + * can post to it. Without the signature the timing check is theatre: `ts` is a number + * in a hidden field and a bot can put yesterday's in it as easily as today's. + * 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab. + * 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes. + * 5. CAP — the form closes at `totalCap` and says so. + * 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and + * the only two that get a specific, useful message. + * + * Steps 3–5 are checked before the address is even parsed, so a limited caller is never + * told anything about an address, and step 6's messages can be specific precisely because + * everything that could be probing has already been turned away. + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs'; +import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs'; + +/** + * The key that signs a rendered form. + * + * Random per process when unset, and that is the right default rather than a compromise: + * the only cost is that forms rendered before a restart are refused (the page re-renders and + * the person tries again), and the alternative — a constant baked into the source — would + * let anyone holding this repository mint tokens for every deployment of it. + */ +const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex'); + +const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex'); + +/** The value of the hidden `ts` field: when the page rendered, and proof that it did. */ +export function issueFormToken(at = Date.now()) { + return `${at}.${sign(at)}`; +} + +/** + * Verify a form token and return how long ago it was issued, or `null` if it is not ours. + */ +export function readFormToken(token) { + if (typeof token !== 'string') return null; + + const dot = token.indexOf('.'); + if (dot < 1) return null; + + const at = Number.parseInt(token.slice(0, dot), 10); + if (!Number.isFinite(at)) return null; + + const given = Buffer.from(token.slice(dot + 1), 'utf8'); + const want = Buffer.from(sign(at), 'utf8'); + if (given.length !== want.length || !timingSafeEqual(given, want)) return null; + + return { at, ageSeconds: (Date.now() - at) / 1000 }; +} + +/** + * Address validation. + * + * Deliberately not RFC 5322. That grammar admits quoted strings, comments and address + * literals, and a form whose job is to produce a line in a Google Play tester list gains + * nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person + * types, with a length bound that stops the column being used as storage. + * + * Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is + * about what gets *written* — a CSV pasted into Play should not carry a stranger's + * capitalisation choices as though they were significant. + */ +const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; + +export function normaliseEmail(raw) { + const value = String(raw ?? '').trim().toLowerCase(); + if (!value || value.length > 254) return null; + if (!EMAIL_RE.test(value)) return null; + return value; +} + +/** + * The outcomes the page renders. One per branch, so the markup never has to interpret a + * message string, and so a new branch cannot be added without giving it a name here. + */ +export const OUTCOME = { + ADDED: 'added', + DUPLICATE: 'duplicate', + /** Honeypot. Renders as success and writes nothing. */ + DECOY: 'decoy', + STALE: 'stale', + TOO_FAST: 'too-fast', + LIMITED: 'limited', + FULL: 'full', + INVALID_EMAIL: 'invalid-email', + NO_CONSENT: 'no-consent', + ERROR: 'error', +}; + +/** Did this outcome put something in front of the person that looks like success? */ +export const isSuccess = (outcome) => + outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY; + +/** + * Run a submitted form through every check and, if it survives, write the row. + * + * `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a + * test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a + * message on one page load, not a stack trace in a person's browser. + */ +export function submit({ form, ip, userAgent }) { + const ipHash = hashIp(ip); + const get = (name) => { + const value = form.get(name); + return typeof value === 'string' ? value : ''; + }; + + // 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real + // person's rate limit for a shared address by tripping a field that person cannot see. + if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY }; + + // 2 and 3. The form has to have come from a page we rendered, recently but not too + // recently. A missing or forged token is treated as staleness rather than as an + // accusation: the honest cause — a restart, a tab open since yesterday — is far more + // common than the dishonest one, and the remedy the page offers is the same. Like the + // honeypot it costs no attempt, for the same reason: a caller that never obtained a + // token must not be able to spend the budget of everyone behind a shared address. + const token = readFormToken(get(fields.ISSUED)); + if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE }; + if (token.ageSeconds < limits.minSeconds) { + recordAttempt(ipHash, OUTCOME.TOO_FAST); + return { outcome: OUTCOME.TOO_FAST }; + } + + // 4. The rate limit, before anything is parsed. + const counts = attemptCounts(ipHash); + if (counts.hour >= limits.perHour || counts.day >= limits.perDay) { + recordAttempt(ipHash, OUTCOME.LIMITED); + return { outcome: OUTCOME.LIMITED }; + } + + // 5. The cap. Checked here rather than only when rendering the form, because the form + // a person is looking at may have been rendered before the last row went in. + if (isFull()) { + recordAttempt(ipHash, OUTCOME.FULL); + return { outcome: OUTCOME.FULL }; + } + + // 6. The two things a real person gets wrong. + const email = normaliseEmail(get(fields.EMAIL)); + if (!email) { + recordAttempt(ipHash, OUTCOME.INVALID_EMAIL); + return { outcome: OUTCOME.INVALID_EMAIL }; + } + + if (!get(fields.CONSENT)) { + recordAttempt(ipHash, OUTCOME.NO_CONSENT); + return { outcome: OUTCOME.NO_CONSENT, email }; + } + + try { + const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT }); + const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED; + recordAttempt(ipHash, outcome); + return { outcome, email }; + } catch (error) { + // A full disk, a read-only mount, a corrupt file. The operator gets the detail; the + // person gets a page that admits it went wrong rather than one that pretends it did not. + console.error('[beta] could not record a signup:', error); + return { outcome: OUTCOME.ERROR, email }; + } +} diff --git a/src/lib/betaStore.mjs b/src/lib/betaStore.mjs new file mode 100644 index 0000000..3578e44 --- /dev/null +++ b/src/lib/betaStore.mjs @@ -0,0 +1,340 @@ +/** + * betaStore.mjs — the closed-beta signup store. PLAN.md §8, built in phase 5. + * + * --------------------------------------------------------------------------------------- + * ONE FILE ON A BIND MOUNT, AND NOTHING ELSE + * --------------------------------------------------------------------------------------- + * SQLite at `/beta.sqlite`, where `` is `/app/data` in the container and + * `./data` in a working tree — the same shape as the brand mount (§6, §7). The whole store + * is one file the operator can copy, back up, or delete, on a disk they already have shell + * on. That is the property that lets §8 refuse to build an admin page: there is no + * authenticated surface on this site, because every operation on this data is performed by + * the one person who can already `cd` to the directory. + * + * --------------------------------------------------------------------------------------- + * WHAT IS AND IS NOT STORED + * --------------------------------------------------------------------------------------- + * The address, because pasting it into Play is the point. The consent wording, because a + * record of consent that cannot reproduce the words is not one. A user agent, because it + * is the only signal that separates a browser from a script after the fact. + * + * **Never the IP address.** `ip_hash` is a salted SHA-256 and the salt lives in the + * environment, so the linkage is destroyed by rotating an env var rather than by a + * migration — and a copy of this file, on its own, cannot be turned back into a list of + * people's addresses. Rate limiting works fine against a hash; identification does not. + * + * --------------------------------------------------------------------------------------- + * OPENED LAZILY, AND SURVIVING NOT BEING OPENABLE + * --------------------------------------------------------------------------------------- + * The database is opened on first use, not at import. Two reasons, both real: the CLI and + * the server import overlapping code and only one of them should create a file in a + * developer's working tree, and `/beta` is a prerendered-looking page whose GET must render + * even when the mount is missing or read-only. A signup that cannot be written is an error + * on one request; a store that throws at import is a site that will not boot. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; + +import { limits } from '../data/beta.mjs'; + +/** The bind mount. `DATA_DIR` matches `BRAND_DIR`'s convention in `brandAssets.mjs`. */ +export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data'); + +/** The store itself. Overridable outright so the tests can point at a scratch file. */ +export const DB_PATH = process.env.BETA_DB || path.join(DATA_DIR, 'beta.sqlite'); + +/** Where `scripts/beta.mjs export` writes. §8 names this path. */ +export const EXPORT_DIR = path.join(DATA_DIR, 'exports'); + +/** + * The salt for `ip_hash`. + * + * A missing salt is not an error, because a site that refuses to serve a signup form over a + * missing environment variable is worse than one that rate-limits against a salt nobody + * wrote down. But an *unset* salt must never be a *constant* — a hard-coded default would + * make every deployment's hashes identical and therefore reversible by anyone with the + * source, which is this project's threat model in one sentence. So the fallback is random + * per process: rate limiting works within a run, and the linkage does not survive a restart. + * + * Set `BETA_IP_SALT` in the compose file to make the limiter outlive a deploy. + * + * Resolved on first use rather than at import, and the warning goes with it. The CLI shares + * this module and never hashes anything, so an eager constant meant `beta.mjs stats` opened + * with a warning about a variable that operation does not use — which is how an operator + * learns to read past warnings. + */ +let ipSalt = null; + +function salt() { + if (ipSalt) return ipSalt; + + ipSalt = process.env.BETA_IP_SALT || ''; + if (!ipSalt) { + console.warn( + '[beta] BETA_IP_SALT is not set — using a random per-process salt. Rate limits will ' + + 'reset on restart. Set it in the compose environment to make them persist.' + ); + ipSalt = randomBytes(32).toString('hex'); + } + return ipSalt; +} + +/** + * The schema of §8, verbatim, plus the one table §8 describes in prose but does not draw. + * + * `email … COLLATE NOCASE` on the UNIQUE constraint is what makes a duplicate answerable + * idempotently rather than twice: `Foo@example.com` and `foo@example.com` are one person, + * and the store — not the caller — is where that has to be true, because the caller is + * three different entry points. + * + * `attempts` is the token bucket §8 asks for, persisted as the events themselves rather + * than as a counter. A counter would need a decay schedule and a clock it trusts; a rolling + * count over rows needs neither, prunes trivially, and can answer "why was this refused" + * long enough after the fact to be useful. It records EVERY attempt that reaches the + * limiter, not every success — a script hammering invalid addresses is exactly the traffic + * the limit exists for, and counting only what succeeded would exempt it. + */ +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS signups ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + created_at TEXT NOT NULL, + ip_hash TEXT NOT NULL, + user_agent TEXT, + consent_text TEXT NOT NULL, + status TEXT NOT NULL, + note TEXT + ); + + CREATE INDEX IF NOT EXISTS signups_status ON signups (status); + + CREATE TABLE IF NOT EXISTS attempts ( + id INTEGER PRIMARY KEY, + ip_hash TEXT NOT NULL, + at TEXT NOT NULL, + outcome TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS attempts_ip_at ON attempts (ip_hash, at); +`; + +/** Row states. `exported` means "pasted into Play", which only the CLI can know. */ +export const STATUS = { NEW: 'new', EXPORTED: 'exported', REMOVED: 'removed' }; + +let db = null; + +/** + * Open (and migrate) the store, creating the mount directory if it is missing. + * + * WAL is on because two processes touch this file: the server, and the operator running the + * CLI against a running container. In the default rollback journal a reader blocks a writer, + * so `beta.mjs stats` during a signup is a locking error rather than a number. `busy_timeout` + * covers the rest — the writes here are single rows, and waiting five seconds is always + * better than failing a person's signup. + */ +export function open() { + if (db) return db; + + fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); + + db = new Database(DB_PATH); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + db.pragma('foreign_keys = ON'); + db.exec(SCHEMA); + + return db; +} + +/** Release the handle. The tests need it; the server never calls it. */ +export function close() { + if (!db) return; + db.close(); + db = null; +} + +/** The salted hash §8 stores in place of an address. */ +export function hashIp(ip) { + return createHash('sha256') + .update(`${salt()}:${ip || 'unknown'}`) + .digest('hex'); +} + +const nowIso = () => new Date().toISOString(); +const isoAgo = (ms) => new Date(Date.now() - ms).toISOString(); + +/** + * Record an attempt, and prune the ones too old to inform any limit. + * + * Pruning on write rather than on a timer keeps the whole store self-maintaining: there is + * no scheduler in this container and nothing should have to remember to run. The window is + * the longest limit plus a margin, so nothing a limit still needs is ever thrown away. + */ +export function recordAttempt(ipHash, outcome) { + const handle = open(); + handle.prepare('INSERT INTO attempts (ip_hash, at, outcome) VALUES (?, ?, ?)').run( + ipHash, + nowIso(), + outcome + ); + handle.prepare('DELETE FROM attempts WHERE at < ?').run(isoAgo(48 * 60 * 60 * 1000)); +} + +/** + * How many attempts this hash has made in the last hour and the last day. + */ +export function attemptCounts(ipHash) { + const handle = open(); + const count = (since) => + handle + .prepare('SELECT COUNT(*) AS n FROM attempts WHERE ip_hash = ? AND at >= ?') + .get(ipHash, since).n; + + return { + hour: count(isoAgo(60 * 60 * 1000)), + day: count(isoAgo(24 * 60 * 60 * 1000)), + }; +} + +/** Rows that count against the global cap — everything not withdrawn. */ +export function liveCount() { + return open() + .prepare('SELECT COUNT(*) AS n FROM signups WHERE status != ?') + .get(STATUS.REMOVED).n; +} + +/** Is the form closed because §8's global cap is reached? */ +export function isFull() { + return liveCount() >= limits.totalCap; +} + +/** + * Insert a signup, or report that it is already there. + * + * The duplicate case returns `{ duplicate: true }` rather than throwing, and the page says + * "you are already on the list" either way — §8's rule, and the reason for it is not + * politeness. An error that distinguishes "added" from "already enrolled" turns this form + * into an oracle for whether a given address is in the beta, which is a disclosure about a + * person made to anyone who can type their address. + * + * A previously removed address is treated as new, and that follows from `removeSignup` + * rather than being a separate decision: removal ERASES the address, so there is nothing + * left to recognise. Keeping a hash of it in order to say "you were removed" would mean + * retaining a derived identifier for the one person who asked not to be retained. Somebody + * who left and signs up again has chosen to sign up again, which is the correct reading. + */ +export function addSignup({ email, ipHash, userAgent, consentText }) { + const handle = open(); + + const existing = handle + .prepare('SELECT id, status FROM signups WHERE email = ?') + .get(email); + if (existing) return { duplicate: true, id: existing.id, status: existing.status }; + + const info = handle + .prepare( + `INSERT INTO signups (email, created_at, ip_hash, user_agent, consent_text, status) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + email, + nowIso(), + ipHash, + // A user agent is a header, and a header is whatever the client felt like sending. + // Truncated because the column is a signal, not a transcript. + (userAgent || '').slice(0, 400) || null, + consentText, + STATUS.NEW + ); + + return { duplicate: false, id: Number(info.lastInsertRowid), status: STATUS.NEW }; +} + +/** Rows for the CLI's export, oldest first so a CSV reads in signup order. */ +export function pending({ all = false } = {}) { + const handle = open(); + return all + ? handle + .prepare('SELECT * FROM signups WHERE status != ? ORDER BY id') + .all(STATUS.REMOVED) + : handle.prepare('SELECT * FROM signups WHERE status = ? ORDER BY id').all(STATUS.NEW); +} + +/** Mark rows as pasted into Play. */ +export function markExported(ids) { + if (!ids.length) return 0; + + const handle = open(); + const stamp = handle.prepare('UPDATE signups SET status = ?, note = ? WHERE id = ?'); + const note = `exported ${nowIso()}`; + + const run = handle.transaction((list) => { + for (const id of list) stamp.run(STATUS.EXPORTED, note, id); + return list.length; + }); + + return run(ids); +} + +/** + * Honour a deletion request. + * + * The address itself is overwritten, not just flagged: §9 promises deletion, and a row that + * still holds the address it promised to delete has not delivered on that. What remains is + * a tombstone — the id, the date, and the fact that a removal happened — which is what lets + * the operator answer "did you action my request" without keeping the thing they asked to + * have removed. The placeholder keeps the UNIQUE constraint satisfiable, so the same person + * signing up again later is an ordinary new row. + * + * ONE CONSEQUENCE, AND IT IS THE RIGHT ONE. After this runs, the store cannot tell a + * removed address from one it has never seen — asking twice gives the same answer as asking + * about a stranger. That is what erasure means. The alternative, keeping a hash so a second + * request could say "already removed", would be retaining a derived identifier for the one + * person who has explicitly asked not to be retained, in order to improve a message only an + * operator reads. + */ +export function removeSignup(email) { + const handle = open(); + + const row = handle.prepare('SELECT id, status FROM signups WHERE email = ?').get(email); + if (!row) return { removed: false }; + + handle + .prepare( + `UPDATE signups + SET email = ?, ip_hash = '', user_agent = NULL, status = ?, note = ? + WHERE id = ?` + ) + .run(`removed-${row.id}@invalid`, STATUS.REMOVED, `removed ${nowIso()}`, row.id); + + return { removed: true, id: row.id }; +} + +/** §8's `stats`. */ +export function stats() { + const handle = open(); + + const byStatus = Object.fromEntries( + handle + .prepare('SELECT status, COUNT(*) AS n FROM signups GROUP BY status') + .all() + .map((row) => [row.status, row.n]) + ); + + return { + total: handle.prepare('SELECT COUNT(*) AS n FROM signups').get().n, + new: byStatus[STATUS.NEW] || 0, + exported: byStatus[STATUS.EXPORTED] || 0, + removed: byStatus[STATUS.REMOVED] || 0, + live: liveCount(), + cap: limits.totalCap, + attemptsLastDay: handle + .prepare('SELECT COUNT(*) AS n FROM attempts WHERE at >= ?') + .get(isoAgo(24 * 60 * 60 * 1000)).n, + path: DB_PATH, + }; +} diff --git a/src/lib/brand.mjs b/src/lib/brand.mjs index ff3c9a9..de44907 100644 --- a/src/lib/brand.mjs +++ b/src/lib/brand.mjs @@ -1,3 +1,6 @@ +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + import brandDefault from '../../brand-default/brand.json' with { type: 'json' }; /** @@ -37,3 +40,76 @@ export const brand = Object.freeze({ ...brandDefault }); export function brandFields() { return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$'))); } + +/* ========================================================================================= + THE LIVE READ, FOR ON-DEMAND ROUTES ONLY (phase 5) + ========================================================================================= + + Everything above is build-time, and the boot rewrite is what carries the mount into + prerendered HTML. Neither reaches a page that renders per request: `applyBrand.mjs` + rewrites files in `dist/client`, and an on-demand route's HTML never existed as a file. + A server-rendered page reading `brand` would therefore show the STOCK value forever, no + matter what is mounted — §7 quietly untrue, on exactly the page that needs it most. + + So `/beta` reads the mount itself. It is allowed to, because it is already executing: + the reason the rest of the site cannot is that it is not running when its HTML is made, + and that argument does not apply here. + + It is also strictly better where it applies. The rewrite happens at boot, so changing a + mounted value means restarting the container; this is picked up on the next request. An + operator who pastes the Play opt-in URL into `brand.json` has a working confirmation + screen before they have finished reading this sentence. + + The mtime guard is what keeps that from being a file read per request. `statSync` on a + file the OS has cached is cheap enough to do on every render and honest enough to notice + an edit immediately, which a TTL would not be. */ + +const MOUNTED_BRAND = path.join( + process.env.BRAND_DIR || path.join(process.cwd(), 'brand'), + 'brand.json' +); + +let cache = { mtimeMs: -1, value: brand }; + +/** + * The brand as it is on disk right now: the mounted `brand.json` layered over the stock + * one, per key. Use from on-demand routes; prerendered pages must keep using `brand`. + * + * Never throws. A missing mount is the normal case, and a malformed one is the operator's + * typo — both fall back to stock with a log line, for the reason `applyBrand.mjs` gives at + * length: a site up with the wrong logo beats a site down with the right one. + */ +export function liveBrand() { + let mtimeMs; + try { + mtimeMs = statSync(MOUNTED_BRAND).mtimeMs; + } catch { + // No mounted file. Cache the stock answer against a sentinel so the miss is not + // re-statted into a re-parse every request. + if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, value: brand }; + return cache.value; + } + + if (mtimeMs === cache.mtimeMs) return cache.value; + + let mounted = null; + try { + mounted = JSON.parse(readFileSync(MOUNTED_BRAND, 'utf8')); + } catch (error) { + console.error(`[brand] the mounted brand.json is not valid JSON and is being ignored: ${error.message}`); + } + + const merged = { ...brand }; + if (mounted && typeof mounted === 'object') { + for (const [key, value] of Object.entries(mounted)) { + // Same two rules as the boot rewrite: `$comment` keys are documentation, and a field + // the stock file does not declare is a typo rather than a new feature. + if (key.startsWith('$')) continue; + if (!(key in brand)) continue; + if (typeof value === 'string') merged[key] = value; + } + } + + cache = { mtimeMs, value: Object.freeze(merged) }; + return cache.value; +} diff --git a/src/pages/app.astro b/src/pages/app.astro new file mode 100644 index 0000000..df78737 --- /dev/null +++ b/src/pages/app.astro @@ -0,0 +1,350 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; +import NotBuilt from '../components/NotBuilt.astro'; +import Screenshots from '../components/app/Screenshots.astro'; + +import platform from '../data/platform.json'; +import { appFeatures, requiresDeployment } from '../data/app.mjs'; +import { playPolicy } from '../data/beta.mjs'; + +/** + * `/app/` — PLAN.md §10, phase 5. + * + * --------------------------------------------------------------------------------------- + * THE PAGE LEADS WITH A LIMITATION, ON PURPOSE + * --------------------------------------------------------------------------------------- + * Directly under the lede, before a single feature, this page says the app ships pointed at + * nothing and will not open until it is given the address of a deployment. That is an + * unusual thing to put above the fold and it is the right thing here, because the + * alternative is somebody installing a 13 MB client and discovering it on the first screen. + * §1's "understated honesty" is cheapest to keep exactly at the moment it costs a download. + * + * --------------------------------------------------------------------------------------- + * TWO WAYS TO GET IT, EQUALLY WEIGHTED — AND ONE OF THEM IS CURRENTLY OFF + * --------------------------------------------------------------------------------------- + * The org lead's call: the sideload and the beta get the same visual weight, with the beta + * arguing for itself on delivery and updates rather than on being the only door. Both + * panels are the same component, side by side, neither styled as the primary. + * + * The APK panel then has a second state, because the published build does not work. It + * renders `platform.androidApk.serviceable ? : `. That flag is a person's judgement rather than a + * fetched fact — `checkFacts.mjs` asserts the assets EXIST but cannot assert they run — so + * turning the link back on is one boolean in `platform.json`, in the same commit as + * whatever release fixed it. + * + * Note what this deliberately does not do: it does not remove the panel. A page that simply + * omitted sideloading while the build is broken would read, to somebody who was told the + * APK exists, as a page hiding it. + * + * --------------------------------------------------------------------------------------- + * THE SCREENSHOT SLOT IS EMPTY AND THAT IS THE DECISION (D26) + * --------------------------------------------------------------------------------------- + * §10 promised "the 14 existing screenshots". They exist, and they are the wrong fourteen: + * a July trusted-device smoke test against an unseeded development instance, captured + * before the theming work landed, showing mostly login and two-factor screens over an + * almost empty home page. Shipping them would break D4 (real screenshots, from the review + * stack, not placeholders) and would show an app that no longer looks like that. + * + * So `Screenshots.astro` renders nothing until phase 9 fills it — the phase that already + * stands up the review stack and seeds presentable content, and now also captures the app + * against it, so the phone shots and the web shots show the same deployment. The component + * exists now so the slot has a defined shape and phase 9 is a data change. + */ +const title = 'The Android app'; +const description = + 'A native Android client for a Runic Gateway deployment: the shard, the site and your ' + + 'account, themed by whichever community you point it at.'; + +const apk = platform.androidApk; +const release = platform.releases['Android-app']; +const releasePage = `${platform.gitea.base}/${platform.gitea.org}/Android-app/releases`; +const downloadBase = `${releasePage}/download/${release}`; +--- + + + +

+ A native Android client — Kotlin and Compose, not a website in a frame. It shows the + live game data a deployment publishes, the news and wiki it hosts, and the parts of + your account that make sense on a phone. +

+

+ It is open source like everything else + here, and it carries no Google messaging dependency: notifications arrive over a + server the operator runs. +

+ + + +
+
+

{requiresDeployment.title}

+

{requiresDeployment.body}

+
+
+ +
+

What it does

+

+ Grouped by what you would open it for. Most of this is conditional on the deployment + you connect to — a community that runs no game module has a news and account app, and + that is a legitimate way to run this. +

+ + { + appFeatures.map((group) => ( +
+

{group.heading}

+ {group.blurb &&

{group.blurb}

} +
    + {group.items.map((item) => ( +
  • +

    {item.title}

    +

    {item.body}

    + {item.gate && ( +

    + Needs + {item.gate} +

    + )} +
  • + ))} +
+
+ )) + } +
+ + + +
+

Two ways to get it

+

+ Neither is the “real” one. Sideloading works today and always will; + the closed test is how it reaches a phone through Play, with updates that install + themselves. +

+ +
+
+

Direct download

+

The signed APK

+ + { + apk.serviceable ? ( + <> +

+ Built and signed by the same CI that cuts every release. Android asks you to + allow installing from your browser or file manager the first time; the + checksum file is there so you can verify what you downloaded before you do. +

+

+ + Download {release} + + + Checksums + +

+ + ) : ( + <> +

+ The published build is being replaced. {release} is on the + releases page but does not install and run correctly, so this page does not + link it — a download that wastes your time is worse than no download. +

+

+ The next release restores this. Nothing about the app has been withdrawn and + the source has not moved; it is one build that went out wrong. +

+

+ + The releases page + +

+ + ) + } + +

+ Android {apk.minAndroid} or newer · installs as {platform.androidApplicationId} +

+
+ +
+

Google Play

+

The closed beta

+

+ Delivery through Play, and updates that arrive on their own instead of being + downloaded again. It is a closed test, so a place on it has to be granted — the + list is being collected now. +

+

+ It has not opened yet, and the page says why in full rather than promising a date: + Play needs {playPolicy.testersRequired} people opted in for {playPolicy.testerDays} + {' '}days before the app can go any further, and a tester needs somewhere to point + it. +

+

+ Join the list +

+

+ No email is ever sent · the address is used for the tester list and nothing else +

+
+
+
+ + + + + diff --git a/src/pages/beta.astro b/src/pages/beta.astro new file mode 100644 index 0000000..d3a63de --- /dev/null +++ b/src/pages/beta.astro @@ -0,0 +1,645 @@ +--- +import Base from '../layouts/Base.astro'; +import PageHeader from '../components/PageHeader.astro'; +import NotBuilt from '../components/NotBuilt.astro'; + +import { liveBrand } from '../lib/brand.mjs'; +import { isFull, liveCount } from '../lib/betaStore.mjs'; +import { issueFormToken, OUTCOME, submit, isSuccess } from '../lib/betaSignup.mjs'; +import { CONSENT_TEXT, fields, limits, playPolicy, requirements } from '../data/beta.mjs'; + +/** + * `/beta/` — the closed-beta signup. PLAN.md §8, phase 5. + * + * --------------------------------------------------------------------------------------- + * THE SECOND ROUTE THAT EXECUTES PER REQUEST — AND IT HANDLES ITS OWN POST (D28) + * --------------------------------------------------------------------------------------- + * §6 lists the dynamic surface as `GET /brand/*` and `POST /api/beta-signup`. The org lead + * amended that on 2026-08-24: this page is the endpoint, and there is no `/api/` route. + * + * The reason is the one thing the endpoint shape cannot do. A separate API route has to + * answer a browser somehow — as JSON, which means the form only works with JavaScript, or + * as a redirect, which means an invalid address returns the person to a blank form with no + * explanation of what went wrong. Both are worse than they sound on a page whose entire job + * is conversion (§8 says to write it to convert), and the first is worse still on a site + * that has no analytics and no third-party anything: a form that silently does nothing for + * a reader with scripts off is a form that has no way of telling anyone it is broken. + * + * Handling the POST here costs one on-demand route and buys a form that works with + * JavaScript disabled, renders every outcome in the real layout, and needs no client-side + * code at all — so nothing on this page has to argue with the strict CSP either. + * + * --------------------------------------------------------------------------------------- + * TWO GATES, BOTH STATED, NEITHER HIDDEN (D27) + * --------------------------------------------------------------------------------------- + * The beta cannot start yet for two independent reasons — no Play track, and nowhere for a + * tester to point the app (see `beta.mjs` for both in full). The page collects addresses + * anyway, because the list is what makes the first batch possible on day one, and says + * plainly that it is a list rather than a queue that is moving. The demo is rendered + * through `NotBuilt` so the absence appears in the same shape it takes everywhere else on + * the site rather than as an apology invented for this page. + * + * --------------------------------------------------------------------------------------- + * WHAT THE SUCCESS SCREEN SHOWS, AND WHY IT CAN SHOW IT + * --------------------------------------------------------------------------------------- + * When `betaOptInUrl` is mounted, the confirmation screen prints the Play opt-in link. That + * is only safe because of how Play's closed testing works: the link admits addresses that + * are already on the tester list and refuses everyone else. It is what lets D7's "the site + * sends no email" hold — Google does not notify testers on the email-list path either, so + * something has to carry the link, and a page the person is already looking at is a better + * channel than an email nobody can send. + */ +export const prerender = false; + +const brand = liveBrand(); + +/** + * A POST is a submission; anything else is somebody arriving. `Astro.request.formData()` + * parses both `application/x-www-form-urlencoded` and `multipart/form-data`, and this form + * is the former — no file input, nothing to stream. + * + * The `try` is not defensive dressing. A malformed body throws here, and the person who + * would see that stack trace is somebody whose browser or proxy mangled a request, not an + * attacker — they should get the form back with a message, the same as a stale token. + */ +let result = null; + +if (Astro.request.method === 'POST') { + try { + const form = await Astro.request.formData(); + result = submit({ + form, + // `x-forwarded-for` is whatever the proxy in front of this container puts there, and + // its first entry is the client as that proxy saw it. It is trusted only as far as + // rate limiting, and it is hashed before it is stored — see betaStore.mjs. Behind a + // proxy that does not set it, everyone shares one bucket, which fails toward refusing + // signups rather than toward accepting abuse. + ip: + Astro.request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + Astro.clientAddress, + userAgent: Astro.request.headers.get('user-agent'), + }); + } catch (error) { + console.error('[beta] could not read the submitted form:', error); + result = { outcome: OUTCOME.ERROR }; + } +} + +/** + * The cap is read per render so the form closes the moment it is reached, and so a store + * that cannot be opened at all does not take the page down with it — a `/beta` that shows + * the argument and admits the form is unavailable is worth more than a 500. + */ +let full = false; +let signed = 0; +let storeDown = false; + +try { + full = isFull(); + signed = liveCount(); +} catch (error) { + console.error('[beta] the signup store is not available:', error); + storeDown = true; +} + +const showForm = !storeDown && !full && !isSuccess(result?.outcome); + +/** + * The message for each outcome. One object rather than a chain of conditionals in the + * markup, so a new outcome added to `OUTCOME` without a message here is visibly missing + * rather than silently rendering an empty box. + * + * The duplicate case says exactly what the added case says, on purpose. §8's rule: an + * answer that distinguished them would turn this form into a way of asking whether any + * given address is in the beta. + */ +const NOTICES = { + [OUTCOME.ADDED]: { + tone: 'ok', + title: "You're on the list.", + body: 'Nothing else is needed from you right now.', + }, + [OUTCOME.DUPLICATE]: { + tone: 'ok', + title: "You're on the list.", + body: 'Nothing else is needed from you right now.', + }, + [OUTCOME.DECOY]: { + tone: 'ok', + title: "You're on the list.", + body: 'Nothing else is needed from you right now.', + }, + [OUTCOME.STALE]: { + tone: 'warn', + title: 'This form had been open a while.', + body: 'Nothing was submitted. Here it is again — the details you typed were not kept.', + }, + [OUTCOME.TOO_FAST]: { + tone: 'warn', + title: 'That was submitted faster than the page could be read.', + body: + 'Nothing was recorded. If you are a person and not a script, wait a moment and send ' + + 'it again — the check is a crude one and it is occasionally wrong about people.', + }, + [OUTCOME.LIMITED]: { + tone: 'warn', + title: 'Too many attempts from your connection.', + body: + 'Try again later. The limit counts attempts rather than signups, so a few mistyped ' + + 'addresses can reach it — nothing has gone wrong with your place on the list.', + }, + [OUTCOME.FULL]: { + tone: 'warn', + title: 'The list is closed for now.', + body: 'It has reached its cap. Discord is the place to hear when it reopens.', + }, + [OUTCOME.INVALID_EMAIL]: { + tone: 'warn', + title: "That address doesn't look right.", + body: 'Check it and send it again. It has to be the Google account you use on your phone.', + }, + [OUTCOME.NO_CONSENT]: { + tone: 'warn', + title: 'The consent box was not ticked.', + body: 'The address cannot be stored without it, so nothing was recorded.', + }, + [OUTCOME.ERROR]: { + tone: 'warn', + title: 'Something went wrong at our end.', + body: + 'Your address was not recorded. This is worth reporting in Discord if it keeps ' + + 'happening — it means the site has a problem, not that you do.', + }, +}; + +const notice = result ? NOTICES[result.outcome] : null; + +/** Only ever shown on a success screen, and only when the track exists. */ +const optInUrl = isSuccess(result?.outcome) ? brand.betaOptInUrl : ''; + +const title = 'The closed beta'; +const description = + 'Join the list for the Runic Gateway Android app closed test. No email is ever sent.'; + +const formToken = issueFormToken(); +--- + + + +

+ The Android app is heading for Google Play by way of a closed + test. This is the list of people who want a place on it. +

+

+ It has not opened yet, and the two reasons are below rather than behind a + “coming soon”. Adding your address now means you are in the first batch + rather than hearing about it afterwards. +

+
+ + { + notice && ( +
+
+

{notice.title}

+

{notice.body}

+ + {isSuccess(result?.outcome) && ( +
+

What happens next

+
    +
  1. + Batches are added to the tester list by hand — there is no way to automate + it, so it happens when a person sits down to do it. +
  2. +
  3. + {optInUrl ? ( + <> + Open the opt-in link with the same Google account once you have been + added. It only works for addresses already on the list, so it is safe + to share this page but not useful to. +
    + + The Play opt-in link + + + ) : ( + <> + When the test track exists you will need to open its opt-in link with + the same Google account. It is not created yet, so there is nothing to + link here — this page will show it as soon as there is. + + )} +
  4. +
  5. + Discord carries + the announcement for each batch. It has to: this site sends no email, to + you or to anyone, ever. +
  6. +
+
+ )} +
+
+ ) + } + +
+

What it is waiting on

+

+ Two things, neither of which is a date. Both are visible from outside, so there is no + reason to be vague about them. +

+ +
    +
  1. +

    Gate one

    +

    The test track

    +

    + The developer account exists; the closed test does not yet. Google needs + {' '}{playPolicy.testersRequired} testers opted in continuously for + {' '}{playPolicy.testerDays} days before the app can be put forward for a + production release, which is exactly why the list is being built before the track + opens rather than after. +

    +

    + Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed + before. +

    +
  2. + +
  3. +

    Gate two

    +

    Somewhere to point it

    +

    + The app is a client and ships pointed at nothing — its first screen asks for the + address of a site running this platform. So a tester needs a deployment, and the + public demo is the one being built for that. Until it is running, a place on the + test would be a place to install an app with nothing behind it. +

    +

    + If you already run a Runic Gateway deployment, this gate does not apply to you — + say so in Discord. +

    +
  4. +
+
+ +
+

What a tester needs

+
    + { + requirements.map((requirement) => ( +
  • +

    {requirement.title}

    +

    {requirement.body}

    +
  • + )) + } +
+
+ +
+

The list

+ + { + storeDown && ( +
+

The form is unavailable.

+

+ Signups cannot be recorded at the moment — this is a fault at our end and it has + been logged. Everything else on this page is still true. +

+
+ ) + } + + { + !storeDown && full && !notice && ( +
+

The list is closed for now.

+

+ It has reached its cap of {limits.totalCap}. Discord is the place to hear when it + reopens. +

+
+ ) + } + + { + showForm && ( +
+ {/* + Posts to itself with no fragment. `#form` was the obvious thing to write and it + is wrong twice: Chrome does not honour a fragment on a POST response anyway, and + if it did it would scroll past the notice — which renders under the page header + and is the thing the person needs to read. Landing at the top is the behaviour, + so the markup should say so rather than ask for something else and get it. + */} +
+

+ One field and a box to tick. The address has to be the Google account you use + on the phone you would test with — Play matches the tester list against the + account, not the device. +

+ + + + + {/* + The honeypot. Hidden from people in three independent ways because any one of + them alone is a browser quirk away from being visible to somebody using a + screen reader or a text browser: off-screen, removed from the accessibility + tree, and excluded from tab order. `autocomplete="off"` matters most of all — + a browser that helpfully fills this in would fail a real person's signup. + */} + + + + + + + + +

+ Stored: the address, the wording above, the date, and a one-way hash of your + connection used only to rate-limit this form. Never your IP address itself. + Ask in Discord to have it deleted and it will be. +

+
+ + +
+ ) + } +
+ + + + + diff --git a/test/beta.test.mjs b/test/beta.test.mjs new file mode 100644 index 0000000..0c11104 --- /dev/null +++ b/test/beta.test.mjs @@ -0,0 +1,319 @@ +/** + * The signup path, tested where it makes decisions. PLAN.md §8, phase 5. + * + * --------------------------------------------------------------------------------------- + * WHY THIS REPOSITORY HAS A TEST SUITE NOW, HAVING NOT NEEDED ONE FOR FOUR PHASES + * --------------------------------------------------------------------------------------- + * Phases 1–4 are pages, and pages are checked by the five scripts in §12: a broken link, a + * stale version, a colour literal, a drifted brand contract. Every one of those defects is + * visible in the built output, which is exactly why a check that reads the built output + * catches them. + * + * Phase 5 is the first thing here that is neither a page nor visible in one. Whether a + * honeypot is checked before the rate limit, whether a duplicate is answered idempotently, + * whether the cap counts removed rows — none of that shows up in `dist`, none of it is + * exercised by loading the page, and all of it is the kind of logic that is wrong quietly. + * The honeypot in particular has the worst failure mode available: it can stop working + * entirely and nothing anywhere gets slower, redder or noisier. + * + * So: `node --test`, the same runner the `website` server uses, against a scratch database. + * + * --------------------------------------------------------------------------------------- + * THE ENVIRONMENT HAS TO BE SET BEFORE THE IMPORTS + * --------------------------------------------------------------------------------------- + * `betaStore.mjs` resolves `DB_PATH` and the salt at module scope, and `beta.mjs` reads the + * limits the same way. A dynamic import after `process.env` is set is therefore not a + * stylistic choice — a static import would bind a developer's real `data/beta.sqlite` and + * this file would quietly test, and pollute, the actual list. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, beforeEach, describe, it } from 'node:test'; + +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-beta-')); + +process.env.BETA_DB = path.join(scratch, 'beta.sqlite'); +process.env.BETA_IP_SALT = 'test-salt'; +process.env.BETA_FORM_KEY = 'test-form-key'; +process.env.BETA_TOTAL_CAP = '5'; +process.env.BETA_PER_HOUR = '3'; +process.env.BETA_PER_DAY = '4'; +process.env.BETA_MIN_SECONDS = '2'; + +let store; +let signup; +let data; + +before(async () => { + store = await import('../src/lib/betaStore.mjs'); + signup = await import('../src/lib/betaSignup.mjs'); + data = await import('../src/data/beta.mjs'); +}); + +after(() => { + store.close(); + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +beforeEach(() => { + const db = store.open(); + db.exec('DELETE FROM signups; DELETE FROM attempts;'); +}); + +/** A form as the handler sees it, aged past the minimum by default. */ +function form(overrides = {}, ageSeconds = 5) { + const { fields } = data; + const map = new Map([ + [fields.EMAIL, 'person@example.com'], + [fields.CONSENT, 'yes'], + [fields.HONEYPOT, ''], + [fields.ISSUED, signup.issueFormToken(Date.now() - ageSeconds * 1000)], + ]); + + for (const [key, value] of Object.entries(overrides)) map.set(key, value); + return map; +} + +const post = (overrides, ageSeconds, ip = '198.51.100.7') => + signup.submit({ form: form(overrides, ageSeconds), ip, userAgent: 'test' }); + +describe('address validation', () => { + it('accepts the shapes a person types, including a plus-alias', () => { + for (const value of ['a@b.co', 'first.last+play@example.co.uk', 'UPPER@Example.COM']) { + assert.ok(signup.normaliseEmail(value), `${value} should be accepted`); + } + }); + + it('lower-cases, so a CSV does not carry capitalisation as though it mattered', () => { + assert.equal(signup.normaliseEmail(' Person@Example.COM '), 'person@example.com'); + }); + + it('rejects what would break a pasted tester list', () => { + for (const value of [ + '', + 'no-at-sign', + 'two@@example.com', + 'trailing@example', + 'a b@example.com', + 'comma,injection@example.com', + `${'x'.repeat(250)}@example.com`, + ]) { + assert.equal(signup.normaliseEmail(value), null, `${value} should be rejected`); + } + }); +}); + +describe('the form token', () => { + it('round-trips an age', () => { + const read = signup.readFormToken(signup.issueFormToken(Date.now() - 30_000)); + assert.ok(read); + assert.ok(read.ageSeconds >= 29 && read.ageSeconds < 32); + }); + + it('refuses a token it did not sign — a bot cannot mint its own timestamp', () => { + assert.equal(signup.readFormToken(`${Date.now()}.deadbeef`), null); + assert.equal(signup.readFormToken(String(Date.now())), null); + assert.equal(signup.readFormToken(''), null); + assert.equal(signup.readFormToken(undefined), null); + }); + + it('refuses a valid signature over a tampered timestamp', () => { + const token = signup.issueFormToken(Date.now() - 5000); + const [, mac] = token.split('.'); + assert.equal(signup.readFormToken(`${Date.now() - 900_000}.${mac}`), null); + }); +}); + +describe('a good submission', () => { + it('is added, and stores the consent wording rather than a version', () => { + const result = post(); + assert.equal(result.outcome, signup.OUTCOME.ADDED); + + const row = store.open().prepare('SELECT * FROM signups').get(); + assert.equal(row.email, 'person@example.com'); + assert.equal(row.status, store.STATUS.NEW); + assert.equal(row.consent_text, data.CONSENT_TEXT); + }); + + it('never stores the IP address itself', () => { + post({}, 5, '203.0.113.9'); + const row = store.open().prepare('SELECT ip_hash FROM signups').get(); + + assert.ok(!row.ip_hash.includes('203.0.113.9')); + assert.equal(row.ip_hash.length, 64); + assert.equal(row.ip_hash, store.hashIp('203.0.113.9')); + }); +}); + +describe('duplicates are answered idempotently', () => { + it('is the same outcome shape whether or not the address was already there', () => { + assert.equal(post().outcome, signup.OUTCOME.ADDED); + assert.equal(post().outcome, signup.OUTCOME.DUPLICATE); + + // Both render the identical screen — §8's rule, so the form cannot be used to ask + // whether a given address is enrolled. This asserts the property the page relies on. + assert.ok(signup.isSuccess(signup.OUTCOME.ADDED)); + assert.ok(signup.isSuccess(signup.OUTCOME.DUPLICATE)); + + assert.equal(store.liveCount(), 1); + }); + + it('treats a differently-cased address as the same person', () => { + post(); + assert.equal(post({ [data.fields.EMAIL]: 'PERSON@EXAMPLE.COM' }).outcome, signup.OUTCOME.DUPLICATE); + assert.equal(store.liveCount(), 1); + }); + + it('treats a re-signup after removal as new, because removal erased the address', () => { + post(); + store.removeSignup('person@example.com'); + + // Not a duplicate: there is nothing left in the store to match against, which is the + // point of erasing rather than flagging. See removeSignup's note. + assert.equal(post().outcome, signup.OUTCOME.ADDED); + + const rows = store.open().prepare('SELECT status FROM signups ORDER BY id').all(); + assert.deepEqual( + rows.map((row) => row.status), + [store.STATUS.REMOVED, store.STATUS.NEW] + ); + }); +}); + +describe('the honeypot', () => { + it('writes nothing and reports success', () => { + const result = post({ [data.fields.HONEYPOT]: 'http://spam.example' }); + + assert.equal(result.outcome, signup.OUTCOME.DECOY); + assert.ok(signup.isSuccess(result.outcome), 'a bot must be told it succeeded'); + assert.equal(store.liveCount(), 0); + }); + + it('costs no rate-limit budget, so it cannot be used to lock out a shared address', () => { + for (let i = 0; i < 10; i += 1) post({ [data.fields.HONEYPOT]: 'x' }); + assert.equal(post().outcome, signup.OUTCOME.ADDED); + }); +}); + +describe('timing', () => { + it('refuses a submission faster than a person could make', () => { + assert.equal(post({}, 0).outcome, signup.OUTCOME.TOO_FAST); + assert.equal(store.liveCount(), 0); + }); + + it('refuses a form rendered too long ago', () => { + const stale = signup.issueFormToken(Date.now() - (data.limits.maxSeconds + 60) * 1000); + assert.equal(post({ [data.fields.ISSUED]: stale }).outcome, signup.OUTCOME.STALE); + }); +}); + +describe('the rate limit', () => { + it('counts attempts rather than successes, so bad addresses are not free', () => { + const bad = { [data.fields.EMAIL]: 'nope' }; + + assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL); + assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL); + assert.equal(post(bad).outcome, signup.OUTCOME.INVALID_EMAIL); + assert.equal(post().outcome, signup.OUTCOME.LIMITED, 'the hourly budget is spent'); + }); + + it('is per connection, not global', () => { + for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.1'); + + assert.equal(post({}, 5, '198.51.100.1').outcome, signup.OUTCOME.LIMITED); + assert.equal(post({}, 5, '198.51.100.2').outcome, signup.OUTCOME.ADDED); + }); + + it('tells a limited caller nothing about any address', () => { + post(); + for (let i = 0; i < 3; i += 1) post({ [data.fields.EMAIL]: 'nope' }, 5, '198.51.100.3'); + + // The address IS on the list; a limited caller must still be told only that they are + // limited. Ordering the limit ahead of the lookup is what makes that true. + const result = post({}, 5, '198.51.100.3'); + assert.equal(result.outcome, signup.OUTCOME.LIMITED); + assert.equal(result.email, undefined); + }); +}); + +describe('the global cap', () => { + const fill = (n) => { + for (let i = 0; i < n; i += 1) post({ [data.fields.EMAIL]: `p${i}@example.com` }, 5, `10.0.0.${i}`); + }; + + it('closes the form at the cap', () => { + fill(5); + assert.equal(store.liveCount(), 5); + assert.ok(store.isFull()); + assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.1').outcome, signup.OUTCOME.FULL); + }); + + it('does not count a removed row against it', () => { + fill(5); + store.removeSignup('p0@example.com'); + + assert.equal(store.liveCount(), 4); + assert.ok(!store.isFull()); + assert.equal(post({ [data.fields.EMAIL]: 'late@example.com' }, 5, '10.0.1.2').outcome, signup.OUTCOME.ADDED); + }); +}); + +describe('consent', () => { + it('is required, and an unticked box records nothing', () => { + assert.equal(post({ [data.fields.CONSENT]: '' }).outcome, signup.OUTCOME.NO_CONSENT); + assert.equal(store.liveCount(), 0); + }); +}); + +describe('removal', () => { + it('overwrites the address rather than flagging the row', () => { + post(); + const result = store.removeSignup('person@example.com'); + assert.ok(result.removed); + + const row = store.open().prepare('SELECT * FROM signups WHERE id = ?').get(result.id); + assert.equal(row.status, store.STATUS.REMOVED); + assert.equal(row.ip_hash, ''); + assert.ok(!row.email.includes('person@example.com'), 'the address must be gone, not marked'); + assert.match(row.note, /^removed /); + }); + + it('is indistinguishable from never having been there, once done', () => { + post(); + store.removeSignup('person@example.com'); + + // Asking again gives exactly the answer a stranger's address gives. That is not a gap + // in the implementation — it is what "the address is gone" has to mean, and the test + // exists to stop somebody "fixing" it by keeping a hash of the removed address. + assert.deepEqual(store.removeSignup('person@example.com'), { removed: false }); + assert.deepEqual(store.removeSignup('nobody@example.com'), { removed: false }); + }); +}); + +describe('the export', () => { + it('takes new rows and marks them, so a second export does not repeat them', () => { + post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.0.1'); + post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.0.2'); + + const first = store.pending(); + assert.equal(first.length, 2); + + store.markExported(first.map((row) => row.id)); + assert.equal(store.pending().length, 0); + assert.equal(store.pending({ all: true }).length, 2); + }); + + it('leaves removed rows out of --all as well as out of the default', () => { + post({ [data.fields.EMAIL]: 'a@example.com' }, 5, '10.1.1.1'); + post({ [data.fields.EMAIL]: 'b@example.com' }, 5, '10.1.1.2'); + store.removeSignup('a@example.com'); + + assert.deepEqual( + store.pending({ all: true }).map((row) => row.email), + ['b@example.com'] + ); + }); +});