cursedbelt-server 4.21.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.
@@ -82,4 +82,4 @@ export declare function createLocalBackup(opts: LocalBackupOpts): DatabaseBackup
82
82
  * a host that genuinely has both.
83
83
  */
84
84
  export declare function backupFor(flavor: 'local' | 'd1', local: () => LocalBackupOpts, remote: () => TimeTravelOpts): DatabaseBackup;
85
- export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, pullD1ToSqlite, servedRuntime, type Wrangler, } from './pullD1.js';
85
+ export { type BackupSourceChoice, type BackupSourceInput, backupTables, chooseBackupSource, type PullD1Options, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, type Wrangler, wranglerArgv, } from './pullD1.js';
@@ -111,4 +111,4 @@ export function backupFor(flavor, local, remote) {
111
111
  // After a Worker cutover the live rows are D1's, and a Mac-hosted backup must pull them down
112
112
  // rather than snapshot the frozen file — see `./pullD1.ts`. Here, not in the `./d1` barrel,
113
113
  // for the reason this file's header gives: it reaches `bun:sqlite`.
114
- export { backupTables, chooseBackupSource, pullD1ToSqlite, servedRuntime, } from './pullD1.js';
114
+ export { backupTables, chooseBackupSource, insertLines, pullD1ToSqlite, servedRuntime, spawnWrangler, stripTrailingNuls, wranglerArgv, } from './pullD1.js';
@@ -56,6 +56,30 @@ export type Wrangler = (args: string[]) => {
56
56
  stdout: string;
57
57
  stderr: string;
58
58
  };
59
+ /**
60
+ * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
61
+ * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
62
+ * line until 4.22.0.
63
+ */
64
+ export declare const wranglerArgv: (bun?: string) => string[];
65
+ /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
66
+ export declare function spawnWrangler(options: {
67
+ cwd: string;
68
+ env?: Record<string, string | undefined>;
69
+ }): Wrangler;
70
+ /**
71
+ * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
72
+ *
73
+ * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
74
+ * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
75
+ * +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
76
+ * reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
77
+ * EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
78
+ * TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
79
+ */
80
+ export declare function stripTrailingNuls(file: string): number;
81
+ /** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
82
+ export declare const insertLines: (dump: string) => number;
59
83
  /**
60
84
  * The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
61
85
  * none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
@@ -27,7 +27,7 @@
27
27
  * (`./http.ts` has the measurement), and a restore must put back what was there.
28
28
  */
29
29
  import { Database } from 'bun:sqlite';
30
- import { existsSync, readFileSync } from 'node:fs';
30
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
31
31
  /** Which side a backup must read. Pure, so every branch is testable without a network. */
32
32
  export function chooseBackupSource(input) {
33
33
  if (input.fromFileFlag) {
@@ -84,6 +84,48 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
84
84
  }
85
85
  /** How many times one table's export is asked for when wrangler says success and writes nothing. */
86
86
  const EXPORT_ATTEMPTS = 3;
87
+ /**
88
+ * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
89
+ * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
90
+ * line until 4.22.0.
91
+ */
92
+ export const wranglerArgv = (bun = process.execPath) => [bun, 'x', 'wrangler'];
93
+ /** The real {@link Wrangler}: `bun x wrangler …` in `cwd`, with `env` added to this process's. */
94
+ export function spawnWrangler(options) {
95
+ return (args) => {
96
+ const r = Bun.spawnSync([...wranglerArgv(), ...args], {
97
+ cwd: options.cwd,
98
+ env: { ...process.env, ...options.env },
99
+ stdout: 'pipe',
100
+ stderr: 'pipe',
101
+ });
102
+ return { code: r.exitCode ?? 1, stdout: r.stdout.toString(), stderr: r.stderr.toString() };
103
+ };
104
+ }
105
+ /**
106
+ * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
107
+ *
108
+ * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
109
+ * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
110
+ * +5,476 across three runs — a different table each time, always trailing). `bun:sqlite`'s `exec`
111
+ * reads SQL as a C string, so the first NUL silently ended the load: every table after it came back
112
+ * EMPTY with no error, and vault's real backup held `vault_keys` = 0 rows (task 2128). Only
113
+ * TRAILING bytes: a NUL inside the SQL is a different fault, and the row-count check refuses it.
114
+ */
115
+ export function stripTrailingNuls(file) {
116
+ if (!existsSync(file))
117
+ return 0;
118
+ const bytes = readFileSync(file);
119
+ let end = bytes.length;
120
+ while (end > 0 && bytes[end - 1] === 0)
121
+ end--;
122
+ if (end === bytes.length)
123
+ return 0;
124
+ writeFileSync(file, bytes.subarray(0, end));
125
+ return bytes.length - end;
126
+ }
127
+ /** `^INSERT INTO ` lines — one per row in a `wrangler d1 export --no-schema` dump. */
128
+ export const insertLines = (dump) => (dump.match(/^INSERT INTO /gm) ?? []).length;
87
129
  /**
88
130
  * The tables a snapshot carries, from D1's own `sqlite_master` rows — every real table, and
89
131
  * none of what cannot or must not be re-inserted: a virtual table and its shadows (`_config`,
@@ -123,50 +165,82 @@ export async function pullD1ToSqlite(options) {
123
165
  throw new Error(`D1 lists no \`${options.requiredTable}\` table (saw: ${rows.map((r) => r.name).join(', ') || 'nothing'})`);
124
166
  }
125
167
  let sql = '';
126
- for (const table of tables) {
127
- const dump = `${destination}.${table}.sql`;
128
- // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
129
- // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
130
- // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
131
- // asked for again, twice, before it is a refusal; a failure that persists still refuses,
132
- // because a snapshot missing a table is not a backup.
133
- let exported = { code: 1, stdout: '', stderr: 'not attempted' };
134
- for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
135
- exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
136
- if (exported.code !== 0 || existsSync(dump))
137
- break;
168
+ const dumps = [];
169
+ try {
170
+ for (const table of tables) {
171
+ const dump = `${destination}.${table}.sql`;
172
+ // 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
173
+ // collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
174
+ // command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
175
+ // asked for again, twice, before it is a refusal; a failure that persists still refuses,
176
+ // because a snapshot missing a table is not a backup.
177
+ let exported = { code: 1, stdout: '', stderr: 'not attempted' };
178
+ for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
179
+ exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
180
+ if (exported.code !== 0 || existsSync(dump))
181
+ break;
182
+ }
183
+ if (exported.code !== 0) {
184
+ throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
185
+ }
186
+ if (!existsSync(dump)) {
187
+ throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
188
+ }
189
+ stripTrailingNuls(dump);
190
+ const text = readFileSync(dump, 'utf8');
191
+ dumps.push({ table, text });
192
+ sql += `${text}\n`;
138
193
  }
139
- if (exported.code !== 0) {
140
- throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
194
+ if (!/INSERT INTO/i.test(sql)) {
195
+ throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
141
196
  }
142
- if (!existsSync(dump)) {
143
- throw new Error(`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`);
197
+ const db = new Database(destination, { create: true });
198
+ try {
199
+ // `exec`, not `run`: each is many statements and `run` would execute only the first,
200
+ // leaving a file with a schema and no rows that looks perfectly valid.
201
+ db.exec(options.schemaSql);
202
+ // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
203
+ // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
204
+ // cache its art job creates where it first needs it — made the whole pull refuse with
205
+ // `no such table`, because its rows were exported and its table was not in the schema.
206
+ const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
207
+ for (const row of rows) {
208
+ if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
209
+ continue;
210
+ db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
211
+ }
212
+ // One table at a time, so a fault is located rather than smeared over the whole load.
213
+ for (const { text } of dumps)
214
+ if (text.trim())
215
+ db.exec(text);
216
+ if (options.afterLoadSql)
217
+ db.exec(options.afterLoadSql);
218
+ // 🔴 Every table holds as many rows as its dump has INSERTs, or this is not a backup: the
219
+ // check that makes a silently truncated load — the NUL padding, or whatever does it next —
220
+ // a REFUSAL. A row count only; nothing here reads what the rows hold.
221
+ const short = [];
222
+ for (const { table, text } of dumps) {
223
+ if (table === 'sqlite_sequence')
224
+ continue;
225
+ const expected = insertLines(text);
226
+ const loaded = db.query(`SELECT COUNT(*) AS n FROM "${table.replaceAll('"', '""')}"`).get().n;
227
+ if (loaded < expected)
228
+ short.push(`${table}: ${loaded} of ${expected}`);
229
+ }
230
+ if (short.length > 0) {
231
+ db.close();
232
+ rmSync(destination, { force: true });
233
+ throw new Error(`the D1 pull did not load every exported row — ${short.join('; ')}. Refusing the snapshot.`);
234
+ }
144
235
  }
145
- sql += `${readFileSync(dump, 'utf8')}\n`;
146
- }
147
- if (!/INSERT INTO/i.test(sql)) {
148
- throw new Error('the D1 export contains no rows — refusing to snapshot an empty database as if it were the library');
149
- }
150
- const db = new Database(destination, { create: true });
151
- try {
152
- // `exec`, not `run`: each is many statements and `run` would execute only the first,
153
- // leaving a file with a schema and no rows that looks perfectly valid.
154
- db.exec(options.schemaSql);
155
- // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
156
- // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
157
- // cache its art job creates where it first needs it — made the whole pull refuse with
158
- // `no such table`, because its rows were exported and its table was not in the schema.
159
- const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
160
- for (const row of rows) {
161
- if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
162
- continue;
163
- db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
236
+ finally {
237
+ db.close();
164
238
  }
165
- db.exec(sql);
166
- if (options.afterLoadSql)
167
- db.exec(options.afterLoadSql);
168
239
  }
169
240
  finally {
170
- db.close();
241
+ // The per-table dumps are a second, unmanaged copy of the data that no retention rule
242
+ // would ever delete — eighteen were found beside vault's snapshot (task 2128).
243
+ for (const table of tables)
244
+ rmSync(`${destination}.${table}.sql`, { force: true });
171
245
  }
172
246
  }
@@ -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 } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
37
37
  /**
38
38
  * The page's own two assets.
39
39
  *
@@ -59,7 +59,7 @@ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
59
59
  export function accountsPageHtml(options) {
60
60
  const label = escapeHtml(options.appLabel);
61
61
  const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
62
- return `<!doctype html>
62
+ return withRevealToggles(`<!doctype html>
63
63
  <html lang="en">
64
64
  <head>
65
65
  <meta charset="utf-8">
@@ -156,7 +156,7 @@ export function accountsPageHtml(options) {
156
156
  <script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
157
157
  </body>
158
158
  </html>
159
- `;
159
+ `);
160
160
  }
161
161
  /**
162
162
  * The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
@@ -212,6 +212,10 @@ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
212
212
  // ── derivation ───────────────────────────────────────────────────────────────
213
213
  ${MASTER_LOCK_DERIVE_SOURCE}
214
214
 
215
+ // ── show / hide ──────────────────────────────────────────────────────────────
216
+ ${MASTER_LOCK_REVEAL_SOURCE}
217
+ wireMasterLockReveal(document);
218
+
215
219
  const rotateForm = document.getElementById("ml-rotate");
216
220
  const addForm = document.getElementById("ml-add");
217
221
  const list = document.getElementById("ml-accounts");
@@ -21,7 +21,26 @@ export interface LockPageOptions {
21
21
  */
22
22
  export declare function lockPageHtml(options: LockPageOptions): string;
23
23
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
24
- export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n";
24
+ export declare const LOCK_STYLE = ":root{color-scheme:light dark;\n --ml-bg:#f4f5f7;--ml-card:#ffffff;--ml-text:#14171c;--ml-muted:#69707c;--ml-border:#d9dde3;\n --ml-accent:#1f6feb;--ml-accent-text:#ffffff;--ml-danger:#b42318;\n --ml-shadow:0 18px 48px rgba(16,18,22,.14)}\n@media (prefers-color-scheme:dark){:root{\n --ml-bg:#0d0f13;--ml-card:#171a20;--ml-text:#e7eaef;--ml-muted:#8b93a1;--ml-border:#2b3039;\n --ml-accent:#4f8cf7;--ml-accent-text:#0b0d11;--ml-danger:#f97066;\n --ml-shadow:0 18px 48px rgba(0,0,0,.55)}}\n*{box-sizing:border-box}\nbody{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;\n background:var(--ml-bg);color:var(--ml-text);\n font:15px/1.55 ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.card{width:100%;max-width:23rem;background:var(--ml-card);border:1px solid var(--ml-border);\n border-radius:16px;padding:28px 26px 24px;box-shadow:var(--ml-shadow);text-align:center}\n.glyph{font-size:30px;line-height:1;margin-bottom:10px}\nh1{margin:0 0 6px;font-size:18px;font-weight:650;letter-spacing:-.01em}\n.note{margin:0 0 20px;font-size:13px;color:var(--ml-muted)}\n.field{display:block;text-align:left;margin-bottom:14px}\n.field span{display:block;font-size:12px;font-weight:600;color:var(--ml-muted);margin-bottom:6px}\ninput{width:100%;height:40px;padding:0 12px;border:1px solid var(--ml-border);border-radius:9px;\n background:var(--ml-bg);color:inherit;font:inherit}\ninput:focus{outline:2px solid var(--ml-accent);outline-offset:1px;border-color:transparent}\nbutton{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-accent);\n color:var(--ml-accent-text);font:inherit;font-weight:600;cursor:pointer}\nbutton[disabled]{opacity:.6;cursor:progress}\n.status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}\n.status[data-tone=\"error\"]{color:var(--ml-danger)}\n.field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}\nbutton.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;\n font-size:12.5px;text-decoration:underline;text-underline-offset:3px}\nbutton.quiet:hover{color:var(--ml-text)}\nbutton.quiet[disabled]{opacity:.5}\n.hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);\n border-radius:10px;background:var(--ml-bg);font-size:12.5px}\n.hints dt{font-weight:650;color:var(--ml-text)}\n.hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}\n.hints dd:last-child{margin-bottom:0}\n.secret{position:relative;display:block}\n.secret input{padding-right:68px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;\n min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);\n font-size:12.5px;font-weight:600}\n.secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}\n.secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}\n";
25
+ /**
26
+ * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
27
+ * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
28
+ *
29
+ * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
30
+ * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
31
+ * included, so a password manager still sees a password field. Behaviour:
32
+ * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
33
+ * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
34
+ * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
35
+ */
36
+ export declare function withRevealToggles(html: string): string;
37
+ /**
38
+ * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
39
+ * keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
40
+ * `password` when its form submits — so a manager saving the credential sees a password field.
41
+ * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
42
+ */
43
+ export declare const MASTER_LOCK_REVEAL_SOURCE = "function wireMasterLockReveal(doc) {\n const buttons = doc.querySelectorAll(\"[data-reveal]\");\n for (const button of buttons) {\n const input = button.previousElementSibling;\n if (!input || input.tagName !== \"INPUT\") continue;\n const show = (on) => {\n input.type = on ? \"text\" : \"password\";\n button.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n button.setAttribute(\"aria-label\", on ? \"Hide password\" : \"Show password\");\n button.textContent = on ? \"Hide\" : \"Show\";\n };\n button.addEventListener(\"click\", () => {\n show(input.type === \"password\");\n input.focus();\n });\n input.form?.addEventListener(\"submit\", () => show(false), true);\n }\n return buttons.length;\n}\nglobalThis.wireMasterLockReveal = wireMasterLockReveal;";
25
44
  /**
26
45
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
27
46
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -41,7 +41,7 @@ export function lockPageHtml(options) {
41
41
  "Choose one now. Nobody else can see it and nobody else can reset it — " +
42
42
  "if you forget it, it cannot be recovered, so leave yourself a hint."
43
43
  : "This site is locked. Signing in is not enough — enter the master password to continue."));
44
- return `<!doctype html>
44
+ return withRevealToggles(`<!doctype html>
45
45
  <html lang="en">
46
46
  <head>
47
47
  <meta charset="utf-8">
@@ -88,7 +88,7 @@ export function lockPageHtml(options) {
88
88
  <script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
89
89
  </body>
90
90
  </html>
91
- `;
91
+ `);
92
92
  }
93
93
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
94
94
  export const LOCK_STYLE = `:root{color-scheme:light dark;
@@ -128,7 +128,54 @@ button.quiet[disabled]{opacity:.5}
128
128
  .hints dt{font-weight:650;color:var(--ml-text)}
129
129
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
130
130
  .hints dd:last-child{margin-bottom:0}
131
+ .secret{position:relative;display:block}
132
+ .secret input{padding-right:68px}
133
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
134
+ min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
135
+ font-size:12.5px;font-weight:600}
136
+ .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
137
+ .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
131
138
  `;
139
+ /**
140
+ * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
141
+ * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
142
+ *
143
+ * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
144
+ * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
145
+ * included, so a password manager still sees a password field. Behaviour:
146
+ * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
147
+ * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
148
+ * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
149
+ */
150
+ export function withRevealToggles(html) {
151
+ return html.replace(/(<input\b[^>]*\btype="password"[^>]*>)/g, '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>');
152
+ }
153
+ /**
154
+ * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
155
+ * keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
156
+ * `password` when its form submits — so a manager saving the credential sees a password field.
157
+ * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
158
+ */
159
+ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
160
+ const buttons = doc.querySelectorAll("[data-reveal]");
161
+ for (const button of buttons) {
162
+ const input = button.previousElementSibling;
163
+ if (!input || input.tagName !== "INPUT") continue;
164
+ const show = (on) => {
165
+ input.type = on ? "text" : "password";
166
+ button.setAttribute("aria-pressed", on ? "true" : "false");
167
+ button.setAttribute("aria-label", on ? "Hide password" : "Show password");
168
+ button.textContent = on ? "Hide" : "Show";
169
+ };
170
+ button.addEventListener("click", () => {
171
+ show(input.type === "password");
172
+ input.focus();
173
+ });
174
+ input.form?.addEventListener("submit", () => show(false), true);
175
+ }
176
+ return buttons.length;
177
+ }
178
+ globalThis.wireMasterLockReveal = wireMasterLockReveal;`;
132
179
  /**
133
180
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
134
181
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -179,6 +226,10 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
179
226
  // ── derivation ───────────────────────────────────────────────────────────────
180
227
  ${MASTER_LOCK_DERIVE_SOURCE}
181
228
 
229
+ // ── show / hide ──────────────────────────────────────────────────────────────
230
+ ${MASTER_LOCK_REVEAL_SOURCE}
231
+ wireMasterLockReveal(document);
232
+
182
233
  const form = document.getElementById("ml-form");
183
234
  const input = document.getElementById("ml-input");
184
235
  const confirmInput = document.getElementById("ml-confirm");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.21.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,7 +213,7 @@
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/_bunOnly.js"
216
+ "import": "./dist/server/auth/passwordCost.js"
217
217
  },
218
218
  "./request-log": {
219
219
  "types": "./dist/server/requestLog/requestMetrics.d.ts",
@@ -302,7 +302,7 @@
302
302
  },
303
303
  "dependencies": {
304
304
  "cursedbelt-core": "^2.1.1",
305
- "cursedops": "^0.5.2",
305
+ "cursedops": "^0.6.0",
306
306
  "cwip": "^4.6.0",
307
307
  "jose": "^6.2.3"
308
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
+ });
@@ -67,7 +67,7 @@ export function lockPageHtml(options: LockPageOptions): string {
67
67
  "if you forget it, it cannot be recovered, so leave yourself a hint."
68
68
  : "This site is locked. Signing in is not enough — enter the master password to continue."),
69
69
  );
70
- return `<!doctype html>
70
+ return withRevealToggles(`<!doctype html>
71
71
  <html lang="en">
72
72
  <head>
73
73
  <meta charset="utf-8">
@@ -120,7 +120,7 @@ export function lockPageHtml(options: LockPageOptions): string {
120
120
  <script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
121
121
  </body>
122
122
  </html>
123
- `;
123
+ `);
124
124
  }
125
125
 
126
126
  /** The stylesheet. Follows the viewer's theme and commits to nothing else. */
@@ -161,8 +161,60 @@ button.quiet[disabled]{opacity:.5}
161
161
  .hints dt{font-weight:650;color:var(--ml-text)}
162
162
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
163
163
  .hints dd:last-child{margin-bottom:0}
164
+ .secret{position:relative;display:block}
165
+ .secret input{padding-right:68px}
166
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:auto;height:30px;
167
+ min-width:56px;padding:0 10px;border-radius:7px;background:none;color:var(--ml-muted);
168
+ font-size:12.5px;font-weight:600}
169
+ .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
170
+ .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
164
171
  `;
165
172
 
173
+ /**
174
+ * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
175
+ * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
176
+ *
177
+ * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
178
+ * `<button type="button" data-reveal>` after it — the inputs keep every attribute, `autocomplete`
179
+ * included, so a password manager still sees a password field. Behaviour:
180
+ * {@link MASTER_LOCK_REVEAL_SOURCE}, interpolated into BOTH same-origin scripts, because the wall's
181
+ * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
182
+ * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
183
+ */
184
+ export function withRevealToggles(html: string): string {
185
+ return html.replace(
186
+ /(<input\b[^>]*\btype="password"[^>]*>)/g,
187
+ '<span class="secret">$1<button type="button" class="reveal" data-reveal aria-label="Show password" aria-pressed="false">Show</button></span>',
188
+ );
189
+ }
190
+
191
+ /**
192
+ * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
193
+ * keeps `aria-pressed`/`aria-label` true to what is on screen, and returns every field to
194
+ * `password` when its form submits — so a manager saving the credential sees a password field.
195
+ * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
196
+ */
197
+ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
198
+ const buttons = doc.querySelectorAll("[data-reveal]");
199
+ for (const button of buttons) {
200
+ const input = button.previousElementSibling;
201
+ if (!input || input.tagName !== "INPUT") continue;
202
+ const show = (on) => {
203
+ input.type = on ? "text" : "password";
204
+ button.setAttribute("aria-pressed", on ? "true" : "false");
205
+ button.setAttribute("aria-label", on ? "Hide password" : "Show password");
206
+ button.textContent = on ? "Hide" : "Show";
207
+ };
208
+ button.addEventListener("click", () => {
209
+ show(input.type === "password");
210
+ input.focus();
211
+ });
212
+ input.form?.addEventListener("submit", () => show(false), true);
213
+ }
214
+ return buttons.length;
215
+ }
216
+ globalThis.wireMasterLockReveal = wireMasterLockReveal;`;
217
+
166
218
  /**
167
219
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
168
220
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -214,6 +266,10 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
214
266
  // ── derivation ───────────────────────────────────────────────────────────────
215
267
  ${MASTER_LOCK_DERIVE_SOURCE}
216
268
 
269
+ // ── show / hide ──────────────────────────────────────────────────────────────
270
+ ${MASTER_LOCK_REVEAL_SOURCE}
271
+ wireMasterLockReveal(document);
272
+
217
273
  const form = document.getElementById("ml-form");
218
274
  const input = document.getElementById("ml-input");
219
275
  const confirmInput = document.getElementById("ml-confirm");
@@ -25,6 +25,24 @@ function runtimeGraph(entry: string): { files: string[]; bunImports: string[] }
25
25
  return { files: [...seen], bunImports };
26
26
  }
27
27
 
28
+ describe('🔴 cursedbelt-server/password — a Worker can import it (task 2129)', () => {
29
+ test('reaches no `bun:` module at runtime; `Bun.password` is touched only at CALL time', () => {
30
+ const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'auth', 'passwordCost.ts'));
31
+ expect(bunImports).toEqual([]);
32
+ expect(files.some((f) => f.endsWith('passwordCost.ts'))).toBe(true);
33
+ });
34
+
35
+ test("its export map's `import` is the real module, not the Bun-only refusal", () => {
36
+ // Until 4.22.0 it was `_bunOnly.js`, and a Worker bundle (esbuild resolves `import`, never
37
+ // `bun`) failed: No matching export in "…/_bunOnly.js" for import "hashSecret". So patterns
38
+ // and vault kept copies of the cost decision — the security-relevant half.
39
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as {
40
+ exports: Record<string, { import?: string }>;
41
+ };
42
+ expect(pkg.exports['./password']?.import).toBe('./dist/server/auth/passwordCost.js');
43
+ });
44
+ });
45
+
28
46
  describe('cursedbelt-server/telemetry', () => {
29
47
  test('🔴 reaches no `bun:` module at runtime — a Worker can import it', () => {
30
48
  const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'telemetry.ts'));