cursedbelt-server 4.20.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- 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/dist/server/requestLog/requestMetrics.d.ts +91 -0
- package/dist/server/requestLog/requestMetrics.js +185 -0
- package/dist/server/ws/hub.js +6 -1
- package/dist/server/ws/types.d.ts +2 -1
- package/dist/subpathReach.js +2 -0
- package/package.json +8 -2
- 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/requestLog/requestMetrics.spec.ts +206 -0
- package/src/server/requestLog/requestMetrics.ts +203 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +18 -0
- package/src/server/ws/hub.spec.ts +28 -0
- package/src/server/ws/hub.ts +6 -1
- package/src/server/ws/types.ts +2 -1
- package/src/subpathReach.ts +2 -0
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ actually keeps.
|
|
|
36
36
|
| `./guard/revocations` | — | `bun:sqlite` |
|
|
37
37
|
| `./sqlite` | — | `bun:sqlite` |
|
|
38
38
|
| `./engagement` | — | `bun:sqlite` |
|
|
39
|
+
| `./request-log` | — | `bun:sqlite` |
|
|
39
40
|
|
|
40
41
|
`sharp` and `@node-rs/argon2` are loaded lazily (`await import()`), so a missing one breaks only
|
|
41
42
|
the feature that asks for it, not the build.
|
|
@@ -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");
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/request-log` — the request log, "is this app healthy": one row per request
|
|
3
|
+
* served, in `<APP_DATA_DIR>/metrics.sqlite`, the file station's Metrics page reads for every app on
|
|
4
|
+
* this Mac (`apps/station/src/server/fleetMetrics.ts`, `METRICS_DB`).
|
|
5
|
+
*
|
|
6
|
+
* ── Why it is here (4.21.0) ─────────────────────────────────────────────────
|
|
7
|
+
* `apps/family` and `apps/roms` held this module byte-identically (task 265's rollout), and
|
|
8
|
+
* `check-copies` counted it as five copy groups. The API, the header marker and the migration name
|
|
9
|
+
* are exactly the apps' — a live corpus is recognised by `METRICS_APPLICATION_ID` and by the
|
|
10
|
+
* `0001_request_metrics` row in its migrations table, so changing either would make every
|
|
11
|
+
* existing `metrics.sqlite` look inherited and archive it. Bun-only (`bun:sqlite`, `node:fs`): under
|
|
12
|
+
* Node the export map routes this subpath to `_bunOnly.js`, the way `./engagement` does; it is NOT
|
|
13
|
+
* part of `./telemetry`, which is Worker-safe (`telemetryIsWorkerSafe.spec.ts`).
|
|
14
|
+
*
|
|
15
|
+
* ── Why it came back, 2026-09-23 (task 265) ─────────────────────────────────
|
|
16
|
+
* `serve.ts` once recorded that request metrics were GONE, on the grounds that the
|
|
17
|
+
* console that read them was in the retired generation. Station reads them now, and
|
|
18
|
+
* a measurement found that all eight apps recorded nothing while their
|
|
19
|
+
* `metrics.sqlite` files — the RETIRED instances' history, inherited at graduation —
|
|
20
|
+
* rendered as a corpus that had simply gone quiet. This mounts `cursedbelt-server`'s
|
|
21
|
+
* own seam (`requestLogger` through `createTelemetrySink`, the modules the Worker-safe
|
|
22
|
+
* `cursedbelt-server/telemetry` leaf re-exports).
|
|
23
|
+
*
|
|
24
|
+
* ── 🔴 The inherited file is ARCHIVED, never written ────────────────────────
|
|
25
|
+
* The file already at `metrics.sqlite` on the live machine is a previous
|
|
26
|
+
* generation's history under a different schema (`id INTEGER AUTOINCREMENT` plus
|
|
27
|
+
* `ip`/`country`/`city`/`device`/`form`; this writer inserts `id TEXT` plus
|
|
28
|
+
* `user_id`). Appending to it would make every future measurement unreadable, so
|
|
29
|
+
* {@link retireInheritedCorpus} runs at every boot, BEFORE the database is opened:
|
|
30
|
+
*
|
|
31
|
+
* - a file that carries {@link METRICS_APPLICATION_ID} in its header is this
|
|
32
|
+
* writer's own, and is left alone — so a second boot is a no-op;
|
|
33
|
+
* - anything else is RENAMED, with its `-wal`/`-shm` sidecars, to
|
|
34
|
+
* `metrics.retired.sqlite` — or `metrics.retired-2.sqlite`, `-3`, … when that
|
|
35
|
+
* name is taken, so two inherited corpora are both kept and nothing is ever
|
|
36
|
+
* overwritten. A rename touches no byte of the file, which is the point.
|
|
37
|
+
*
|
|
38
|
+
* The marker lives in the SQLite header (`PRAGMA application_id`, offset 68) so it
|
|
39
|
+
* can be read without opening the database — opening an inherited WAL database
|
|
40
|
+
* read-write would checkpoint into it on close, which IS writing into it. The
|
|
41
|
+
* migration checkpoints after stamping it, so the marker is on disk before the
|
|
42
|
+
* first request is recorded and a crash cannot leave a fresh corpus that the next
|
|
43
|
+
* boot mistakes for an inherited one.
|
|
44
|
+
*
|
|
45
|
+
* ── 🔴 No user id, ever ─────────────────────────────────────────────────────
|
|
46
|
+
* `cursedbelt-server/engagement`'s header states the split: `metrics/` promises it
|
|
47
|
+
* holds NO user id, in every app; `engagement.sqlite` is the user-keyed store. The
|
|
48
|
+
* writer's schema has a `user_id` column and `requestLogger` fills it from
|
|
49
|
+
* `c.get("userId")`, so the sink here nulls it on every event — whatever any future
|
|
50
|
+
* middleware puts on the context — and the logger is given no database of its own,
|
|
51
|
+
* which means it writes no `event_logs` rows either (those carry a `user_id` too,
|
|
52
|
+
* and nothing in this generation reads them).
|
|
53
|
+
*
|
|
54
|
+
* ── Never takes the app down ────────────────────────────────────────────────
|
|
55
|
+
* {@link openRequestMetricsFromEnv} catches everything and says so on stderr: an
|
|
56
|
+
* app that cannot record its traffic must still SERVE it. What catches an app that
|
|
57
|
+
* silently STOPS recording is `requestMetrics.test.ts`, which drives `/healthz`
|
|
58
|
+
* through a real server and fails when no row lands — `requestMetrics.spec.ts` here, and each
|
|
59
|
+
* app's own test through its own `createServer`.
|
|
60
|
+
*/
|
|
61
|
+
import { Database } from "bun:sqlite";
|
|
62
|
+
import type { MiddlewareHandler } from "hono";
|
|
63
|
+
import { type TelemetrySink } from "../metrics/telemetrySink.js";
|
|
64
|
+
/** The file station reads, inside the app's data directory. */
|
|
65
|
+
export declare const METRICS_DB = "metrics.sqlite";
|
|
66
|
+
/** Where an inherited corpus is moved to — the first free of this and `metrics.retired-<n>.sqlite`. */
|
|
67
|
+
export declare const RETIRED_METRICS_DB = "metrics.retired.sqlite";
|
|
68
|
+
/**
|
|
69
|
+
* Move an inherited `metrics.sqlite` out of the way, or do nothing. Returns the
|
|
70
|
+
* path it was archived to, or null when there was nothing to archive. See the
|
|
71
|
+
* header: never writes a byte of the file, never overwrites anything, idempotent.
|
|
72
|
+
*/
|
|
73
|
+
export declare function retireInheritedCorpus(dir: string): string | null;
|
|
74
|
+
export interface RequestMetrics {
|
|
75
|
+
db: Database;
|
|
76
|
+
/** Pending rows are flushed on a 1.5 s timer; `flush()` forces it (tests, shutdown). */
|
|
77
|
+
sink: TelemetrySink;
|
|
78
|
+
/** Register FIRST — ahead of every route it must see. */
|
|
79
|
+
middleware: MiddlewareHandler;
|
|
80
|
+
/** Where an inherited corpus went at this boot, if one was found. */
|
|
81
|
+
archived: string | null;
|
|
82
|
+
}
|
|
83
|
+
/** Open the corpus in `dir` and build the logger that writes into it. */
|
|
84
|
+
export declare function openRequestMetrics(dir: string): RequestMetrics;
|
|
85
|
+
/**
|
|
86
|
+
* The request log for a DEPLOYED instance, or null. Deployed means `APP_DATA_DIR`
|
|
87
|
+
* is set (the launchd plist sets it) and no test runner is in charge. A dev shell
|
|
88
|
+
* records nothing rather than appending a developer's clicks to the live corpus.
|
|
89
|
+
* Never throws: a failure is one stderr line and an app that serves unmeasured.
|
|
90
|
+
*/
|
|
91
|
+
export declare function openRequestMetricsFromEnv(name: string, env: NodeJS.ProcessEnv): RequestMetrics | null;
|