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:
183
src/lib/betaSignup.mjs
Normal file
183
src/lib/betaSignup.mjs
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* betaSignup.mjs — everything between a POST body and a row. PLAN.md §8, phase 5.
|
||||
*
|
||||
* Separate from `betaStore.mjs` on purpose: the store is about a file on a disk, and this
|
||||
* is about not trusting a request. The split is what lets the tests drive the whole
|
||||
* decision path — honeypot, timing, limits, cap, validation, duplicate — against a scratch
|
||||
* database without a server, which is the only way this logic gets exercised at all.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* THE ORDER OF THE CHECKS IS PART OF THE DESIGN
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Cheap and silent first, expensive and honest last:
|
||||
*
|
||||
* 1. HONEYPOT — a filled hidden field. Answered with the success screen, deliberately.
|
||||
* A bot that is told it failed learns which field to leave alone next time; a bot that
|
||||
* is told it succeeded goes away. Nothing is written.
|
||||
* 2. FORM TOKEN — the timestamp is signed, so a script has to fetch the page before it
|
||||
* can post to it. Without the signature the timing check is theatre: `ts` is a number
|
||||
* in a hidden field and a bot can put yesterday's in it as easily as today's.
|
||||
* 3. TIMING — under `minSeconds` from render is a script; over `maxSeconds` is a stale tab.
|
||||
* 4. RATE LIMIT — per `ip_hash`, counting attempts rather than successes.
|
||||
* 5. CAP — the form closes at `totalCap` and says so.
|
||||
* 6. VALIDATION and CONSENT — the only two failures a real person can plausibly hit, and
|
||||
* the only two that get a specific, useful message.
|
||||
*
|
||||
* Steps 3–5 are checked before the address is even parsed, so a limited caller is never
|
||||
* told anything about an address, and step 6's messages can be specific precisely because
|
||||
* everything that could be probing has already been turned away.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { CONSENT_TEXT, fields, limits } from '../data/beta.mjs';
|
||||
import { addSignup, attemptCounts, hashIp, isFull, recordAttempt } from './betaStore.mjs';
|
||||
|
||||
/**
|
||||
* The key that signs a rendered form.
|
||||
*
|
||||
* Random per process when unset, and that is the right default rather than a compromise:
|
||||
* the only cost is that forms rendered before a restart are refused (the page re-renders and
|
||||
* the person tries again), and the alternative — a constant baked into the source — would
|
||||
* let anyone holding this repository mint tokens for every deployment of it.
|
||||
*/
|
||||
const FORM_KEY = process.env.BETA_FORM_KEY || randomBytes(32).toString('hex');
|
||||
|
||||
const sign = (value) => createHmac('sha256', FORM_KEY).update(String(value)).digest('hex');
|
||||
|
||||
/** The value of the hidden `ts` field: when the page rendered, and proof that it did. */
|
||||
export function issueFormToken(at = Date.now()) {
|
||||
return `${at}.${sign(at)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a form token and return how long ago it was issued, or `null` if it is not ours.
|
||||
*/
|
||||
export function readFormToken(token) {
|
||||
if (typeof token !== 'string') return null;
|
||||
|
||||
const dot = token.indexOf('.');
|
||||
if (dot < 1) return null;
|
||||
|
||||
const at = Number.parseInt(token.slice(0, dot), 10);
|
||||
if (!Number.isFinite(at)) return null;
|
||||
|
||||
const given = Buffer.from(token.slice(dot + 1), 'utf8');
|
||||
const want = Buffer.from(sign(at), 'utf8');
|
||||
if (given.length !== want.length || !timingSafeEqual(given, want)) return null;
|
||||
|
||||
return { at, ageSeconds: (Date.now() - at) / 1000 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Address validation.
|
||||
*
|
||||
* Deliberately not RFC 5322. That grammar admits quoted strings, comments and address
|
||||
* literals, and a form whose job is to produce a line in a Google Play tester list gains
|
||||
* nothing by accepting `"a b"(c)@[192.0.2.1]`. This is the shape of an address a person
|
||||
* types, with a length bound that stops the column being used as storage.
|
||||
*
|
||||
* Lower-cased on the way in. The store's UNIQUE constraint is already NOCASE, so this is
|
||||
* about what gets *written* — a CSV pasted into Play should not carry a stranger's
|
||||
* capitalisation choices as though they were significant.
|
||||
*/
|
||||
const EMAIL_RE = /^[^\s@,;:<>"'()[\]\\]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
export function normaliseEmail(raw) {
|
||||
const value = String(raw ?? '').trim().toLowerCase();
|
||||
if (!value || value.length > 254) return null;
|
||||
if (!EMAIL_RE.test(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcomes the page renders. One per branch, so the markup never has to interpret a
|
||||
* message string, and so a new branch cannot be added without giving it a name here.
|
||||
*/
|
||||
export const OUTCOME = {
|
||||
ADDED: 'added',
|
||||
DUPLICATE: 'duplicate',
|
||||
/** Honeypot. Renders as success and writes nothing. */
|
||||
DECOY: 'decoy',
|
||||
STALE: 'stale',
|
||||
TOO_FAST: 'too-fast',
|
||||
LIMITED: 'limited',
|
||||
FULL: 'full',
|
||||
INVALID_EMAIL: 'invalid-email',
|
||||
NO_CONSENT: 'no-consent',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
/** Did this outcome put something in front of the person that looks like success? */
|
||||
export const isSuccess = (outcome) =>
|
||||
outcome === OUTCOME.ADDED || outcome === OUTCOME.DUPLICATE || outcome === OUTCOME.DECOY;
|
||||
|
||||
/**
|
||||
* Run a submitted form through every check and, if it survives, write the row.
|
||||
*
|
||||
* `form` is anything with `.get(name)` — a `FormData` from the request, or a `Map` in a
|
||||
* test. Returns `{ outcome, email? }` and never throws: a store that cannot be written is a
|
||||
* message on one page load, not a stack trace in a person's browser.
|
||||
*/
|
||||
export function submit({ form, ip, userAgent }) {
|
||||
const ipHash = hashIp(ip);
|
||||
const get = (name) => {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
|
||||
// 1. The honeypot. No attempt is recorded — a bot must not be able to consume a real
|
||||
// person's rate limit for a shared address by tripping a field that person cannot see.
|
||||
if (get(fields.HONEYPOT).trim() !== '') return { outcome: OUTCOME.DECOY };
|
||||
|
||||
// 2 and 3. The form has to have come from a page we rendered, recently but not too
|
||||
// recently. A missing or forged token is treated as staleness rather than as an
|
||||
// accusation: the honest cause — a restart, a tab open since yesterday — is far more
|
||||
// common than the dishonest one, and the remedy the page offers is the same. Like the
|
||||
// honeypot it costs no attempt, for the same reason: a caller that never obtained a
|
||||
// token must not be able to spend the budget of everyone behind a shared address.
|
||||
const token = readFormToken(get(fields.ISSUED));
|
||||
if (!token || token.ageSeconds > limits.maxSeconds) return { outcome: OUTCOME.STALE };
|
||||
if (token.ageSeconds < limits.minSeconds) {
|
||||
recordAttempt(ipHash, OUTCOME.TOO_FAST);
|
||||
return { outcome: OUTCOME.TOO_FAST };
|
||||
}
|
||||
|
||||
// 4. The rate limit, before anything is parsed.
|
||||
const counts = attemptCounts(ipHash);
|
||||
if (counts.hour >= limits.perHour || counts.day >= limits.perDay) {
|
||||
recordAttempt(ipHash, OUTCOME.LIMITED);
|
||||
return { outcome: OUTCOME.LIMITED };
|
||||
}
|
||||
|
||||
// 5. The cap. Checked here rather than only when rendering the form, because the form
|
||||
// a person is looking at may have been rendered before the last row went in.
|
||||
if (isFull()) {
|
||||
recordAttempt(ipHash, OUTCOME.FULL);
|
||||
return { outcome: OUTCOME.FULL };
|
||||
}
|
||||
|
||||
// 6. The two things a real person gets wrong.
|
||||
const email = normaliseEmail(get(fields.EMAIL));
|
||||
if (!email) {
|
||||
recordAttempt(ipHash, OUTCOME.INVALID_EMAIL);
|
||||
return { outcome: OUTCOME.INVALID_EMAIL };
|
||||
}
|
||||
|
||||
if (!get(fields.CONSENT)) {
|
||||
recordAttempt(ipHash, OUTCOME.NO_CONSENT);
|
||||
return { outcome: OUTCOME.NO_CONSENT, email };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = addSignup({ email, ipHash, userAgent, consentText: CONSENT_TEXT });
|
||||
const outcome = result.duplicate ? OUTCOME.DUPLICATE : OUTCOME.ADDED;
|
||||
recordAttempt(ipHash, outcome);
|
||||
return { outcome, email };
|
||||
} catch (error) {
|
||||
// A full disk, a read-only mount, a corrupt file. The operator gets the detail; the
|
||||
// person gets a page that admits it went wrong rather than one that pretends it did not.
|
||||
console.error('[beta] could not record a signup:', error);
|
||||
return { outcome: OUTCOME.ERROR, email };
|
||||
}
|
||||
}
|
||||
340
src/lib/betaStore.mjs
Normal file
340
src/lib/betaStore.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* betaStore.mjs — the closed-beta signup store. PLAN.md §8, built in phase 5.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* ONE FILE ON A BIND MOUNT, AND NOTHING ELSE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* SQLite at `<data>/beta.sqlite`, where `<data>` is `/app/data` in the container and
|
||||
* `./data` in a working tree — the same shape as the brand mount (§6, §7). The whole store
|
||||
* is one file the operator can copy, back up, or delete, on a disk they already have shell
|
||||
* on. That is the property that lets §8 refuse to build an admin page: there is no
|
||||
* authenticated surface on this site, because every operation on this data is performed by
|
||||
* the one person who can already `cd` to the directory.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* WHAT IS AND IS NOT STORED
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The address, because pasting it into Play is the point. The consent wording, because a
|
||||
* record of consent that cannot reproduce the words is not one. A user agent, because it
|
||||
* is the only signal that separates a browser from a script after the fact.
|
||||
*
|
||||
* **Never the IP address.** `ip_hash` is a salted SHA-256 and the salt lives in the
|
||||
* environment, so the linkage is destroyed by rotating an env var rather than by a
|
||||
* migration — and a copy of this file, on its own, cannot be turned back into a list of
|
||||
* people's addresses. Rate limiting works fine against a hash; identification does not.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* OPENED LAZILY, AND SURVIVING NOT BEING OPENABLE
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* The database is opened on first use, not at import. Two reasons, both real: the CLI and
|
||||
* the server import overlapping code and only one of them should create a file in a
|
||||
* developer's working tree, and `/beta` is a prerendered-looking page whose GET must render
|
||||
* even when the mount is missing or read-only. A signup that cannot be written is an error
|
||||
* on one request; a store that throws at import is a site that will not boot.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { limits } from '../data/beta.mjs';
|
||||
|
||||
/** The bind mount. `DATA_DIR` matches `BRAND_DIR`'s convention in `brandAssets.mjs`. */
|
||||
export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
|
||||
/** The store itself. Overridable outright so the tests can point at a scratch file. */
|
||||
export const DB_PATH = process.env.BETA_DB || path.join(DATA_DIR, 'beta.sqlite');
|
||||
|
||||
/** Where `scripts/beta.mjs export` writes. §8 names this path. */
|
||||
export const EXPORT_DIR = path.join(DATA_DIR, 'exports');
|
||||
|
||||
/**
|
||||
* The salt for `ip_hash`.
|
||||
*
|
||||
* A missing salt is not an error, because a site that refuses to serve a signup form over a
|
||||
* missing environment variable is worse than one that rate-limits against a salt nobody
|
||||
* wrote down. But an *unset* salt must never be a *constant* — a hard-coded default would
|
||||
* make every deployment's hashes identical and therefore reversible by anyone with the
|
||||
* source, which is this project's threat model in one sentence. So the fallback is random
|
||||
* per process: rate limiting works within a run, and the linkage does not survive a restart.
|
||||
*
|
||||
* Set `BETA_IP_SALT` in the compose file to make the limiter outlive a deploy.
|
||||
*
|
||||
* Resolved on first use rather than at import, and the warning goes with it. The CLI shares
|
||||
* this module and never hashes anything, so an eager constant meant `beta.mjs stats` opened
|
||||
* with a warning about a variable that operation does not use — which is how an operator
|
||||
* learns to read past warnings.
|
||||
*/
|
||||
let ipSalt = null;
|
||||
|
||||
function salt() {
|
||||
if (ipSalt) return ipSalt;
|
||||
|
||||
ipSalt = process.env.BETA_IP_SALT || '';
|
||||
if (!ipSalt) {
|
||||
console.warn(
|
||||
'[beta] BETA_IP_SALT is not set — using a random per-process salt. Rate limits will ' +
|
||||
'reset on restart. Set it in the compose environment to make them persist.'
|
||||
);
|
||||
ipSalt = randomBytes(32).toString('hex');
|
||||
}
|
||||
return ipSalt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema of §8, verbatim, plus the one table §8 describes in prose but does not draw.
|
||||
*
|
||||
* `email … COLLATE NOCASE` on the UNIQUE constraint is what makes a duplicate answerable
|
||||
* idempotently rather than twice: `Foo@example.com` and `foo@example.com` are one person,
|
||||
* and the store — not the caller — is where that has to be true, because the caller is
|
||||
* three different entry points.
|
||||
*
|
||||
* `attempts` is the token bucket §8 asks for, persisted as the events themselves rather
|
||||
* than as a counter. A counter would need a decay schedule and a clock it trusts; a rolling
|
||||
* count over rows needs neither, prunes trivially, and can answer "why was this refused"
|
||||
* long enough after the fact to be useful. It records EVERY attempt that reaches the
|
||||
* limiter, not every success — a script hammering invalid addresses is exactly the traffic
|
||||
* the limit exists for, and counting only what succeeded would exempt it.
|
||||
*/
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS signups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL,
|
||||
ip_hash TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
consent_text TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS signups_status ON signups (status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attempts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ip_hash TEXT NOT NULL,
|
||||
at TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS attempts_ip_at ON attempts (ip_hash, at);
|
||||
`;
|
||||
|
||||
/** Row states. `exported` means "pasted into Play", which only the CLI can know. */
|
||||
export const STATUS = { NEW: 'new', EXPORTED: 'exported', REMOVED: 'removed' };
|
||||
|
||||
let db = null;
|
||||
|
||||
/**
|
||||
* Open (and migrate) the store, creating the mount directory if it is missing.
|
||||
*
|
||||
* WAL is on because two processes touch this file: the server, and the operator running the
|
||||
* CLI against a running container. In the default rollback journal a reader blocks a writer,
|
||||
* so `beta.mjs stats` during a signup is a locking error rather than a number. `busy_timeout`
|
||||
* covers the rest — the writes here are single rows, and waiting five seconds is always
|
||||
* better than failing a person's signup.
|
||||
*/
|
||||
export function open() {
|
||||
if (db) return db;
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(SCHEMA);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Release the handle. The tests need it; the server never calls it. */
|
||||
export function close() {
|
||||
if (!db) return;
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
|
||||
/** The salted hash §8 stores in place of an address. */
|
||||
export function hashIp(ip) {
|
||||
return createHash('sha256')
|
||||
.update(`${salt()}:${ip || 'unknown'}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
const isoAgo = (ms) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
/**
|
||||
* Record an attempt, and prune the ones too old to inform any limit.
|
||||
*
|
||||
* Pruning on write rather than on a timer keeps the whole store self-maintaining: there is
|
||||
* no scheduler in this container and nothing should have to remember to run. The window is
|
||||
* the longest limit plus a margin, so nothing a limit still needs is ever thrown away.
|
||||
*/
|
||||
export function recordAttempt(ipHash, outcome) {
|
||||
const handle = open();
|
||||
handle.prepare('INSERT INTO attempts (ip_hash, at, outcome) VALUES (?, ?, ?)').run(
|
||||
ipHash,
|
||||
nowIso(),
|
||||
outcome
|
||||
);
|
||||
handle.prepare('DELETE FROM attempts WHERE at < ?').run(isoAgo(48 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many attempts this hash has made in the last hour and the last day.
|
||||
*/
|
||||
export function attemptCounts(ipHash) {
|
||||
const handle = open();
|
||||
const count = (since) =>
|
||||
handle
|
||||
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE ip_hash = ? AND at >= ?')
|
||||
.get(ipHash, since).n;
|
||||
|
||||
return {
|
||||
hour: count(isoAgo(60 * 60 * 1000)),
|
||||
day: count(isoAgo(24 * 60 * 60 * 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows that count against the global cap — everything not withdrawn. */
|
||||
export function liveCount() {
|
||||
return open()
|
||||
.prepare('SELECT COUNT(*) AS n FROM signups WHERE status != ?')
|
||||
.get(STATUS.REMOVED).n;
|
||||
}
|
||||
|
||||
/** Is the form closed because §8's global cap is reached? */
|
||||
export function isFull() {
|
||||
return liveCount() >= limits.totalCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a signup, or report that it is already there.
|
||||
*
|
||||
* The duplicate case returns `{ duplicate: true }` rather than throwing, and the page says
|
||||
* "you are already on the list" either way — §8's rule, and the reason for it is not
|
||||
* politeness. An error that distinguishes "added" from "already enrolled" turns this form
|
||||
* into an oracle for whether a given address is in the beta, which is a disclosure about a
|
||||
* person made to anyone who can type their address.
|
||||
*
|
||||
* A previously removed address is treated as new, and that follows from `removeSignup`
|
||||
* rather than being a separate decision: removal ERASES the address, so there is nothing
|
||||
* left to recognise. Keeping a hash of it in order to say "you were removed" would mean
|
||||
* retaining a derived identifier for the one person who asked not to be retained. Somebody
|
||||
* who left and signs up again has chosen to sign up again, which is the correct reading.
|
||||
*/
|
||||
export function addSignup({ email, ipHash, userAgent, consentText }) {
|
||||
const handle = open();
|
||||
|
||||
const existing = handle
|
||||
.prepare('SELECT id, status FROM signups WHERE email = ?')
|
||||
.get(email);
|
||||
if (existing) return { duplicate: true, id: existing.id, status: existing.status };
|
||||
|
||||
const info = handle
|
||||
.prepare(
|
||||
`INSERT INTO signups (email, created_at, ip_hash, user_agent, consent_text, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
email,
|
||||
nowIso(),
|
||||
ipHash,
|
||||
// A user agent is a header, and a header is whatever the client felt like sending.
|
||||
// Truncated because the column is a signal, not a transcript.
|
||||
(userAgent || '').slice(0, 400) || null,
|
||||
consentText,
|
||||
STATUS.NEW
|
||||
);
|
||||
|
||||
return { duplicate: false, id: Number(info.lastInsertRowid), status: STATUS.NEW };
|
||||
}
|
||||
|
||||
/** Rows for the CLI's export, oldest first so a CSV reads in signup order. */
|
||||
export function pending({ all = false } = {}) {
|
||||
const handle = open();
|
||||
return all
|
||||
? handle
|
||||
.prepare('SELECT * FROM signups WHERE status != ? ORDER BY id')
|
||||
.all(STATUS.REMOVED)
|
||||
: handle.prepare('SELECT * FROM signups WHERE status = ? ORDER BY id').all(STATUS.NEW);
|
||||
}
|
||||
|
||||
/** Mark rows as pasted into Play. */
|
||||
export function markExported(ids) {
|
||||
if (!ids.length) return 0;
|
||||
|
||||
const handle = open();
|
||||
const stamp = handle.prepare('UPDATE signups SET status = ?, note = ? WHERE id = ?');
|
||||
const note = `exported ${nowIso()}`;
|
||||
|
||||
const run = handle.transaction((list) => {
|
||||
for (const id of list) stamp.run(STATUS.EXPORTED, note, id);
|
||||
return list.length;
|
||||
});
|
||||
|
||||
return run(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Honour a deletion request.
|
||||
*
|
||||
* The address itself is overwritten, not just flagged: §9 promises deletion, and a row that
|
||||
* still holds the address it promised to delete has not delivered on that. What remains is
|
||||
* a tombstone — the id, the date, and the fact that a removal happened — which is what lets
|
||||
* the operator answer "did you action my request" without keeping the thing they asked to
|
||||
* have removed. The placeholder keeps the UNIQUE constraint satisfiable, so the same person
|
||||
* signing up again later is an ordinary new row.
|
||||
*
|
||||
* ONE CONSEQUENCE, AND IT IS THE RIGHT ONE. After this runs, the store cannot tell a
|
||||
* removed address from one it has never seen — asking twice gives the same answer as asking
|
||||
* about a stranger. That is what erasure means. The alternative, keeping a hash so a second
|
||||
* request could say "already removed", would be retaining a derived identifier for the one
|
||||
* person who has explicitly asked not to be retained, in order to improve a message only an
|
||||
* operator reads.
|
||||
*/
|
||||
export function removeSignup(email) {
|
||||
const handle = open();
|
||||
|
||||
const row = handle.prepare('SELECT id, status FROM signups WHERE email = ?').get(email);
|
||||
if (!row) return { removed: false };
|
||||
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE signups
|
||||
SET email = ?, ip_hash = '', user_agent = NULL, status = ?, note = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(`removed-${row.id}@invalid`, STATUS.REMOVED, `removed ${nowIso()}`, row.id);
|
||||
|
||||
return { removed: true, id: row.id };
|
||||
}
|
||||
|
||||
/** §8's `stats`. */
|
||||
export function stats() {
|
||||
const handle = open();
|
||||
|
||||
const byStatus = Object.fromEntries(
|
||||
handle
|
||||
.prepare('SELECT status, COUNT(*) AS n FROM signups GROUP BY status')
|
||||
.all()
|
||||
.map((row) => [row.status, row.n])
|
||||
);
|
||||
|
||||
return {
|
||||
total: handle.prepare('SELECT COUNT(*) AS n FROM signups').get().n,
|
||||
new: byStatus[STATUS.NEW] || 0,
|
||||
exported: byStatus[STATUS.EXPORTED] || 0,
|
||||
removed: byStatus[STATUS.REMOVED] || 0,
|
||||
live: liveCount(),
|
||||
cap: limits.totalCap,
|
||||
attemptsLastDay: handle
|
||||
.prepare('SELECT COUNT(*) AS n FROM attempts WHERE at >= ?')
|
||||
.get(isoAgo(24 * 60 * 60 * 1000)).n,
|
||||
path: DB_PATH,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import brandDefault from '../../brand-default/brand.json' with { type: 'json' };
|
||||
|
||||
/**
|
||||
@@ -37,3 +40,76 @@ export const brand = Object.freeze({ ...brandDefault });
|
||||
export function brandFields() {
|
||||
return Object.fromEntries(Object.entries(brand).filter(([k]) => !k.startsWith('$')));
|
||||
}
|
||||
|
||||
/* =========================================================================================
|
||||
THE LIVE READ, FOR ON-DEMAND ROUTES ONLY (phase 5)
|
||||
=========================================================================================
|
||||
|
||||
Everything above is build-time, and the boot rewrite is what carries the mount into
|
||||
prerendered HTML. Neither reaches a page that renders per request: `applyBrand.mjs`
|
||||
rewrites files in `dist/client`, and an on-demand route's HTML never existed as a file.
|
||||
A server-rendered page reading `brand` would therefore show the STOCK value forever, no
|
||||
matter what is mounted — §7 quietly untrue, on exactly the page that needs it most.
|
||||
|
||||
So `/beta` reads the mount itself. It is allowed to, because it is already executing:
|
||||
the reason the rest of the site cannot is that it is not running when its HTML is made,
|
||||
and that argument does not apply here.
|
||||
|
||||
It is also strictly better where it applies. The rewrite happens at boot, so changing a
|
||||
mounted value means restarting the container; this is picked up on the next request. An
|
||||
operator who pastes the Play opt-in URL into `brand.json` has a working confirmation
|
||||
screen before they have finished reading this sentence.
|
||||
|
||||
The mtime guard is what keeps that from being a file read per request. `statSync` on a
|
||||
file the OS has cached is cheap enough to do on every render and honest enough to notice
|
||||
an edit immediately, which a TTL would not be. */
|
||||
|
||||
const MOUNTED_BRAND = path.join(
|
||||
process.env.BRAND_DIR || path.join(process.cwd(), 'brand'),
|
||||
'brand.json'
|
||||
);
|
||||
|
||||
let cache = { mtimeMs: -1, value: brand };
|
||||
|
||||
/**
|
||||
* The brand as it is on disk right now: the mounted `brand.json` layered over the stock
|
||||
* one, per key. Use from on-demand routes; prerendered pages must keep using `brand`.
|
||||
*
|
||||
* Never throws. A missing mount is the normal case, and a malformed one is the operator's
|
||||
* typo — both fall back to stock with a log line, for the reason `applyBrand.mjs` gives at
|
||||
* length: a site up with the wrong logo beats a site down with the right one.
|
||||
*/
|
||||
export function liveBrand() {
|
||||
let mtimeMs;
|
||||
try {
|
||||
mtimeMs = statSync(MOUNTED_BRAND).mtimeMs;
|
||||
} catch {
|
||||
// No mounted file. Cache the stock answer against a sentinel so the miss is not
|
||||
// re-statted into a re-parse every request.
|
||||
if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, value: brand };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
if (mtimeMs === cache.mtimeMs) return cache.value;
|
||||
|
||||
let mounted = null;
|
||||
try {
|
||||
mounted = JSON.parse(readFileSync(MOUNTED_BRAND, 'utf8'));
|
||||
} catch (error) {
|
||||
console.error(`[brand] the mounted brand.json is not valid JSON and is being ignored: ${error.message}`);
|
||||
}
|
||||
|
||||
const merged = { ...brand };
|
||||
if (mounted && typeof mounted === 'object') {
|
||||
for (const [key, value] of Object.entries(mounted)) {
|
||||
// Same two rules as the boot rewrite: `$comment` keys are documentation, and a field
|
||||
// the stock file does not declare is a typo rather than a new feature.
|
||||
if (key.startsWith('$')) continue;
|
||||
if (!(key in brand)) continue;
|
||||
if (typeof value === 'string') merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
cache = { mtimeMs, value: Object.freeze(merged) };
|
||||
return cache.value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user