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>
170 lines
6.1 KiB
JavaScript
170 lines
6.1 KiB
JavaScript
#!/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();
|
|
}
|