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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user