cursedbelt-server 4.9.0 โ†’ 4.11.1

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.
@@ -0,0 +1,233 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * ๐Ÿ”ด **No `typeof x === 'function'` gate in front of a Node built-in. Probe by CALLING.**
7
+ *
8
+ * Measured 2026-09-19, the first real deploy of a cursedbelt app onto Workers:
9
+ * `https://collections.curtwphillips.workers.dev/healthz` answered `500` on **every path**,
10
+ * with 843 green tests behind it. One line did it โ€” `bench/cpuClock.ts`:
11
+ *
12
+ * ```ts
13
+ * const usable = typeof process !== 'undefined' && typeof process.cpuUsage === 'function';
14
+ * ```
15
+ *
16
+ * Under `nodejs_compat`, workerd's strategy for the parts of `node:process` it does not
17
+ * implement is to PROVIDE the method and throw when it is called:
18
+ *
19
+ * ```
20
+ * Error [ERR_METHOD_NOT_IMPLEMENTED]: The process.cpuUsage method is not implemented
21
+ * at process.cpuUsage (node-internal:public_process:235:11)
22
+ * at Object.start (index.js:2772:30) <- cpuBudget's middleware
23
+ * ```
24
+ *
25
+ * So the availability probe answered *yes* about the one runtime it exists to answer *no*
26
+ * about, and because `cpuBudget()` is mounted first by design, the throw landed in front of
27
+ * the whole app.
28
+ *
29
+ * ๐Ÿ”ด **That is a capability CLASS, not one method.** `nodejs_compat` stubs a great deal of
30
+ * `node:process`, `node:os` and `node:v8` the same way, and five apps have a "move to a
31
+ * Worker" task queued behind this library. `cpuClock.ts` is fixed; this is what stops the
32
+ * shape coming back somewhere else, where the next deploy would find it instead of a test.
33
+ *
34
+ * ## What it measures
35
+ *
36
+ * For every non-spec source file: blank the comments and string/template literals, work out
37
+ * which identifiers name a Node built-in (the `process` global, plus every binding imported
38
+ * from a `node:*` module, static or dynamic), and red on `typeof <that>.โ€ฆ === 'function'` in
39
+ * either direction and either operand order.
40
+ *
41
+ * `typeof x === 'undefined'` is deliberately NOT flagged: "is there a `process` at all" is a
42
+ * question a stub cannot lie about, and it is the guard that stops a bare reference throwing
43
+ * a `ReferenceError` before any `try` can catch it. What is forbidden is concluding a method
44
+ * WORKS because it EXISTS.
45
+ *
46
+ * ## The excuse, and why there is one
47
+ *
48
+ * A line carrying `probe-by-calling:ignore` (on it, or on the line above) is allowed โ€” for
49
+ * the one honest use left: labelling. `cpuClock.ts` decides by calling and then uses a
50
+ * presence check only to say `(absent)` or `(throws)` in the clock's source string. Excuse
51
+ * the LINE, never delete the rule.
52
+ *
53
+ * ## Verified failing before it was trusted, 2026-09-19
54
+ *
55
+ * Four probes, each written to a scratch `src/server/bench/probe.agent.ts`, run, and deleted:
56
+ *
57
+ * ยท the pre-fix line itself, `typeof process !== 'undefined' && typeof process.cpuUsage
58
+ * === 'function'` โ†’ **red**, `1 pass, 1 fail`, naming
59
+ * `server/bench/probe.agent.ts:1 โ€” typeof process.cpuUsage === 'function'`
60
+ * ยท the same line under a `probe-by-calling:ignore` comment โ†’ green โ€” the excuse working
61
+ * ยท `typeof process !== 'undefined'` alone โ†’ green โ€” the carve-out working
62
+ * ยท `import { homedir } from 'node:os'` + `typeof homedir === 'function'` โ†’ **red**, naming
63
+ * line 2. That is the import-tracking half, which the `process` global never exercises.
64
+ *
65
+ * ๐Ÿ”ด The first probe is why there was a rewrite rather than a lucky green: run against the
66
+ * scanner's first draft it PASSED, because that draft blanked string literals along with
67
+ * comments and the pattern it hunts for ends in the literal `'function'`. A check whose
68
+ * first probe is green is a check nobody has seen work โ€” see {@link stripComments}.
69
+ */
70
+
71
+ const SRC = fileURLToPath(new URL('.', import.meta.url));
72
+
73
+ /** `typeof A.b.c === 'function'`, and the three other ways to write it. */
74
+ const TYPEOF_FUNCTION =
75
+ /typeof\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*[!=]==?\s*['"]function['"]|['"]function['"]\s*[!=]==?\s*typeof\s+([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)/g;
76
+
77
+ /** The one global that is a Node built-in without being imported. */
78
+ const GLOBAL_NODE_BUILTINS = new Set(['process']);
79
+
80
+ const IGNORE_MARKER = 'probe-by-calling:ignore';
81
+
82
+ /**
83
+ * Blank COMMENTS, preserving line structure and every string literal, so a scanner is not
84
+ * fooled by prose.
85
+ *
86
+ * ๐Ÿ”ด Both halves of that are load-bearing, and each was found by a probe rather than by
87
+ * reasoning. Comments must go: this package is comment-dense and `cpuClock.ts`'s own header
88
+ * QUOTES the forbidden line as the bug it fixed, so a comment-blind scan reds on the fix.
89
+ * Strings must STAY: the pattern this looks for ends in `'function'`, which IS a string
90
+ * literal โ€” blanking literals made the check silently unable to match anything at all, and
91
+ * a probe file carrying the exact pre-fix line passed. Strings are still TRACKED (never
92
+ * blanked) so that a `//` inside one โ€” `'https://โ€ฆ'` โ€” cannot be read as a comment.
93
+ */
94
+ function stripComments(source: string): string {
95
+ let out = '';
96
+ let i = 0;
97
+ while (i < source.length) {
98
+ const c = source[i];
99
+ const next = source[i + 1];
100
+ if (c === '/' && next === '/') {
101
+ while (i < source.length && source[i] !== '\n') {
102
+ out += ' ';
103
+ i += 1;
104
+ }
105
+ continue;
106
+ }
107
+ if (c === '/' && next === '*') {
108
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
109
+ out += source[i] === '\n' ? '\n' : ' ';
110
+ i += 1;
111
+ }
112
+ out += ' ';
113
+ i += 2;
114
+ continue;
115
+ }
116
+ if (c === '"' || c === "'" || c === '`') {
117
+ const quote = c;
118
+ out += c;
119
+ i += 1;
120
+ while (i < source.length && source[i] !== quote) {
121
+ if (source[i] === '\\') {
122
+ out += source[i];
123
+ i += 1;
124
+ if (i < source.length) {
125
+ out += source[i];
126
+ i += 1;
127
+ }
128
+ continue;
129
+ }
130
+ out += source[i];
131
+ i += 1;
132
+ }
133
+ out += source[i] ?? '';
134
+ i += 1;
135
+ continue;
136
+ }
137
+ out += c;
138
+ i += 1;
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /** Every local name in this file that refers to something out of a `node:*` module. */
144
+ function nodeBuiltinRoots(code: string): Set<string> {
145
+ const roots = new Set(GLOBAL_NODE_BUILTINS);
146
+ // `import x, { a, b as c } from 'node:os'` / `import * as os from 'node:os'`
147
+ // and `const { homedir } = await import('node:os')`.
148
+ const statics = /import\s+([\s\S]*?)\s+from\s+['"]node:[^'"]+['"]/g;
149
+ const dynamics =
150
+ /(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*await\s+import\s*\(\s*['"]node:/g;
151
+ // ๐Ÿ”ด Read from the RAW source, not the stripped copy: the module specifier is a string
152
+ // literal, so stripping blanks the very `'node:os'` this has to match. A binding named
153
+ // only inside a comment widens the rule by one identifier, which is the safe direction.
154
+ for (const m of code.matchAll(statics)) collectBindings(m[1] ?? '', roots);
155
+ for (const m of code.matchAll(dynamics)) collectBindings(m[1] ?? '', roots);
156
+ return roots;
157
+ }
158
+
159
+ /** Pull the local names out of an import clause: `x, { a, b as c }`, `* as ns`, `{ a }`. */
160
+ function collectBindings(clause: string, into: Set<string>): void {
161
+ for (const part of clause.replace(/[{}]/g, ',').split(',')) {
162
+ const token = part.trim();
163
+ if (!token || token === 'type') continue;
164
+ const aliased = token.match(/\bas\s+([A-Za-z_$][\w$]*)$/);
165
+ const name = aliased ? aliased[1] : token.replace(/^type\s+/, '');
166
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) into.add(name);
167
+ }
168
+ }
169
+
170
+ function sourceFiles(dir: string, found: string[] = []): string[] {
171
+ for (const entry of readdirSync(dir)) {
172
+ const full = `${dir}${entry}`;
173
+ if (statSync(full).isDirectory()) {
174
+ sourceFiles(`${full}/`, found);
175
+ continue;
176
+ }
177
+ // Specs are excluded: a test may legitimately assert about a probe, and a `typeof`
178
+ // gate in one cannot 500 a deployed app.
179
+ if (entry.endsWith('.ts') && !entry.endsWith('.spec.ts')) found.push(full);
180
+ }
181
+ return found;
182
+ }
183
+
184
+ interface Violation {
185
+ file: string;
186
+ line: number;
187
+ text: string;
188
+ }
189
+
190
+ function scan(): Violation[] {
191
+ const violations: Violation[] = [];
192
+ for (const file of sourceFiles(SRC)) {
193
+ const raw = readFileSync(file, 'utf8');
194
+ const rawLines = raw.split('\n');
195
+ const code = stripComments(raw);
196
+ const roots = nodeBuiltinRoots(raw);
197
+ code.split('\n').forEach((line, index) => {
198
+ for (const m of line.matchAll(TYPEOF_FUNCTION)) {
199
+ const subject = (m[1] ?? m[2] ?? '').replace(/\s+/g, '');
200
+ const root = subject.split('.')[0];
201
+ if (!roots.has(root)) continue;
202
+ const here = rawLines[index] ?? '';
203
+ const above = rawLines[index - 1] ?? '';
204
+ if (here.includes(IGNORE_MARKER) || above.includes(IGNORE_MARKER)) continue;
205
+ violations.push({
206
+ file: file.slice(SRC.length),
207
+ line: index + 1,
208
+ text: `typeof ${subject} === 'function'`,
209
+ });
210
+ }
211
+ });
212
+ }
213
+ return violations;
214
+ }
215
+
216
+ describe('Node built-ins are probed by CALLING, never by typeof', () => {
217
+ it('has a real tree to scan โ€” otherwise every assertion below passes vacuously', () => {
218
+ const files = sourceFiles(SRC);
219
+ expect(files.length).toBeGreaterThan(100);
220
+ // The scanner must actually resolve `node:*` imports, or the import half is dead.
221
+ const withOsImport = files.find((f) => f.endsWith('server/sqlite/export.ts'));
222
+ expect(withOsImport).toBeTruthy();
223
+ expect(nodeBuiltinRoots(readFileSync(withOsImport as string, 'utf8')).has('tmpdir')).toBe(true);
224
+ });
225
+
226
+ it('finds no typeof gate in front of a Node built-in anywhere in src', () => {
227
+ const violations = scan();
228
+ const message = violations.map((v) => ` ${v.file}:${v.line} โ€” ${v.text}`).join('\n');
229
+ expect(
230
+ violations.length === 0 ? '' : `\n${message}\n\nProbe by calling, inside a try.\n`,
231
+ ).toBe('');
232
+ });
233
+ });
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { run } from '../scripts/publicSurface';
3
+
4
+ /**
5
+ * The public-surface ratchet, wired into `bun run verify` at its cheapest correct
6
+ * home: `verify` ends in `bun test src`, so this needs no new script and cannot be a
7
+ * script somebody forgot to chain.
8
+ *
9
+ * WHY it exists, WHAT a symbol count includes and excludes, why the dependency list
10
+ * and the used-vs-exported ratio are deliberately NOT computed here, and what
11
+ * `--prune` may and may not do are all in the header of `scripts/publicSurface.ts` โ€”
12
+ * one check, one baseline (`publicSurface.baseline`), one place that explains itself.
13
+ * This file is only the wiring.
14
+ *
15
+ * ๐Ÿ”ด Verified failing, 2026-09-19, before it was trusted โ€” every branch, not just the
16
+ * easy one. Each probe was applied, observed, and removed again:
17
+ *
18
+ * ยท `export const ratchetProbe = 1` appended to `src/server/errors.ts`
19
+ * โ†’ this file's "ceiling" test red โ€” `3 pass, 1 fail`, "exports no more symbols per
20
+ * subpath than the baseline allows" โ€” and the CLI:
21
+ * "1 subpath(s) that gained exported symbols ยท ./errors โ€” baseline allows 14,
22
+ * exports 15 now"
23
+ * ยท the same line appended to `src/server/guard/revocationStore.ts`, which is where the
24
+ * transitive `export *` walk is actually visible: `./guard/revocations` points at that
25
+ * file and `./guard`'s barrel does `export * from './revocationStore'`
26
+ * โ†’ "2 subpath(s) that gained exported symbols ยท ./guard โ€” baseline allows 25,
27
+ * exports 26 now ยท ./guard/revocations โ€” baseline allows 7, exports 8 now".
28
+ * ๐Ÿ”ด TWO from one line, which is the walk the header promises, seen working. Note
29
+ * which probe does NOT do this: `.` is a barrel of 219 NAMED re-exports, so a new
30
+ * symbol in `errors.ts` does not reach it. A probe chosen in the wrong module
31
+ * would have "proved" the transitive walk by never exercising it.
32
+ * ยท a `"./probe"` subpath added to `package.json` `exports`, pointed at
33
+ * `./src/server/errors.ts`
34
+ * โ†’ "1 export subpath(s) the baseline does not know ยท ./probe โ€” NEW subpath,
35
+ * 14 exported symbol(s)"
36
+ * ยท `VALIDATION_STATUS` removed from the re-export list in `src/server/errors.ts`, no prune
37
+ * โ†’ "1 baseline entr(y|ies) that no longer match `exports` ยท ./errors โ€” baseline
38
+ * says 14, exports 13"
39
+ * ยท a `5 ./gone` line added to the baseline by hand
40
+ * โ†’ same failure, "./gone โ€” baseline says 5, the subpath is gone". An entry
41
+ * matching nothing is the half that stops a deleted module leaving an allowance.
42
+ * ยท `--prune` run against the grown surface
43
+ * โ†’ "refuses while the surface has GROWN โ€” it may only lower the baseline", exit 1
44
+ * ยท `--prune` run against the shrunk surface
45
+ * โ†’ "pruned to 34 subpath(s), 0 removed, 990 symbols", exit 0, and `14 ./errors`
46
+ * became `13 ./errors` in the file โ€” the burn-down path
47
+ * ยท `--seed` run over the seeded baseline
48
+ * โ†’ "refuses: โ€ฆ already records 34 subpath(s)", exit 1
49
+ *
50
+ * Every probe was reverted with `git checkout -- <path>`; the tree afterwards held only
51
+ * this file, `scripts/publicSurface.ts` and `publicSurface.baseline`.
52
+ *
53
+ * A ratchet nobody has seen red is a ratchet nobody knows is wired up.
54
+ */
55
+ const result = await run();
56
+
57
+ describe('public surface', () => {
58
+ it('has measured a real surface', () => {
59
+ // Guards the three assertions below: if `exports` were ever read as empty, every
60
+ // one of them passes vacuously and the ratchet quietly dies.
61
+ // ๐Ÿ”ด 20, not 34: a vacuity guard set at the real surface fails for the one reason
62
+ // a vacuity guard must never fail โ€” the surface being healthy and simply smaller.
63
+ expect(result.surface.counts.size).toBeGreaterThan(20);
64
+ });
65
+
66
+ it('publishes no export subpath the baseline does not know', () => {
67
+ expect(
68
+ result.fresh,
69
+ `new public subpath(s) โ€” a permanent promise. Add the line to publicSurface.baseline and say why:\n ${result.fresh.join('\n ')}`,
70
+ ).toEqual([]);
71
+ });
72
+
73
+ it('exports no more symbols per subpath than the baseline allows', () => {
74
+ expect(
75
+ result.grown,
76
+ `a baseline entry is a ceiling, not an amnesty:\n ${result.grown.join('\n ')}`,
77
+ ).toEqual([]);
78
+ });
79
+
80
+ it('has no baseline entry that outlived the surface it recorded', () => {
81
+ // The half that makes it a RATCHET: a deleted module may not leave an allowance
82
+ // behind for the next one to grow into.
83
+ expect(
84
+ result.stale,
85
+ `surface came off and the ratchet was not tightened โ€” run \`bun scripts/publicSurface.ts --prune\`:\n ${result.stale.join('\n ')}`,
86
+ ).toEqual([]);
87
+ });
88
+ });
@@ -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
  }