cursedbelt-server 4.20.0 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * `cursedbelt-server/request-log` — the request log, "is this app healthy": one row per request
3
+ * served, in `<APP_DATA_DIR>/metrics.sqlite`, the file station's Metrics page reads for every app on
4
+ * this Mac (`apps/station/src/server/fleetMetrics.ts`, `METRICS_DB`).
5
+ *
6
+ * ── Why it is here (4.21.0) ─────────────────────────────────────────────────
7
+ * `apps/family` and `apps/roms` held this module byte-identically (task 265's rollout), and
8
+ * `check-copies` counted it as five copy groups. The API, the header marker and the migration name
9
+ * are exactly the apps' — a live corpus is recognised by `METRICS_APPLICATION_ID` and by the
10
+ * `0001_request_metrics` row in its migrations table, so changing either would make every
11
+ * existing `metrics.sqlite` look inherited and archive it. Bun-only (`bun:sqlite`, `node:fs`): under
12
+ * Node the export map routes this subpath to `_bunOnly.js`, the way `./engagement` does; it is NOT
13
+ * part of `./telemetry`, which is Worker-safe (`telemetryIsWorkerSafe.spec.ts`).
14
+ *
15
+ * ── Why it came back, 2026-09-23 (task 265) ─────────────────────────────────
16
+ * `serve.ts` once recorded that request metrics were GONE, on the grounds that the
17
+ * console that read them was in the retired generation. Station reads them now, and
18
+ * a measurement found that all eight apps recorded nothing while their
19
+ * `metrics.sqlite` files — the RETIRED instances' history, inherited at graduation —
20
+ * rendered as a corpus that had simply gone quiet. This mounts `cursedbelt-server`'s
21
+ * own seam (`requestLogger` through `createTelemetrySink`, the modules the Worker-safe
22
+ * `cursedbelt-server/telemetry` leaf re-exports).
23
+ *
24
+ * ── 🔴 The inherited file is ARCHIVED, never written ────────────────────────
25
+ * The file already at `metrics.sqlite` on the live machine is a previous
26
+ * generation's history under a different schema (`id INTEGER AUTOINCREMENT` plus
27
+ * `ip`/`country`/`city`/`device`/`form`; this writer inserts `id TEXT` plus
28
+ * `user_id`). Appending to it would make every future measurement unreadable, so
29
+ * {@link retireInheritedCorpus} runs at every boot, BEFORE the database is opened:
30
+ *
31
+ * - a file that carries {@link METRICS_APPLICATION_ID} in its header is this
32
+ * writer's own, and is left alone — so a second boot is a no-op;
33
+ * - anything else is RENAMED, with its `-wal`/`-shm` sidecars, to
34
+ * `metrics.retired.sqlite` — or `metrics.retired-2.sqlite`, `-3`, … when that
35
+ * name is taken, so two inherited corpora are both kept and nothing is ever
36
+ * overwritten. A rename touches no byte of the file, which is the point.
37
+ *
38
+ * The marker lives in the SQLite header (`PRAGMA application_id`, offset 68) so it
39
+ * can be read without opening the database — opening an inherited WAL database
40
+ * read-write would checkpoint into it on close, which IS writing into it. The
41
+ * migration checkpoints after stamping it, so the marker is on disk before the
42
+ * first request is recorded and a crash cannot leave a fresh corpus that the next
43
+ * boot mistakes for an inherited one.
44
+ *
45
+ * ── 🔴 No user id, ever ─────────────────────────────────────────────────────
46
+ * `cursedbelt-server/engagement`'s header states the split: `metrics/` promises it
47
+ * holds NO user id, in every app; `engagement.sqlite` is the user-keyed store. The
48
+ * writer's schema has a `user_id` column and `requestLogger` fills it from
49
+ * `c.get("userId")`, so the sink here nulls it on every event — whatever any future
50
+ * middleware puts on the context — and the logger is given no database of its own,
51
+ * which means it writes no `event_logs` rows either (those carry a `user_id` too,
52
+ * and nothing in this generation reads them).
53
+ *
54
+ * ── Never takes the app down ────────────────────────────────────────────────
55
+ * {@link openRequestMetricsFromEnv} catches everything and says so on stderr: an
56
+ * app that cannot record its traffic must still SERVE it. What catches an app that
57
+ * silently STOPS recording is `requestMetrics.test.ts`, which drives `/healthz`
58
+ * through a real server and fails when no row lands — `requestMetrics.spec.ts` here, and each
59
+ * app's own test through its own `createServer`.
60
+ */
61
+ import { Database } from "bun:sqlite";
62
+ import { closeSync, existsSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
63
+ import { join } from "node:path";
64
+ import { applyCcPragmas } from "cwip/sqlite";
65
+ import { createTelemetrySink } from "../metrics/telemetrySink.js";
66
+ import { requestLogger } from "../middleware/requestLogger.js";
67
+ import { runMigrations } from "../migration/runner.js";
68
+ import { isTestRuntime } from "../satellite/door.js";
69
+ /** The file station reads, inside the app's data directory. */
70
+ export const METRICS_DB = "metrics.sqlite";
71
+ /** Where an inherited corpus is moved to — the first free of this and `metrics.retired-<n>.sqlite`. */
72
+ export const RETIRED_METRICS_DB = "metrics.retired.sqlite";
73
+ /** `PRAGMA application_id` of a corpus THIS writer created: "CFRM", cursedforge request metrics. */
74
+ const METRICS_APPLICATION_ID = 0x4346524d;
75
+ const SIDECARS = ["-wal", "-shm", "-journal"];
76
+ /**
77
+ * The writer's own schema — the columns `cursedbelt-server`'s `metricsBuffer`
78
+ * inserts, and the ones station's reader asks for (`ts`, `method`, `route`,
79
+ * `status`, `duration_ms`, `bytes_out`). Append-only: a migration, once applied,
80
+ * never changes.
81
+ */
82
+ const REQUEST_METRICS_MIGRATIONS = [
83
+ {
84
+ name: "0001_request_metrics",
85
+ up(db) {
86
+ db.run(`PRAGMA application_id = ${METRICS_APPLICATION_ID}`);
87
+ db.run(`CREATE TABLE request_metrics (
88
+ id TEXT PRIMARY KEY, ts INTEGER NOT NULL, method TEXT, route TEXT,
89
+ status INTEGER, duration_ms REAL, bytes_out INTEGER, user_id TEXT)`);
90
+ db.run("CREATE INDEX idx_request_metrics_ts ON request_metrics (ts)");
91
+ },
92
+ },
93
+ ];
94
+ /** The `application_id` stamped in a SQLite file's header, or null when it has no header yet. */
95
+ function headerApplicationId(path) {
96
+ const fd = openSync(path, "r");
97
+ try {
98
+ const header = Buffer.alloc(72);
99
+ if (readSync(fd, header, 0, 72, 0) < 72)
100
+ return null;
101
+ return header.readUInt32BE(68);
102
+ }
103
+ finally {
104
+ closeSync(fd);
105
+ }
106
+ }
107
+ const sizeOf = (path) => (existsSync(path) ? statSync(path).size : 0);
108
+ /**
109
+ * Move an inherited `metrics.sqlite` out of the way, or do nothing. Returns the
110
+ * path it was archived to, or null when there was nothing to archive. See the
111
+ * header: never writes a byte of the file, never overwrites anything, idempotent.
112
+ */
113
+ export function retireInheritedCorpus(dir) {
114
+ const live = join(dir, METRICS_DB);
115
+ if (!existsSync(live))
116
+ return null;
117
+ // An empty file with no journal holds nothing — SQLite simply initialises it.
118
+ if (sizeOf(live) === 0 && SIDECARS.every((s) => sizeOf(live + s) === 0))
119
+ return null;
120
+ if (headerApplicationId(live) === METRICS_APPLICATION_ID)
121
+ return null;
122
+ const present = SIDECARS.filter((s) => existsSync(live + s));
123
+ let target = join(dir, RETIRED_METRICS_DB);
124
+ for (let n = 2; existsSync(target) || present.some((s) => existsSync(target + s)); n++) {
125
+ target = join(dir, RETIRED_METRICS_DB.replace(/\.sqlite$/, `-${n}.sqlite`));
126
+ }
127
+ // Sidecars FIRST, then the database: a crash between the two leaves the database
128
+ // where the next boot finds it again, and — because a moved sidecar is no longer
129
+ // at the source — the loop above picks this same `target`, so the pair is rejoined.
130
+ for (const s of present)
131
+ renameSync(live + s, target + s);
132
+ renameSync(live, target);
133
+ return target;
134
+ }
135
+ /** Retire any inherited corpus, then open (creating if need be) this writer's own. */
136
+ function openMetricsDb(dir) {
137
+ mkdirSync(dir, { recursive: true });
138
+ const archived = retireInheritedCorpus(dir);
139
+ const db = new Database(join(dir, METRICS_DB), { create: true });
140
+ applyCcPragmas(db);
141
+ if (runMigrations(db, REQUEST_METRICS_MIGRATIONS).length > 0) {
142
+ // Put the header marker in the main file now, not at some later checkpoint.
143
+ db.run("PRAGMA wal_checkpoint(TRUNCATE)");
144
+ }
145
+ return { db, archived };
146
+ }
147
+ /** Open the corpus in `dir` and build the logger that writes into it. */
148
+ export function openRequestMetrics(dir) {
149
+ const { db, archived } = openMetricsDb(dir);
150
+ const inner = createTelemetrySink({ db });
151
+ // 🔴 No user id — see the header. Nulled here so no context value can reach the row.
152
+ const sink = {
153
+ record: (event) => inner.record({ ...event, userId: null }),
154
+ flush: () => inner.flush(),
155
+ stop: () => inner.stop(),
156
+ get size() {
157
+ return inner.size;
158
+ },
159
+ };
160
+ // `null` database: the logger writes through `sink` only, and no `event_logs`.
161
+ const middleware = requestLogger(null, { sink });
162
+ return { db, sink, middleware, archived };
163
+ }
164
+ /**
165
+ * The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
166
+ * is set (the launchd plist sets it) and no test runner is in charge. A dev shell
167
+ * records nothing rather than appending a developer's clicks to the live corpus.
168
+ * Never throws: a failure is one stderr line and an app that serves unmeasured.
169
+ */
170
+ export function openRequestMetricsFromEnv(name, env) {
171
+ const dir = env.APP_DATA_DIR?.trim();
172
+ if (!dir || isTestRuntime(env))
173
+ return null;
174
+ try {
175
+ const metrics = openRequestMetrics(dir);
176
+ if (metrics.archived) {
177
+ console.log(`[${name}] request metrics: archived the inherited corpus to ${metrics.archived}`);
178
+ }
179
+ return metrics;
180
+ }
181
+ catch (error) {
182
+ console.error(`[${name}] request metrics disabled: ${error instanceof Error ? error.message : String(error)}`);
183
+ return null;
184
+ }
185
+ }
@@ -116,8 +116,13 @@ export function createWsHub(opts) {
116
116
  const client = clientFromWs(ws);
117
117
  if (!client)
118
118
  return;
119
- channelOf(client.channel)?.onDisconnect?.(client, hub);
119
+ // 🔴 Remove FIRST, then tell the channel. The recipe every presence app follows is
120
+ // "broadcast hub.getPresence(channel) from onDisconnect" (cursedbelt's usePresence doc),
121
+ // and until 4.20.1 this called onDisconnect while the leaver was still registered — so
122
+ // the roster it broadcast still listed the person who had just left. apps/roms deferred a
123
+ // microtask to work around it (src/server/presence.ts). hub.spec.ts pins the order.
120
124
  clients.delete(client.id);
125
+ channelOf(client.channel)?.onDisconnect?.(client, hub);
121
126
  },
122
127
  },
123
128
  broadcast(channel, event, data) {
@@ -61,7 +61,8 @@ export interface WsChannel<TMsg = WsMessage> {
61
61
  onConnect?(client: WsClient, hub: WsHub): void;
62
62
  /** Fired once per inbound frame (the parsed `{ event, data }` envelope). */
63
63
  onMessage(client: WsClient, msg: TMsg, hub: WsHub): void;
64
- /** Fired when the socket closes (explicit, stale-culled, or peer-dropped). */
64
+ /** Fired when the socket closes (explicit, stale-culled, or peer-dropped) — AFTER the client is
65
+ * removed, so `hub.getPresence()` here is the roster without it (4.20.1). */
65
66
  onDisconnect?(client: WsClient, hub: WsHub): void;
66
67
  /** Whether this channel contributes to {@link WsHub.getPresence}. Default: false. */
67
68
  presence?: boolean;
@@ -55,4 +55,6 @@ export const MAY_NEED_BUN = {
55
55
  // The engagement store IS `engagement.sqlite` on the Mac (4.19.0, task 280) — the recorder
56
56
  // runs where the app's own database is. A Worker app records nothing until it has a D1 store.
57
57
  './engagement': ['bun:sqlite'],
58
+ // The request log IS `metrics.sqlite` beside the app's data (4.21.0) — a Mac-hosted server's.
59
+ './request-log': ['bun:sqlite'],
58
60
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.20.0",
3
+ "version": "4.22.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -213,6 +213,12 @@
213
213
  "types": "./dist/server/auth/passwordCost.d.ts",
214
214
  "bun": "./src/server/auth/passwordCost.ts",
215
215
  "source": "./src/server/auth/passwordCost.ts",
216
+ "import": "./dist/server/auth/passwordCost.js"
217
+ },
218
+ "./request-log": {
219
+ "types": "./dist/server/requestLog/requestMetrics.d.ts",
220
+ "bun": "./src/server/requestLog/requestMetrics.ts",
221
+ "source": "./src/server/requestLog/requestMetrics.ts",
216
222
  "import": "./dist/server/_bunOnly.js"
217
223
  },
218
224
  "./notifications": {
@@ -296,7 +302,7 @@
296
302
  },
297
303
  "dependencies": {
298
304
  "cursedbelt-core": "^2.1.1",
299
- "cursedops": "^0.5.2",
305
+ "cursedops": "^0.6.0",
300
306
  "cwip": "^4.6.0",
301
307
  "jose": "^6.2.3"
302
308
  },
@@ -145,7 +145,11 @@ export {
145
145
  backupTables,
146
146
  chooseBackupSource,
147
147
  type PullD1Options,
148
+ insertLines,
148
149
  pullD1ToSqlite,
149
150
  servedRuntime,
151
+ spawnWrangler,
152
+ stripTrailingNuls,
150
153
  type Wrangler,
154
+ wranglerArgv,
151
155
  } from './pullD1.js';
@@ -1,9 +1,17 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { afterEach, describe, expect, test } from 'bun:test';
3
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { backupTables, chooseBackupSource, pullD1ToSqlite, servedRuntime, type Wrangler } from './localBackup.js';
6
+ import {
7
+ backupTables,
8
+ chooseBackupSource,
9
+ pullD1ToSqlite,
10
+ servedRuntime,
11
+ stripTrailingNuls,
12
+ type Wrangler,
13
+ wranglerArgv,
14
+ } from './localBackup.js';
7
15
 
8
16
  const dirs: string[] = [];
9
17
  const scratch = (): string => {
@@ -198,6 +206,58 @@ describe('pulling D1 down — table by table, because a whole-database export re
198
206
  await expect(pull(dir, run)).rejects.toThrow(/--table meta failed/);
199
207
  });
200
208
 
209
+ test("🔴 bun:sqlite's exec stops at a NUL with no error — the defect, pinned (task 2128)", () => {
210
+ const db = new Database(':memory:');
211
+ db.exec('CREATE TABLE t (v TEXT)');
212
+ db.exec("INSERT INTO t VALUES('a');\n\0\0\nINSERT INTO t VALUES('lost');");
213
+ expect((db.query('SELECT COUNT(*) AS n FROM t').get() as { n: number }).n).toBe(1);
214
+ db.close();
215
+ });
216
+
217
+ test('🔴 a NUL-padded export still loads EVERY table (vault, 2026-09-23: vault_keys came back empty)', async () => {
218
+ const dir = scratch();
219
+ const { run } = fake((table) =>
220
+ table === 'items'
221
+ ? `INSERT INTO items (id, name) VALUES ('itm_1', 'a');\n${'\0'.repeat(988)}`
222
+ : table === 'meta'
223
+ ? "INSERT INTO meta (key, value) VALUES ('k', 'v');\nINSERT INTO meta (key, value) VALUES ('k2', 'v2');"
224
+ : '',
225
+ );
226
+ await pull(dir, run);
227
+ const db = new Database(join(dir, 'pulled.sqlite'), { readonly: true });
228
+ expect((db.query('SELECT COUNT(*) AS n FROM meta').get() as { n: number }).n).toBe(2);
229
+ db.close();
230
+ });
231
+
232
+ test('🔴 a load SHORT of its dump — whatever truncated it — is refused, and leaves no snapshot', async () => {
233
+ const dir = scratch();
234
+ // A NUL INSIDE the SQL (not trailing, so not stripped) ends the exec mid-table.
235
+ const { run } = fake((table) =>
236
+ table === 'items' ? "INSERT INTO items (id) VALUES ('a');\n\0\nINSERT INTO items (id) VALUES ('b');" : '',
237
+ );
238
+ await expect(pull(dir, run)).rejects.toThrow(/items: 1 of 2/);
239
+ expect(existsSync(join(dir, 'pulled.sqlite'))).toBe(false);
240
+ });
241
+
242
+ test('the per-table dumps are deleted — on success AND on a refusal', async () => {
243
+ const dir = scratch();
244
+ const { run } = fake((table) => (table === 'items' ? "INSERT INTO items (id) VALUES ('a');" : ''));
245
+ await pull(dir, run);
246
+ expect(readdirSync(dir).filter((f) => f.endsWith('.sql'))).toEqual([]);
247
+ const bad = fake((table) => (table === 'meta' ? null : "INSERT INTO items (id) VALUES ('x');"));
248
+ await expect(pull(scratch(), bad.run)).rejects.toThrow();
249
+ });
250
+
251
+ test('stripTrailingNuls strips only TRAILING NULs; wranglerArgv is this bun, never a bare bunx', () => {
252
+ const dir = scratch();
253
+ const f = join(dir, 'x.sql');
254
+ writeFileSync(f, 'a\0b\0\0\0');
255
+ expect(stripTrailingNuls(f)).toBe(3);
256
+ expect(readFileSync(f, 'utf8')).toBe('a\0b');
257
+ expect(stripTrailingNuls(f)).toBe(0);
258
+ expect(wranglerArgv('/x/bun')).toEqual(['/x/bun', 'x', 'wrangler']);
259
+ });
260
+
201
261
  test('🔴 an export with no rows at all THROWS — never an empty library dated today', async () => {
202
262
  const dir = scratch();
203
263
  const { run } = fake(() => '');
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { Database } from 'bun:sqlite';
31
- import { existsSync, readFileSync } from 'node:fs';
31
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
32
32
 
33
33
  export type BackupSourceChoice = { kind: 'file' | 'd1' | 'refuse'; why: string };
34
34
 
@@ -103,6 +103,49 @@ const EXPORT_ATTEMPTS = 3;
103
103
  /** One `wrangler` invocation, as {@link pullD1ToSqlite} needs it. Injected so a spec runs none. */
104
104
  export type Wrangler = (args: string[]) => { code: number; stdout: string; stderr: string };
105
105
 
106
+ /**
107
+ * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
108
+ * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
109
+ * line until 4.22.0.
110
+ */
111
+ export const wranglerArgv = (bun: string = process.execPath): string[] => [bun, 'x', 'wrangler'];
112
+
113
+ /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
114
+ export function spawnWrangler(options: { cwd: string; env?: Record<string, string | undefined> }): Wrangler {
115
+ return (args) => {
116
+ const r = Bun.spawnSync([...wranglerArgv(), ...args], {
117
+ cwd: options.cwd,
118
+ env: { ...process.env, ...options.env },
119
+ stdout: 'pipe',
120
+ stderr: 'pipe',
121
+ });
122
+ return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
123
+ };
124
+ }
125
+
126
+ /**
127
+ * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
128
+ *
129
+ * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
130
+ * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
131
+ * +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
132
+ * reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
133
+ * EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
134
+ * TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
135
+ */
136
+ export function stripTrailingNuls(file: string): number {
137
+ if (!existsSync(file)) return 0;
138
+ const bytes = readFileSync(file);
139
+ let end = bytes.length;
140
+ while (end > 0 && bytes[end - 1] === 0) end--;
141
+ if (end === bytes.length) return 0;
142
+ writeFileSync(file, bytes.subarray(0, end));
143
+ return bytes.length - end;
144
+ }
145
+
146
+ /** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
147
+ export const insertLines = (dump: string): number => (dump.match(/^INSERT INTO /gm) ?? []).length;
148
+
106
149
  /**
107
150
  * The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
108
151
  * none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
@@ -161,51 +204,77 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
161
204
  }
162
205
 
163
206
  let sql = '';
164
- for (const table of tables) {
165
- const dump = `${destination}.${table}.sql`;
166
- // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
167
- // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
168
- // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
169
- // asked for again, twice, before it is a refusal; a failure that persists still refuses,
170
- // because a snapshot missing a table is not a backup.
171
- let exported = { code: 1, stdout: '', stderr: 'not attempted' };
172
- for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
173
- exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
174
- if (exported.code !== 0 || existsSync(dump)) break;
207
+ const dumps: { table: string; text: string }[] = [];
208
+ try {
209
+ for (const table of tables) {
210
+ const dump = `${destination}.${table}.sql`;
211
+ // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
212
+ // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
213
+ // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
214
+ // asked for again, twice, before it is a refusal; a failure that persists still refuses,
215
+ // because a snapshot missing a table is not a backup.
216
+ let exported = { code: 1, stdout: '', stderr: 'not attempted' };
217
+ for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
218
+ exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
219
+ if (exported.code !== 0 || existsSync(dump)) break;
220
+ }
221
+ if (exported.code !== 0) {
222
+ throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
223
+ }
224
+ if (!existsSync(dump)) {
225
+ throw new Error(
226
+ `wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`,
227
+ );
228
+ }
229
+ stripTrailingNuls(dump);
230
+ const text = readFileSync(dump, 'utf8');
231
+ dumps.push({ table, text });
232
+ sql += `${text}\n`;
175
233
  }
176
- if (exported.code !== 0) {
177
- throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
234
+ if (!/INSERT INTO/i.test(sql)) {
235
+ throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
178
236
  }
179
- if (!existsSync(dump)) {
180
- throw new Error(
181
- `wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`,
182
- );
183
- }
184
- sql += `${readFileSync(dump, 'utf8')}\n`;
185
- }
186
- if (!/INSERT INTO/i.test(sql)) {
187
- throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
188
- }
189
237
 
190
- const db = new Database(destination, { create: true });
191
- try {
192
- // `exec`, not `run`: each is many statements and `run` would execute only the first,
193
- // leaving a file with a schema and no rows that looks perfectly valid.
194
- db.exec(options.schemaSql);
195
- // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
196
- // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
197
- // cache its art job creates where it first needs it — made the whole pull refuse with
198
- // `no such table`, because its rows were exported and its table was not in the schema.
199
- const have = new Set(
200
- (db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]).map((r) => r.name),
201
- );
202
- for (const row of rows) {
203
- if (!tables.includes(row.name) || have.has(row.name) || !row.sql) continue;
204
- db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
238
+ const db = new Database(destination, { create: true });
239
+ try {
240
+ // `exec`, not `run`: each is many statements and `run` would execute only the first,
241
+ // leaving a file with a schema and no rows that looks perfectly valid.
242
+ db.exec(options.schemaSql);
243
+ // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
244
+ // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
245
+ // cache its art job creates where it first needs it — made the whole pull refuse with
246
+ // `no such table`, because its rows were exported and its table was not in the schema.
247
+ const have = new Set(
248
+ (db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]).map((r) => r.name),
249
+ );
250
+ for (const row of rows) {
251
+ if (!tables.includes(row.name) || have.has(row.name) || !row.sql) continue;
252
+ db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
253
+ }
254
+ // One table at a time, so a fault is located rather than smeared over the whole load.
255
+ for (const { text } of dumps) if (text.trim()) db.exec(text);
256
+ if (options.afterLoadSql) db.exec(options.afterLoadSql);
257
+ // 🔴 Every table holds as many rows as its dump has INSERTs, or this is not a backup: the
258
+ // check that makes a silently truncated load — the NUL padding, or whatever does it next —
259
+ // a REFUSAL. A row count only; nothing here reads what the rows hold.
260
+ const short: string[] = [];
261
+ for (const { table, text } of dumps) {
262
+ if (table === 'sqlite_sequence') continue;
263
+ const expected = insertLines(text);
264
+ const loaded = (db.query(`SELECT COUNT(*) AS n FROM "${table.replaceAll('"', '""')}"`).get() as { n: number }).n;
265
+ if (loaded < expected) short.push(`${table}: ${loaded} of ${expected}`);
266
+ }
267
+ if (short.length > 0) {
268
+ db.close();
269
+ rmSync(destination, { force: true });
270
+ throw new Error(`the D1 pull did not load every exported row — ${short.join('; ')}. Refusing the snapshot.`);
271
+ }
272
+ } finally {
273
+ db.close();
205
274
  }
206
- db.exec(sql);
207
- if (options.afterLoadSql) db.exec(options.afterLoadSql);
208
275
  } finally {
209
- db.close();
276
+ // The per-table dumps are a second, unmanaged copy of the data that no retention rule
277
+ // would ever delete — eighteen were found beside vault's snapshot (task 2128).
278
+ for (const table of tables) rmSync(`${destination}.${table}.sql`, { force: true });
210
279
  }
211
280
  }
@@ -33,7 +33,7 @@
33
33
  * only be watching one of them.
34
34
  */
35
35
  import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
- import { MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, type LockPageOptions, withRevealToggles } from "./lockPage.js";
37
37
 
38
38
  /**
39
39
  * The page's own two assets.
@@ -67,7 +67,7 @@ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
67
67
  export function accountsPageHtml(options: LockPageOptions): string {
68
68
  const label = escapeHtml(options.appLabel);
69
69
  const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
70
- return `<!doctype html>
70
+ return withRevealToggles(`<!doctype html>
71
71
  <html lang="en">
72
72
  <head>
73
73
  <meta charset="utf-8">
@@ -164,7 +164,7 @@ export function accountsPageHtml(options: LockPageOptions): string {
164
164
  <script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
165
165
  </body>
166
166
  </html>
167
- `;
167
+ `);
168
168
  }
169
169
 
170
170
  /**
@@ -222,6 +222,10 @@ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
222
222
  // ── derivation ───────────────────────────────────────────────────────────────
223
223
  ${MASTER_LOCK_DERIVE_SOURCE}
224
224
 
225
+ // ── show / hide ──────────────────────────────────────────────────────────────
226
+ ${MASTER_LOCK_REVEAL_SOURCE}
227
+ wireMasterLockReveal(document);
228
+
225
229
  const rotateForm = document.getElementById("ml-rotate");
226
230
  const addForm = document.getElementById("ml-add");
227
231
  const list = document.getElementById("ml-accounts");
@@ -13,8 +13,16 @@
13
13
  */
14
14
  import { describe, expect, test } from "bun:test";
15
15
  import { MASTER_LOCK_PATHS, type MasterLockKdfParams, deriveMasterLockVerifier } from "cursedbelt-core/master-lock";
16
- import { MASTER_LOCK_ACCOUNTS_SCRIPT } from "./accountsPage.js";
17
- import { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml } from "./lockPage.js";
16
+ import { accountsPageHtml, MASTER_LOCK_ACCOUNTS_SCRIPT } from "./accountsPage.js";
17
+ import { MASTER_LOCK_CSP } from "./guard.js";
18
+ import {
19
+ LOCK_SCRIPT,
20
+ LOCK_STYLE,
21
+ MASTER_LOCK_DERIVE_SOURCE,
22
+ MASTER_LOCK_REVEAL_SOURCE,
23
+ lockPageHtml,
24
+ withRevealToggles,
25
+ } from "./lockPage.js";
18
26
 
19
27
  const KDF: MasterLockKdfParams = {
20
28
  v: 1,
@@ -90,3 +98,55 @@ describe("the page itself", () => {
90
98
  expect(LOCK_STYLE).not.toContain("@import");
91
99
  });
92
100
  });
101
+
102
+ describe("🔴 every password field on the wall can be shown and hidden (task 077-438)", () => {
103
+ const pages = {
104
+ "lock (unlock)": lockPageHtml({ appLabel: "Vault" }),
105
+ "lock (enroll)": lockPageHtml({ appLabel: "Vault", enroll: true }),
106
+ accounts: accountsPageHtml({ appLabel: "Vault" }),
107
+ };
108
+
109
+ for (const [name, html] of Object.entries(pages)) {
110
+ test(`${name}: one Show control per password input, each input keeping its autocomplete`, () => {
111
+ const inputs = [...html.matchAll(/<input\b[^>]*\btype="password"[^>]*>/g)].map((m) => m[0]);
112
+ expect(inputs.length).toBeGreaterThan(0);
113
+ expect((html.match(/data-reveal/g) ?? []).length).toBe(inputs.length);
114
+ for (const input of inputs) expect(input).toMatch(/autocomplete="(current|new)-password"/);
115
+ // …and still no inline script: the behaviour ships in the same-origin file.
116
+ expect(html).not.toMatch(/<script(?![^>]*\bsrc=)[^>]*>/);
117
+ });
118
+ }
119
+
120
+ test("both served scripts carry the ONE reveal source and call it", () => {
121
+ for (const script of [LOCK_SCRIPT, MASTER_LOCK_ACCOUNTS_SCRIPT]) {
122
+ expect(script).toContain(MASTER_LOCK_REVEAL_SOURCE);
123
+ expect(script).toContain("wireMasterLockReveal(document);");
124
+ }
125
+ });
126
+
127
+ test("clicking shows, clicking again hides, and a submit returns the field to a password", () => {
128
+ document.body.innerHTML = withRevealToggles(
129
+ '<form><input type="password" name="p" autocomplete="current-password"><button type="submit">Go</button></form>',
130
+ );
131
+ const wire = new Function(`${MASTER_LOCK_REVEAL_SOURCE}\nreturn globalThis.wireMasterLockReveal;`)() as (d: Document) => number;
132
+ expect(wire(document)).toBe(1);
133
+ const input = document.querySelector("input") as HTMLInputElement;
134
+ const button = document.querySelector("[data-reveal]") as HTMLButtonElement;
135
+ button.click();
136
+ expect(input.type).toBe("text");
137
+ expect(button.getAttribute("aria-pressed")).toBe("true");
138
+ expect(button.getAttribute("aria-label")).toBe("Hide password");
139
+ button.click();
140
+ expect(input.type).toBe("password");
141
+ button.click();
142
+ (document.querySelector("form") as HTMLFormElement).dispatchEvent(new Event("submit", { cancelable: true }));
143
+ expect(input.type).toBe("password");
144
+ expect(button.type).toBe("button"); // never submits the form itself
145
+ });
146
+
147
+ test("🔴 the CSP is unchanged — still no inline script and nothing off-origin", () => {
148
+ expect(MASTER_LOCK_CSP).toContain("script-src 'self'");
149
+ expect(MASTER_LOCK_CSP).not.toContain("unsafe-inline");
150
+ expect(MASTER_LOCK_CSP).not.toMatch(/nonce-|sha256-/);
151
+ });
152
+ });