cursedbelt-server 4.30.1 → 4.32.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.
@@ -18,13 +18,30 @@
18
18
  * the cutover (a GREEN backup of nothing current — the worst failure a backup has), guessing
19
19
  * D1 snapshots a stale copy while the Mac is live.
20
20
  *
21
- * ## 🔴 Why table by table, and through `wrangler`
21
+ * ## 🔴 Why paged `SELECT`s, and never `wrangler d1 export`
22
22
  *
23
- * `wrangler d1 export` REFUSES a whole database holding a virtual table — *"cannot export
24
- * databases with Virtual Tables (fts5)"*, measured on collections' first post-cutover night —
25
- * and a per-table export is allowed. `wrangler` rather than the REST API because the export
26
- * is typed SQL: the REST API's JSON cannot tell the REAL `5.0` from the INTEGER `5`
27
- * (`./http.ts` has the measurement), and a restore must put back what was there.
23
+ * **A D1 export blocks the database from serving anything else for as long as it runs**
24
+ * (Cloudflare's own docs). Measured on family 2026-09-25 00:32–00:33 UTC: a manual `bun run
25
+ * backup` — then a per-table `wrangler d1 export` — held production for ~28 s at a time, and
26
+ * 14 requests to the family Worker sat waiting and threw Error 1101 (wall p50 28 s, CPU p50
27
+ * 1.6 ms). Two earlier throws at 13:56/13:58 had the same shape. So every nightly backup of
28
+ * every app on this seam was a scheduled outage.
29
+ *
30
+ * Since 4.32.0 the rows are read with ordinary `wrangler d1 execute --remote` SELECTs, a page
31
+ * of {@link PAGE_ROWS} rows at a time on a rowid keyset, which D1 serves between other
32
+ * requests like any read. Each value comes back as SQLite's own `quote()` of it — an SQL
33
+ * literal, as TEXT — so the REAL `5.0` stays `5.0` and the INTEGER `5` stays `5` (the reason
34
+ * this used `export` and not the REST API's JSON: `./http.ts` has the measurement), a BLOB is
35
+ * `X'…'`, and `quote()` round-trips a double exactly. The dump is then the same
36
+ * `INSERT INTO …` text an export wrote, so everything after it is unchanged.
37
+ *
38
+ * Still table by table: a whole-database export refused fts5 (*"cannot export databases with
39
+ * Virtual Tables"*), and a virtual table's shadow tables are an index, rebuilt, never copied.
40
+ *
41
+ * 🔴 The trade: an export was one point in time; pages are not. A row written to one table
42
+ * while another is being paged may be in the snapshot without its partner. For a nightly
43
+ * backup that is the right trade against taking production down, and the row-count check
44
+ * below still refuses a load short of what was read.
28
45
  */
29
46
  export type BackupSourceChoice = {
30
47
  kind: 'file' | 'd1' | 'refuse';
@@ -50,6 +67,13 @@ export declare function servedRuntime(url: string, { attempts, sleep }?: {
50
67
  attempts?: number | undefined;
51
68
  sleep?: ((ms: number) => Promise<void>) | undefined;
52
69
  }): Promise<string | null>;
70
+ /**
71
+ * Rows per paged `SELECT`. The fleet's largest table held 290 rows when this was set (collections'
72
+ * `items`, 2026-09-25) and one `wrangler` call costs ~1 s, so a page this size is one call per
73
+ * table for every app today. A page D1 refuses — too large a response — is halved and asked
74
+ * again, down to one row, before it is a refusal.
75
+ */
76
+ export declare const PAGE_ROWS = 500;
53
77
  /** One `wrangler` invocation, as {@link pullD1ToSqlite} needs it. Injected so a spec runs none. */
54
78
  export type Wrangler = (args: string[]) => {
55
79
  code: number;
@@ -69,6 +93,8 @@ export declare function spawnWrangler(options: {
69
93
  }): Wrangler;
70
94
  /**
71
95
  * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
96
+ * {@link pullD1ToSqlite} no longer exports (see the header), so nothing here calls this since
97
+ * 4.32.0; it stays exported for anyone still reading an export file by hand.
72
98
  *
73
99
  * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
74
100
  * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
@@ -104,11 +130,34 @@ export interface PullD1Options {
104
130
  afterLoadSql?: string;
105
131
  wrangler: Wrangler;
106
132
  }
133
+ /** How a table is walked in pages: by a rowid keyset, or — a `WITHOUT ROWID` table — by offset. */
134
+ export interface TablePlan {
135
+ table: string;
136
+ columns: string[];
137
+ /** The rowid alias to page on, or `null` for a table with none (then `order` + OFFSET). */
138
+ key: string | null;
139
+ order: string[];
140
+ }
141
+ /**
142
+ * The paging plan for one table. The key is the first of `rowid`, `_rowid_`, `oid` that no
143
+ * declared column shadows — `items_fts_map` declares a column NAMED `rowid`, so it pages on
144
+ * `_rowid_`, which is the same value.
145
+ */
146
+ export declare function tablePlan(table: string, ddl: string | null, cols: ReadonlyArray<{
147
+ name: string;
148
+ pk: number;
149
+ }>): TablePlan;
150
+ /** The SELECT for one page: the key (if any) and every column as its `quote()`d SQL literal. */
151
+ export declare function pageSql(plan: TablePlan, limit: number, after: {
152
+ key?: number;
153
+ offset?: number;
154
+ }): string;
107
155
  /**
108
- * Pull the live D1 database into a real SQLite file.
156
+ * Pull the live D1 database into a real SQLite file, reading it with paged SELECTs that never
157
+ * block production (see the header — this used to be `wrangler d1 export`, which did).
109
158
  *
110
159
  * 🔴 It THROWS rather than returning an empty database. Every layer beneath treats a readable
111
- * SQLite file as success, so an export that produced nothing would sail through the
160
+ * SQLite file as success, so a pull that produced nothing would sail through the
112
161
  * magic-byte check and `integrity_check` and land on the shelf with today's date on it.
113
162
  */
114
163
  export declare function pullD1ToSqlite(options: PullD1Options): Promise<void>;
@@ -18,13 +18,30 @@
18
18
  * the cutover (a GREEN backup of nothing current — the worst failure a backup has), guessing
19
19
  * D1 snapshots a stale copy while the Mac is live.
20
20
  *
21
- * ## 🔴 Why table by table, and through `wrangler`
21
+ * ## 🔴 Why paged `SELECT`s, and never `wrangler d1 export`
22
22
  *
23
- * `wrangler d1 export` REFUSES a whole database holding a virtual table — *"cannot export
24
- * databases with Virtual Tables (fts5)"*, measured on collections' first post-cutover night —
25
- * and a per-table export is allowed. `wrangler` rather than the REST API because the export
26
- * is typed SQL: the REST API's JSON cannot tell the REAL `5.0` from the INTEGER `5`
27
- * (`./http.ts` has the measurement), and a restore must put back what was there.
23
+ * **A D1 export blocks the database from serving anything else for as long as it runs**
24
+ * (Cloudflare's own docs). Measured on family 2026-09-25 00:32–00:33 UTC: a manual `bun run
25
+ * backup` — then a per-table `wrangler d1 export` — held production for ~28 s at a time, and
26
+ * 14 requests to the family Worker sat waiting and threw Error 1101 (wall p50 28 s, CPU p50
27
+ * 1.6 ms). Two earlier throws at 13:56/13:58 had the same shape. So every nightly backup of
28
+ * every app on this seam was a scheduled outage.
29
+ *
30
+ * Since 4.32.0 the rows are read with ordinary `wrangler d1 execute --remote` SELECTs, a page
31
+ * of {@link PAGE_ROWS} rows at a time on a rowid keyset, which D1 serves between other
32
+ * requests like any read. Each value comes back as SQLite's own `quote()` of it — an SQL
33
+ * literal, as TEXT — so the REAL `5.0` stays `5.0` and the INTEGER `5` stays `5` (the reason
34
+ * this used `export` and not the REST API's JSON: `./http.ts` has the measurement), a BLOB is
35
+ * `X'…'`, and `quote()` round-trips a double exactly. The dump is then the same
36
+ * `INSERT INTO …` text an export wrote, so everything after it is unchanged.
37
+ *
38
+ * Still table by table: a whole-database export refused fts5 (*"cannot export databases with
39
+ * Virtual Tables"*), and a virtual table's shadow tables are an index, rebuilt, never copied.
40
+ *
41
+ * 🔴 The trade: an export was one point in time; pages are not. A row written to one table
42
+ * while another is being paged may be in the snapshot without its partner. For a nightly
43
+ * backup that is the right trade against taking production down, and the row-count check
44
+ * below still refuses a load short of what was read.
28
45
  */
29
46
  import { Database } from 'bun:sqlite';
30
47
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -82,8 +99,13 @@ export async function servedRuntime(url, { attempts = 3, sleep = (ms) => new Pro
82
99
  }
83
100
  return null;
84
101
  }
85
- /** How many times one table's export is asked for when wrangler says success and writes nothing. */
86
- const EXPORT_ATTEMPTS = 3;
102
+ /**
103
+ * Rows per paged `SELECT`. The fleet's largest table held 290 rows when this was set (collections'
104
+ * `items`, 2026-09-25) and one `wrangler` call costs ~1 s, so a page this size is one call per
105
+ * table for every app today. A page D1 refuses — too large a response — is halved and asked
106
+ * again, down to one row, before it is a refusal.
107
+ */
108
+ export const PAGE_ROWS = 500;
87
109
  /**
88
110
  * The argv that runs `wrangler`: THIS bun binary's `x`, never a bare `bunx` — a launchd job's
89
111
  * PATH has no `bunx` (task 2112), and vault's and patterns' nightly backups each carried this
@@ -104,6 +126,8 @@ export function spawnWrangler(options) {
104
126
  }
105
127
  /**
106
128
  * 🔴 Trailing NUL bytes off a `wrangler d1 export` file, in place. Returns how many were removed.
129
+ * {@link pullD1ToSqlite} no longer exports (see the header), so nothing here calls this since
130
+ * 4.32.0; it stays exported for anyone still reading an export file by hand.
107
131
  *
108
132
  * Measured 2026-09-23 on vault's D1 (wrangler 4.136.3): `wrangler d1 export --table … --output`
109
133
  * INTERMITTENTLY ends the file in hundreds of NUL bytes (`entries` +988, `oplog` +1,369, `sync_ops`
@@ -143,104 +167,148 @@ export function backupTables(rows) {
143
167
  .filter((name) => name === 'sqlite_sequence' || !name.startsWith('sqlite_'))
144
168
  .sort();
145
169
  }
170
+ const sqlIdent = (name) => `"${name.replaceAll('"', '""')}"`;
171
+ const sqlText = (text) => `'${text.replaceAll("'", "''")}'`;
172
+ /** One `wrangler d1 execute --remote --json` — every statement's result rows, in order. */
173
+ function execute(wrangler, database, sql) {
174
+ const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
175
+ if (r.code !== 0)
176
+ throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
177
+ const parsed = JSON.parse(r.stdout.slice(r.stdout.indexOf('[')));
178
+ return parsed.map((statement) => statement.results ?? []);
179
+ }
180
+ /**
181
+ * The paging plan for one table. The key is the first of `rowid`, `_rowid_`, `oid` that no
182
+ * declared column shadows — `items_fts_map` declares a column NAMED `rowid`, so it pages on
183
+ * `_rowid_`, which is the same value.
184
+ */
185
+ export function tablePlan(table, ddl, cols) {
186
+ const columns = cols.map((c) => c.name);
187
+ if (columns.length === 0)
188
+ throw new Error(`D1 reports no columns for \`${table}\``);
189
+ const lower = new Set(columns.map((c) => c.toLowerCase()));
190
+ const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(ddl ?? '');
191
+ const key = withoutRowid ? null : (['rowid', '_rowid_', 'oid'].find((alias) => !lower.has(alias)) ?? null);
192
+ const pk = cols.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk).map((c) => c.name);
193
+ return { table, columns, key, order: pk.length > 0 ? pk : columns };
194
+ }
195
+ /** The SELECT for one page: the key (if any) and every column as its `quote()`d SQL literal. */
196
+ export function pageSql(plan, limit, after) {
197
+ const values = plan.columns.map((c, i) => `quote(${sqlIdent(c)}) AS c${i}`).join(', ');
198
+ if (plan.key) {
199
+ const where = after.key === undefined ? '' : ` WHERE ${plan.key} > ${after.key}`;
200
+ return `SELECT ${plan.key} AS k, ${values} FROM ${sqlIdent(plan.table)}${where} ORDER BY ${plan.key} LIMIT ${limit}`;
201
+ }
202
+ const order = plan.order.map(sqlIdent).join(', ');
203
+ return `SELECT ${values} FROM ${sqlIdent(plan.table)} ORDER BY ${order} LIMIT ${limit} OFFSET ${after.offset ?? 0}`;
204
+ }
205
+ /** Every row of one table as `INSERT INTO …;` lines, read in pages that never block D1. */
206
+ function dumpTable(wrangler, database, plan) {
207
+ const into = `INSERT INTO ${sqlIdent(plan.table)} (${plan.columns.map(sqlIdent).join(', ')}) VALUES (`;
208
+ const lines = [];
209
+ let limit = PAGE_ROWS;
210
+ let after = {};
211
+ for (;;) {
212
+ let page;
213
+ try {
214
+ page = execute(wrangler, database, pageSql(plan, limit, after))[0] ?? [];
215
+ }
216
+ catch (error) {
217
+ if (limit > 1) {
218
+ limit = Math.max(1, Math.floor(limit / 2));
219
+ continue;
220
+ }
221
+ throw new Error(`could not read \`${plan.table}\` from D1: ${error.message}`);
222
+ }
223
+ for (const row of page) {
224
+ const literals = plan.columns.map((_, i) => row[`c${i}`]);
225
+ if (literals.some((v) => typeof v !== 'string')) {
226
+ throw new Error(`D1 returned a non-literal value paging \`${plan.table}\` — refusing a snapshot that may not restore`);
227
+ }
228
+ lines.push(`${into}${literals.join(', ')});`);
229
+ }
230
+ if (page.length < limit)
231
+ break;
232
+ after = plan.key ? { key: Number(page[page.length - 1]?.k) } : { offset: (after.offset ?? 0) + page.length };
233
+ }
234
+ return { text: lines.join('\n'), rows: lines.length };
235
+ }
146
236
  /**
147
- * Pull the live D1 database into a real SQLite file.
237
+ * Pull the live D1 database into a real SQLite file, reading it with paged SELECTs that never
238
+ * block production (see the header — this used to be `wrangler d1 export`, which did).
148
239
  *
149
240
  * 🔴 It THROWS rather than returning an empty database. Every layer beneath treats a readable
150
- * SQLite file as success, so an export that produced nothing would sail through the
241
+ * SQLite file as success, so a pull that produced nothing would sail through the
151
242
  * magic-byte check and `integrity_check` and land on the shelf with today's date on it.
152
243
  */
153
244
  export async function pullD1ToSqlite(options) {
154
245
  const { destination, database, wrangler } = options;
155
- const listed = wrangler([
156
- 'd1', 'execute', database, '--remote', '--env=', '--json',
157
- '--command', "select name, sql from sqlite_master where type = 'table'",
158
- ]);
159
- if (listed.code !== 0)
160
- throw new Error(`could not list D1's tables: ${listed.stderr.trim() || listed.stdout.trim()}`);
161
- const json = listed.stdout.slice(listed.stdout.indexOf('['));
162
- const rows = JSON.parse(json)[0]?.results ?? [];
246
+ let rows;
247
+ try {
248
+ rows = (execute(wrangler, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []);
249
+ }
250
+ catch (error) {
251
+ throw new Error(`could not list D1's tables: ${error.message}`);
252
+ }
163
253
  const tables = backupTables(rows);
164
254
  if (!tables.includes(options.requiredTable)) {
165
255
  throw new Error(`D1 lists no \`${options.requiredTable}\` table (saw: ${rows.map((r) => r.name).join(', ') || 'nothing'})`);
166
256
  }
167
- let sql = '';
168
- const dumps = [];
257
+ // Every table's columns in ONE call: one statement each, answered in order.
258
+ let columnSets;
169
259
  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`;
193
- }
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');
260
+ columnSets = execute(wrangler, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
261
+ }
262
+ catch (error) {
263
+ throw new Error(`could not read D1's columns: ${error.message}`);
264
+ }
265
+ const dumps = tables.map((table, i) => {
266
+ const cols = (columnSets[i] ?? []);
267
+ const plan = tablePlan(table, rows.find((r) => r.name === table)?.sql ?? null, cols);
268
+ return { table, ...dumpTable(wrangler, database, plan) };
269
+ });
270
+ if (dumps.every((d) => d.rows === 0)) {
271
+ throw new Error('the D1 pull contains no rows — refusing to snapshot an empty database as if it were the library');
272
+ }
273
+ const db = new Database(destination, { create: true });
274
+ try {
275
+ // `exec`, not `run`: each is many statements and `run` would execute only the first,
276
+ // leaving a file with a schema and no rows that looks perfectly valid.
277
+ db.exec(options.schemaSql);
278
+ // 🔴 A table D1 holds that the schema does not declare is created from D1's OWN DDL, never
279
+ // dropped: measured on music's first post-cutover pull (2026-09-23), `art_lookups` — a
280
+ // cache its art job creates where it first needs it — made the whole pull refuse with
281
+ // `no such table`, because its rows were exported and its table was not in the schema.
282
+ const have = new Set(db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
283
+ for (const row of rows) {
284
+ if (!tables.includes(row.name) || have.has(row.name) || !row.sql)
285
+ continue;
286
+ db.exec(row.sql.replace(/^\s*CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)/i, 'CREATE TABLE IF NOT EXISTS '));
196
287
  }
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
- }
288
+ // One table at a time, so a fault is located rather than smeared over the whole load.
289
+ for (const { text } of dumps)
290
+ if (text)
291
+ db.exec(text);
292
+ if (options.afterLoadSql)
293
+ db.exec(options.afterLoadSql);
294
+ // 🔴 Every table holds as many rows as were read from D1, or this is not a backup: the check
295
+ // that makes a silently truncated load (task 2128's NUL padding, or whatever does it next)
296
+ // a REFUSAL. A row count only; nothing here reads what the rows hold.
297
+ const short = [];
298
+ for (const { table, rows: expected } of dumps) {
299
+ if (table === 'sqlite_sequence')
300
+ continue;
301
+ const loaded = db.query(`SELECT COUNT(*) AS n FROM ${sqlIdent(table)}`).get().n;
302
+ if (loaded < expected)
303
+ short.push(`${table}: ${loaded} of ${expected}`);
235
304
  }
236
- finally {
305
+ if (short.length > 0) {
237
306
  db.close();
307
+ rmSync(destination, { force: true });
308
+ throw new Error(`the D1 pull did not load every row it read — ${short.join('; ')}. Refusing the snapshot.`);
238
309
  }
239
310
  }
240
311
  finally {
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 });
312
+ db.close();
245
313
  }
246
314
  }
@@ -11,9 +11,9 @@ export declare const MASTER_LOCK_ACCOUNTS_PAGE_PATHS: {
11
11
  readonly style: "/__lock/accounts.css";
12
12
  readonly script: "/__lock/accounts.js";
13
13
  };
14
- /** The shortest password worth calling one. Matched by every `minlength` below and by the
15
- * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
16
- export declare const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
14
+ /** The shortest password worth calling one — defined beside the lock page's enrollment form
15
+ * (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
16
+ export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
17
17
  /**
18
18
  * The document.
19
19
  *
@@ -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, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, MASTER_LOCK_MIN_PASSWORD_LENGTH, MASTER_LOCK_REVEAL_SOURCE, withRevealToggles } from "./lockPage.js";
37
37
  /**
38
38
  * The page's own two assets.
39
39
  *
@@ -47,9 +47,9 @@ export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
47
47
  script: `${MASTER_LOCK_PREFIX}/accounts.js`,
48
48
  };
49
49
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch);
50
- /** The shortest password worth calling one. Matched by every `minlength` below and by the
51
- * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
52
- export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
50
+ /** The shortest password worth calling one — defined beside the lock page's enrollment form
51
+ * (`lockPage.ts`) and re-exported, so a rotation cannot quietly differ from what enrollment demands. */
52
+ export { MASTER_LOCK_MIN_PASSWORD_LENGTH } from "./lockPage.js";
53
53
  /**
54
54
  * The document.
55
55
  *
@@ -1,3 +1,9 @@
1
+ /**
2
+ * The shortest master password the wall accepts — every `minlength` on both pages and both
3
+ * scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
4
+ * 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
5
+ */
6
+ export declare const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
1
7
  /** Everything the page needs to know about the app it is standing in front of. */
2
8
  export interface LockPageOptions {
3
9
  /** What to call this app — "Collections", "binary-server inspector". */
@@ -21,18 +27,7 @@ export interface LockPageOptions {
21
27
  */
22
28
  export declare function lockPageHtml(options: LockPageOptions): string;
23
29
  /** 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.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
- */
30
+ 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:48px}\n.secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;\n display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}\n.secret .reveal svg{width:18px;height:18px}\n.secret .reveal .eye-off,.secret .reveal[aria-pressed=\"true\"] .eye{display:none}\n.secret .reveal[aria-pressed=\"true\"] .eye-off{display:block}\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";
36
31
  export declare function withRevealToggles(html: string): string;
37
32
  /**
38
33
  * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
@@ -40,7 +35,7 @@ export declare function withRevealToggles(html: string): string;
40
35
  * `password` when its form submits — so a manager saving the credential sees a password field.
41
36
  * Publishes itself on `globalThis`, the seam `lockPage.spec.ts` drives it through.
42
37
  */
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;";
38
+ 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 };\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;";
44
39
  /**
45
40
  * 🔴 The ONE browser copy of `deriveMasterLockVerifier`, shared by every page this feature
46
41
  * serves. Pinned by `lockPage.spec.ts`, which evaluates it and compares against the
@@ -26,6 +26,12 @@
26
26
  * scripts interpolate this string, and the spec asserts that they do.
27
27
  */
28
28
  import { MASTER_LOCK_PATHS } from "cursedbelt-core/master-lock";
29
+ /**
30
+ * The shortest master password the wall accepts — every `minlength` on both pages and both
31
+ * scripts' own checks read this one number, so a rotation cannot quietly differ from enrollment.
32
+ * 10 until 2026-09-24; the owner: *"make the minimum password length 6 characters"*.
33
+ */
34
+ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 6;
29
35
  const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch);
30
36
  /**
31
37
  * The document. Deliberately says the app's NAME and nothing else about it — whoever is
@@ -61,14 +67,14 @@ export function lockPageHtml(options) {
61
67
  <span>${enroll ? "New master password" : "Master password"}</span>
62
68
  <input id="ml-input" type="password" name="master-password"
63
69
  autocomplete="${enroll ? "new-password" : "current-password"}"
64
- autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ? ' minlength="10"' : ""}>
70
+ autofocus required spellcheck="false" enterkeyhint="${enroll ? "next" : "go"}"${enroll ? ` minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}"` : ""}>
65
71
  </label>${enroll
66
72
  ? `
67
73
  <label class="field">
68
74
  <span>Confirm master password</span>
69
75
  <input id="ml-confirm" type="password" name="confirm-master-password"
70
76
  autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
71
- minlength="10">
77
+ minlength="${MASTER_LOCK_MIN_PASSWORD_LENGTH}">
72
78
  </label>
73
79
  <label class="field">
74
80
  <span>Hint <em>optional</em></span>
@@ -129,15 +135,17 @@ button.quiet[disabled]{opacity:.5}
129
135
  .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
130
136
  .hints dd:last-child{margin-bottom:0}
131
137
  .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}
138
+ .secret input{padding-right:48px}
139
+ .secret .reveal{position:absolute;right:5px;top:50%;transform:translateY(-50%);width:36px;height:32px;
140
+ display:grid;place-items:center;padding:0;border-radius:7px;background:none;color:var(--ml-muted)}
141
+ .secret .reveal svg{width:18px;height:18px}
142
+ .secret .reveal .eye-off,.secret .reveal[aria-pressed="true"] .eye{display:none}
143
+ .secret .reveal[aria-pressed="true"] .eye-off{display:block}
136
144
  .secret .reveal:hover{color:var(--ml-text);background:var(--ml-card)}
137
145
  .secret .reveal:focus-visible{outline:2px solid var(--ml-accent);outline-offset:1px}
138
146
  `;
139
147
  /**
140
- * 🔴 Every password field on the wall gets a Show/Hide control (task 077-438's library share) — a
148
+ * 🔴 Every password field on the wall gets an eye (show/hide) control (task 077-438's library share) — a
141
149
  * master password is long, typed once and never recoverable, and a typo in "choose" is a lock-out.
142
150
  *
143
151
  * Markup: {@link withRevealToggles} wraps each `type="password"` input in `.secret` with a
@@ -147,8 +155,16 @@ button.quiet[disabled]{opacity:.5}
147
155
  * CSP is `script-src 'self'` and allows no inline script (`MASTER_LOCK_CSP`, pinned by
148
156
  * `beaconNeverReachesTheWall.spec.ts`). Nothing about the CSP changes for this.
149
157
  */
158
+ /*
159
+ * An eye, and an eye struck through — the owner, 2026-09-24: *"use an eye icon instead of words
160
+ * like 'Show'"*. Both are in the button; the stylesheet shows one by `aria-pressed`, so the
161
+ * script only flips state and the accessible name, never markup. Same glyphs as cursedbelt's
162
+ * React `SecretInput`, drawn inline because this page ships no icon font and no framework.
163
+ */
164
+ const EYE = '<svg class="eye" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
165
+ const EYE_OFF = '<svg class="eye-off" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.3 3.3"/><path d="M6.6 6.6C3.9 8.4 2 12 2 12s3.5 7 10 7c1.8 0 3.4-.5 4.8-1.3"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="M2 2l20 20"/></svg>';
150
166
  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>');
167
+ 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">${EYE}${EYE_OFF}</button></span>`);
152
168
  }
153
169
  /**
154
170
  * The toggle's behaviour, as browser source. It flips the input BEFORE it to `text` and back,
@@ -165,7 +181,6 @@ export const MASTER_LOCK_REVEAL_SOURCE = `function wireMasterLockReveal(doc) {
165
181
  input.type = on ? "text" : "password";
166
182
  button.setAttribute("aria-pressed", on ? "true" : "false");
167
183
  button.setAttribute("aria-label", on ? "Hide password" : "Show password");
168
- button.textContent = on ? "Hide" : "Show";
169
184
  };
170
185
  button.addEventListener("click", () => {
171
186
  show(input.type === "password");
@@ -252,7 +267,7 @@ const enrolling = document.body.dataset.mode === "enroll";
252
267
 
253
268
  /** The shortest password worth calling one. Matched by the input's own minlength, and
254
269
  * checked here too because a pasted value can beat the constraint on some engines. */
255
- const MIN_LENGTH = 10;
270
+ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
256
271
 
257
272
  let kdf = null;
258
273
  let busy = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.30.1",
3
+ "version": "4.32.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.",