feat(beta): phase 5 — the app page and the closed-beta signup
All checks were successful
PR checks / checks (pull_request) Successful in 1m5s
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:
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(/[?#].*$/, ''));
|
||||
|
||||
Reference in New Issue
Block a user