feat(beta): phase 5 — the app page and the closed-beta signup #8
@@ -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
|
||||
|
||||
|
||||
98
PLAN.md
98
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 <email> # 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) |
|
||||
|
||||
64
README.md
64
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=<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=<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
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
420
package-lock.json
generated
420
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
169
scripts/beta.mjs
Normal file
169
scripts/beta.mjs
Normal file
@@ -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/<date>.csv, marks rows exported
|
||||
* node scripts/beta.mjs export --all → everything, including already-exported rows
|
||||
* node scripts/beta.mjs remove <email> → 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 <email> 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();
|
||||
}
|
||||
@@ -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().'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(/[?#].*$/, ''));
|
||||
|
||||
124
src/components/app/Screenshots.astro
Normal file
124
src/components/app/Screenshots.astro
Normal file
@@ -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 && (
|
||||
<section class="page section shots">
|
||||
<h2>What it looks like</h2>
|
||||
<p class="prose shots__lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<ul class="shots__grid">
|
||||
{shots.map((shot) => (
|
||||
<li class="shots__item">
|
||||
<img
|
||||
src={shot.src}
|
||||
alt={shot.alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="shots__caption">{shot.caption}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<style>
|
||||
.shots h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.shots__lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shots__grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
}
|
||||
|
||||
.shots__item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.shots__caption {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
183
src/data/app.mjs
Normal file
183
src/data/app.mjs
Normal file
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
179
src/data/beta.mjs
Normal file
179
src/data/beta.mjs
Normal file
@@ -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',
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
|
||||
183
src/lib/betaSignup.mjs
Normal file
183
src/lib/betaSignup.mjs
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
340
src/lib/betaStore.mjs
Normal file
340
src/lib/betaStore.mjs
Normal file
@@ -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 `<data>/beta.sqlite`, where `<data>` 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
350
src/pages/app.astro
Normal file
350
src/pages/app.astro
Normal file
@@ -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 ? <the two download links> : <a plain statement
|
||||
* that the build is being replaced>`. 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}`;
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="On your phone" title="The app for a deployment you already use">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
It is <a href={releasePage} rel="noopener noreferrer">open source like everything else
|
||||
here</a>, and it carries no Google messaging dependency: notifications arrive over a
|
||||
server the operator runs.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
<!-- The limitation, before the features. See the note above. -->
|
||||
<section class="page section">
|
||||
<div class="panel prereq">
|
||||
<h2>{requiresDeployment.title}</h2>
|
||||
<p>{requiresDeployment.body}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<h2 class="app-h2">What it does</h2>
|
||||
<p class="prose app-lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{
|
||||
appFeatures.map((group) => (
|
||||
<section class="app-group">
|
||||
<h3>{group.heading}</h3>
|
||||
{group.blurb && <p class="app-group__blurb">{group.blurb}</p>}
|
||||
<ul class="app-grid">
|
||||
{group.items.map((item) => (
|
||||
<li class="panel app-item">
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.body}</p>
|
||||
{item.gate && (
|
||||
<p class="app-item__gate">
|
||||
<span class="app-item__gate-label">Needs</span>
|
||||
{item.gate}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</section>
|
||||
|
||||
<Screenshots />
|
||||
|
||||
<section class="page section" id="get-it">
|
||||
<h2 class="app-h2">Two ways to get it</h2>
|
||||
<p class="prose app-lede">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="app-getgrid">
|
||||
<div class="panel app-get">
|
||||
<p class="eyebrow">Direct download</p>
|
||||
<h3>The signed APK</h3>
|
||||
|
||||
{
|
||||
apk.serviceable ? (
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--primary" href={`${downloadBase}/${apk.asset}`} rel="noopener noreferrer">
|
||||
Download {release}
|
||||
</a>
|
||||
<a class="btn btn--ghost" href={`${downloadBase}/${apk.checksums}`} rel="noopener noreferrer">
|
||||
Checksums
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<strong>The published build is being replaced.</strong> {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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--ghost" href={releasePage} rel="noopener noreferrer">
|
||||
The releases page
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<p class="app-get__foot">
|
||||
Android {apk.minAndroid} or newer · installs as <code>{platform.androidApplicationId}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel app-get">
|
||||
<p class="eyebrow">Google Play</p>
|
||||
<h3>The closed beta</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p class="app-get__actions">
|
||||
<a class="btn btn--primary" href="/beta/">Join the list</a>
|
||||
</p>
|
||||
<p class="app-get__foot">
|
||||
No email is ever sent · the address is used for the tester list and nothing else
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="app" title="What the app does not do" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.app-h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.app-lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The prerequisite panel. Given a gold edge rather than a warning colour: it is a fact
|
||||
about the product, not an error state, and D8's house style does not shout. */
|
||||
.prereq {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.prereq h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: clamp(1.25rem, 2.6vw, 1.5rem);
|
||||
}
|
||||
|
||||
.prereq p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-group + .app-group {
|
||||
margin-top: 2.75rem;
|
||||
}
|
||||
|
||||
.app-group h3 {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--head);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.app-group__blurb {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-item h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--gold);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.app-item p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
/* Same foot-of-card treatment as NotBuilt's exit condition, so "what this needs" reads
|
||||
as the same kind of statement in the same place on every card in a row. */
|
||||
.app-item__gate {
|
||||
flex: 0;
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.app-item__gate-label {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Two columns that stay equal. `1fr 1fr` rather than auto-fit is the whole point of the
|
||||
org lead's "equal billing": auto-fit would let the longer panel take more room and
|
||||
turn a deliberate tie into an accidental winner. */
|
||||
.app-getgrid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-getgrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-get {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-get h3 {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.app-get p {
|
||||
margin: 0 0 0.9rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.app-get__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
/* Pushes the buttons to the same line in both panels regardless of prose length —
|
||||
the second half of keeping the billing equal. */
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.app-get__foot {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.app-get__foot code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
</style>
|
||||
645
src/pages/beta.astro
Normal file
645
src/pages/beta.astro
Normal file
@@ -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();
|
||||
---
|
||||
|
||||
<Base title={title} description={description}>
|
||||
<PageHeader eyebrow="Android" title="Join the closed test">
|
||||
<p>
|
||||
The <a href="/app/">Android app</a> is heading for Google Play by way of a closed
|
||||
test. This is the list of people who want a place on it.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
{
|
||||
notice && (
|
||||
<section class="page section beta-notice-wrap">
|
||||
<div class={`panel beta-notice beta-notice--${notice.tone}`} role="status">
|
||||
<h2>{notice.title}</h2>
|
||||
<p>{notice.body}</p>
|
||||
|
||||
{isSuccess(result?.outcome) && (
|
||||
<div class="beta-next">
|
||||
<h3>What happens next</h3>
|
||||
<ol>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
{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.
|
||||
<br />
|
||||
<a class="btn btn--primary beta-next__optin" href={optInUrl} rel="noopener noreferrer">
|
||||
The Play opt-in link
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
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.
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<a href={brand.discordInvite} rel="noopener noreferrer">Discord</a> carries
|
||||
the announcement for each batch. It has to: this site sends no email, to
|
||||
you or to anyone, ever.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<section class="page section">
|
||||
<h2 class="beta-h2">What it is waiting on</h2>
|
||||
<p class="prose beta-lede">
|
||||
Two things, neither of which is a date. Both are visible from outside, so there is no
|
||||
reason to be vague about them.
|
||||
</p>
|
||||
|
||||
<ol class="beta-gates">
|
||||
<li class="panel beta-gate">
|
||||
<p class="eyebrow">Gate one</p>
|
||||
<h3>The test track</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p class="beta-gate__foot">
|
||||
Play's testing rules as published on {playPolicy.verifiedOn}, and they have changed
|
||||
before.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="panel beta-gate">
|
||||
<p class="eyebrow">Gate two</p>
|
||||
<h3>Somewhere to point it</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p class="beta-gate__foot">
|
||||
If you already run a Runic Gateway deployment, this gate does not apply to you —
|
||||
say so in Discord.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="page section">
|
||||
<h2 class="beta-h2">What a tester needs</h2>
|
||||
<ul class="beta-reqs">
|
||||
{
|
||||
requirements.map((requirement) => (
|
||||
<li class="panel beta-req">
|
||||
<h3>{requirement.title}</h3>
|
||||
<p>{requirement.body}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="page section" id="form">
|
||||
<h2 class="beta-h2">The list</h2>
|
||||
|
||||
{
|
||||
storeDown && (
|
||||
<div class="panel beta-notice beta-notice--warn">
|
||||
<h3>The form is unavailable.</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!storeDown && full && !notice && (
|
||||
<div class="panel beta-notice beta-notice--warn">
|
||||
<h3>The list is closed for now.</h3>
|
||||
<p>
|
||||
It has reached its cap of {limits.totalCap}. Discord is the place to hear when it
|
||||
reopens.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
showForm && (
|
||||
<div class="beta-formwrap">
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<form class="panel beta-form" method="post" action="/beta/">
|
||||
<p class="beta-form__intro">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label class="beta-form__label" for="beta-email">
|
||||
Google account email
|
||||
</label>
|
||||
<input
|
||||
class="beta-form__input"
|
||||
id="beta-email"
|
||||
type="email"
|
||||
name={fields.EMAIL}
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
required
|
||||
maxlength="254"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div class="beta-form__decoy" aria-hidden="true">
|
||||
<label for="beta-website">Website</label>
|
||||
<input
|
||||
id="beta-website"
|
||||
type="text"
|
||||
name={fields.HONEYPOT}
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name={fields.ISSUED} value={formToken} />
|
||||
|
||||
<label class="beta-form__consent">
|
||||
<input type="checkbox" name={fields.CONSENT} value="yes" required />
|
||||
<span>{CONSENT_TEXT}</span>
|
||||
</label>
|
||||
|
||||
<button class="btn btn--primary beta-form__submit" type="submit">
|
||||
Add me to the list
|
||||
</button>
|
||||
|
||||
<p class="beta-form__foot">
|
||||
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.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside class="beta-count">
|
||||
<p class="beta-count__n">{signed}</p>
|
||||
<p class="beta-count__label">
|
||||
on the list · cap {limits.totalCap}
|
||||
</p>
|
||||
<p class="beta-count__note">
|
||||
Published because a number nobody can see is a number people assume. Play needs
|
||||
{' '}{playPolicy.testersRequired} to actually opt in, which is a different and
|
||||
harder number than this one.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
<NotBuilt scope="beta" title="What is not in place yet" />
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.beta-h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: clamp(1.6rem, 3.2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.beta-lede {
|
||||
margin: 0 0 2.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* The result of a submission, directly under the header where a person's eye already is
|
||||
after a page reload. `role="status"` so it is announced rather than silently replacing
|
||||
the form for anyone not looking at the screen. */
|
||||
.beta-notice-wrap {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.beta-notice h2,
|
||||
.beta-notice h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.beta-notice p {
|
||||
margin: 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.beta-notice--ok {
|
||||
border-color: var(--mode-live);
|
||||
}
|
||||
|
||||
.beta-notice--ok h2 {
|
||||
color: var(--mode-live);
|
||||
}
|
||||
|
||||
.beta-notice--warn {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.beta-notice--warn h2,
|
||||
.beta-notice--warn h3 {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.beta-next {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.beta-next h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--head);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.beta-next ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.beta-next li + li {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.beta-next__optin {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.beta-gates,
|
||||
.beta-reqs {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
}
|
||||
|
||||
.beta-gate h3,
|
||||
.beta-req h3 {
|
||||
margin: 0.35rem 0 0.6rem;
|
||||
color: var(--gold);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.beta-gate p,
|
||||
.beta-req p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.beta-gate__foot {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* The form and the counter. The counter is deliberately narrow and secondary — it is
|
||||
context for the decision, not the reason to make it. */
|
||||
.beta-formwrap {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.beta-formwrap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.beta-form__intro {
|
||||
margin: 0 0 1.5rem;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.beta-form__label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--head);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.beta-form__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-input);
|
||||
background: var(--panel-flat);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.beta-form__input:focus-visible {
|
||||
border-color: var(--portal);
|
||||
outline: 2px solid var(--portal-bright);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Off-screen rather than `display: none`: a bot that reads CSS skips a hidden field, and
|
||||
one that does not read CSS fills this in. Kept in the layout and out of everything
|
||||
else — see the markup for why all three of these are needed. */
|
||||
.beta-form__decoy {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.beta-form__consent {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: flex-start;
|
||||
margin: 1.5rem 0;
|
||||
max-width: var(--measure);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.beta-form__consent input {
|
||||
flex: none;
|
||||
margin-top: 0.2rem;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
accent-color: var(--portal);
|
||||
}
|
||||
|
||||
.beta-form__submit {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.beta-form__foot {
|
||||
margin: 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
max-width: var(--measure);
|
||||
color: var(--dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.beta-count {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-panel);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.beta-count__n {
|
||||
margin: 0;
|
||||
color: var(--gold);
|
||||
font-family: var(--display);
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.beta-count__label {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.beta-count__note {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
color: var(--dim);
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
319
test/beta.test.mjs
Normal file
319
test/beta.test.mjs
Normal file
@@ -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']
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user