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