feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s

Builds `/app/` and `/beta/`, the SQLite signup store, the rate limiting and the
export CLI of PLAN.md §8, and adds this repository's first test suite.

Four decisions of record, D26–D29 (§8, "How phase 5 built the app and the beta"):

- D26 — the screenshot slot ships empty, reserved for phase 9. §10 promised
  `/app/` "the 14 existing screenshots"; they are a July trusted-device smoke
  test against an unseeded dev instance, captured before the theming work, and
  five of the fourteen are two-factor prompts. Shipping them would break D4.
  Phase 9 already builds the rig, so it gains an emulator pass.
- D27 — the public demo is the tester target. `ConnectScreen.kt` gates the whole
  app on a validated deployment address, so a tester needs somewhere to point it.
  The beta therefore waits on the demo VM, and the page says so.
- D28 — `/beta` handles its own POST; there is no `/api/beta-signup`. An endpoint
  cannot report a validation error without JavaScript. §6's diagram is amended.
- D29 — the APK and the beta get equal billing, and the APK link is off:
  `androidApk.serviceable` is false because the published v0.5.0 build does not
  work. The panel stays and states that plainly rather than being removed.

Three mechanisms the plan did not anticipate:

- `liveBrand()` — a server-rendered page never passes through the boot rewrite,
  so `/beta` reads the mounted brand.json itself. Pasting the Play opt-in URL in
  takes effect on the next request rather than the next restart.
- `checkLinks.mjs` derives on-demand routes from `prerender = false` in the
  source. A PLANNED_ROUTES entry would have been wrong: 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.
- `npm test` — the five existing checks all read built output, and none of this
  logic appears there. A honeypot can stop working and leave the build identical.

Also: `checkFacts.mjs` gains the APK assets and `minSdk`, and learns that RFC 2606
reserved domains are not contact addresses; the D13 rule is otherwise unchanged.

Verified end to end against the built server: every outcome renders with no
JavaScript, cross-origin POSTs are refused, a mounted opt-in URL appears without
a restart, and the export CLI round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:50:51 -05:00
parent fbd7bbe6fd
commit 1313e748ae
21 changed files with 3366 additions and 22 deletions

169
scripts/beta.mjs Normal file
View 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();
}

View File

@@ -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().'
);
}
}
}

View File

@@ -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) {

View File

@@ -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(/[?#].*$/, ''));