Skip to content

Commit 40ecdf9

Browse files
feat(cli): the console static plugin takes a cloudflareWebAnalytics option that adds the beacon to the console document (#30961)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DR4cMbxTcpbSHbpgshgVCDr)_ domain:server#5 · os-elon-musk · session_016WV4cxGr3fDvcDkCLUwZAo Fixes #30936 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台静态插件 `createConsoleStaticPlugin` 新增一个选项 `cloudflareWebAnalytics: { token }`。传了它,控制台页面就带上 Cloudflare Web Analytics 的统计脚本(带页面的 nonce),安全策略的 `connect-src` 多放行一个上报地址 `https://cloudflareinsights.com`。不传,页面和安全策略逐字节不变。 - **为什么改**:目前没人知道托管控制台有多少人打开、从哪里来。cloud#3590 要加免费、无 cookie 的 Cloudflare 访问统计,但控制台页面和它的安全策略归框架,cloud 只能等框架给一个选项。做成选项而不是环境变量,是为了 cloud 只在控制平面传它,租户平面永远不带。 - **风险与回滚**:默认关闭,不传选项的部署零变化(测试逐字节钉住)。`script-src` 没有放宽,统计脚本靠 nonce 加载。token 必须是 32 位十六进制,否则启动即拒绝,且报错不回显 token。回滚即 revert。 - **先看**:① `packages/cli/src/utils/console.ts` 的 `readCloudflareWebAnalyticsToken` 与 `cloudflareWebAnalyticsBeaconScript`(校验与转义);② 同文件 `readIndexHtml` 里写入脚本的位置(必须在打 nonce 之前);③ `consoleContentSecurityPolicy` 的 `connect-src` 一行。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/console.content-security-policy.test.ts`,预期 66 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes `createConsoleStaticPlugin(distPath, options?)` takes one more option, `cloudflareWebAnalytics?: { token: string }`, also on the published `@objectstack/cli/console` subpath. All of it is in `packages/cli/src/utils/console.ts`: 1. **Refused at boot.** `start()` reads the option once, beside `readExtraConnectSources` and `readMapStyleOrigin`. A token that is not exactly 32 hexadecimal digits (either case), or a missing or non-string token, throws `Console static: the cloudflareWebAnalytics option's token is …, which is not a Cloudflare Web Analytics site token. …`. The message gives the value's length and kind, never the value, so a credential passed there by mistake does not land in the boot log. 2. **The beacon tag.** `readIndexHtml` writes, after the capability global and before `stampScriptNonce`, a deferred script element for `https://static.cloudflareinsights.com/beacon.min.js` whose `data-cf-beacon` attribute holds `JSON.stringify({ token })`, escaped with the file's existing `escapeHtml` (so the JSON's quotes are `&quot;`). The nonce pass then stamps it like every other script in the document. The token is fixed at boot, so the comment's invariant (nothing read at request time is written before the stamp) still holds. 3. **`connect-src`.** `consoleContentSecurityPolicy` takes `cloudflareWebAnalytics: boolean` and adds `https://cloudflareinsights.com` (`CLOUDFLARE_WEB_ANALYTICS_ORIGIN`) after the storage origin and before the operator's `OS_CONSOLE_CSP_CONNECT_SRC` sources. The policy jsdoc documents it the way `TURNSTILE_ORIGIN` is documented. 4. **`script-src` is unchanged.** No host is added for the beacon (the card's open point; triage 6685354963). See Acceptance notes for what was and was not measured. Without the option nothing is written: no tag, no origin, and the document and the header are byte-identical to `main`. `test/published-subpath-console.pin.test.ts` pins the subpath's exact signatures, so its `CreateConsoleStaticPluginKeepsItsRatifiedSignature` fixture carries the widened options type. ## Tests New `describe('the host\'s cloudflareWebAnalytics option')` in `packages/cli/src/utils/console.content-security-policy.test.ts`, on the real plugin and a real `HonoHttpServer`, as the rest of that file. `mount()` gained a `plugin` field for the plugin's own options. - With the option: exactly one beacon tag, carrying the header's nonce, `defer` and the exact `src`; its `data-cf-beacon`, decoded as a browser decodes it, is `{ token }`; five script tags, all nonced; `connect-src` is `'self'`, the default map style host and the report origin; `script-src` is exactly `'self'`, the nonce and `'wasm-unsafe-eval'`; the header never names `static.cloudflareinsights.com`. - Beside `OS_CONSOLE_CSP_CONNECT_SRC`: the operator's source follows the report origin. - Byte-identity, three ways (no options, the other two options only, the key left `undefined`): the served document and the policy, nonce written as `NONCE`, equal literals read from `main` at `f108e6d2` (captured with the unmodified module before the change; the literal was then checked equal to that capture). - Refusal: nine malformed tokens (empty, 31 and 33 hex digits, 32 non-hex characters, whitespace-padded, an attribute breakout, a JSON breakout, a number, `null`) and a missing token each reject the boot; the message never contains the token. - Breakout: the exact start tag, with the JSON's quotes as `&quot;`, followed by its own end tag. ## Acceptance notes - **`script-src` and the beacon: NOT MEASURED from this container.** The card asks whether the beacon loads anything further from `static.cloudflareinsights.com`. `curl https://static.cloudflareinsights.com/beacon.min.js` is refused by this environment's egress proxy (`CONNECT tunnel failed, response 403`), so the beacon's own code could not be read here. What holds without that reading: a nonce source admits the element that carries it, whatever its `src` and across redirects, so the beacon script itself loads under this policy. Whether it injects a further script element (which would carry no nonce and be refused) is the open point, and it stays with the seat's decision card, as triage 6685354963 and the claim direct. A browser load of a console served with the option, with the devtools console open for CSP reports, answers it in one step wherever that host is reachable. - **`Clause-②: yes (widening)`, against the claim's `no`.** Re-derived from the diff, as the claim asks: the change widens the public surface of a published subpath, the options bag of `createConsoleStaticPlugin`, which `published-subpath-console.pin.test.ts` pins by shape. That is the widening the line asks about (「本卡放宽接受集或扩大公开面吗」); nothing existing is narrowed. - **Changeset level `patch`.** AGENTS.md Post-Task Checklist step 3: the level follows the metadata-protocol kind alone, and `Metadata-protocol: none` ships `patch`, widening included. `src/console.ts`'s header and the pin's docblock say a widening of that subpath is a "`minor`-bumped act"; that text predates the level ruling and AGENTS.md wins where they disagree. Noted, not changed here. - **Docs.** No page under `content/docs/` lists the console static plugin's options (`rootRedirect`, `isDev` and the plugin name have zero hits there), so the option is documented in the function's jsdoc and the changeset only. - **Tag placement.** The tag is written right after the head start tag, the same seam the base tag and the capability global use, so a shell without a head tag gets none of the three. `defer` keeps it from blocking the parse; its `src` is absolute, so its position before the base tag changes nothing. ## Verification All at `cbef4f627b`, the head this PR opens with (branched from `main` `f108e6d2`; `origin/main` has since moved 5 commits, none touching `packages/cli/src/utils/console.ts` or either test file here, so it is not merged in). - `pnpm --filter @objectstack/cli exec vitest run --project unit src/utils/console.content-security-policy.test.ts`: `Tests 66 passed (66)`. - `pnpm --filter @objectstack/cli typecheck`: exit 0 (the lock's `VERDICT command-exit 0`; `check:test-typecheck: OK`). - `pnpm --filter @objectstack/cli build`, then `vitest run --project unit test/published-subpath-console.pin.test.ts test/published-subpath-hook-body.pin.test.ts`: `Tests 29 passed (29)`; `dist/utils/console.d.ts` carries `cloudflareWebAnalytics`. - Whole `unit` project (`vitest run --project unit --maxWorkers=2`, before the CLI build): 387/389 files, 5469 tests passed; the two failures were the two published-subpath pins refusing on a missing `packages/cli/dist` ("packages/cli is not built"), both green after the build above. The `integration` layer is declared to CI. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 commands; all ran, plus `pnpm lint`, `pnpm check:cli-command-ids` and `pnpm check:adr-symbol-anchors`: 65 exit 0. `check:dual-build-cjs-loads` and `check:i18n-coverage` first exited 3 (PREREQUISITE NOT MET, no whole-tree dist) and exited 0 after `turbo run build --concurrency=2` (lock VERDICT 0). `dispatch-gates.mjs --ran`: exit 0, "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED". - `node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD`: exit 0. <details><summary>Ablations: each pin red once, then restored (scripts/ablation-replace.mjs; the CLI unit project imports src/, so no dist leg)</summary> Each run: `ablation-replace.mjs --file packages/cli/src/utils/console.ts --anchor … --replacement … -- vitest run --project unit src/utils/console.content-security-policy.test.ts`; every mutation landed (anchor 1 → 0, blob `96f2c47898c3` → a new blob) and every restore read `blob == HEAD (96f2c47)` and `git diff HEAD` empty. - A1, the option writes no tag (the head insertion replaced by `void beaconTag;`): `Tests 2 failed | 64 passed`. - A2, the report origin added without the option (the `if (input.cloudflareWebAnalytics)` guard dropped): `Tests 16 failed | 50 passed` (the three byte-identity cases and every exact `connect-src` assertion in the file). - A3, any token accepted (`CLOUDFLARE_WEB_ANALYTICS_TOKEN` replaced by a pattern that matches every string): `Tests 7 failed | 59 passed` (the seven string tokens; the number and `null` still refuse on type). - A4, the attribute unescaped (`escapeHtml` dropped): `Tests 2 failed | 64 passed`. - The pin's own control: `published-subpath-console.pin.test.ts` with the old options type restored reds `TS2344: Type 'false' does not satisfy the constraint 'true'` on the signature equality (`Tests 1 failed | 13 passed`), so the pin reads the rebuilt declaration. Restored: blob == HEAD. </details> <details><summary>65 commands, exit codes at cbef4f6</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm lint :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:adr-symbol-anchors :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:i18n-coverage :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxGr3fDvcDkCLUwZAo)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea41c49 commit 40ecdf9

4 files changed

Lines changed: 236 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `createConsoleStaticPlugin` takes a `cloudflareWebAnalytics: { token }` option that adds the Cloudflare Web Analytics beacon to the console document
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
- **The option.** `createConsoleStaticPlugin(distPath, { cloudflareWebAnalytics: { token } })`, also on the published `@objectstack/cli/console` subpath. The document that plugin serves then carries a deferred `script` tag for `https://static.cloudflareinsights.com/beacon.min.js` with the token as its `data-cf-beacon` JSON, stamped with the document's nonce like every other script on the page, and its `content-security-policy` adds `https://cloudflareinsights.com`, where the beacon reports page views, to `connect-src`. `script-src` is unchanged: the nonce admits the beacon tag, and no host is added.
11+
- **Per mount.** It is an option, not an env var, so a host that mounts the Console more than once (a control plane and its tenant planes) passes it to the one mount it means to count.
12+
- **Refused at boot.** A token that is not 32 hexadecimal digits (or a missing one) throws when the plugin starts, and the message gives the value's length and kind, not the value.
13+
- **Unchanged without it.** Left out, the document and its policy are byte-identical to before.

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ async function mount(opts: {
8686
/** The kernel services the console plugin's context resolves by name. */
8787
services?: Map<string, unknown>;
8888
warnings?: string[];
89+
/** The host's options to the console plugin itself. */
90+
plugin?: Parameters<typeof createConsoleStaticPlugin>[1];
8991
} = {}) {
9092
const server = new HonoHttpServer(0);
9193
server.installNotFoundSeam();
@@ -107,7 +109,7 @@ async function mount(opts: {
107109
} as any);
108110
for (const fn of hooks) await fn();
109111
}
110-
await createConsoleStaticPlugin(distPath).start({
112+
await createConsoleStaticPlugin(distPath, opts.plugin).start({
111113
getServiceAsync: async () => server,
112114
getService: (name: string) => {
113115
if (opts.services?.has(name)) return opts.services.get(name);
@@ -561,3 +563,145 @@ describe('the opt-in Turnstile challenge on sign-up', () => {
561563
return response.headers.get('content-security-policy');
562564
}
563565
});
566+
567+
describe('the host\'s cloudflareWebAnalytics option', () => {
568+
const TOKEN = '0123456789abcdef0123456789ABCDEF';
569+
const BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js';
570+
const REPORT_ORIGIN = 'https://cloudflareinsights.com';
571+
572+
/**
573+
* The document and the policy this suite's shell is served with when no
574+
* option is passed, as `main` served them before the option existed (read
575+
* at `f108e6d2`), with the per-response nonce written as `NONCE`.
576+
*/
577+
const DOCUMENT_WITHOUT_OPTION = [
578+
'<!doctype html><html><head>',
579+
' <script nonce="NONCE">window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat(\'react-pages\');</script>',
580+
' <base href="/_console/"><meta charset="UTF-8"><title></title>',
581+
'<script nonce="NONCE">window.early = 1;</script>',
582+
'<SCRIPT nonce="NONCE">window.shim = "</" + "script>";</SCRIPT>',
583+
'<style>#boot-splash{display:flex}</style>',
584+
'<script nonce="NONCE" type="module" crossorigin src="./assets/index-BQWjE1TW.js"></script>',
585+
'</head><body><div id="root"></div></body></html>',
586+
].join('\n');
587+
const POLICY_WITHOUT_OPTION = [
588+
"default-src 'self'",
589+
"script-src 'self' 'nonce-NONCE' 'wasm-unsafe-eval'",
590+
"style-src 'self' 'unsafe-inline'",
591+
"img-src 'self' data: blob: https:",
592+
"connect-src 'self' https://demotiles.maplibre.org",
593+
"manifest-src 'self' blob:",
594+
"object-src 'none'",
595+
"base-uri 'self'",
596+
"frame-ancestors 'self'",
597+
].join('; ');
598+
599+
/** The response's document and policy, its nonce written as `NONCE`. */
600+
async function served(request: (p: string) => Promise<Response>): Promise<{ html: string; policy: string }> {
601+
const res = await request('/_console/');
602+
expect(res.status).toBe(200);
603+
const policy = res.headers.get('content-security-policy')!;
604+
const nonce = headerNonce(policy);
605+
const html = await res.text();
606+
return { html: html.split(nonce).join('NONCE'), policy: policy.split(nonce).join('NONCE') };
607+
}
608+
609+
/** The `data-cf-beacon` value of the document's one beacon tag, as a browser decodes it. */
610+
function beaconConfig(html: string): unknown {
611+
const tags = scriptTags(html).filter((tag) => tag.includes(BEACON_SRC));
612+
expect(tags).toHaveLength(1);
613+
const match = /\sdata-cf-beacon="([^"]*)"/.exec(tags[0]);
614+
expect(match, 'a double-quoted data-cf-beacon attribute').not.toBeNull();
615+
const decoded = match![1]
616+
.replace(/&quot;/g, '"')
617+
.replace(/&#39;/g, "'")
618+
.replace(/&lt;/g, '<')
619+
.replace(/&gt;/g, '>')
620+
.replace(/&amp;/g, '&');
621+
return JSON.parse(decoded);
622+
}
623+
624+
it('writes the beacon tag under the document\'s nonce, and adds the origin it reports to in connect-src', async () => {
625+
const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } });
626+
const res = await request('/_console/');
627+
const raw = res.headers.get('content-security-policy');
628+
const nonce = headerNonce(raw);
629+
const html = await res.text();
630+
631+
const beacon = scriptTags(html).filter((tag) => tag.includes(BEACON_SRC));
632+
expect(beacon).toHaveLength(1);
633+
expect(beacon[0]).toContain(` nonce="${nonce}"`);
634+
expect(beacon[0]).toMatch(/\sdefer\b/);
635+
expect(beacon[0]).toContain(` src="${BEACON_SRC}"`);
636+
expect(beaconConfig(html)).toEqual({ token: TOKEN });
637+
// The build's three, the capability global and the beacon: every one nonced.
638+
const tags = scriptTags(html);
639+
expect(tags).toHaveLength(5);
640+
for (const tag of tags) expect(tag).toContain(` nonce="${nonce}"`);
641+
642+
const policy = directives(raw);
643+
expect(policy.get('connect-src')).toEqual(["'self'", 'https://demotiles.maplibre.org', REPORT_ORIGIN]);
644+
// The beacon loads by the nonce: script-src names no host for it.
645+
expect(policy.get('script-src')).toEqual(["'self'", `'nonce-${nonce}'`, "'wasm-unsafe-eval'"]);
646+
expect(raw).not.toContain('static.cloudflareinsights.com');
647+
});
648+
649+
it('keeps OS_CONSOLE_CSP_CONNECT_SRC working beside it, after the report origin', async () => {
650+
process.env.OS_CONSOLE_CSP_CONNECT_SRC = 'https://tiles.example.com';
651+
const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } });
652+
const { policy } = await served(request);
653+
expect(directives(policy).get('connect-src')).toEqual([
654+
"'self'",
655+
'https://demotiles.maplibre.org',
656+
REPORT_ORIGIN,
657+
'https://tiles.example.com',
658+
]);
659+
});
660+
661+
for (const [label, plugin] of [
662+
['no options at all', undefined],
663+
['the other options only', { isDev: false, rootRedirect: true }],
664+
['the option key left undefined', { cloudflareWebAnalytics: undefined }],
665+
] as const) {
666+
it(`serves the document and the policy byte-identical to before with ${label}`, async () => {
667+
const request = await mount({ plugin });
668+
const { html, policy } = await served(request);
669+
expect(html).toBe(DOCUMENT_WITHOUT_OPTION);
670+
expect(policy).toBe(POLICY_WITHOUT_OPTION);
671+
});
672+
}
673+
674+
it.each([
675+
['an empty string', ''],
676+
['31 hex digits', TOKEN.slice(1)],
677+
['33 hex digits', `${TOKEN}0`],
678+
['32 characters that are not all hex', 'g'.repeat(32)],
679+
['a token padded with whitespace', ` ${TOKEN} `],
680+
['a token that would close the attribute', `${TOKEN.slice(0, 16)}"><script>x()</script>`],
681+
['a token that would close the JSON', `${TOKEN.slice(0, 30)}",`],
682+
['a number', 42],
683+
['null', null],
684+
])('refuses the boot when the token is %s, without echoing it', async (_label, token) => {
685+
const failure = await mount({ plugin: { cloudflareWebAnalytics: { token } as unknown as { token: string } } })
686+
.then(() => undefined, (error: unknown) => error);
687+
expect(failure).toBeInstanceOf(Error);
688+
const { message } = failure as Error;
689+
expect(message).toMatch(/cloudflareWebAnalytics option's token is .*not a Cloudflare Web Analytics site token/);
690+
if (typeof token === 'string' && token.length > 0) expect(message).not.toContain(token);
691+
});
692+
693+
it('refuses the boot when the option carries no token', async () => {
694+
await expect(mount({ plugin: { cloudflareWebAnalytics: {} as unknown as { token: string } } }))
695+
.rejects.toThrow(/cloudflareWebAnalytics option's token is undefined/);
696+
});
697+
698+
it('keeps the token inside the attribute: the JSON\'s own quotes are escaped, so the tag parses as one element', async () => {
699+
const request = await mount({ plugin: { cloudflareWebAnalytics: { token: TOKEN } } });
700+
const { html } = await served(request);
701+
const tag = scriptTags(html).find((t) => t.includes(BEACON_SRC))!;
702+
expect(tag).toBe(
703+
`<script nonce="NONCE" defer src="${BEACON_SRC}" data-cf-beacon="{&quot;token&quot;:&quot;${TOKEN}&quot;}">`,
704+
);
705+
expect(html).toContain(`${tag}</script>`);
706+
});
707+
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -849,11 +849,17 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
849849
* sign-up form loads Cloudflare's script, which draws the challenge in a
850850
* frame from the same origin. With the challenge off, neither is listed and
851851
* `frame-src` is left to `default-src 'self'`, exactly as before.
852+
* - {@link CLOUDFLARE_WEB_ANALYTICS_ORIGIN} in `connect-src` only while the
853+
* host passes the `cloudflareWebAnalytics` option: the beacon the document
854+
* then carries reports each page view there. The beacon itself loads by the
855+
* document's nonce, so its host is not in `script-src`. Without the option,
856+
* neither the tag nor the origin is written, exactly as before.
852857
*/
853858
function consoleContentSecurityPolicy(input: {
854859
nonce: string;
855860
reactPages: boolean;
856861
turnstile: boolean;
862+
cloudflareWebAnalytics: boolean;
857863
errorReportingOrigin: string | undefined;
858864
mapStyleOrigin: string | undefined;
859865
storageUploadOrigin: string | undefined;
@@ -867,6 +873,7 @@ function consoleContentSecurityPolicy(input: {
867873
if (input.mapStyleOrigin !== undefined) connectSrc.push(input.mapStyleOrigin);
868874
if (input.errorReportingOrigin !== undefined) connectSrc.push(input.errorReportingOrigin);
869875
if (input.storageUploadOrigin !== undefined) connectSrc.push(input.storageUploadOrigin);
876+
if (input.cloudflareWebAnalytics) connectSrc.push(CLOUDFLARE_WEB_ANALYTICS_ORIGIN);
870877
connectSrc.push(...input.extraConnectSources);
871878
const directives: string[][] = [
872879
['default-src', "'self'"],
@@ -886,6 +893,57 @@ function consoleContentSecurityPolicy(input: {
886893
/** Cloudflare Turnstile's origin: its loader script and its challenge frame. */
887894
const TURNSTILE_ORIGIN = 'https://challenges.cloudflare.com';
888895

896+
/**
897+
* Cloudflare Web Analytics' beacon script, as the site's manual JavaScript
898+
* snippet names it. It is written into the document only when the host passes
899+
* the `cloudflareWebAnalytics` option ({@link cloudflareWebAnalyticsBeaconScript}).
900+
*
901+
* Its host is ⛔ NOT added to `script-src`: the tag carries the document's
902+
* nonce, which admits that one element whatever its `src`, while a host entry
903+
* would admit every file the host serves.
904+
*/
905+
const CLOUDFLARE_WEB_ANALYTICS_BEACON_SRC = 'https://static.cloudflareinsights.com/beacon.min.js';
906+
907+
/** The origin Cloudflare Web Analytics' beacon reports page views to. */
908+
const CLOUDFLARE_WEB_ANALYTICS_ORIGIN = 'https://cloudflareinsights.com';
909+
910+
/**
911+
* A Cloudflare Web Analytics site token: 32 hexadecimal digits. The character
912+
* set is what keeps the token from carrying anything but itself into the
913+
* document; the attribute it lands in is escaped as well.
914+
*/
915+
const CLOUDFLARE_WEB_ANALYTICS_TOKEN = /^[0-9a-f]{32}$/i;
916+
917+
/**
918+
* The site token of the host's `cloudflareWebAnalytics` option, or `undefined`
919+
* when the option is left out; a token outside {@link CLOUDFLARE_WEB_ANALYTICS_TOKEN}
920+
* refuses the boot. The refusal names the value's length and kind, never the
921+
* value: a credential passed here by mistake would otherwise land in the boot
922+
* log.
923+
*/
924+
function readCloudflareWebAnalyticsToken(option: { token: string } | undefined): string | undefined {
925+
if (option === undefined) return undefined;
926+
const token: unknown = (option as { token?: unknown } | null)?.token;
927+
if (typeof token === 'string' && CLOUDFLARE_WEB_ANALYTICS_TOKEN.test(token)) return token;
928+
const shown = typeof token === 'string' ? `a ${token.length}-character string` : token === null ? 'null' : typeof token;
929+
throw new Error(
930+
`Console static: the cloudflareWebAnalytics option's token is ${shown}, which is not a Cloudflare Web Analytics `
931+
+ 'site token. Pass the 32 hexadecimal digits the site\'s JavaScript snippet carries as its token, or leave the '
932+
+ 'option out to serve the console without the beacon.',
933+
);
934+
}
935+
936+
/**
937+
* The Cloudflare Web Analytics beacon tag for `token`, deferred, with the
938+
* token as the `data-cf-beacon` JSON the beacon reads. The attribute value is
939+
* escaped, so nothing in it can close the attribute or the tag. It is written
940+
* before {@link stampScriptNonce} runs, which gives it the document's nonce.
941+
*/
942+
function cloudflareWebAnalyticsBeaconScript(token: string): string {
943+
const config = escapeHtml(JSON.stringify({ token }));
944+
return `<script defer src="${CLOUDFLARE_WEB_ANALYTICS_BEACON_SRC}" data-cf-beacon="${config}"></script>`;
945+
}
946+
889947
/**
890948
* Whether the opt-in Turnstile challenge on sign-up is on: BOTH variables set
891949
* and non-blank, the rule `@objectstack/plugin-auth` mounts the challenge and
@@ -995,8 +1053,19 @@ function stampScriptNonce(html: string, nonce: string): string {
9951053
* the anonymous form door serves that slug, and otherwise gets the same
9961054
* not-found answer as before. Mounted
9971055
* only with the Console, because without it there is no page to send anyone to.
1056+
*
1057+
* `cloudflareWebAnalytics: { token }` adds Cloudflare Web Analytics to the
1058+
* document this plugin serves, and to no other: the beacon tag, stamped with
1059+
* the document's nonce, and the origin it reports to in `connect-src`. It is an
1060+
* option rather than an env var so a host that mounts the Console more than
1061+
* once (a control plane and its tenant planes) can count one mount only. A
1062+
* token that is not 32 hexadecimal digits refuses the boot. Left out, the
1063+
* document and its policy are exactly what they were without it.
9981064
*/
999-
export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: boolean; rootRedirect?: boolean }) {
1065+
export function createConsoleStaticPlugin(
1066+
distPath: string,
1067+
options?: { isDev?: boolean; rootRedirect?: boolean; cloudflareWebAnalytics?: { token: string } },
1068+
) {
10001069
return {
10011070
name: 'com.objectstack.console-static',
10021071

@@ -1017,6 +1086,8 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
10171086
// The deployment's default map style (#26001), on the contract the
10181087
// runtime-config door serves it under, so the policy allows that URL.
10191088
const mapStyleOrigin = readMapStyleOrigin(process.env);
1089+
// The host's own option, checked at boot like the two reads above.
1090+
const cloudflareWebAnalyticsToken = readCloudflareWebAnalyticsToken(options?.cloudflareWebAnalytics);
10201091

10211092
const indexPath = path.join(absoluteDist, 'index.html');
10221093
if (!fs.existsSync(indexPath)) {
@@ -1097,6 +1168,10 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
10971168
}
10981169
const capTag = reactPagesCapabilityScript(reactPages);
10991170
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${capTag}`);
1171+
if (cloudflareWebAnalyticsToken !== undefined) {
1172+
const beaconTag = cloudflareWebAnalyticsBeaconScript(cloudflareWebAnalyticsToken);
1173+
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${beaconTag}`);
1174+
}
11001175
// Stamped while the document holds only the build's markup and the
11011176
// server's own tags: nothing read at request time is written yet, so
11021177
// no runtime value can ever carry the nonce.
@@ -1117,6 +1192,7 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
11171192
nonce,
11181193
reactPages,
11191194
turnstile: readTurnstileEnabled(),
1195+
cloudflareWebAnalytics: cloudflareWebAnalyticsToken !== undefined,
11201196
errorReportingOrigin: shell?.errorReportingOrigin,
11211197
mapStyleOrigin,
11221198
storageUploadOrigin: await readStorageUploadOrigin(ctx, reportStorageUploadOriginFailure),

‎packages/cli/test/published-subpath-console.pin.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ type CreateConsoleStaticPluginKeepsItsRatifiedSignature = Expect<
327327
typeof createConsoleStaticPlugin,
328328
(
329329
distPath: string,
330-
options?: { isDev?: boolean; rootRedirect?: boolean },
330+
options?: { isDev?: boolean; rootRedirect?: boolean; cloudflareWebAnalytics?: { token: string } },
331331
) => { name: string; init: () => Promise<void>; start: (ctx: any) => Promise<void> }
332332
>
333333
>;

0 commit comments

Comments
 (0)