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.
- package/dist/server/analytics/webAnalytics.d.ts +32 -3
- package/dist/server/analytics/webAnalytics.js +40 -6
- package/dist/server/bench/cpuClock.d.ts +4 -0
- package/dist/server/bench/cpuClock.js +57 -3
- package/dist/server/bench/runBench.d.ts +27 -0
- package/dist/server/bench/runBench.js +42 -2
- package/dist/server/d1/index.d.ts +1 -1
- package/dist/server/d1/index.js +1 -1
- package/dist/server/d1/invocation.d.ts +72 -0
- package/dist/server/d1/invocation.js +65 -0
- package/dist/server/master-lock/guard.d.ts +8 -3
- package/dist/server/master-lock/guard.js +8 -3
- package/package.json +1 -1
- package/src/nodeBuiltinsAreProbedByCalling.spec.ts +233 -0
- package/src/publicSurface.spec.ts +88 -0
- 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/bench/cpuBudget.spec.ts +191 -1
- package/src/server/bench/cpuClock.spec.ts +141 -0
- package/src/server/bench/cpuClock.ts +55 -2
- package/src/server/bench/runBench.ts +73 -2
- package/src/server/d1/index.ts +1 -1
- package/src/server/d1/invocation.spec.ts +65 -1
- package/src/server/d1/invocation.ts +86 -0
- package/src/server/incidents/incidents.spec.ts +5 -1
- package/src/server/master-lock/guard.ts +8 -3
|
@@ -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("
|
|
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
|
}
|