cursedbelt-server 4.11.1 → 4.12.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.
@@ -26,4 +26,4 @@ export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, type LockPageOption
26
26
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, type MasterLockDirectoryOptions, masterLockPrincipalKey, } from "./principals";
27
27
  export { FIRST_ACCOUNT_ID, MasterLock, type MasterLockAccount, type MasterLockAddAccountResult, type MasterLockEnrollResult, type MasterLockOptions, type MasterLockRecord, type MasterLockStore, type MasterLockUnlockResult, readRecord, } from "./masterLock";
28
28
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, type MintMasterLockSeedOptions, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
29
- export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
29
+ export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
@@ -26,4 +26,4 @@ export { LOCK_SCRIPT, LOCK_STYLE, MASTER_LOCK_DERIVE_SOURCE, lockPageHtml, } fro
26
26
  export { MASTER_LOCK_PRINCIPAL_KEY_PREFIX, MasterLockDirectory, masterLockPrincipalKey, } from "./principals";
27
27
  export { FIRST_ACCOUNT_ID, MasterLock, readRecord, } from "./masterLock";
28
28
  export { DEFAULT_SEED_IDLE_MS, generateStagePassword, mintMasterLockSeed, serializeMasterLockSeed, } from "./seed";
29
- export { MASTER_LOCK_SETTING_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
29
+ export { MASTER_LOCK_SETTING_KEY, MASTER_LOCK_STATE_KEY, createKvMasterLockStore, createMemoryMasterLockStore, } from "./store";
@@ -1,52 +1,3 @@
1
- /**
2
- * The sitewide MASTER LOCK — a second wall, in front of a whole app, that the app's own
3
- * sign-in cannot open.
4
- *
5
- * ── What the owner asked for (2026-08-15, their words) ──────────────────────
6
- * *"I want to be able to share my laptop without certain apps being at risk of other people
7
- * viewing them while I am still logged in … Until the master password is entered a user
8
- * logged in as me cannot see anything about the site, not even text or images, nothing at
9
- * all."*
10
- *
11
- * So: standard auth still happens FIRST and is unchanged. This sits behind it. A request
12
- * that has passed the app's own gate and carries no live unlock is answered with the lock
13
- * page (for a document) or a 401 (for anything else) — never with the app.
14
- *
15
- * ── 🔴 BE HONEST ABOUT WHAT THIS BUYS ───────────────────────────────────────
16
- * In `apps/vault` the master password IS the encryption key: the server holds ciphertext
17
- * and a total breach yields noise. **This cannot work that way, and must never be described
18
- * as if it does.** The apps it guards serve their bytes to browsers; those bytes are
19
- * plaintext at rest by nature. This is a LOCK ON THE DOOR — an unlocked laptop, a borrowed
20
- * phone, a hijacked session still cannot open the UI. The owner said the same thing back:
21
- * *"I am aware that a tech savvy user of my laptop could still get around it by changing
22
- * code. This work does not worry about that, we are only securing app UI access."*
23
- * The same distinction is written in `fleet/rules/owner-uis-are-one-ui.md` and in
24
- * binary-server's `src/admin/passcode.ts`, which is this feature's nearest relative.
25
- *
26
- * ── The four decisions that are not obvious ─────────────────────────────────
27
- *
28
- * **1. Sessions live in MEMORY, so a restart re-locks.** The correct default for a surface
29
- * whose whole risk model is "the browser is somewhere it should not be". Same call as the
30
- * inspector passcode.
31
- *
32
- * **2. The idle clock is slid by INTERACTION, never by traffic.** The owner said *"if I
33
- * leave the site up but don't interact with it"* — and every app here polls. Sliding on any
34
- * request would let a background refetch hold the lock open all night with nobody at the
35
- * desk, which is exactly the scenario the feature exists for. So an ordinary request only
36
- * READS the clock; `touch()` — reached solely by `POST /__lock/ping`, which the browser
37
- * sends on real pointer/key/touch activity — is the only thing that moves it.
38
- *
39
- * **3. The password is never stored, and this process never sees it.** The browser derives
40
- * `verifier = PBKDF2(KEK ‖ password)` (see `../../core/master-lock/kdf.ts`); the record
41
- * holds `argon2id(verifier)`. It is seeded from the vault's OWN record so the owner's
42
- * existing vault master password works on day one without anyone knowing it.
43
- *
44
- * **4. Every refusal is the same refusal.** Wrong verifier, throttled, unconfigured and
45
- * "no unlock" answer with the identical status and body, and a dummy argon2 verify runs on
46
- * every path so the TIMING does not separate them either. The one thing that varies is the
47
- * `retryAfterMs` a throttled caller is told — which they already know, since they are the
48
- * one who has been guessing.
49
- */
50
1
  import { type MasterLockAccountSummary, type MasterLockKdfParams, type MasterLockStatus } from "cursedbelt-core/master-lock";
51
2
  /**
52
3
  * ONE master password, and therefore one TENANT of the app behind it.
@@ -127,8 +78,29 @@ export interface MasterLockStore {
127
78
  read(): string | null;
128
79
  write(json: string): void;
129
80
  }
81
+ /**
82
+ * Where the LIVE state goes when the process that holds it does not live — the unlocks that are
83
+ * open and the throttle's failure count. Synchronous by the same contract as
84
+ * {@link MasterLockStore}, so a Worker satisfies it from its per-invocation snapshot exactly as
85
+ * it does the record.
86
+ *
87
+ * 🔴 Why it exists (2026-09-22, the night `collections` moved to a Worker): the lock kept its
88
+ * unlocks in a `Map` on the instance, and a Worker builds the instance once per REQUEST. The
89
+ * owner typed the right password, got a 200 and a cookie, and the very next request was locked
90
+ * again — measured on `collections-stage` by `worker-stage-walk.ts`. The throttle had the same
91
+ * shape: every guess met a fresh counter, so nothing slowed a brute force at all.
92
+ *
93
+ * Omitted (the Mac's daemons), the state stays in memory, exactly as before: a restart locks.
94
+ * Tokens are never stored — only their SHA-256, so a leaked row opens nothing.
95
+ */
96
+ export interface MasterLockStateStore {
97
+ read(): string | null;
98
+ write(json: string): void;
99
+ }
130
100
  export interface MasterLockOptions {
131
101
  store: MasterLockStore;
102
+ /** See {@link MasterLockStateStore}. Required wherever the instance does not outlive a request. */
103
+ state?: MasterLockStateStore;
132
104
  /**
133
105
  * The BOOTSTRAP record, as JSON, used only when the store is empty — the same shape as
134
106
  * {@link MasterLockRecord} with `idleMs` optional.
@@ -225,6 +197,7 @@ export declare class MasterLock {
225
197
  * entered again"* — one button, every device.
226
198
  */
227
199
  private sessions;
200
+ private readonly stateStore;
228
201
  private expiryTimer;
229
202
  private failures;
230
203
  private lastFailureAt;
@@ -449,6 +422,10 @@ export declare class MasterLock {
449
422
  private write;
450
423
  /** Drop the in-memory unlock without notifying — for tests between cases. */
451
424
  resetForTest(): void;
425
+ /** Adopt the persisted live state, if there is a store and it holds a readable one. */
426
+ private loadState;
427
+ /** Persist the live state — every mutation of it calls this, or a Worker forgets it. */
428
+ private saveState;
452
429
  /** Retire a session whose idle window has passed. Called on every read of the state, so
453
430
  * correctness never depends on the timer having fired. */
454
431
  private sweep;
@@ -47,6 +47,7 @@
47
47
  * `retryAfterMs` a throttled caller is told — which they already know, since they are the
48
48
  * one who has been guessing.
49
49
  */
50
+ import { createHash } from "node:crypto";
50
51
  import { clampIdleMs, isMasterLockKdfParams, windowRemaining, } from "cursedbelt-core/master-lock";
51
52
  /** The id a legacy single-password record migrates to. Fixed, because the app that adopts
52
53
  * this stamps its existing rows with it — a random id would orphan the library it is
@@ -87,11 +88,14 @@ export class MasterLock {
87
88
  * entered again"* — one button, every device.
88
89
  */
89
90
  sessions = new Map();
91
+ stateStore = null;
90
92
  expiryTimer = null;
91
93
  failures = 0;
92
94
  lastFailureAt = 0;
93
95
  constructor(options) {
94
96
  this.store = options.store;
97
+ this.stateStore = options.state ?? null;
98
+ this.loadState();
95
99
  this.now = options.now ?? Date.now;
96
100
  this.onLockedChange = options.onLockedChange;
97
101
  this.log = options.log ?? (() => undefined);
@@ -220,7 +224,7 @@ export class MasterLock {
220
224
  if (this.sessions.size === 0)
221
225
  return null;
222
226
  let found = null;
223
- for (const presented of presentedTokens(req)) {
227
+ for (const presented of presentedTokens(req).map(tokenDigest)) {
224
228
  for (const [live, session] of this.sessions) {
225
229
  // No early `return`: every candidate is compared against every live token so
226
230
  // the loop's duration does not say which cookie in the jar was the right one.
@@ -307,6 +311,7 @@ export class MasterLock {
307
311
  }
308
312
  this.failures += 1;
309
313
  this.lastFailureAt = now;
314
+ this.saveState();
310
315
  return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
311
316
  }
312
317
  return { ok: true, ...this.open(opened.id, now) };
@@ -322,7 +327,8 @@ export class MasterLock {
322
327
  const wasLocked = this.sessions.size === 0;
323
328
  // ADDED, never replacing: the desk and the phone are the same person and both stay
324
329
  // open. See the `sessions` note.
325
- this.sessions.set(token, { accountId, lastActivityAt: now });
330
+ this.sessions.set(tokenDigest(token), { accountId, lastActivityAt: now });
331
+ this.saveState();
326
332
  this.scheduleExpiry();
327
333
  if (wasLocked)
328
334
  this.onLockedChange?.(false);
@@ -481,7 +487,7 @@ export class MasterLock {
481
487
  // phone left on the counter locks on its own schedule while the desk stays open —
482
488
  // which is decision 2 applied per device rather than per person.
483
489
  let slid = false;
484
- for (const presented of presentedTokens(req)) {
490
+ for (const presented of presentedTokens(req).map(tokenDigest)) {
485
491
  for (const [live, session] of this.sessions) {
486
492
  // No early `return`, for the reason `accountFor` gives: the loop's duration
487
493
  // must not say which of the jar's cookies was this app's.
@@ -491,8 +497,10 @@ export class MasterLock {
491
497
  }
492
498
  }
493
499
  }
494
- if (slid)
500
+ if (slid) {
501
+ this.saveState();
495
502
  this.scheduleExpiry();
503
+ }
496
504
  return slid;
497
505
  }
498
506
  /** The manual lock — the owner's *"I want a lock option to turn the site back to needing
@@ -504,6 +512,7 @@ export class MasterLock {
504
512
  // owner's requirement is "no one can see anything until the master password is
505
513
  // entered again", and a lock that left the phone open would not be that.
506
514
  this.sessions.clear();
515
+ this.saveState();
507
516
  this.clearExpiry();
508
517
  this.onLockedChange?.(true);
509
518
  }
@@ -588,6 +597,33 @@ export class MasterLock {
588
597
  this.lastFailureAt = 0;
589
598
  this.clearExpiry();
590
599
  }
600
+ /** Adopt the persisted live state, if there is a store and it holds a readable one. */
601
+ loadState() {
602
+ if (!this.stateStore)
603
+ return;
604
+ const raw = this.stateStore.read();
605
+ if (!raw)
606
+ return;
607
+ try {
608
+ const parsed = JSON.parse(raw);
609
+ if (parsed.v !== 1)
610
+ return;
611
+ for (const [digest, session] of parsed.sessions ?? []) {
612
+ if (typeof digest === "string" && typeof session?.accountId === "string" && Number.isFinite(session.lastActivityAt)) {
613
+ this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
614
+ }
615
+ }
616
+ this.failures = Number.isFinite(parsed.failures) ? Number(parsed.failures) : 0;
617
+ this.lastFailureAt = Number.isFinite(parsed.lastFailureAt) ? Number(parsed.lastFailureAt) : 0;
618
+ }
619
+ catch {
620
+ // Unreadable state is LOCKED state: nothing is open, which is the safe reading.
621
+ }
622
+ }
623
+ /** Persist the live state — every mutation of it calls this, or a Worker forgets it. */
624
+ saveState() {
625
+ this.stateStore?.write(JSON.stringify({ v: 1, sessions: [...this.sessions], failures: this.failures, lastFailureAt: this.lastFailureAt }));
626
+ }
591
627
  /** Retire a session whose idle window has passed. Called on every read of the state, so
592
628
  * correctness never depends on the timer having fired. */
593
629
  sweep() {
@@ -596,11 +632,14 @@ export class MasterLock {
596
632
  return;
597
633
  const now = this.now();
598
634
  const wasOpen = this.sessions.size > 0;
635
+ const before = this.sessions.size;
599
636
  for (const [token, session] of this.sessions) {
600
637
  // Each device expires on its OWN clock, so one going idle never shortens another.
601
638
  if (now - session.lastActivityAt > record.idleMs)
602
639
  this.sessions.delete(token);
603
640
  }
641
+ if (this.sessions.size !== before)
642
+ this.saveState();
604
643
  if (wasOpen && this.sessions.size === 0) {
605
644
  this.clearExpiry();
606
645
  this.onLockedChange?.(true);
@@ -791,6 +830,10 @@ async function equalize(candidate) {
791
830
  equalizerHash ??= Bun.password.hash("master-lock-timing-equalizer", { algorithm: "argon2id" });
792
831
  await Bun.password.verify(candidate || "x", await equalizerHash).catch(() => false);
793
832
  }
833
+ /** What a session is KEYED by — never the token itself, so persisted state opens nothing. */
834
+ function tokenDigest(token) {
835
+ return createHash("sha256").update(token).digest("base64url");
836
+ }
794
837
  function randomToken() {
795
838
  const bytes = new Uint8Array(TOKEN_BYTES);
796
839
  crypto.getRandomValues(bytes);
@@ -9,6 +9,11 @@
9
9
  import type { MasterLockStore } from "./masterLock";
10
10
  /** The default key, so two apps' rows look the same when somebody goes looking. */
11
11
  export declare const MASTER_LOCK_SETTING_KEY = "master_lock:record";
12
+ /**
13
+ * The key the LIVE state (open unlocks, the throttle) is kept under, beside the record — see
14
+ * `MasterLockStateStore`. `createKvMasterLockStore(kv, MASTER_LOCK_STATE_KEY)` is that store.
15
+ */
16
+ export declare const MASTER_LOCK_STATE_KEY = "master_lock:state";
12
17
  /** Wrap an app's existing key/value settings surface. */
13
18
  export declare function createKvMasterLockStore(kv: {
14
19
  get(key: string): string | null | undefined;
@@ -1,5 +1,10 @@
1
1
  /** The default key, so two apps' rows look the same when somebody goes looking. */
2
2
  export const MASTER_LOCK_SETTING_KEY = "master_lock:record";
3
+ /**
4
+ * The key the LIVE state (open unlocks, the throttle) is kept under, beside the record — see
5
+ * `MasterLockStateStore`. `createKvMasterLockStore(kv, MASTER_LOCK_STATE_KEY)` is that store.
6
+ */
7
+ export const MASTER_LOCK_STATE_KEY = "master_lock:state";
3
8
  /** Wrap an app's existing key/value settings surface. */
4
9
  export function createKvMasterLockStore(kv, key = MASTER_LOCK_SETTING_KEY) {
5
10
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.11.1",
3
+ "version": "4.12.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -67,6 +67,7 @@ export {
67
67
  } from "./seed";
68
68
  export {
69
69
  MASTER_LOCK_SETTING_KEY,
70
+ MASTER_LOCK_STATE_KEY,
70
71
  createKvMasterLockStore,
71
72
  createMemoryMasterLockStore,
72
73
  } from "./store";
@@ -369,3 +369,60 @@ describe("readRecord", () => {
369
369
  expect(readRecord(JSON.stringify({ ...base, idleMs: -5 }))?.idleMs).toBe(MIN_IDLE_MS);
370
370
  });
371
371
  });
372
+
373
+ describe("the live state, when the instance does not outlive a request (a Worker)", () => {
374
+ /** One record store and one state store, and a fresh MasterLock per "request" over both. */
375
+ function perRequest(now: () => number = Date.now) {
376
+ const store = createMemoryMasterLockStore(null);
377
+ const state = createMemoryMasterLockStore(null);
378
+ const fresh = () => new MasterLock({ store, state, seedJson: SEED, now });
379
+ return { fresh, state };
380
+ }
381
+
382
+ test("🔴 an unlock made in one instance opens the NEXT one — the collections-stage defect", async () => {
383
+ const { fresh } = perRequest();
384
+ const result = await fresh().unlock(VERIFIER);
385
+ if (!result.ok) throw new Error("expected an unlock");
386
+ const next = fresh();
387
+ expect(next.presents(req(result.token))).toBe(true);
388
+ expect(next.unlocked).toBe(true);
389
+ });
390
+
391
+ test("the defect it replaced: with no state store, the next instance has forgotten", async () => {
392
+ const store = createMemoryMasterLockStore(null);
393
+ const result = await new MasterLock({ store, seedJson: SEED }).unlock(VERIFIER);
394
+ if (!result.ok) throw new Error("expected an unlock");
395
+ expect(new MasterLock({ store, seedJson: SEED }).presents(req(result.token))).toBe(false);
396
+ });
397
+
398
+ test("🔴 the throttle holds ACROSS instances — a fresh counter per request throttled nothing", async () => {
399
+ const now = 1_000_000;
400
+ const { fresh } = perRequest(() => now);
401
+ for (let i = 0; i < 6; i += 1) await fresh().unlock("wrong");
402
+ expect(fresh().status().retryAfterMs).toBe(30_000);
403
+ expect((await fresh().unlock(VERIFIER)).ok).toBe(false);
404
+ }, KDF_BULK_MS);
405
+
406
+ test("a lock in one instance closes every other — the manual lock reaches every device", async () => {
407
+ const { fresh } = perRequest();
408
+ const result = await fresh().unlock(VERIFIER);
409
+ if (!result.ok) throw new Error("expected an unlock");
410
+ fresh().lock();
411
+ expect(fresh().presents(req(result.token))).toBe(false);
412
+ });
413
+
414
+ test("🔴 the persisted state holds no token — a leaked row opens nothing", async () => {
415
+ const { fresh, state } = perRequest();
416
+ const result = await fresh().unlock(VERIFIER);
417
+ if (!result.ok) throw new Error("expected an unlock");
418
+ const stored = state.read() ?? "";
419
+ expect(stored).toContain("sessions");
420
+ expect(stored).not.toContain(result.token);
421
+ });
422
+
423
+ test("unreadable state is LOCKED state", () => {
424
+ const store = createMemoryMasterLockStore(null);
425
+ const lock = new MasterLock({ store, state: createMemoryMasterLockStore("{not json"), seedJson: SEED });
426
+ expect(lock.unlocked).toBe(false);
427
+ });
428
+ });
@@ -47,6 +47,7 @@
47
47
  * `retryAfterMs` a throttled caller is told — which they already know, since they are the
48
48
  * one who has been guessing.
49
49
  */
50
+ import { createHash } from "node:crypto";
50
51
  import {
51
52
  type MasterLockAccountSummary,
52
53
  type MasterLockKdfParams,
@@ -139,8 +140,30 @@ export interface MasterLockStore {
139
140
  write(json: string): void;
140
141
  }
141
142
 
143
+ /**
144
+ * Where the LIVE state goes when the process that holds it does not live — the unlocks that are
145
+ * open and the throttle's failure count. Synchronous by the same contract as
146
+ * {@link MasterLockStore}, so a Worker satisfies it from its per-invocation snapshot exactly as
147
+ * it does the record.
148
+ *
149
+ * 🔴 Why it exists (2026-09-22, the night `collections` moved to a Worker): the lock kept its
150
+ * unlocks in a `Map` on the instance, and a Worker builds the instance once per REQUEST. The
151
+ * owner typed the right password, got a 200 and a cookie, and the very next request was locked
152
+ * again — measured on `collections-stage` by `worker-stage-walk.ts`. The throttle had the same
153
+ * shape: every guess met a fresh counter, so nothing slowed a brute force at all.
154
+ *
155
+ * Omitted (the Mac's daemons), the state stays in memory, exactly as before: a restart locks.
156
+ * Tokens are never stored — only their SHA-256, so a leaked row opens nothing.
157
+ */
158
+ export interface MasterLockStateStore {
159
+ read(): string | null;
160
+ write(json: string): void;
161
+ }
162
+
142
163
  export interface MasterLockOptions {
143
164
  store: MasterLockStore;
165
+ /** See {@link MasterLockStateStore}. Required wherever the instance does not outlive a request. */
166
+ state?: MasterLockStateStore;
144
167
  /**
145
168
  * The BOOTSTRAP record, as JSON, used only when the store is empty — the same shape as
146
169
  * {@link MasterLockRecord} with `idleMs` optional.
@@ -236,6 +259,7 @@ export class MasterLock {
236
259
  * entered again"* — one button, every device.
237
260
  */
238
261
  private sessions = new Map<string, MasterLockSession>();
262
+ private readonly stateStore: MasterLockStateStore | null = null;
239
263
  private expiryTimer: ReturnType<typeof setTimeout> | null = null;
240
264
 
241
265
  private failures = 0;
@@ -243,6 +267,8 @@ export class MasterLock {
243
267
 
244
268
  constructor(options: MasterLockOptions) {
245
269
  this.store = options.store;
270
+ this.stateStore = options.state ?? null;
271
+ this.loadState();
246
272
  this.now = options.now ?? Date.now;
247
273
  this.onLockedChange = options.onLockedChange;
248
274
  this.log = options.log ?? (() => undefined);
@@ -374,7 +400,7 @@ export class MasterLock {
374
400
  this.sweep();
375
401
  if (this.sessions.size === 0) return null;
376
402
  let found: string | null = null;
377
- for (const presented of presentedTokens(req)) {
403
+ for (const presented of presentedTokens(req).map(tokenDigest)) {
378
404
  for (const [live, session] of this.sessions) {
379
405
  // No early `return`: every candidate is compared against every live token so
380
406
  // the loop's duration does not say which cookie in the jar was the right one.
@@ -465,6 +491,7 @@ export class MasterLock {
465
491
  }
466
492
  this.failures += 1;
467
493
  this.lastFailureAt = now;
494
+ this.saveState();
468
495
  return { ok: false, retryAfterMs: windowRemaining(this.failures, this.lastFailureAt, now) };
469
496
  }
470
497
 
@@ -482,7 +509,8 @@ export class MasterLock {
482
509
  const wasLocked = this.sessions.size === 0;
483
510
  // ADDED, never replacing: the desk and the phone are the same person and both stay
484
511
  // open. See the `sessions` note.
485
- this.sessions.set(token, { accountId, lastActivityAt: now });
512
+ this.sessions.set(tokenDigest(token), { accountId, lastActivityAt: now });
513
+ this.saveState();
486
514
  this.scheduleExpiry();
487
515
  if (wasLocked) this.onLockedChange?.(false);
488
516
  return { token, accountId };
@@ -651,7 +679,7 @@ export class MasterLock {
651
679
  // phone left on the counter locks on its own schedule while the desk stays open —
652
680
  // which is decision 2 applied per device rather than per person.
653
681
  let slid = false;
654
- for (const presented of presentedTokens(req)) {
682
+ for (const presented of presentedTokens(req).map(tokenDigest)) {
655
683
  for (const [live, session] of this.sessions) {
656
684
  // No early `return`, for the reason `accountFor` gives: the loop's duration
657
685
  // must not say which of the jar's cookies was this app's.
@@ -661,7 +689,10 @@ export class MasterLock {
661
689
  }
662
690
  }
663
691
  }
664
- if (slid) this.scheduleExpiry();
692
+ if (slid) {
693
+ this.saveState();
694
+ this.scheduleExpiry();
695
+ }
665
696
  return slid;
666
697
  }
667
698
 
@@ -673,6 +704,7 @@ export class MasterLock {
673
704
  // owner's requirement is "no one can see anything until the master password is
674
705
  // entered again", and a lock that left the phone open would not be that.
675
706
  this.sessions.clear();
707
+ this.saveState();
676
708
  this.clearExpiry();
677
709
  this.onLockedChange?.(true);
678
710
  }
@@ -762,6 +794,38 @@ export class MasterLock {
762
794
  this.clearExpiry();
763
795
  }
764
796
 
797
+ /** Adopt the persisted live state, if there is a store and it holds a readable one. */
798
+ private loadState(): void {
799
+ if (!this.stateStore) return;
800
+ const raw = this.stateStore.read();
801
+ if (!raw) return;
802
+ try {
803
+ const parsed = JSON.parse(raw) as {
804
+ v?: number;
805
+ sessions?: Array<[string, MasterLockSession]>;
806
+ failures?: number;
807
+ lastFailureAt?: number;
808
+ };
809
+ if (parsed.v !== 1) return;
810
+ for (const [digest, session] of parsed.sessions ?? []) {
811
+ if (typeof digest === "string" && typeof session?.accountId === "string" && Number.isFinite(session.lastActivityAt)) {
812
+ this.sessions.set(digest, { accountId: session.accountId, lastActivityAt: session.lastActivityAt });
813
+ }
814
+ }
815
+ this.failures = Number.isFinite(parsed.failures) ? Number(parsed.failures) : 0;
816
+ this.lastFailureAt = Number.isFinite(parsed.lastFailureAt) ? Number(parsed.lastFailureAt) : 0;
817
+ } catch {
818
+ // Unreadable state is LOCKED state: nothing is open, which is the safe reading.
819
+ }
820
+ }
821
+
822
+ /** Persist the live state — every mutation of it calls this, or a Worker forgets it. */
823
+ private saveState(): void {
824
+ this.stateStore?.write(
825
+ JSON.stringify({ v: 1, sessions: [...this.sessions], failures: this.failures, lastFailureAt: this.lastFailureAt }),
826
+ );
827
+ }
828
+
765
829
  /** Retire a session whose idle window has passed. Called on every read of the state, so
766
830
  * correctness never depends on the timer having fired. */
767
831
  private sweep(): void {
@@ -769,10 +833,12 @@ export class MasterLock {
769
833
  if (!record || this.sessions.size === 0) return;
770
834
  const now = this.now();
771
835
  const wasOpen = this.sessions.size > 0;
836
+ const before = this.sessions.size;
772
837
  for (const [token, session] of this.sessions) {
773
838
  // Each device expires on its OWN clock, so one going idle never shortens another.
774
839
  if (now - session.lastActivityAt > record.idleMs) this.sessions.delete(token);
775
840
  }
841
+ if (this.sessions.size !== before) this.saveState();
776
842
  if (wasOpen && this.sessions.size === 0) {
777
843
  this.clearExpiry();
778
844
  this.onLockedChange?.(true);
@@ -963,6 +1029,11 @@ async function equalize(candidate: string): Promise<void> {
963
1029
  await Bun.password.verify(candidate || "x", await equalizerHash).catch(() => false);
964
1030
  }
965
1031
 
1032
+ /** What a session is KEYED by — never the token itself, so persisted state opens nothing. */
1033
+ function tokenDigest(token: string): string {
1034
+ return createHash("sha256").update(token).digest("base64url");
1035
+ }
1036
+
966
1037
  function randomToken(): string {
967
1038
  const bytes = new Uint8Array(TOKEN_BYTES);
968
1039
  crypto.getRandomValues(bytes);
@@ -11,6 +11,12 @@ import type { MasterLockStore } from "./masterLock";
11
11
  /** The default key, so two apps' rows look the same when somebody goes looking. */
12
12
  export const MASTER_LOCK_SETTING_KEY = "master_lock:record";
13
13
 
14
+ /**
15
+ * The key the LIVE state (open unlocks, the throttle) is kept under, beside the record — see
16
+ * `MasterLockStateStore`. `createKvMasterLockStore(kv, MASTER_LOCK_STATE_KEY)` is that store.
17
+ */
18
+ export const MASTER_LOCK_STATE_KEY = "master_lock:state";
19
+
14
20
  /** Wrap an app's existing key/value settings surface. */
15
21
  export function createKvMasterLockStore(
16
22
  kv: { get(key: string): string | null | undefined; set(key: string, value: string): void },