cursedbelt-server 4.11.0 → 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.
@@ -48,7 +48,29 @@ export declare const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc"
48
48
  /** The `cursedalchemy.com` site's `data-cf-beacon` token. This is what the tag carries. */
49
49
  export declare const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
50
50
  /**
51
- * The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
51
+ * 🔴 The `version` the beacon's config must carry, or NOTHING is recorded.
52
+ *
53
+ * Measured 2026-09-22, after the RUM API showed every manual-snippet shell recording ZERO
54
+ * pageviews from 2026-09-19 on — collections, music, roms, flix, station and desk, which had
55
+ * been 130–290 a day between them — while the hosts still on the edge's injection kept
56
+ * recording. The beacon copies this value into its payload as `versions.fl`, and
57
+ * `cloudflareinsights.com/cdn-cgi/rum` answers a payload without `fl` with a **404 that carries
58
+ * no CORS header**, which a browser prints as a CORS refusal and nobody reads as "recorded
59
+ * nothing". Same token, same src, driven in a real browser from a `*.cursedalchemy.com` origin:
60
+ * `{"token": …}` → 404 every time; `{"version": "2024.11.0", "token": …}` → 204 every time,
61
+ * whether the script path was pinned or not. Any value was accepted; this one is what the
62
+ * edge's own injection carries, so it is the value Cloudflare is known to send.
63
+ *
64
+ * Cloudflare's own `site_info` snippet OMITS it, so "exactly the vendor's text" was the
65
+ * defect, not the safety it looked like. `tools/check-web-analytics.ts` reds a hand copy
66
+ * without it.
67
+ */
68
+ export declare const WEB_ANALYTICS_BEACON_VERSION = "2024.11.0";
69
+ /** The `data-cf-beacon` value — one spelling for the tag, the bootstrap and every hand copy. */
70
+ export declare function webAnalyticsBeaconConfig(token?: string): string;
71
+ /**
72
+ * The snippet as Cloudflare's own `site_info` endpoint spells it — plus the `version` key it
73
+ * leaves out, without which nothing is recorded (see {@link WEB_ANALYTICS_BEACON_VERSION}).
52
74
  *
53
75
  * 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
54
76
  * style choice, and they are kept because this string is COMPARED against what an app's
@@ -104,7 +126,14 @@ export declare function webAnalyticsBootstrap(token?: string): string;
104
126
  *
105
127
  * Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
106
128
  * hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
107
- * What must be true is that the beacon's source is there AND that it is carrying the right
108
- * token — the two halves that decide whether a pageview is recorded at all.
129
+ * What must be true is that the beacon's source is there, that it is carrying the right
130
+ * token, and that its config carries a `version` — the three things that decide whether a
131
+ * pageview is recorded at all. The third was missed until 2026-09-22 and cost four days of
132
+ * every manual shell's data; see {@link WEB_ANALYTICS_BEACON_VERSION}.
109
133
  */
110
134
  export declare function htmlCarriesWebAnalytics(html: string, token?: string): boolean;
135
+ /**
136
+ * Every `data-cf-beacon` config in `html` names a `version` — and there is at least one.
137
+ * Read up to the config's closing brace, so key order and a formatter's spacing do not matter.
138
+ */
139
+ export declare function beaconConfigsCarryVersion(html: string): boolean;
@@ -48,7 +48,31 @@ export const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc";
48
48
  /** The `cursedalchemy.com` site's `data-cf-beacon` token. This is what the tag carries. */
49
49
  export const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
50
50
  /**
51
- * The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
51
+ * 🔴 The `version` the beacon's config must carry, or NOTHING is recorded.
52
+ *
53
+ * Measured 2026-09-22, after the RUM API showed every manual-snippet shell recording ZERO
54
+ * pageviews from 2026-09-19 on — collections, music, roms, flix, station and desk, which had
55
+ * been 130–290 a day between them — while the hosts still on the edge's injection kept
56
+ * recording. The beacon copies this value into its payload as `versions.fl`, and
57
+ * `cloudflareinsights.com/cdn-cgi/rum` answers a payload without `fl` with a **404 that carries
58
+ * no CORS header**, which a browser prints as a CORS refusal and nobody reads as "recorded
59
+ * nothing". Same token, same src, driven in a real browser from a `*.cursedalchemy.com` origin:
60
+ * `{"token": …}` → 404 every time; `{"version": "2024.11.0", "token": …}` → 204 every time,
61
+ * whether the script path was pinned or not. Any value was accepted; this one is what the
62
+ * edge's own injection carries, so it is the value Cloudflare is known to send.
63
+ *
64
+ * Cloudflare's own `site_info` snippet OMITS it, so "exactly the vendor's text" was the
65
+ * defect, not the safety it looked like. `tools/check-web-analytics.ts` reds a hand copy
66
+ * without it.
67
+ */
68
+ export const WEB_ANALYTICS_BEACON_VERSION = "2024.11.0";
69
+ /** The `data-cf-beacon` value — one spelling for the tag, the bootstrap and every hand copy. */
70
+ export function webAnalyticsBeaconConfig(token = WEB_ANALYTICS_SITE_TOKEN) {
71
+ return `{"version": "${WEB_ANALYTICS_BEACON_VERSION}", "token": "${token}"}`;
72
+ }
73
+ /**
74
+ * The snippet as Cloudflare's own `site_info` endpoint spells it — plus the `version` key it
75
+ * leaves out, without which nothing is recorded (see {@link WEB_ANALYTICS_BEACON_VERSION}).
52
76
  *
53
77
  * 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
54
78
  * style choice, and they are kept because this string is COMPARED against what an app's
@@ -62,7 +86,7 @@ export const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
62
86
  export function webAnalyticsTag(token = WEB_ANALYTICS_SITE_TOKEN) {
63
87
  return ("<!-- Cloudflare Web Analytics -->" +
64
88
  `<script type='module' src='${WEB_ANALYTICS_BEACON_SRC}' ` +
65
- `data-cf-beacon='{"token": "${token}"}'></script>` +
89
+ `data-cf-beacon='${webAnalyticsBeaconConfig(token)}'></script>` +
66
90
  "<!-- End Cloudflare Web Analytics -->");
67
91
  }
68
92
  /** The fleet's tag, for the one site this generation serves. */
@@ -111,7 +135,7 @@ export function webAnalyticsBootstrap(token = WEB_ANALYTICS_SITE_TOKEN) {
111
135
  `\t\tvar beacon = document.createElement("script");`,
112
136
  `\t\tbeacon.type = "module";`,
113
137
  `\t\tbeacon.src = "${WEB_ANALYTICS_BEACON_SRC}";`,
114
- `\t\tbeacon.setAttribute("data-cf-beacon", '{"token": "${token}"}');`,
138
+ `\t\tbeacon.setAttribute("data-cf-beacon", '${webAnalyticsBeaconConfig(token)}');`,
115
139
  `\t\tdocument.head.appendChild(beacon);`,
116
140
  "\t}",
117
141
  "</script>",
@@ -122,9 +146,19 @@ export function webAnalyticsBootstrap(token = WEB_ANALYTICS_SITE_TOKEN) {
122
146
  *
123
147
  * Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
124
148
  * hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
125
- * What must be true is that the beacon's source is there AND that it is carrying the right
126
- * token — the two halves that decide whether a pageview is recorded at all.
149
+ * What must be true is that the beacon's source is there, that it is carrying the right
150
+ * token, and that its config carries a `version` — the three things that decide whether a
151
+ * pageview is recorded at all. The third was missed until 2026-09-22 and cost four days of
152
+ * every manual shell's data; see {@link WEB_ANALYTICS_BEACON_VERSION}.
127
153
  */
128
154
  export function htmlCarriesWebAnalytics(html, token = WEB_ANALYTICS_SITE_TOKEN) {
129
- return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token);
155
+ return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token) && beaconConfigsCarryVersion(html);
156
+ }
157
+ /**
158
+ * Every `data-cf-beacon` config in `html` names a `version` — and there is at least one.
159
+ * Read up to the config's closing brace, so key order and a formatter's spacing do not matter.
160
+ */
161
+ export function beaconConfigsCarryVersion(html) {
162
+ const configs = [...html.matchAll(/data-cf-beacon[^{]{0,24}(\{[^}]*\})/g)].map((m) => m[1] ?? "");
163
+ return configs.length > 0 && configs.every((c) => /"version"\s*:\s*"[^"]+"/.test(c));
130
164
  }
@@ -51,9 +51,14 @@ export interface MasterLockGuardOptions extends LockPageOptions {
51
51
  * loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
52
52
  * script does the POST), and the page cannot be framed.
53
53
  *
54
- * 🔴 **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
55
- * duplicate of this string in a test until 2026-09-18, which is a policy that can drift
56
- * without anything going red. It is also what
54
+ * 🔴 **EXPORTED so that nothing has to copy it** — and exporting it is not the same as nothing
55
+ * copying it. This sentence used to read *"`apps/collections` had a hand-typed duplicate … until
56
+ * 2026-09-18"*, and it was false the day it was written: the export landed, and BOTH re-typed
57
+ * copies stayed — one in `apps/collections/scripts/injectedScripts.test.ts` and one three
58
+ * directories from here in `../analytics/injectedScripts.spec.ts`. They were collapsed onto this
59
+ * constant on 2026-09-21. A policy that is typed twice can be loosened in one place and stay
60
+ * green in the other, which is a wall that no longer refuses what its own test says it does.
61
+ * It is also what
57
62
  * `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
58
63
  * `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
59
64
  * exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
@@ -20,9 +20,14 @@ const noStore = { "cache-control": "no-store, no-cache, must-revalidate", pragma
20
20
  * loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
21
21
  * script does the POST), and the page cannot be framed.
22
22
  *
23
- * 🔴 **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
24
- * duplicate of this string in a test until 2026-09-18, which is a policy that can drift
25
- * without anything going red. It is also what
23
+ * 🔴 **EXPORTED so that nothing has to copy it** — and exporting it is not the same as nothing
24
+ * copying it. This sentence used to read *"`apps/collections` had a hand-typed duplicate … until
25
+ * 2026-09-18"*, and it was false the day it was written: the export landed, and BOTH re-typed
26
+ * copies stayed — one in `apps/collections/scripts/injectedScripts.test.ts` and one three
27
+ * directories from here in `../analytics/injectedScripts.spec.ts`. They were collapsed onto this
28
+ * constant on 2026-09-21. A policy that is typed twice can be loosened in one place and stay
29
+ * green in the other, which is a wall that no longer refuses what its own test says it does.
30
+ * It is also what
26
31
  * `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
27
32
  * `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
28
33
  * exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
@@ -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.0",
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.",
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { foreignScriptSources, scriptsRefusedByCsp } from "./injectedScripts";
3
+ import { MASTER_LOCK_CSP } from "../master-lock/guard";
3
4
  import { WEB_ANALYTICS_BEACON_ORIGIN, WEB_ANALYTICS_TAG } from "./webAnalytics";
4
5
 
5
6
  const PAGE = "https://collections.cursedalchemy.com/";
@@ -61,14 +62,17 @@ describe("scriptsRefusedByCsp", () => {
61
62
  /**
62
63
  * 🔴 The failure path this whole file exists for — the owner's console line, turned into a
63
64
  * refusal. `script-src 'self'` with something injecting is exactly what he pasted.
65
+ *
66
+ * 🔴 **`MASTER_LOCK_CSP` itself, never a hand-typed copy of it.** `guard.ts:91` exports the
67
+ * policy for exactly this reason and says so — *"EXPORTED so that nothing has to copy it"* —
68
+ * and until 2026-09-21 the two readers that mattered both re-typed the string anyway: this
69
+ * spec, and a byte-identical fork of this whole file in `apps/collections`. A policy that is
70
+ * typed twice can be loosened in one place and stay green in the other, which would leave
71
+ * this test passing about a CSP the wall no longer serves. Importing it means relaxing the
72
+ * real policy REDS here, which is the only version of this test worth having.
64
73
  */
65
- test("script-src 'self' refuses the injected beacon and names its origin", () => {
66
- const refused = scriptsRefusedByCsp(
67
- "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; " +
68
- "img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
69
- beacon,
70
- PAGE,
71
- );
74
+ test("the wall's own CSP refuses the injected beacon and names its origin", () => {
75
+ const refused = scriptsRefusedByCsp(MASTER_LOCK_CSP, beacon, PAGE);
72
76
  expect(refused.map((s) => s.origin)).toEqual([WEB_ANALYTICS_BEACON_ORIGIN]);
73
77
  });
74
78
 
@@ -2,27 +2,46 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  WEB_ANALYTICS_BEACON_ORIGIN,
4
4
  WEB_ANALYTICS_BEACON_SRC,
5
+ WEB_ANALYTICS_BEACON_VERSION,
5
6
  WEB_ANALYTICS_SITE_TAG,
6
7
  WEB_ANALYTICS_SITE_TOKEN,
7
8
  WEB_ANALYTICS_TAG,
9
+ beaconConfigsCarryVersion,
8
10
  htmlCarriesWebAnalytics,
11
+ webAnalyticsBeaconConfig,
9
12
  webAnalyticsBootstrap,
10
13
  webAnalyticsTag,
11
14
  } from "./webAnalytics";
12
15
 
13
16
  /**
14
17
  * 🔴 The snippet, as Cloudflare's own `GET /accounts/<acct>/rum/site_info/list` returned it on
15
- * 2026-09-18 for `cursedalchemy.com`, unescaped. Pinning it byte-for-byte is the point: this
16
- * is the thing a shell pastes, and the day it drifts from the vendor's text is a day nobody
17
- * would otherwise notice until a dashboard stopped filling.
18
+ * 2026-09-18 for `cursedalchemy.com`, unescaped — and it records NOTHING. Measured 2026-09-22:
19
+ * the RUM endpoint 404s every payload this config produces, and four days of every manual
20
+ * shell's pageviews went with it while this test pinned the vendor's text as the safe choice.
18
21
  */
19
22
  const FROM_CLOUDFLARE =
20
23
  `<!-- Cloudflare Web Analytics --><script type='module' src='https://static.cloudflareinsights.com/beacon.min.js' ` +
21
24
  `data-cf-beacon='{"token": "bd88d63678b64bc89f9702f48846bc88"}'></script><!-- End Cloudflare Web Analytics -->`;
22
25
 
26
+ /** The vendor's snippet with the one key it leaves out, which is what the fleet ships. */
27
+ const WHAT_RECORDS = FROM_CLOUDFLARE.replace(`'{"token": `, `'{"version": "2024.11.0", "token": `);
28
+
23
29
  describe("the fleet's beacon", () => {
24
- test("is exactly the snippet Cloudflare hands out for this site", () => {
25
- expect(WEB_ANALYTICS_TAG).toBe(FROM_CLOUDFLARE);
30
+ test("is Cloudflare's snippet plus the `version` the endpoint requires", () => {
31
+ expect(WHAT_RECORDS).not.toBe(FROM_CLOUDFLARE);
32
+ expect(WEB_ANALYTICS_TAG).toBe(WHAT_RECORDS);
33
+ expect(webAnalyticsBeaconConfig()).toBe(`{"version": "${WEB_ANALYTICS_BEACON_VERSION}", "token": "${WEB_ANALYTICS_SITE_TOKEN}"}`);
34
+ });
35
+
36
+ test("🔴 a config without `version` is refused — the vendor's own text among them", () => {
37
+ expect(htmlCarriesWebAnalytics(FROM_CLOUDFLARE)).toBe(false);
38
+ expect(beaconConfigsCarryVersion(FROM_CLOUDFLARE)).toBe(false);
39
+ // Two beacons on one page, one of them without it, is still a page that records nothing.
40
+ expect(beaconConfigsCarryVersion(WHAT_RECORDS + FROM_CLOUDFLARE)).toBe(false);
41
+ // The edge's own spelling — no spaces, other keys after — is accepted.
42
+ expect(beaconConfigsCarryVersion(`data-cf-beacon='{"version":"2024.11.0","token":"x","r":1,"spa":2}'`)).toBe(true);
43
+ // And a page with no config at all is not vacuously fine.
44
+ expect(beaconConfigsCarryVersion("<html></html>")).toBe(false);
26
45
  });
27
46
 
28
47
  test("carries the site TOKEN, never the site TAG", () => {
@@ -79,7 +98,7 @@ describe("the hostname guard", () => {
79
98
  for (const host of ["cursedalchemy.com", "collections.cursedalchemy.com", "vault.cursedalchemy.com"]) {
80
99
  const { appended, attrs } = runBootstrap(host);
81
100
  expect(appended, `${host} should load the beacon`).toEqual([WEB_ANALYTICS_BEACON_SRC]);
82
- expect(attrs["data-cf-beacon"]).toBe(`{"token": "${WEB_ANALYTICS_SITE_TOKEN}"}`);
101
+ expect(attrs["data-cf-beacon"]).toBe(webAnalyticsBeaconConfig());
83
102
  }
84
103
  });
85
104
 
@@ -113,7 +132,7 @@ describe("htmlCarriesWebAnalytics", () => {
113
132
  // A formatter is allowed to break the attributes across lines; the check must survive it.
114
133
  const rewrapped =
115
134
  `<script\n\ttype="module"\n\tsrc="${WEB_ANALYTICS_BEACON_SRC}"\n` +
116
- `\tdata-cf-beacon='{"token": "${WEB_ANALYTICS_SITE_TOKEN}"}'\n></script>`;
135
+ `\tdata-cf-beacon='${webAnalyticsBeaconConfig()}'\n></script>`;
117
136
  expect(htmlCarriesWebAnalytics(rewrapped)).toBe(true);
118
137
  });
119
138
 
@@ -53,7 +53,33 @@ export const WEB_ANALYTICS_SITE_TAG = "d1bea8562544421fbd2db5f11b74d1cc";
53
53
  export const WEB_ANALYTICS_SITE_TOKEN = "bd88d63678b64bc89f9702f48846bc88";
54
54
 
55
55
  /**
56
- * The snippet, exactly as Cloudflare's own `site_info` endpoint spells it.
56
+ * 🔴 The `version` the beacon's config must carry, or NOTHING is recorded.
57
+ *
58
+ * Measured 2026-09-22, after the RUM API showed every manual-snippet shell recording ZERO
59
+ * pageviews from 2026-09-19 on — collections, music, roms, flix, station and desk, which had
60
+ * been 130–290 a day between them — while the hosts still on the edge's injection kept
61
+ * recording. The beacon copies this value into its payload as `versions.fl`, and
62
+ * `cloudflareinsights.com/cdn-cgi/rum` answers a payload without `fl` with a **404 that carries
63
+ * no CORS header**, which a browser prints as a CORS refusal and nobody reads as "recorded
64
+ * nothing". Same token, same src, driven in a real browser from a `*.cursedalchemy.com` origin:
65
+ * `{"token": …}` → 404 every time; `{"version": "2024.11.0", "token": …}` → 204 every time,
66
+ * whether the script path was pinned or not. Any value was accepted; this one is what the
67
+ * edge's own injection carries, so it is the value Cloudflare is known to send.
68
+ *
69
+ * Cloudflare's own `site_info` snippet OMITS it, so "exactly the vendor's text" was the
70
+ * defect, not the safety it looked like. `tools/check-web-analytics.ts` reds a hand copy
71
+ * without it.
72
+ */
73
+ export const WEB_ANALYTICS_BEACON_VERSION = "2024.11.0";
74
+
75
+ /** The `data-cf-beacon` value — one spelling for the tag, the bootstrap and every hand copy. */
76
+ export function webAnalyticsBeaconConfig(token: string = WEB_ANALYTICS_SITE_TOKEN): string {
77
+ return `{"version": "${WEB_ANALYTICS_BEACON_VERSION}", "token": "${token}"}`;
78
+ }
79
+
80
+ /**
81
+ * The snippet as Cloudflare's own `site_info` endpoint spells it — plus the `version` key it
82
+ * leaves out, without which nothing is recorded (see {@link WEB_ANALYTICS_BEACON_VERSION}).
57
83
  *
58
84
  * 🔴 The single-quoted attributes and the space after `"token":` are Cloudflare's, not a
59
85
  * style choice, and they are kept because this string is COMPARED against what an app's
@@ -68,7 +94,7 @@ export function webAnalyticsTag(token: string = WEB_ANALYTICS_SITE_TOKEN): strin
68
94
  return (
69
95
  "<!-- Cloudflare Web Analytics -->" +
70
96
  `<script type='module' src='${WEB_ANALYTICS_BEACON_SRC}' ` +
71
- `data-cf-beacon='{"token": "${token}"}'></script>` +
97
+ `data-cf-beacon='${webAnalyticsBeaconConfig(token)}'></script>` +
72
98
  "<!-- End Cloudflare Web Analytics -->"
73
99
  );
74
100
  }
@@ -121,7 +147,7 @@ export function webAnalyticsBootstrap(token: string = WEB_ANALYTICS_SITE_TOKEN):
121
147
  `\t\tvar beacon = document.createElement("script");`,
122
148
  `\t\tbeacon.type = "module";`,
123
149
  `\t\tbeacon.src = "${WEB_ANALYTICS_BEACON_SRC}";`,
124
- `\t\tbeacon.setAttribute("data-cf-beacon", '{"token": "${token}"}');`,
150
+ `\t\tbeacon.setAttribute("data-cf-beacon", '${webAnalyticsBeaconConfig(token)}');`,
125
151
  `\t\tdocument.head.appendChild(beacon);`,
126
152
  "\t}",
127
153
  "</script>",
@@ -133,12 +159,23 @@ export function webAnalyticsBootstrap(token: string = WEB_ANALYTICS_SITE_TOKEN):
133
159
  *
134
160
  * Deliberately NOT a substring test against {@link WEB_ANALYTICS_TAG}: a shell is authored by
135
161
  * hand and a formatter is allowed to re-wrap the tag across lines or re-order its attributes.
136
- * What must be true is that the beacon's source is there AND that it is carrying the right
137
- * token — the two halves that decide whether a pageview is recorded at all.
162
+ * What must be true is that the beacon's source is there, that it is carrying the right
163
+ * token, and that its config carries a `version` — the three things that decide whether a
164
+ * pageview is recorded at all. The third was missed until 2026-09-22 and cost four days of
165
+ * every manual shell's data; see {@link WEB_ANALYTICS_BEACON_VERSION}.
138
166
  */
139
167
  export function htmlCarriesWebAnalytics(
140
168
  html: string,
141
169
  token: string = WEB_ANALYTICS_SITE_TOKEN,
142
170
  ): boolean {
143
- return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token);
171
+ return html.includes(WEB_ANALYTICS_BEACON_SRC) && html.includes(token) && beaconConfigsCarryVersion(html);
172
+ }
173
+
174
+ /**
175
+ * Every `data-cf-beacon` config in `html` names a `version` — and there is at least one.
176
+ * Read up to the config's closing brace, so key order and a formatter's spacing do not matter.
177
+ */
178
+ export function beaconConfigsCarryVersion(html: string): boolean {
179
+ const configs = [...html.matchAll(/data-cf-beacon[^{]{0,24}(\{[^}]*\})/g)].map((m) => m[1] ?? "");
180
+ return configs.length > 0 && configs.every((c) => /"version"\s*:\s*"[^"]+"/.test(c));
144
181
  }
@@ -30,7 +30,11 @@ const stuckIngest = (failed = 42): IncidentReport => ({
30
30
  title: "collections: every queued upload is failing",
31
31
  whatBroke: "The scheduled ingest ran and every one of its items failed.",
32
32
  whatItBlocks: "Nothing new reaches the collections app, and the queue keeps growing.",
33
- whatToDo: "cd ~/code/local/binary-server && bun run storage-drift",
33
+ // 🔴 Not a `cd ~/…`. This is a PUBLISHED library: a fixture here is read by whoever
34
+ // installs it, and the string that stood until 2026-09-22 named a directory on one
35
+ // machine that had not existed for days. `check-paths`'s `deadCd` rule is what found
36
+ // it, and a fixture is the one kind of violation that can simply be reworded.
37
+ whatToDo: "Run the storage-drift sweep for this tenant and clear the failed uploads.",
34
38
  facts: { failed, pending: failed },
35
39
  });
36
40
 
@@ -80,9 +80,14 @@ const noStore = { "cache-control": "no-store, no-cache, must-revalidate", pragma
80
80
  * loosening it: nothing loads from anywhere but this origin, the form posts nowhere (the
81
81
  * script does the POST), and the page cannot be framed.
82
82
  *
83
- * 🔴 **EXPORTED so that nothing has to copy it.** `apps/collections` had a hand-typed
84
- * duplicate of this string in a test until 2026-09-18, which is a policy that can drift
85
- * without anything going red. It is also what
83
+ * 🔴 **EXPORTED so that nothing has to copy it** — and exporting it is not the same as nothing
84
+ * copying it. This sentence used to read *"`apps/collections` had a hand-typed duplicate … until
85
+ * 2026-09-18"*, and it was false the day it was written: the export landed, and BOTH re-typed
86
+ * copies stayed — one in `apps/collections/scripts/injectedScripts.test.ts` and one three
87
+ * directories from here in `../analytics/injectedScripts.spec.ts`. They were collapsed onto this
88
+ * constant on 2026-09-21. A policy that is typed twice can be loosened in one place and stay
89
+ * green in the other, which is a wall that no longer refuses what its own test says it does.
90
+ * It is also what
86
91
  * `beaconNeverReachesTheWall.spec.ts` measures the beacon against: the wall refuses
87
92
  * `static.cloudflareinsights.com`, so the analytics tag and this response are mutually
88
93
  * exclusive BY CONSTRUCTION rather than by anyone remembering. The owner's reason, 2026-09-18:
@@ -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 },