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.
- package/dist/server/analytics/webAnalytics.d.ts +32 -3
- package/dist/server/analytics/webAnalytics.js +40 -6
- package/dist/server/master-lock/guard.d.ts +8 -3
- package/dist/server/master-lock/guard.js +8 -3
- package/dist/server/master-lock/index.d.ts +1 -1
- package/dist/server/master-lock/index.js +1 -1
- package/dist/server/master-lock/masterLock.d.ts +26 -49
- package/dist/server/master-lock/masterLock.js +47 -4
- package/dist/server/master-lock/store.d.ts +5 -0
- package/dist/server/master-lock/store.js +5 -0
- package/package.json +1 -1
- package/src/server/analytics/injectedScripts.spec.ts +11 -7
- package/src/server/analytics/webAnalytics.spec.ts +26 -7
- package/src/server/analytics/webAnalytics.ts +43 -6
- package/src/server/incidents/incidents.spec.ts +5 -1
- package/src/server/master-lock/guard.ts +8 -3
- package/src/server/master-lock/index.ts +1 -0
- package/src/server/master-lock/masterLock.spec.ts +57 -0
- package/src/server/master-lock/masterLock.ts +75 -4
- package/src/server/master-lock/store.ts +6 -0
|
@@ -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
|
|
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
|
|
108
|
-
* token — the
|
|
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
|
|
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='
|
|
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", '
|
|
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
|
|
126
|
-
* token — the
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
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("
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
25
|
-
expect(
|
|
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(
|
|
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='
|
|
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
|
|
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='
|
|
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", '
|
|
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
|
|
137
|
-
* token — the
|
|
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
|
-
|
|
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
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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:
|
|
@@ -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)
|
|
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 },
|