cursedbelt-server 3.0.1 → 4.1.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.
Files changed (84) hide show
  1. package/dist/server/auth/jwt.d.ts +1 -1
  2. package/dist/server/auth/jwt.js +2 -2
  3. package/dist/server/master-lock/accountsPage.d.ts +39 -0
  4. package/dist/server/master-lock/accountsPage.js +455 -0
  5. package/dist/server/master-lock/guard.js +39 -1
  6. package/dist/server/master-lock/index.d.ts +10 -2
  7. package/dist/server/master-lock/index.js +10 -2
  8. package/dist/server/master-lock/lockPage.d.ts +9 -0
  9. package/dist/server/master-lock/lockPage.js +44 -36
  10. package/dist/server/media-bun/streaming.d.ts +0 -1
  11. package/dist/server/media-bun/streaming.js +1 -1
  12. package/dist/server/storage/binaryStore.d.ts +385 -0
  13. package/dist/server/storage/binaryStore.js +739 -0
  14. package/dist/server/storage/binaryStoreFake.d.ts +56 -0
  15. package/dist/server/storage/binaryStoreFake.js +63 -0
  16. package/dist/server/storage/files/catalogue.d.ts +2 -2
  17. package/dist/server/storage/files/catalogue.js +18 -208
  18. package/dist/server/storage/files/fileRoutes.d.ts +2 -20
  19. package/dist/server/storage/files/fileRoutes.js +1 -74
  20. package/dist/server/storage/files/index.d.ts +1 -1
  21. package/dist/server/storage/files/index.js +1 -1
  22. package/dist/server/storage/files/migrations.js +9 -6
  23. package/dist/server/storage/files/stack.d.ts +6 -31
  24. package/dist/server/storage/files/stack.js +0 -27
  25. package/dist/server/storage/files/types.d.ts +10 -60
  26. package/dist/server/storage/files/types.js +9 -0
  27. package/dist/server/storage/index.d.ts +2 -2
  28. package/dist/server/storage/index.js +1 -1
  29. package/dist/server/storage/mountMediaRoutes.d.ts +1 -4
  30. package/dist/server/storage/types.d.ts +0 -9
  31. package/dist/server/storage/types.js +2 -9
  32. package/package.json +14 -2
  33. package/src/leafSubpathsImportNothing.spec.ts +30 -4
  34. package/src/server/auth/jwt.ts +2 -2
  35. package/src/server/master-lock/accountsPage.spec.ts +231 -0
  36. package/src/server/master-lock/accountsPage.ts +465 -0
  37. package/src/server/master-lock/guard.ts +44 -1
  38. package/src/server/master-lock/index.ts +22 -2
  39. package/src/server/master-lock/lockPage.spec.ts +20 -16
  40. package/src/server/master-lock/lockPage.ts +45 -36
  41. package/src/server/media-bun/streaming.ts +0 -6
  42. package/src/server/storage/binaryStore.spec.ts +908 -0
  43. package/src/server/storage/binaryStore.ts +1049 -0
  44. package/src/server/storage/binaryStoreFake.ts +111 -0
  45. package/src/server/storage/fileUploadClient.integration.spec.ts +2 -21
  46. package/src/server/storage/files/adapter.photo.display.spec.ts +2 -2
  47. package/src/server/storage/files/adapter.photo.spec.ts +2 -2
  48. package/src/server/storage/files/adapter.spec.ts +0 -1
  49. package/src/server/storage/files/catalogue.media-host.spec.ts +6 -71
  50. package/src/server/storage/files/catalogue.recover.spec.ts +14 -23
  51. package/src/server/storage/files/catalogue.ts +19 -240
  52. package/src/server/storage/files/fileRoutes.replace.spec.ts +2 -2
  53. package/src/server/storage/files/fileRoutes.spec.ts +3 -97
  54. package/src/server/storage/files/fileRoutes.ts +2 -96
  55. package/src/server/storage/files/index.ts +1 -4
  56. package/src/server/storage/files/migrations.ts +9 -7
  57. package/src/server/storage/files/stack.spec.ts +11 -111
  58. package/src/server/storage/files/stack.ts +9 -61
  59. package/src/server/storage/files/types.ts +20 -68
  60. package/src/server/storage/index.ts +0 -4
  61. package/src/server/storage/mountMediaRoutes.ts +1 -7
  62. package/src/server/storage/types.ts +2 -11
  63. package/src/shippedFilesAreTracked.spec.ts +3 -2
  64. package/dist/server/private-media/crypto.d.ts +0 -37
  65. package/dist/server/private-media/crypto.js +0 -111
  66. package/dist/server/private-media/index-store.d.ts +0 -31
  67. package/dist/server/private-media/index-store.js +0 -36
  68. package/dist/server/private-media/mountPrivateRoutes.d.ts +0 -26
  69. package/dist/server/private-media/mountPrivateRoutes.js +0 -219
  70. package/dist/server/private-media/pipeline.d.ts +0 -37
  71. package/dist/server/private-media/pipeline.js +0 -97
  72. package/dist/server/private-media/unlock.d.ts +0 -52
  73. package/dist/server/private-media/unlock.js +0 -55
  74. package/src/server/private-media/blobRoute.spec.ts +0 -165
  75. package/src/server/private-media/crypto.spec.ts +0 -81
  76. package/src/server/private-media/crypto.ts +0 -155
  77. package/src/server/private-media/gatePolicy.spec.ts +0 -98
  78. package/src/server/private-media/index-store.ts +0 -83
  79. package/src/server/private-media/mountPrivateRoutes.ts +0 -265
  80. package/src/server/private-media/pipeline.spec.ts +0 -111
  81. package/src/server/private-media/pipeline.ts +0 -170
  82. package/src/server/private-media/routes.spec.ts +0 -265
  83. package/src/server/private-media/unlock.ts +0 -112
  84. package/src/server/storage/files/catalogue.private.spec.ts +0 -106
@@ -0,0 +1,465 @@
1
+ /**
2
+ * The ACCOUNTS page — where the owner changes his master password.
3
+ *
4
+ * ── Why it is here and not in the app ───────────────────────────────────────
5
+ * `cursedbelt@2.5.1`'s React `MasterLockMenuItems` dialog minted a FRESH `kdf` salt on every
6
+ * rotation, which is what the wire looked like before accounts existed. Every account on an
7
+ * app now derives under ONE shared descriptor, so honouring that salt would store a verifier
8
+ * hash nothing could ever match: the owner sets a new password, the rotation logs him out,
9
+ * and neither the old nor the new one opens the app. `guard.ts` therefore answers that shape
10
+ * `409 {"error":"stale-client"}`. That is the safe failure; this page is the fix.
11
+ *
12
+ * It could not be fixed where it broke. `cursedbelt@3.0.0` deleted `src/server` outright and
13
+ * `apps/collections` is two majors behind on the React design system, so reaching the in-app
14
+ * dialog means a migration that is its own program. The lock page already proves that a
15
+ * server-rendered surface under `/__lock/` reaches every app that mounts the guard — no
16
+ * bundle, no framework, no version to migrate, and no app-local copy to drift. So the
17
+ * rotation lives next to the lock, and an app needs one link to it.
18
+ *
19
+ * The owner asked for the rotation in as many words (2026-08-15): *"give me a way to change
20
+ * it to a value I want it to be anytime later."* `bun run accounts` in an app cannot do it —
21
+ * nobody can change a password they have never seen, and this server has never seen one.
22
+ *
23
+ * ── What it is allowed to show ──────────────────────────────────────────────
24
+ * 🔴 It is served ONLY to a request carrying a live unlock, because listing the accounts says
25
+ * how many master passwords exist and what each is called — the one fact the lock page is
26
+ * built never to disclose. The guard enforces that; this file renders a shell that contains
27
+ * no account data at all and fetches the list over `GET /__lock/accounts`, so there is one
28
+ * source of truth for who may read it.
29
+ *
30
+ * ── The derivation ──────────────────────────────────────────────────────────
31
+ * 🔴 Interpolated from {@link MASTER_LOCK_DERIVE_SOURCE}, never re-typed. A second hand copy
32
+ * would be a second thing to keep in step with `cursedbelt-core`, and the pinning spec would
33
+ * only be watching one of them.
34
+ */
35
+ import { MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX } from "cursedbelt-core/master-lock";
36
+ import { MASTER_LOCK_DERIVE_SOURCE, type LockPageOptions } from "./lockPage";
37
+
38
+ /**
39
+ * The page's own two assets.
40
+ *
41
+ * Authored HERE rather than in `cursedbelt-core`'s `MASTER_LOCK_PATHS`, because nothing but
42
+ * this server ever names them: the document that links them is served by the same process,
43
+ * and no client constructs either URL. Adding them to the shared wire would mean publishing
44
+ * `cursedbelt-core` for a string only one file reads.
45
+ */
46
+ export const MASTER_LOCK_ACCOUNTS_PAGE_PATHS = {
47
+ style: `${MASTER_LOCK_PREFIX}/accounts.css`,
48
+ script: `${MASTER_LOCK_PREFIX}/accounts.js`,
49
+ } as const;
50
+
51
+ const escapeHtml = (value: string): string =>
52
+ value.replace(
53
+ /[&<>"']/g,
54
+ (ch) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[ch] ?? ch,
55
+ );
56
+
57
+ /** The shortest password worth calling one. Matched by every `minlength` below and by the
58
+ * lock page's enrollment form, so a rotation cannot quietly weaken what enrollment demands. */
59
+ export const MASTER_LOCK_MIN_PASSWORD_LENGTH = 10;
60
+
61
+ /**
62
+ * The document.
63
+ *
64
+ * It links the LOCK's stylesheet first and its own second, so the two pages share one set of
65
+ * colour tokens and a change to the palette cannot land on only one of them.
66
+ */
67
+ export function accountsPageHtml(options: LockPageOptions): string {
68
+ const label = escapeHtml(options.appLabel);
69
+ const min = MASTER_LOCK_MIN_PASSWORD_LENGTH;
70
+ return `<!doctype html>
71
+ <html lang="en">
72
+ <head>
73
+ <meta charset="utf-8">
74
+ <meta name="viewport" content="width=device-width, initial-scale=1">
75
+ <meta name="robots" content="noindex, nofollow">
76
+ <title>Master passwords · ${label}</title>
77
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔑</text></svg>">
78
+ <link rel="stylesheet" href="${MASTER_LOCK_PATHS.style}">
79
+ <link rel="stylesheet" href="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style}">
80
+ </head>
81
+ <body>
82
+ <main class="sheet">
83
+ <header class="sheet-head">
84
+ <div>
85
+ <h1>Master passwords</h1>
86
+ <p class="note">${label} · signed in as <strong id="ml-current">…</strong></p>
87
+ </div>
88
+ <a class="back" href="/">Back to the app</a>
89
+ </header>
90
+
91
+ <section class="panel" aria-labelledby="ml-rotate-title">
92
+ <h2 id="ml-rotate-title">Change this account's master password</h2>
93
+ <p class="note">
94
+ It opens the same content afterwards — the password is how you get in, not what you
95
+ get in to. Every device you have open is locked the moment it changes.
96
+ </p>
97
+ <form id="ml-rotate" autocomplete="off">
98
+ <label class="field">
99
+ <span>Current master password</span>
100
+ <input type="password" name="current" autocomplete="current-password" required
101
+ spellcheck="false">
102
+ </label>
103
+ <label class="field">
104
+ <span>New master password</span>
105
+ <input type="password" name="next" autocomplete="new-password" required
106
+ spellcheck="false" minlength="${min}">
107
+ </label>
108
+ <label class="field">
109
+ <span>Confirm new master password</span>
110
+ <input type="password" name="confirm" autocomplete="new-password" required
111
+ spellcheck="false" minlength="${min}">
112
+ </label>
113
+ <button type="submit">Change master password</button>
114
+ <p class="status" role="status" aria-live="polite"></p>
115
+ </form>
116
+ </section>
117
+
118
+ <section class="panel" aria-labelledby="ml-list-title">
119
+ <h2 id="ml-list-title">Accounts on this site</h2>
120
+ <p class="note">
121
+ Each master password is its own account, with its own content. You can rename the one
122
+ you are signed in to and rewrite its hint; the others are only editable from inside them.
123
+ </p>
124
+ <ul id="ml-accounts" class="accounts"><li class="empty">Loading…</li></ul>
125
+ </section>
126
+
127
+ <section class="panel" aria-labelledby="ml-add-title">
128
+ <h2 id="ml-add-title">Add another master password</h2>
129
+ <p class="note">
130
+ A new password is a new, empty account. Adding one does not sign you into it — you stay
131
+ here until you type it on the lock screen. Nobody can recover it, so leave yourself a hint.
132
+ </p>
133
+ <form id="ml-add" autocomplete="off">
134
+ <label class="field">
135
+ <span>Your current master password</span>
136
+ <input type="password" name="current" autocomplete="current-password" required
137
+ spellcheck="false">
138
+ </label>
139
+ <label class="field">
140
+ <span>The new account's master password</span>
141
+ <input type="password" name="next" autocomplete="new-password" required
142
+ spellcheck="false" minlength="${min}">
143
+ </label>
144
+ <label class="field">
145
+ <span>Confirm it</span>
146
+ <input type="password" name="confirm" autocomplete="new-password" required
147
+ spellcheck="false" minlength="${min}">
148
+ </label>
149
+ <label class="field">
150
+ <span>Name</span>
151
+ <input type="text" name="label" required maxlength="60" spellcheck="false"
152
+ autocomplete="off" placeholder="Work, Family, …">
153
+ </label>
154
+ <label class="field">
155
+ <span>Hint <em>optional</em></span>
156
+ <input type="text" name="hint" maxlength="200" spellcheck="false" autocomplete="off"
157
+ placeholder="Something only you would understand">
158
+ </label>
159
+ <button type="submit">Add master password</button>
160
+ <p class="status" role="status" aria-live="polite"></p>
161
+ </form>
162
+ </section>
163
+ </main>
164
+ <script type="module" src="${MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script}"></script>
165
+ </body>
166
+ </html>
167
+ `;
168
+ }
169
+
170
+ /**
171
+ * The page-specific stylesheet. Loaded AFTER `lock.css` and inheriting every `--ml-*` token
172
+ * from it, so this file only ever describes layout this page adds.
173
+ */
174
+ export const MASTER_LOCK_ACCOUNTS_STYLE = `body{display:block;padding:32px 20px 64px}
175
+ .sheet{max-width:46rem;margin:0 auto;display:flex;flex-direction:column;gap:18px}
176
+ .sheet-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;
177
+ padding:0 2px 2px}
178
+ .sheet-head h1{margin:0 0 4px;font-size:22px;font-weight:660;letter-spacing:-.015em}
179
+ .sheet-head .note{margin:0}
180
+ .back{flex:none;font-size:12.5px;font-weight:600;color:var(--ml-muted);text-decoration:none;
181
+ border:1px solid var(--ml-border);border-radius:8px;padding:7px 12px;background:var(--ml-card)}
182
+ .back:hover{color:var(--ml-text);border-color:var(--ml-muted)}
183
+ .panel{background:var(--ml-card);border:1px solid var(--ml-border);border-radius:14px;
184
+ padding:22px 22px 20px;box-shadow:var(--ml-shadow);text-align:left}
185
+ .panel h2{margin:0 0 6px;font-size:15px;font-weight:650;letter-spacing:-.01em}
186
+ .panel .note{margin:0 0 18px;max-width:42rem}
187
+ .panel form{display:flex;flex-direction:column}
188
+ .panel button{margin-top:4px;align-self:flex-start;width:auto;min-width:13rem;padding:0 18px}
189
+ .panel .status{margin:12px 0 0}
190
+ .status[data-tone="ok"]{color:var(--ml-text)}
191
+ .accounts{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
192
+ .accounts .empty{color:var(--ml-muted);font-size:13px}
193
+ .row{border:1px solid var(--ml-border);border-radius:11px;padding:14px 16px;background:var(--ml-bg)}
194
+ .row[data-current="true"]{border-color:var(--ml-accent)}
195
+ .row-head{display:flex;align-items:center;gap:8px;margin-bottom:2px}
196
+ .row-head strong{font-size:14px;font-weight:640}
197
+ .badge{font-size:11px;font-weight:650;letter-spacing:.02em;text-transform:uppercase;
198
+ color:var(--ml-accent-text);background:var(--ml-accent);border-radius:999px;padding:2px 8px}
199
+ .row .meta{margin:0;font-size:12.5px;color:var(--ml-muted);overflow-wrap:anywhere}
200
+ .row form{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;margin-top:12px}
201
+ .row .field{flex:1 1 12rem;margin-bottom:0}
202
+ .row button{margin-top:0;min-width:0;width:auto;padding:0 16px;flex:none}
203
+ @media (max-width:640px){
204
+ .sheet-head{flex-direction:column}
205
+ .panel button{align-self:stretch;width:100%}
206
+ }
207
+ `;
208
+
209
+ /**
210
+ * The page's script.
211
+ *
212
+ * Every password is turned into a verifier HERE and only the verifier is sent, exactly as on
213
+ * the lock page — so a rotation never puts either the old or the new password on the wire.
214
+ *
215
+ * 🔴 It never sends a `kdf`. The params are one app-wide descriptor that every account
216
+ * derives under; minting a fresh salt on a rotation is precisely the bug this page exists to
217
+ * replace, and `guard.ts` answers that shape `409 stale-client` no matter who sends it.
218
+ */
219
+ export const MASTER_LOCK_ACCOUNTS_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
220
+ const MIN_LENGTH = ${MASTER_LOCK_MIN_PASSWORD_LENGTH};
221
+
222
+ // ── derivation ───────────────────────────────────────────────────────────────
223
+ ${MASTER_LOCK_DERIVE_SOURCE}
224
+
225
+ const rotateForm = document.getElementById("ml-rotate");
226
+ const addForm = document.getElementById("ml-add");
227
+ const list = document.getElementById("ml-accounts");
228
+ const currentName = document.getElementById("ml-current");
229
+
230
+ /** The app's KDF params, read once. Every derivation on this page uses these and no other. */
231
+ let kdf = null;
232
+
233
+ function say(form, message, tone) {
234
+ const status = form.querySelector(".status");
235
+ status.textContent = message;
236
+ if (tone) status.setAttribute("data-tone", tone);
237
+ else status.removeAttribute("data-tone");
238
+ }
239
+
240
+ async function post(path, body, method) {
241
+ const res = await fetch(path, {
242
+ method: method || "POST",
243
+ credentials: "same-origin",
244
+ headers: { "content-type": "application/json" },
245
+ body: JSON.stringify(body),
246
+ });
247
+ const parsed = await res.json().catch(() => ({}));
248
+ return { ok: res.ok, status: res.status, body: parsed || {} };
249
+ }
250
+
251
+ /**
252
+ * One refusal turned into one sentence.
253
+ *
254
+ * \`stale-client\` can only appear if something OTHER than this page is posting, since this
255
+ * one never sends a kdf — so it is named rather than folded into "something went wrong",
256
+ * which is what sent the owner looking at a 409 with no explanation in the first place.
257
+ */
258
+ function refusalText(result) {
259
+ if (result.status === 401) return "Your unlock lapsed. Reload and enter the master password again.";
260
+ if (result.status === 409) return "This site refused an out-of-date client. Reload the page and try again.";
261
+ const reason = result.body && result.body.error;
262
+ if (reason === "wrong") return "That is not your current master password.";
263
+ if (reason === "duplicate") return "That password already opens one of these accounts. Pick a different one.";
264
+ if (reason === "invalid") return "That password cannot be used here — it may already open another account.";
265
+ if (reason === "unconfigured") return "This site has no master password set.";
266
+ if (reason === "locked") return "Your unlock lapsed. Reload and enter the master password again.";
267
+ return "That could not be saved. Reload and try again.";
268
+ }
269
+
270
+ /** The two checks the browser can make that the server cannot: length, and the retype. */
271
+ function passwordFrom(form) {
272
+ const next = form.elements.next.value;
273
+ const confirm = form.elements.confirm.value;
274
+ if (next.length < MIN_LENGTH) {
275
+ say(form, "Use at least " + MIN_LENGTH + " characters.", "error");
276
+ return null;
277
+ }
278
+ if (next !== confirm) {
279
+ say(form, "Those two do not match.", "error");
280
+ form.elements.confirm.value = "";
281
+ form.elements.confirm.focus();
282
+ return null;
283
+ }
284
+ return next;
285
+ }
286
+
287
+ async function busy(form, message, work) {
288
+ const button = form.querySelector("button[type=submit]");
289
+ if (button.disabled) return;
290
+ button.disabled = true;
291
+ say(form, message);
292
+ try {
293
+ await work();
294
+ } catch {
295
+ say(form, "Cannot reach this site right now.", "error");
296
+ } finally {
297
+ button.disabled = false;
298
+ }
299
+ }
300
+
301
+ // ── the list ─────────────────────────────────────────────────────────────────
302
+ // 🔴 textContent for every value, never innerHTML. A label and a hint are free text the
303
+ // owner typed, and this page runs under a CSP that permits no inline script — writing them
304
+ // as markup would be the one way to get some in.
305
+ function renderAccounts(accounts) {
306
+ list.replaceChildren();
307
+ if (accounts.length === 0) {
308
+ const li = document.createElement("li");
309
+ li.className = "empty";
310
+ li.textContent = "No master passwords are set.";
311
+ list.append(li);
312
+ return;
313
+ }
314
+ for (const account of accounts) {
315
+ const li = document.createElement("li");
316
+ li.className = "row";
317
+ li.dataset.current = String(account.current === true);
318
+
319
+ const head = document.createElement("div");
320
+ head.className = "row-head";
321
+ const name = document.createElement("strong");
322
+ name.textContent = account.label;
323
+ head.append(name);
324
+ if (account.current) {
325
+ const badge = document.createElement("span");
326
+ badge.className = "badge";
327
+ badge.textContent = "signed in";
328
+ head.append(badge);
329
+ }
330
+ li.append(head);
331
+
332
+ const meta = document.createElement("p");
333
+ meta.className = "meta";
334
+ meta.textContent = account.hint ? "Hint: " + account.hint : "No hint written.";
335
+ li.append(meta);
336
+
337
+ if (account.current) li.append(editForm(account));
338
+ list.append(li);
339
+ }
340
+ }
341
+
342
+ /** Rename / re-hint, and only ever for the account the caller's own unlock opened — the
343
+ * server refuses a sibling, so offering the form for one would be a button that lies. */
344
+ function editForm(account) {
345
+ const form = document.createElement("form");
346
+ form.autocomplete = "off";
347
+
348
+ const labelField = field("Name", "label", account.label, 60);
349
+ const hintField = field("Hint", "hint", account.hint, 200);
350
+ const button = document.createElement("button");
351
+ button.type = "submit";
352
+ button.textContent = "Save";
353
+ const status = document.createElement("p");
354
+ status.className = "status";
355
+ status.setAttribute("role", "status");
356
+ status.setAttribute("aria-live", "polite");
357
+ form.append(labelField, hintField, button, status);
358
+
359
+ form.addEventListener("submit", (event) => {
360
+ event.preventDefault();
361
+ busy(form, "Saving…", async () => {
362
+ const result = await post(
363
+ paths.accounts,
364
+ {
365
+ id: account.id,
366
+ label: form.elements.label.value,
367
+ hint: form.elements.hint.value,
368
+ },
369
+ "PATCH",
370
+ );
371
+ if (!result.ok) {
372
+ say(form, refusalText(result), "error");
373
+ return;
374
+ }
375
+ renderAccounts(result.body.accounts || []);
376
+ });
377
+ });
378
+ return form;
379
+ }
380
+
381
+ function field(text, name, value, maxLength) {
382
+ const label = document.createElement("label");
383
+ label.className = "field";
384
+ const span = document.createElement("span");
385
+ span.textContent = text;
386
+ const input = document.createElement("input");
387
+ input.type = "text";
388
+ input.name = name;
389
+ input.value = value;
390
+ input.maxLength = maxLength;
391
+ input.spellcheck = false;
392
+ input.autocomplete = "off";
393
+ label.append(span, input);
394
+ return label;
395
+ }
396
+
397
+ async function load() {
398
+ try {
399
+ const status = await fetch(paths.status, { credentials: "same-origin" });
400
+ if (status.ok) {
401
+ const body = await status.json();
402
+ kdf = body.kdf;
403
+ currentName.textContent = body.accountLabel || "this account";
404
+ }
405
+ const res = await fetch(paths.accounts, { credentials: "same-origin" });
406
+ if (res.status === 401) { location.href = paths.page; return; }
407
+ const body = await res.json();
408
+ renderAccounts(body.accounts || []);
409
+ } catch {
410
+ list.replaceChildren();
411
+ const li = document.createElement("li");
412
+ li.className = "empty";
413
+ li.textContent = "Cannot reach this site right now.";
414
+ list.append(li);
415
+ }
416
+ }
417
+
418
+ // ── the rotation ─────────────────────────────────────────────────────────────
419
+ rotateForm.addEventListener("submit", (event) => {
420
+ event.preventDefault();
421
+ const next = passwordFrom(rotateForm);
422
+ if (next === null) return;
423
+ busy(rotateForm, "Changing… this takes a moment.", async () => {
424
+ if (!kdf) { say(rotateForm, "Cannot reach this site right now.", "error"); return; }
425
+ const result = await post(paths.change, {
426
+ currentVerifier: await deriveVerifier(rotateForm.elements.current.value, kdf),
427
+ verifier: await deriveVerifier(next, kdf),
428
+ });
429
+ if (!result.ok) {
430
+ say(rotateForm, refusalText(result), "error");
431
+ rotateForm.elements.current.value = "";
432
+ return;
433
+ }
434
+ // The rotation locked every device, this one included, and dropped the cookie with it.
435
+ // So there is nothing to stay on: send them to the lock screen to type the new one.
436
+ say(rotateForm, "Changed. Every device is locked — sign in with the new password.", "ok");
437
+ setTimeout(() => { location.href = paths.page; }, 1200);
438
+ });
439
+ });
440
+
441
+ // ── adding one ───────────────────────────────────────────────────────────────
442
+ addForm.addEventListener("submit", (event) => {
443
+ event.preventDefault();
444
+ const next = passwordFrom(addForm);
445
+ if (next === null) return;
446
+ busy(addForm, "Adding… this takes a moment.", async () => {
447
+ if (!kdf) { say(addForm, "Cannot reach this site right now.", "error"); return; }
448
+ const result = await post(paths.accounts, {
449
+ currentVerifier: await deriveVerifier(addForm.elements.current.value, kdf),
450
+ verifier: await deriveVerifier(next, kdf),
451
+ label: addForm.elements.label.value,
452
+ hint: addForm.elements.hint.value,
453
+ });
454
+ if (!result.ok) {
455
+ say(addForm, refusalText(result), "error");
456
+ return;
457
+ }
458
+ addForm.reset();
459
+ say(addForm, "Added. It is empty until you sign in with it.", "ok");
460
+ await load();
461
+ });
462
+ });
463
+
464
+ load();
465
+ `;
@@ -17,6 +17,12 @@ import {
17
17
  MASTER_LOCK_STATE_HEADER,
18
18
  masterLockCookieName,
19
19
  } from "cursedbelt-core/master-lock";
20
+ import {
21
+ MASTER_LOCK_ACCOUNTS_PAGE_PATHS,
22
+ MASTER_LOCK_ACCOUNTS_SCRIPT,
23
+ MASTER_LOCK_ACCOUNTS_STYLE,
24
+ accountsPageHtml,
25
+ } from "./accountsPage";
20
26
  import { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
21
27
  import type { MasterLock } from "./masterLock";
22
28
 
@@ -160,9 +166,27 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
160
166
  // whether this app has one tenant or ten.
161
167
  return Response.json({ hints: lock.hints() }, { headers: noStore });
162
168
  }
169
+ if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.style) {
170
+ return new Response(MASTER_LOCK_ACCOUNTS_STYLE, {
171
+ headers: { "content-type": "text/css; charset=utf-8", ...noStore },
172
+ });
173
+ }
174
+ if (path === MASTER_LOCK_ACCOUNTS_PAGE_PATHS.script) {
175
+ return new Response(MASTER_LOCK_ACCOUNTS_SCRIPT, {
176
+ headers: { "content-type": "text/javascript; charset=utf-8", ...noStore },
177
+ });
178
+ }
163
179
  if (path === MASTER_LOCK_PATHS.accounts) {
164
180
  // Unlike the hints, this needs the site already open — it names every tenant.
165
- if (!opens(lock, req)) return refuse();
181
+ // A DOCUMENT request while locked is answered with the lock page in place, for
182
+ // the reason the app's own paths are: unlocking then reloads to where they were
183
+ // going, which is the management page, instead of dropping them at the root.
184
+ if (!opens(lock, req)) return wantsDocument(req) ? lockPage(options, lock) : refuse();
185
+ // 🔴 Content-negotiated, one route. The browser asks for a PAGE and gets the
186
+ // management surface; the page's own `fetch` asks for DATA and gets the list it
187
+ // renders. One path means one rule about who may read it, so the page can never
188
+ // be reachable by a caller the JSON would have refused.
189
+ if (wantsDocument(req)) return accountsPage(options);
166
190
  return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
167
191
  }
168
192
  if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
@@ -341,6 +365,25 @@ function lockPage(options: LockPageOptions, lock: MasterLock): Response {
341
365
  });
342
366
  }
343
367
 
368
+ /**
369
+ * The accounts page. Same wall as the lock page — one CSP, no inline script, never framed —
370
+ * because it carries the one form that can change a master password.
371
+ *
372
+ * It is handed NO account data: the shell fetches the list over this same route's JSON half,
373
+ * so there is exactly one place that decides who may read it.
374
+ */
375
+ function accountsPage(options: LockPageOptions): Response {
376
+ return new Response(accountsPageHtml(options), {
377
+ status: 200,
378
+ headers: {
379
+ "content-type": "text/html; charset=utf-8",
380
+ "content-security-policy": LOCK_CSP,
381
+ "x-content-type-options": "nosniff",
382
+ ...noStore,
383
+ },
384
+ });
385
+ }
386
+
344
387
  /** Is this the browser asking for a PAGE, as opposed to the app asking for data? */
345
388
  function wantsDocument(req: Request): boolean {
346
389
  if (req.method !== "GET") return false;
@@ -7,14 +7,34 @@
7
7
  * Adopting it in an app is three things:
8
8
  * 1. a {@link MasterLock} built over whatever key/value the app already has,
9
9
  * 2. `createMasterLockGuard(...).handle(req)` in front of everything the app serves,
10
- * 3. `cursedbelt/react/master-lock` in the header, for the manual lock and the rotation.
10
+ * 3. one link to `/__lock/accounts` wherever the app keeps its account menu.
11
11
  *
12
12
  * Removing it is deleting those three. That is the shape the owner asked for: *"the reusable
13
13
  * functionality for this should be in a place that makes it easy to share to add or remove
14
14
  * from apps in the code, such as cb."*
15
+ *
16
+ * 🔴 Step 3 used to be `cursedbelt/react/master-lock` in the header, and it is a LINK now.
17
+ * That dialog minted a fresh `kdf` salt on every rotation — the pre-accounts wire — which the
18
+ * server has to refuse, so the owner could lock a site and enroll a first password but could
19
+ * not change one. `accountsPage.ts` carries the reasoning; the short version is that a
20
+ * server-rendered surface under `/__lock/` reaches every app that mounts the guard with no
21
+ * bundle, no framework version to migrate, and no app-local copy to drift.
15
22
  */
23
+ export {
24
+ MASTER_LOCK_ACCOUNTS_PAGE_PATHS,
25
+ MASTER_LOCK_ACCOUNTS_SCRIPT,
26
+ MASTER_LOCK_ACCOUNTS_STYLE,
27
+ MASTER_LOCK_MIN_PASSWORD_LENGTH,
28
+ accountsPageHtml,
29
+ } from "./accountsPage";
16
30
  export { type MasterLockGuard, type MasterLockGuardOptions, createMasterLockGuard } from "./guard";
17
- export { LOCK_SCRIPT, LOCK_STYLE, type LockPageOptions, lockPageHtml } from "./lockPage";
31
+ export {
32
+ LOCK_SCRIPT,
33
+ LOCK_STYLE,
34
+ MASTER_LOCK_DERIVE_SOURCE,
35
+ type LockPageOptions,
36
+ lockPageHtml,
37
+ } from "./lockPage";
18
38
  export {
19
39
  MASTER_LOCK_PRINCIPAL_KEY_PREFIX,
20
40
  MasterLockDirectory,
@@ -13,7 +13,8 @@
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 { LOCK_SCRIPT, LOCK_STYLE, lockPageHtml } from "./lockPage";
16
+ import { MASTER_LOCK_ACCOUNTS_SCRIPT } from "./accountsPage";
17
+ import { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml } from "./lockPage";
17
18
 
18
19
  const KDF: MasterLockKdfParams = {
19
20
  v: 1,
@@ -24,25 +25,17 @@ const KDF: MasterLockKdfParams = {
24
25
  const PASSWORD = "correct horse battery staple";
25
26
 
26
27
  /**
27
- * Pull `deriveVerifier` out of the served script.
28
+ * Evaluate `deriveVerifier` out of the source that is actually served.
28
29
  *
29
- * The script assigns it to `globalThis.__masterLockDerive` for exactly this — a seam that
30
- * costs one line in the browser and is the difference between a pinned copy and a hopeful
31
- * one. Everything after that assignment touches `document`, so the source is cut there.
30
+ * It assigns itself to `globalThis.__masterLockDerive` for exactly this — a seam that costs
31
+ * one line in the browser and is the difference between a pinned copy and a hopeful one. The
32
+ * constant touches nothing but `crypto`, so it evaluates as-is with no DOM stub.
32
33
  */
33
34
  function extractDerive(): (password: string, kdf: MasterLockKdfParams) => Promise<string> {
34
- const marker = "globalThis.__masterLockDerive = deriveVerifier;";
35
- const cut = LOCK_SCRIPT.indexOf(marker);
36
- expect(cut).toBeGreaterThan(0);
37
- const source = LOCK_SCRIPT.slice(0, cut + marker.length);
38
- // The prelude reads `document`, which does not exist under `bun test`; the slice above
39
- // stops before it, but the leading `const` declarations still run — so evaluate in a
40
- // function scope with those globals stubbed.
41
35
  const factory = new Function(
42
- "document",
43
- `${source}\nreturn globalThis.__masterLockDerive;`,
44
- ) as (doc: unknown) => (password: string, kdf: MasterLockKdfParams) => Promise<string>;
45
- return factory({ getElementById: () => ({ addEventListener() {}, setAttribute() {}, removeAttribute() {} }) });
36
+ `${MASTER_LOCK_DERIVE_SOURCE}\nreturn globalThis.__masterLockDerive;`,
37
+ ) as () => (password: string, kdf: MasterLockKdfParams) => Promise<string>;
38
+ return factory();
46
39
  }
47
40
 
48
41
  describe("the lock page's hand-copied derivation", () => {
@@ -62,6 +55,17 @@ describe("the lock page's hand-copied derivation", () => {
62
55
  await deriveMasterLockVerifier("another password", kdf),
63
56
  );
64
57
  });
58
+
59
+ test("🔴 EVERY served script uses that one copy, so none can drift on its own", () => {
60
+ // The accounts page derives too — a rotation is two verifiers. If it ever grew its own
61
+ // transcription, the two tests above would keep passing while the owner's correct
62
+ // password was refused on exactly one of the two pages.
63
+ expect(LOCK_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
64
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT).toContain(MASTER_LOCK_DERIVE_SOURCE);
65
+ // And nobody has quietly pasted a second `deriveVerifier` alongside the shared one.
66
+ expect(LOCK_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
67
+ expect(MASTER_LOCK_ACCOUNTS_SCRIPT.split("async function deriveVerifier").length - 1).toBe(1);
68
+ });
65
69
  });
66
70
 
67
71
  describe("the page itself", () => {