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.
- package/dist/server/d1/localBackup.d.ts +1 -1
- package/dist/server/d1/localBackup.js +1 -1
- package/dist/server/d1/pullD1.d.ts +24 -0
- package/dist/server/d1/pullD1.js +114 -40
- package/dist/server/master-lock/accountsPage.js +7 -3
- package/dist/server/master-lock/lockPage.d.ts +20 -1
- package/dist/server/master-lock/lockPage.js +53 -2
- package/package.json +3 -3
- package/src/server/d1/localBackup.ts +4 -0
- package/src/server/d1/pullD1.spec.ts +62 -2
- package/src/server/d1/pullD1.ts +111 -42
- package/src/server/master-lock/accountsPage.ts +7 -3
- package/src/server/master-lock/lockPage.spec.ts +62 -2
- package/src/server/master-lock/lockPage.ts +58 -2
- package/src/server/telemetryIsWorkerSafe.spec.ts +18 -0
|
@@ -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`,
|
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
exported =
|
|
136
|
-
|
|
137
|
-
|
|
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 (
|
|
140
|
-
throw new Error(
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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.
|
|
305
|
+
"cursedops": "^0.6.0",
|
|
306
306
|
"cwip": "^4.6.0",
|
|
307
307
|
"jose": "^6.2.3"
|
|
308
308
|
},
|
|
@@ -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 {
|
|
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(() => '');
|
package/src/server/d1/pullD1.ts
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
exported =
|
|
174
|
-
|
|
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 (
|
|
177
|
-
throw new Error(
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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 {
|
|
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'));
|