cursedbelt-server 2.0.0 → 3.0.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 (82) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/master-lock/guard.d.ts +10 -0
  36. package/dist/server/master-lock/guard.js +70 -19
  37. package/dist/server/master-lock/index.d.ts +1 -1
  38. package/dist/server/master-lock/index.js +1 -1
  39. package/dist/server/master-lock/lockPage.d.ts +1 -1
  40. package/dist/server/master-lock/lockPage.js +68 -3
  41. package/dist/server/master-lock/masterLock.d.ts +250 -76
  42. package/dist/server/master-lock/masterLock.js +426 -114
  43. package/dist/server/master-lock/principals.js +6 -1
  44. package/dist/server/master-lock/seed.d.ts +5 -1
  45. package/dist/server/master-lock/seed.js +18 -1
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/server/bench/assert.ts +192 -0
  49. package/src/server/bench/budget.spec.ts +126 -0
  50. package/src/server/bench/budget.ts +207 -0
  51. package/src/server/bench/cpuBudget.spec.ts +302 -0
  52. package/src/server/bench/cpuBudget.ts +81 -0
  53. package/src/server/bench/cpuClock.ts +119 -0
  54. package/src/server/bench/index.ts +81 -0
  55. package/src/server/bench/recorder.ts +163 -0
  56. package/src/server/bench/runBench.ts +110 -0
  57. package/src/server/d1/backup.spec.ts +121 -0
  58. package/src/server/d1/backup.ts +186 -0
  59. package/src/server/d1/fakeD1.ts +193 -0
  60. package/src/server/d1/index.ts +62 -0
  61. package/src/server/d1/kysely.spec.ts +145 -0
  62. package/src/server/d1/kysely.ts +169 -0
  63. package/src/server/d1/limits.spec.ts +90 -0
  64. package/src/server/d1/limits.ts +123 -0
  65. package/src/server/d1/local.ts +173 -0
  66. package/src/server/d1/remote.ts +182 -0
  67. package/src/server/d1/sameShape.spec.ts +279 -0
  68. package/src/server/d1/scheduling.spec.ts +120 -0
  69. package/src/server/d1/scheduling.ts +210 -0
  70. package/src/server/d1/types.ts +163 -0
  71. package/src/server/d1/values.ts +138 -0
  72. package/src/server/master-lock/accounts.spec.ts +308 -0
  73. package/src/server/master-lock/guard.spec.ts +69 -7
  74. package/src/server/master-lock/guard.ts +78 -20
  75. package/src/server/master-lock/index.ts +3 -0
  76. package/src/server/master-lock/lockPage.ts +70 -3
  77. package/src/server/master-lock/masterLock.spec.ts +56 -23
  78. package/src/server/master-lock/masterLock.ts +529 -151
  79. package/src/server/master-lock/principals.spec.ts +45 -15
  80. package/src/server/master-lock/principals.ts +6 -1
  81. package/src/server/master-lock/seed.spec.ts +7 -2
  82. package/src/server/master-lock/seed.ts +22 -2
@@ -150,10 +150,52 @@ describe("while locked", () => {
150
150
  idleMs: 5 * 60_000,
151
151
  remainingMs: 0,
152
152
  retryAfterMs: 0,
153
+ // Locked, so there is no account to name.
154
+ accountId: null,
155
+ accountLabel: null,
153
156
  });
154
157
  expect(JSON.stringify(body)).not.toContain("argon2");
155
158
  });
156
159
 
160
+ test("🔴 status never says how many accounts this app has", async () => {
161
+ // The lock screen must read identically whether the owner keeps one tenant or ten:
162
+ // the number of master passwords is itself a fact worth hiding, and `status` is
163
+ // served to anyone who got past the app's own sign-in, before they prove anything
164
+ // else. `hints` is the one surface that discloses it, behind a deliberate click.
165
+ const { lock, guard } = build();
166
+ const cookie = await open(guard);
167
+ const added = await guard.handle(
168
+ post(
169
+ MASTER_LOCK_PATHS.accounts,
170
+ {
171
+ currentVerifier: VERIFIER,
172
+ verifier: await deriveMasterLockVerifier("second tenant", KDF),
173
+ label: "Second",
174
+ },
175
+ cookie,
176
+ ),
177
+ );
178
+ expect(added?.status).toBe(200);
179
+ expect(lock.accounts.length).toBe(2);
180
+
181
+ const res = await guard.handle(xhr(MASTER_LOCK_PATHS.status));
182
+ const body = (await res?.json()) as Record<string, unknown>;
183
+ expect(Object.keys(body).sort()).toEqual([
184
+ "accountId",
185
+ "accountLabel",
186
+ "configured",
187
+ "enrollable",
188
+ "idleMs",
189
+ "kdf",
190
+ "locked",
191
+ "remainingMs",
192
+ "retryAfterMs",
193
+ ]);
194
+ const serialized = JSON.stringify(body);
195
+ expect(serialized).not.toContain("Second");
196
+ expect(serialized).not.toContain("acct-2");
197
+ });
198
+
157
199
  test("an `allow` path is passed through — that is where a machine surface lives", async () => {
158
200
  const { guard } = build((url) => url.pathname === "/healthz");
159
201
  expect(await guard.handle(xhr("/healthz"))).toBeNull();
@@ -249,11 +291,12 @@ describe("while unlocked", () => {
249
291
  test("rotation needs the current password and locks the site behind it", async () => {
250
292
  const { guard } = build();
251
293
  const cookie = await open(guard);
252
- const nextKdf = newMasterLockKdfParams();
253
- const nextVerifier = await deriveMasterLockVerifier("something else", nextKdf);
294
+ // 🔴 Derived under the app's OWN params, not a fresh set. Every account shares one
295
+ // descriptor, so a rotation that minted a new salt would invalidate the others.
296
+ const nextVerifier = await deriveMasterLockVerifier("something else", KDF);
254
297
 
255
298
  const refused = await guard.handle(
256
- post(MASTER_LOCK_PATHS.change, { currentVerifier: "no", kdf: nextKdf, verifier: nextVerifier }, cookie),
299
+ post(MASTER_LOCK_PATHS.change, { currentVerifier: "no", verifier: nextVerifier }, cookie),
257
300
  );
258
301
  expect(refused?.status).toBe(400);
259
302
  expect(await refused?.json()).toEqual({ error: "wrong" });
@@ -261,7 +304,7 @@ describe("while unlocked", () => {
261
304
  const done = await guard.handle(
262
305
  post(
263
306
  MASTER_LOCK_PATHS.change,
264
- { currentVerifier: VERIFIER, kdf: nextKdf, verifier: nextVerifier },
307
+ { currentVerifier: VERIFIER, verifier: nextVerifier },
265
308
  cookie,
266
309
  ),
267
310
  );
@@ -272,12 +315,31 @@ describe("while unlocked", () => {
272
315
  });
273
316
 
274
317
  describe("an app with no master password configured", () => {
275
- test("is served normally — an unopenable lock would brick it", async () => {
318
+ test("🔴 boots to NOTHING, and offers to take a first password", async () => {
319
+ // This INVERTS what it asserted until the accounts model. "Unconfigured means open"
320
+ // was right while the lock only decided whether a UI was visible — failing closed
321
+ // would have bricked an app nobody could type a password into. Both halves of that
322
+ // reasoning are gone: `enroll` means an unconfigured app offers to take one, and the
323
+ // password now decides WHOSE DATA the app serves, so "open with no password" has no
324
+ // tenant it could answer with. The owner ruled the same way: *"It would be booting
325
+ // to nothing until the password is created."*
276
326
  const lock = new MasterLock({ store: createMemoryMasterLockStore(), seedJson: null });
277
327
  const guard = createMasterLockGuard({ lock, appLabel: "Test App" });
278
- expect(await guard.handle(doc("/"))).toBeNull();
328
+
329
+ const page = await guard.handle(doc("/"));
330
+ expect(page?.status).toBe(200);
331
+ expect(await page?.text()).toContain("master password");
332
+ // And no tenant is invented for a caller who has typed nothing.
333
+ expect(guard.accountFor(doc("/"))).toBeNull();
334
+
279
335
  const status = await guard.handle(xhr(MASTER_LOCK_PATHS.status));
280
- expect(await status?.json()).toMatchObject({ configured: false, locked: false, kdf: null });
336
+ expect(await status?.json()).toMatchObject({
337
+ configured: false,
338
+ locked: true,
339
+ enrollable: true,
340
+ kdf: null,
341
+ accountId: null,
342
+ });
281
343
  });
282
344
  });
283
345
 
@@ -81,6 +81,16 @@ const LOCK_CSP =
81
81
  export interface MasterLockGuard {
82
82
  /** `Response` when handled, `null` when the app should serve the request. */
83
83
  handle(req: Request): Promise<Response | null>;
84
+ /**
85
+ * 🔴 **WHICH tenant this request is for** — the answer an app must scope every query by.
86
+ *
87
+ * `null` when the request carries no live unlock, which is exactly when {@link handle}
88
+ * would have refused it. So the safe shape in an app is to read this AFTER the guard has
89
+ * stood aside, and to treat `null` as "serve nothing" rather than "serve the default" —
90
+ * a fallback tenant is how a locked request ends up being answered with somebody's
91
+ * library.
92
+ */
93
+ accountFor(req: Request): string | null;
84
94
  }
85
95
 
86
96
  export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLockGuard {
@@ -105,24 +115,23 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
105
115
  );
106
116
 
107
117
  /**
108
- * 🔴 May THIS request through — and the difference between the two modes is the whole
109
- * security property of the per-person wall.
118
+ * 🔴 May THIS request through — and the answer is a PRESENTED TOKEN, always.
110
119
  *
111
- * **App-wide**: `unlocked` is enough. The site has one user and one unlock; the token
112
- * is how a caller with no cookie jar says "it's still me", not a per-browser
113
- * credential. Reading the ambient state is the behaviour `apps/collections` and the
114
- * binary-server inspector have always had.
120
+ * Until the accounts model there was a second branch: an app-wide lock let `unlocked`
121
+ * alone open the door, on the reasoning that such a site had one user and one unlock,
122
+ * so the token was merely how a caller with no cookie jar said "it's still me". The
123
+ * per-person wall already refused that shape, and 2026-08-25 measured why — with
124
+ * `unlocked ||` in front, one browser unlocking meant every OTHER browser signed in as
125
+ * that person walked straight through having typed nothing, and an invented
126
+ * `x-master-lock` value was accepted because the check never looked at it.
115
127
  *
116
- * **Per-person**: the token is REQUIRED. `unlocked` there means only "somebody's
117
- * device is open", and honouring that would make the unlock ambient — measured while
118
- * building this (2026-08-25): with `unlocked ||` in front, one browser unlocking meant
119
- * every OTHER browser signed in as that person walked straight through, having typed
120
- * nothing. The owner's phone would ride his desk's unlock, and an invented
121
- * `x-master-lock` value would be accepted because the check never looked at it. Two
122
- * tests in `principals.spec.ts` fail on the `||`, which is why it is gone.
128
+ * The accounts model removes the last reason to keep the branch, and turns it from
129
+ * sloppy into wrong: the password decides WHICH TENANT'S DATA the app serves, so a
130
+ * request with no token has no account, and admitting it means serving whichever
131
+ * library somebody else's browser happens to have open. There is one rule now, for
132
+ * every app.
123
133
  */
124
- const opens = (lock: MasterLock, req: Request): boolean =>
125
- lock.isEnrollable ? lock.presents(req) : lock.unlocked || lock.presents(req);
134
+ const opens = (lock: MasterLock, req: Request): boolean => lock.presents(req);
126
135
 
127
136
  async function handleOwnRoute(req: Request, url: URL, lock: MasterLock): Promise<Response> {
128
137
  const path = url.pathname;
@@ -140,7 +149,21 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
140
149
  });
141
150
  }
142
151
  if (path === MASTER_LOCK_PATHS.status) {
143
- return Response.json(lock.status(), { headers: noStore });
152
+ return Response.json(lock.status(req), { headers: noStore });
153
+ }
154
+ if (path === MASTER_LOCK_PATHS.hints) {
155
+ // 🔴 Readable WHILE LOCKED, and the only surface here that is. See the path's
156
+ // note in `cursedbelt-core/master-lock/wire.ts`: a hint nobody can read until
157
+ // they are already in is not a hint, and everyone who reaches this has passed
158
+ // the app's own owner-only sign-in. It is a separate route, fetched only when
159
+ // somebody clicks "I forgot", so the default lock screen stays byte-identical
160
+ // whether this app has one tenant or ten.
161
+ return Response.json({ hints: lock.hints() }, { headers: noStore });
162
+ }
163
+ if (path === MASTER_LOCK_PATHS.accounts) {
164
+ // Unlike the hints, this needs the site already open — it names every tenant.
165
+ if (!opens(lock, req)) return refuse();
166
+ return Response.json({ accounts: lock.summaries(req) }, { headers: noStore });
144
167
  }
145
168
  if (path === MASTER_LOCK_PATHS.page || path === MASTER_LOCK_PREFIX) {
146
169
  // Already open FOR THIS CALLER, so there is nothing to type — send them to
@@ -153,6 +176,18 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
153
176
  return new Response("not found", { status: 404, headers: noStore });
154
177
  }
155
178
 
179
+ if (req.method === "PATCH" && path === MASTER_LOCK_PATHS.accounts) {
180
+ if (!opens(lock, req)) return refuse();
181
+ const body = await readJson(req);
182
+ const ok = lock.editAccount(req, {
183
+ id: typeof body?.id === "string" ? body.id : "",
184
+ ...(typeof body?.label === "string" ? { label: body.label } : {}),
185
+ ...(typeof body?.hint === "string" ? { hint: body.hint } : {}),
186
+ });
187
+ if (!ok) return Response.json({ error: "wrong" }, { status: 400, headers: noStore });
188
+ return Response.json({ ok: true, accounts: lock.summaries(req) }, { headers: noStore });
189
+ }
190
+
156
191
  if (req.method !== "POST") {
157
192
  return new Response("method not allowed", { status: 405, headers: noStore });
158
193
  }
@@ -168,7 +203,7 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
168
203
  // becomes a way to ask whether a given account has a master password yet.
169
204
  if (!result.ok) return refuse();
170
205
  return Response.json(
171
- { ok: true, idleMs: lock.idleMs },
206
+ { ok: true, idleMs: lock.idleMs, accountId: result.accountId },
172
207
  { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } },
173
208
  );
174
209
  }
@@ -178,8 +213,12 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
178
213
  const verifier = typeof body?.verifier === "string" ? body.verifier : "";
179
214
  const result = await lock.unlock(verifier);
180
215
  if (!result.ok) return refuse(result.retryAfterMs);
216
+ // 🔴 The account id is in the REPLY and not only in the cookie, because the page
217
+ // that just unlocked has to reload into the right tenant. It names the account
218
+ // that opened and never the ones that did not, so a wrong password still learns
219
+ // nothing about what else is here.
181
220
  return Response.json(
182
- { ok: true, idleMs: lock.idleMs },
221
+ { ok: true, idleMs: lock.idleMs, accountId: result.accountId },
183
222
  { headers: { ...noStore, "set-cookie": cookie(result.token, secure) } },
184
223
  );
185
224
  }
@@ -203,11 +242,25 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
203
242
  const idleMs = lock.setIdleMs(Number(body?.idleMs));
204
243
  return Response.json({ ok: true, idleMs }, { headers: noStore });
205
244
  }
245
+ if (path === MASTER_LOCK_PATHS.accounts) {
246
+ const body = await readJson(req);
247
+ const result = await lock.addAccount(req, {
248
+ currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
249
+ verifier: typeof body?.verifier === "string" ? body.verifier : "",
250
+ label: typeof body?.label === "string" ? body.label : "",
251
+ hint: typeof body?.hint === "string" ? body.hint : "",
252
+ });
253
+ if (!result.ok) {
254
+ return Response.json({ error: result.reason }, { status: 400, headers: noStore });
255
+ }
256
+ // 🔴 No cookie. Creating a tenant does not enter it — switching means typing that
257
+ // tenant's password, which is the entire boundary this feature is made of.
258
+ return Response.json({ ok: true, account: result.account }, { headers: noStore });
259
+ }
206
260
  if (path === MASTER_LOCK_PATHS.change) {
207
261
  const body = await readJson(req);
208
- const result = await lock.change({
262
+ const result = await lock.change(req, {
209
263
  currentVerifier: typeof body?.currentVerifier === "string" ? body.currentVerifier : "",
210
- kdf: body?.kdf as never,
211
264
  verifier: typeof body?.verifier === "string" ? body.verifier : "",
212
265
  });
213
266
  if (!result.ok) {
@@ -224,6 +277,11 @@ export function createMasterLockGuard(options: MasterLockGuardOptions): MasterLo
224
277
  }
225
278
 
226
279
  return {
280
+ accountFor(req: Request): string | null {
281
+ // The SAME resolution the guard itself uses, so an app can never be told a
282
+ // different tenant from the one the wall admitted.
283
+ return resolve(req)?.accountFor(req) ?? null;
284
+ },
227
285
  async handle(req: Request): Promise<Response | null> {
228
286
  const url = new URL(req.url);
229
287
  const own =
@@ -22,7 +22,10 @@ export {
22
22
  masterLockPrincipalKey,
23
23
  } from "./principals";
24
24
  export {
25
+ FIRST_ACCOUNT_ID,
25
26
  MasterLock,
27
+ type MasterLockAccount,
28
+ type MasterLockAddAccountResult,
26
29
  type MasterLockEnrollResult,
27
30
  type MasterLockOptions,
28
31
  type MasterLockRecord,
@@ -59,7 +59,7 @@ export function lockPageHtml(options: LockPageOptions): string {
59
59
  (enroll
60
60
  ? "You are signed in, but this site needs a master password of your own. " +
61
61
  "Choose one now. Nobody else can see it and nobody else can reset it — " +
62
- "if you forget it, it cannot be recovered."
62
+ "if you forget it, it cannot be recovered, so leave yourself a hint."
63
63
  : "This site is locked. Signing in is not enough — enter the master password to continue."),
64
64
  );
65
65
  return `<!doctype html>
@@ -93,12 +93,24 @@ export function lockPageHtml(options: LockPageOptions): string {
93
93
  <input id="ml-confirm" type="password" name="confirm-master-password"
94
94
  autocomplete="new-password" required spellcheck="false" enterkeyhint="go"
95
95
  minlength="10">
96
+ </label>
97
+ <label class="field">
98
+ <span>Hint <em>optional</em></span>
99
+ <input id="ml-hint" type="text" name="master-password-hint" maxlength="200"
100
+ spellcheck="false" autocomplete="off"
101
+ placeholder="Something only you would understand">
96
102
  </label>`
97
103
  : ""
98
104
  }
99
105
  <button id="ml-submit" type="submit">${enroll ? "Set master password" : "Unlock"}</button>
100
106
  </form>
101
- <p id="ml-status" class="status" role="status" aria-live="polite"></p>
107
+ <p id="ml-status" class="status" role="status" aria-live="polite"></p>${
108
+ enroll
109
+ ? ""
110
+ : `
111
+ <button id="ml-forgot" type="button" class="quiet">I forgot which password</button>
112
+ <dl id="ml-hints" class="hints" hidden></dl>`
113
+ }
102
114
  </main>
103
115
  <script type="module" src="${MASTER_LOCK_PATHS.script}"></script>
104
116
  </body>
@@ -134,6 +146,16 @@ button{width:100%;height:40px;border:0;border-radius:9px;background:var(--ml-acc
134
146
  button[disabled]{opacity:.6;cursor:progress}
135
147
  .status{margin:14px 0 0;min-height:1.2em;font-size:12.5px;color:var(--ml-muted)}
136
148
  .status[data-tone="error"]{color:var(--ml-danger)}
149
+ .field span em{font-style:normal;font-weight:500;text-transform:none;opacity:.7}
150
+ button.quiet{margin-top:10px;height:32px;background:none;color:var(--ml-muted);font-weight:500;
151
+ font-size:12.5px;text-decoration:underline;text-underline-offset:3px}
152
+ button.quiet:hover{color:var(--ml-text)}
153
+ button.quiet[disabled]{opacity:.5}
154
+ .hints{margin:10px 0 0;padding:12px 14px;text-align:left;border:1px solid var(--ml-border);
155
+ border-radius:10px;background:var(--ml-bg);font-size:12.5px}
156
+ .hints dt{font-weight:650;color:var(--ml-text)}
157
+ .hints dd{margin:2px 0 10px;color:var(--ml-muted);overflow-wrap:anywhere}
158
+ .hints dd:last-child{margin-bottom:0}
137
159
  `;
138
160
 
139
161
  /**
@@ -150,8 +172,11 @@ export const LOCK_SCRIPT = `const paths = ${JSON.stringify(MASTER_LOCK_PATHS)};
150
172
  const form = document.getElementById("ml-form");
151
173
  const input = document.getElementById("ml-input");
152
174
  const confirmInput = document.getElementById("ml-confirm");
175
+ const hintInput = document.getElementById("ml-hint");
153
176
  const submit = document.getElementById("ml-submit");
154
177
  const status = document.getElementById("ml-status");
178
+ const forgot = document.getElementById("ml-forgot");
179
+ const hints = document.getElementById("ml-hints");
155
180
 
156
181
  function say(message, tone) {
157
182
  status.textContent = message;
@@ -249,7 +274,11 @@ async function doEnroll(password) {
249
274
  method: "POST",
250
275
  credentials: "same-origin",
251
276
  headers: { "content-type": "application/json" },
252
- body: JSON.stringify({ kdf: params, verifier: verifier }),
277
+ body: JSON.stringify({
278
+ kdf: params,
279
+ verifier: verifier,
280
+ hint: hintInput ? hintInput.value : "",
281
+ }),
253
282
  });
254
283
  if (res.ok) { say("Master password set."); location.reload(); return true; }
255
284
  // The server collapses every enrollment refusal into one, so this cannot say more
@@ -275,6 +304,44 @@ async function doUnlock(password) {
275
304
  return false;
276
305
  }
277
306
 
307
+ // ── the hints, and why they are behind a click ───────────────────────────────
308
+ // Reading them says how many master passwords this app has and what each is called, which
309
+ // is the one fact the rest of this page is built never to disclose. Fetching them on load
310
+ // would leak it to anyone who merely opened the door; a button makes it something the
311
+ // owner ASKS for. Everyone who gets this far has already passed the app's own sign-in.
312
+ if (forgot && hints) {
313
+ forgot.addEventListener("click", async () => {
314
+ if (!hints.hidden) { hints.hidden = true; return; }
315
+ forgot.disabled = true;
316
+ try {
317
+ const res = await fetch(paths.hints, { credentials: "same-origin" });
318
+ const body = await res.json();
319
+ const rows = (body && body.hints) || [];
320
+ hints.replaceChildren();
321
+ if (rows.length === 0) {
322
+ const dd = document.createElement("dd");
323
+ dd.textContent = "No hints have been written yet.";
324
+ hints.append(dd);
325
+ }
326
+ for (const row of rows) {
327
+ const dt = document.createElement("dt");
328
+ dt.textContent = row.label;
329
+ const dd = document.createElement("dd");
330
+ // 🔴 textContent, never innerHTML. The hint is free text the owner typed and this
331
+ // page runs under a CSP that permits no inline script — writing it as markup would
332
+ // be the one way to get some in.
333
+ dd.textContent = row.hint || "— no hint —";
334
+ hints.append(dt, dd);
335
+ }
336
+ hints.hidden = false;
337
+ } catch {
338
+ say("Cannot reach this site right now.", "error");
339
+ } finally {
340
+ forgot.disabled = false;
341
+ }
342
+ });
343
+ }
344
+
278
345
  form.addEventListener("submit", async (event) => {
279
346
  event.preventDefault();
280
347
  if (busy || submit.disabled) return;
@@ -77,10 +77,32 @@ describe("seeding", () => {
77
77
  expect(lock.configured).toBe(false);
78
78
  });
79
79
 
80
- test("🔴 an UNCONFIGURED app reads as unlocked — a lock nobody can open is not protection", () => {
80
+ test("🔴 an UNCONFIGURED app reads as LOCKED, and offers to take a first password", () => {
81
+ // This INVERTS what it asserted until the accounts model, and the inversion is the
82
+ // owner's ruling: *"if there is no master password then it has to get one set before
83
+ // a user could add anything. It would be booting to nothing until the password is
84
+ // created."* Nothing is bricked, because `enroll` is the way out — which is the
85
+ // half that made the old "unconfigured means open" reasoning necessary.
81
86
  const { lock } = build({ seed: null });
82
87
  expect(lock.configured).toBe(false);
88
+ expect(lock.unlocked).toBe(false);
89
+ expect(lock.awaitingEnrollment).toBe(true);
90
+ });
91
+
92
+ test("🔴 an unlock is never AMBIENT — a second browser that typed nothing gets no account", async () => {
93
+ // The property the whole tenant model rests on. `unlocked` says somebody's device is
94
+ // open; it may never answer "whose library do I serve", because the answer would be
95
+ // "whichever one is open", and that is how one tenant's files reach a session that
96
+ // holds the other tenant's password.
97
+ const { lock } = build();
98
+ const opened = await lock.unlock(VERIFIER);
99
+ expect(opened.ok).toBe(true);
83
100
  expect(lock.unlocked).toBe(true);
101
+ // A request carrying no token at all.
102
+ expect(lock.accountFor(req())).toBeNull();
103
+ expect(lock.presents(req())).toBe(false);
104
+ // An invented one.
105
+ expect(lock.accountFor(req("not-a-real-token"))).toBeNull();
84
106
  });
85
107
 
86
108
  test("a seed whose hash arrived shell-quoted is repaired, not refused", async () => {
@@ -262,24 +284,24 @@ describe("the guess throttle", () => {
262
284
  });
263
285
 
264
286
  describe("rotation", () => {
265
- test("needs the current password, rewrites the record, and locks the site", async () => {
287
+ test("needs the current password, keeps the ACCOUNT, and locks the site", async () => {
266
288
  const { lock, store, locked } = build();
267
- await lock.unlock(VERIFIER);
289
+ const opened = await lock.unlock(VERIFIER);
290
+ const token = opened.ok ? opened.token : "";
291
+ const account = opened.ok ? opened.accountId : "";
292
+ expect(account).toBe("acct-1");
268
293
 
269
- const nextKdf = newMasterLockKdfParams();
270
- const nextVerifier = await deriveMasterLockVerifier("a different one", nextKdf);
294
+ const nextVerifier = await deriveMasterLockVerifier("a different one", KDF);
271
295
 
272
- const refused = await lock.change({
296
+ const refused = await lock.change(req(token), {
273
297
  currentVerifier: "wrong",
274
- kdf: nextKdf,
275
298
  verifier: nextVerifier,
276
299
  });
277
300
  expect(refused).toEqual({ ok: false, reason: "wrong" });
278
301
  expect(lock.unlocked).toBe(true);
279
302
 
280
- const done = await lock.change({
303
+ const done = await lock.change(req(token), {
281
304
  currentVerifier: VERIFIER,
282
- kdf: nextKdf,
283
305
  verifier: nextVerifier,
284
306
  });
285
307
  expect(done).toEqual({ ok: true });
@@ -287,36 +309,47 @@ describe("rotation", () => {
287
309
  expect(lock.unlocked).toBe(false);
288
310
  expect(locked.at(-1)).toBe(true);
289
311
 
290
- // The OLD one no longer opens it; the new one does.
312
+ // The OLD one no longer opens it; the new one does — and opens the SAME account.
291
313
  expect((await lock.unlock(VERIFIER)).ok).toBe(false);
292
- expect((await lock.unlock(nextVerifier)).ok).toBe(true);
293
- expect(readRecord(store.read())?.kdf.salt).toBe(nextKdf.salt);
314
+ const again = await lock.unlock(nextVerifier);
315
+ expect(again.ok && again.accountId).toBe("acct-1");
316
+ // 🔴 The salt did NOT move. Every account derives under one app-wide descriptor, so
317
+ // a rotation that minted a fresh one would invalidate every sibling's stored hash.
318
+ expect(readRecord(store.read())?.kdf.salt).toBe(KDF.salt);
319
+ }, KDF_BULK_MS);
320
+
321
+ test("🔴 a rotation is refused outright without a live unlock of the account it names", async () => {
322
+ // An open session is not proof the owner is at the keyboard, and a caller with no
323
+ // session at all has not even got that far. There is no account to rotate.
324
+ const { lock, store } = build();
325
+ const before = store.read();
326
+ const result = await lock.change(req(), { currentVerifier: VERIFIER, verifier: "x" });
327
+ expect(result).toEqual({ ok: false, reason: "locked" });
328
+ expect(store.read()).toBe(before);
294
329
  });
295
330
 
296
- test("refuses params it could not derive under, before touching the record", async () => {
331
+ test("refuses an empty verifier before touching the record", async () => {
297
332
  const { lock, store } = build();
333
+ const opened = await lock.unlock(VERIFIER);
298
334
  const before = store.read();
299
- const result = await lock.change({
335
+ const result = await lock.change(req(opened.ok ? opened.token : ""), {
300
336
  currentVerifier: VERIFIER,
301
- kdf: { v: 2 } as unknown as MasterLockKdfParams,
302
- verifier: "x",
337
+ verifier: "",
303
338
  });
304
339
  expect(result).toEqual({ ok: false, reason: "invalid" });
305
340
  expect(store.read()).toBe(before);
306
- });
341
+ }, KDF_BULK_MS);
307
342
 
308
343
  test("keeps this app's idle timeout across a rotation", async () => {
309
344
  const { lock, store } = build();
310
345
  lock.setIdleMs(120_000);
311
- await lock.unlock(VERIFIER);
312
- const nextKdf = newMasterLockKdfParams();
313
- await lock.change({
346
+ const opened = await lock.unlock(VERIFIER);
347
+ await lock.change(req(opened.ok ? opened.token : ""), {
314
348
  currentVerifier: VERIFIER,
315
- kdf: nextKdf,
316
- verifier: await deriveMasterLockVerifier("next", nextKdf),
349
+ verifier: await deriveMasterLockVerifier("next", KDF),
317
350
  });
318
351
  expect(readRecord(store.read())?.idleMs).toBe(120_000);
319
- });
352
+ }, KDF_BULK_MS);
320
353
  });
321
354
 
322
355
  describe("readRecord", () => {