From 8682f4b02b702597bfade67ffa1d2cd231d64491 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 14:35:58 +0000 Subject: [PATCH] cloud-connection: serve a fail-closed SPA telemetry permission on /api/v1/runtime/config (#10805) Upstream half of cloud#1508 (p0/security). An air-gapped on-prem EE Console was measured sending 14 Sentry envelopes per session to sentry.io carrying IP and User-Agent PII, with no way to turn it off: every telemetry knob in the SPA is a Vite build-time variable frozen into the bundle, so a build that DID opt in had no post-build off switch. The only server-to-SPA channel is GET /api/v1/runtime/config, which this package owns. The payload now carries `telemetry: { allowClientErrorReporting: boolean }`, denied unless a runtime positively grants it via OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED or the matching host option. - A permission, not a source: the server supplies no DSN and cannot start telemetry for a build that carries none. - Denied on EVERY posture until granted, not only the air-gapped one: an internet-connected on-prem box runs the same build artifact as the hosted console, so the DSN cannot tell them apart. - A runtime that declared its control plane off (OS_CLOUD_URL=off/none/local/ disabled) refuses the grant and says so at mount time. That is the repo's one existing network-posture declaration and the EE image already defaults to it, so the operator this failed needs no configuration. - Absence is denial, and the reading ships with the contract: `isClientErrorReportingAllowed(payload)` answers false for a legacy payload, a malformed body, a 404 and a failed fetch. `isControlPlaneDeclined()` is factored out of cloud-url.ts so "what counts as off" has one definition; resolveCloudUrl() is unchanged in behaviour. The consumer half (gating initSentry) is objectui's and out of scope here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../runtime-config-telemetry-posture.md | 25 ++ .../docs/deployment/environment-variables.mdx | 1 + ...marketplace-offline-runtime-config.test.ts | 85 ++++ packages/cloud-connection/README.md | 43 +- packages/cloud-connection/src/cloud-url.ts | 49 ++- .../src/error-envelope.conformance.test.ts | 9 +- packages/cloud-connection/src/index.ts | 7 + .../src/runtime-config-plugin.ts | 195 ++++++++- .../src/runtime-config-telemetry.test.ts | 370 ++++++++++++++++++ .../cloud-connection/src/telemetry-posture.ts | 166 ++++++++ 10 files changed, 943 insertions(+), 7 deletions(-) create mode 100644 .changeset/runtime-config-telemetry-posture.md create mode 100644 packages/cloud-connection/src/runtime-config-telemetry.test.ts create mode 100644 packages/cloud-connection/src/telemetry-posture.ts diff --git a/.changeset/runtime-config-telemetry-posture.md b/.changeset/runtime-config-telemetry-posture.md new file mode 100644 index 0000000000..0abe29ff93 --- /dev/null +++ b/.changeset/runtime-config-telemetry-posture.md @@ -0,0 +1,25 @@ +--- +"@objectstack/cloud-connection": minor +--- + +**Security (p0, upstream half):** `GET /api/v1/runtime/config` now carries a `telemetry` block, giving the Console SPA a **post-build off switch** for client error reporting (#10805, upstream half of `cloud#1508`). + +An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns — a build that never opts in now issues no third-party request at all — and could not close the other: every telemetry knob there is a Vite build-time variable inlined into the bundle as a frozen literal, so a build that **did** opt in (the hosted console, and the identical artifact shipped on-prem) had no switch that editing env vars on the deployed host could reach. The only server-to-SPA channel is this endpoint. + +```json +{ "telemetry": { "allowClientErrorReporting": false } } +``` + +Operators grant it with `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true`, or hosts with `new RuntimeConfigPlugin({ allowClientErrorReporting: true })`. The switch answers to the repo's usual truthy vocabulary (`1` / `true` / `on` / `yes`); an unrecognised spelling is refused and named at mount time rather than coerced. + +**Denied by default on every posture, not only the air-gapped one.** Deriving "connected therefore allowed" would have left the reported injury class open one deployment over: an internet-connected on-prem box runs the *same build artifact* as the hosted console, so the DSN cannot tell them apart, and its customer has equally never heard of Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, and satisfies it without having to identify the posture correctly — which matters, because a posture predicate that is wrong in the *allow* direction is this card's own defect. + +**A permission, never a source.** The server supplies no DSN and cannot start telemetry for a build that carries none; `true` means only "this deployment does not object to the sink you were compiled with". The composed decision stays `Boolean(buildTimeDsn) && isClientErrorReportingAllowed(payload)`. + +**A runtime that declared its control plane off cannot grant it.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration. + +**Absence is denial, and the reading ships with the contract.** A new export, `isClientErrorReportingAllowed(payload)`, is the canonical fail-closed reader: an older runtime's payload, a malformed body, a 404 and a failed fetch (pass `undefined`) all answer `false`. It is exported rather than left to consumers because "absent means do not send" is a claim about *their* code, and a hand-written `?.` chain is one `!== false` away from re-opening the leak on exactly the legacy payloads the guarantee is for. The key is spelled as a permission for the same reason: a negative `disabled` flag would have read falsy — therefore "send" — on every one of those states. + +`isControlPlaneDeclined()` is factored out of `cloud-url.ts` so "what counts as off" has one definition shared by the URL resolution and the telemetry refusal. No behaviour change to `resolveCloudUrl()`. + +The consumer half (reading the key and gating `initSentry`) is objectui's and is filed separately. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index f9d3bc605b..18468c1270 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -340,6 +340,7 @@ the hosted ObjectOS Cloud control plane. | `OS_OTLP_ENDPOINT` | url | — | OTLP/HTTP collector endpoint. Required when `OS_OBS_EXPORTER=otlp`. | | `OS_OTLP_HEADERS` | csv | — | Comma-separated `key=value` pairs added to every OTLP export. | | `OS_OTLP_FLUSH_MS` | number | `10000` | OTLP batch flush interval. | +| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED` | boolean | `false` | Permits the Console SPA to send client error reports to the sink its build was compiled with, via `telemetry.allowClientErrorReporting` on `/api/v1/runtime/config`. Opt-in on every posture: an unset switch, an unrecognised value, or a runtime that declared its control plane off (`OS_CLOUD_URL=off`) all deny. It is a permission, not a source — it cannot start telemetry for a build that carries no DSN. | --- diff --git a/packages/cli/test/serve-marketplace-offline-runtime-config.test.ts b/packages/cli/test/serve-marketplace-offline-runtime-config.test.ts index cf2fdb2a13..9bf0544278 100644 --- a/packages/cli/test/serve-marketplace-offline-runtime-config.test.ts +++ b/packages/cli/test/serve-marketplace-offline-runtime-config.test.ts @@ -429,3 +429,88 @@ describe('#8389: the identities and options the arm mounts with are the real one expect(Object.isFrozen(Serve.RUNTIME_CONFIG_OPTIONS)).toBe(true); }); }); + +/** + * #10805 — the same offline arm must also serve the SPA telemetry refusal. + * + * This is cloud#1508's acceptance expressed on the server side: on a + * composed / air-gapped posture, a Console build that DOES carry a Sentry DSN + * must be told not to send, through a switch that needs no rebuild. + * + * It belongs here rather than only in the plugin's own suite because of one + * measured property of this wiring: `Serve.RUNTIME_CONFIG_OPTIONS` hands the + * plugin `controlPlaneUrl: ''` on BOTH arms, so on the real product path the + * constructor argument carries no posture information whatsoever and only + * `OS_CLOUD_URL` does. A posture read built on the resolved URL would pass + * every hand-built fixture and be wrong exactly here. The correspondence this + * pins is exact: `resolveCloudUrl()` maps an unset env var to the PUBLIC + * default (truthy), so this arm is reached if and only if `OS_CLOUD_URL` is + * one of the decline spellings — the same condition the refusal reads. + * + * The env var is set for real, not simulated by `marketplaceUrl: ''` as the + * blocks above do, because that simulation is precisely the half that would + * hide the defect. + */ +describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_URL=off boot', () => { + const GRANT_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED'; + + async function bootAirGapped(run: (body: any) => void | Promise): Promise { + const savedCloudUrl = process.env.OS_CLOUD_URL; + const savedGrant = process.env[GRANT_ENV]; + const dir = tempStorageDir(); + try { + process.env.OS_CLOUD_URL = 'off'; + const { app } = await bootOfflineArm({ storageDir: dir }); + await run(await readConfig(app)); + } finally { + if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = savedCloudUrl; + if (savedGrant === undefined) delete process.env[GRANT_ENV]; + else process.env[GRANT_ENV] = savedGrant; + rmSync(dir, { recursive: true, force: true }); + } + } + + it('THE ACCEPTANCE — an air-gapped boot tells the Console not to send, with zero configuration', async () => { + await bootAirGapped((body) => { + expect( + body.telemetry.allowClientErrorReporting, + 'an operator who has never heard of Sentry must be safe without configuring anything', + ).toBe(false); + }); + }); + + it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => { + process.env[GRANT_ENV] = 'true'; + await bootAirGapped(async (body) => { + const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection'); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + // Read through the exported contract too: what the SPA will actually do + // with this payload is the thing under test, not the raw boolean. + expect(isClientErrorReportingAllowed(body)).toBe(false); + }); + }); + + it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => { + // Without this, the refusal above could be an artifact of the fixture + // rather than a posture reading, and the pin would stay green on a build + // that denies everything unconditionally. + const savedCloudUrl = process.env.OS_CLOUD_URL; + const savedGrant = process.env[GRANT_ENV]; + try { + process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai'; + process.env[GRANT_ENV] = 'true'; + const { RuntimeConfigPlugin } = await import('@objectstack/cloud-connection'); + const app = createApp(); + // The cloud arm's own mount, verbatim — same shared frozen options. + await startOn(app, new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS })); + const body = await readConfig(app); + expect(body.telemetry.allowClientErrorReporting).toBe(true); + } finally { + if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = savedCloudUrl; + if (savedGrant === undefined) delete process.env[GRANT_ENV]; + else process.env[GRANT_ENV] = savedGrant; + } + }); +}); diff --git a/packages/cloud-connection/README.md b/packages/cloud-connection/README.md index 8c3dc6d2d2..1284d5314e 100644 --- a/packages/cloud-connection/README.md +++ b/packages/cloud-connection/README.md @@ -55,6 +55,46 @@ const plugins = [ ]; ``` +## SPA telemetry is denied unless a runtime grants it + +`GET /api/v1/runtime/config` carries a `telemetry` block: + +```json +{ "telemetry": { "allowClientErrorReporting": false } } +``` + +It is the Console's **post-build off switch**. Every telemetry knob in the SPA +is a build-time variable frozen into the bundle, so a build that opted in has +no other way to be turned off on a deployed host — and an air-gapped +deployment measurably shipped one that could not be (`cloud#1508`: 14 Sentry +envelopes per session carrying IP and User-Agent PII). + +It is **denied by default on every posture**. Grant it explicitly: + +```bash +OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true # or: new RuntimeConfigPlugin({ allowClientErrorReporting: true }) +``` + +Three properties worth knowing before you build on it: + +- **A permission, not a source.** The server supplies no DSN and cannot start + telemetry for a build that carries none. `true` means only "this deployment + does not object to the sink you were compiled with". +- **A runtime that declared its control plane off cannot grant it.** + `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and + says so in the boot log, so an air-gapped box stays silent even if a hosted + configuration is copied onto it. +- **Absence means denied.** An older runtime, a third-party host, a 404 or a + failed fetch all read the same way. Consumers should use the reading that + ships with the contract rather than writing their own: + +```ts +import { isClientErrorReportingAllowed } from '@objectstack/cloud-connection'; + +// `payload` may be the parsed body, or undefined when the fetch failed. +if (buildTimeDsn && isClientErrorReportingAllowed(payload)) initErrorReporting(); +``` + ## Boundary (open mechanism, closed intelligence) This package is **mechanism**: proxying a catalog, installing into the local @@ -65,7 +105,8 @@ rules. Plan-derived feature flags are injected by the host via `RuntimeConfigPluginConfig.resolvePlanFeatures`. `OS_CLOUD_URL=off` disables every remote call; air-gapped installs keep -working via inline manifests handed to `install-local`. +working via inline manifests handed to `install-local`, and the SPA telemetry +permission above cannot be granted. See `docs/adr` in the cloud repository (ADR-0008) for the full architecture decision. diff --git a/packages/cloud-connection/src/cloud-url.ts b/packages/cloud-connection/src/cloud-url.ts index ecc5fa5d6b..eeaff20174 100644 --- a/packages/cloud-connection/src/cloud-url.ts +++ b/packages/cloud-connection/src/cloud-url.ts @@ -11,6 +11,24 @@ */ export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai'; +/** + * The spellings by which a deployment declares "this runtime has no control + * plane" — the repo's one existing, documented, network-posture declaration. + * + * ONE definition, read in two directions: `resolveCloudUrl()` turns it into an + * empty URL, and {@link isControlPlaneDeclined} answers whether it was said at + * all. They were a single inline list until the telemetry posture (#10805) + * needed the second question; keeping two copies would let "what counts as + * off" drift between "no cloud calls" and "no client telemetry", which is + * precisely the pair that must never disagree. + */ +const CLOUD_DECLINED_SPELLINGS: readonly string[] = ['off', 'none', 'local', 'disabled']; + +/** Is this raw declaration one of the documented "no control plane" spellings? */ +function isDeclinedSpelling(raw: string): boolean { + return CLOUD_DECLINED_SPELLINGS.includes(raw.trim().toLowerCase()); +} + /** * Resolve the effective control-plane URL from an explicit constructor * value, the OS_CLOUD_URL env var, or the default. Returns an empty @@ -20,10 +38,37 @@ export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai'; */ export function resolveCloudUrl(explicit?: string | null): string { const raw = (explicit ?? process.env.OS_CLOUD_URL ?? '').trim(); - const lower = raw.toLowerCase(); - if (lower === 'off' || lower === 'none' || lower === 'local' || lower === 'disabled') { + if (isDeclinedSpelling(raw)) { return ''; } const picked = raw || DEFAULT_CLOUD_URL; return picked.replace(/\/+$/, ''); } + +/** + * Did this deployment DECLARE that it has no control plane (#10805)? + * + * ## Why this is not `resolveCloudUrl(...) === ''` + * + * That test conflates two opposite deployments, and the conflation is not + * theoretical — it is what every CLI-served runtime looks like. `''` is also + * how a host says **"this runtime IS the cloud"** (same origin), which + * `RuntimeConfigPlugin`'s constructor special-cases before it ever calls the + * resolver. Measured on `main`: `Serve.RUNTIME_CONFIG_OPTIONS` passes + * `controlPlaneUrl: ''` on **both** the cloud-connected arm and the air-gapped + * arm of the CLI's marketplace wiring, so the resolved URL carries no posture + * information whatsoever on the product path. A posture read built on it would + * report every hosted console as air-gapped and every air-gapped box as + * hosted — in the second direction, silently. + * + * This asks the different, answerable question: was one of the documented + * decline spellings actually said? An empty string is not one of them, an + * unset env var is not one of them, and `https://…` is not one of them. + * + * Pass the host's explicit argument to ask about that argument; pass nothing to + * ask about the deployment's environment. Callers that must catch both doors + * ask twice — see `RuntimeConfigPlugin.declinesControlPlane()`. + */ +export function isControlPlaneDeclined(explicit?: string | null): boolean { + return isDeclinedSpelling(explicit ?? process.env.OS_CLOUD_URL ?? ''); +} diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index da823c50c4..66c58a439f 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -481,8 +481,8 @@ describe('plugin-route door — MarketplaceInstallLocalPlugin error exits (#9267 /** * `GET /api/v1/runtime/config` answers a BARE payload — * `{ cloudUrl, singleEnvironment, defaultOrgId, defaultEnvironmentId, features, - * branding }` — with no `success` flag and six top-level keys the envelope does - * not declare. + * branding, telemetry }` — with no `success` flag and seven top-level keys the + * envelope does not declare. * * ⚠️ This is NOT blessed, and this pin is not an assertion that the shape is * right. It is the honest record of measured drift, in the same spirit as the @@ -527,6 +527,11 @@ describe('plugin-route door — RuntimeConfigPlugin is NOT enveloped (recorded, 'stray top-level key `defaultEnvironmentId` — the payload belongs under `data`', 'stray top-level key `features` — the payload belongs under `data`', 'stray top-level key `branding` — the payload belongs under `data`', + // #10805 added the seventh: the SPA telemetry permission. Recorded + // here for the same reason as the six above — this route is read + // bare by the Console before first paint, so the drift grows with + // the payload until #9364 envelopes it. + 'stray top-level key `telemetry` — the payload belongs under `data`', ]); }); }); diff --git a/packages/cloud-connection/src/index.ts b/packages/cloud-connection/src/index.ts index 19b1a91eb1..d08c009eaa 100644 --- a/packages/cloud-connection/src/index.ts +++ b/packages/cloud-connection/src/index.ts @@ -55,6 +55,13 @@ export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-conn export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js'; export { RuntimeConfigPlugin } from './runtime-config-plugin.js'; export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures, PlatformStage } from './runtime-config-plugin.js'; +// #10805 — the SPA telemetry permission carried on that payload, and the +// canonical fail-closed way to read it. The reader is exported deliberately: +// "an absent key means do not send" is a claim about consumer code, and a +// consumer writing its own `?.` chain is one `!== false` away from re-opening +// the PII leak on exactly the legacy payloads the guarantee is for. +export { isClientErrorReportingAllowed, CLIENT_ERROR_REPORTING_ENV } from './telemetry-posture.js'; +export type { RuntimeTelemetryPosture } from './telemetry-posture.js'; // ADR-0008 consumption side — the self-hosted credential ledger (bind // persists the oscc_ bearer here; forwards present it to the control plane). export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js'; diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index 75ec559a7e..6debe4703e 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -15,7 +15,8 @@ * singleEnvironment: boolean, * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... }, - * branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor } + * branding: { productName, productShortName, stage?, logoUrl, faviconUrl, brandColor, pwaDescription, pwaThemeColor }, + * telemetry: { allowClientErrorReporting: boolean } * } * * ## `branding.stage` — a documented knob that this runtime never sent (#9252) @@ -57,6 +58,67 @@ * so the Console keeps applying its own documented `'preview'` default and * nothing that works today changes. * + * ## `telemetry.allowClientErrorReporting` — the post-build off switch (#10805) + * + * Upstream half of cloud#1508 (p0/security): an air-gapped on-prem EE Console + * was measured sending 14 Sentry envelopes per session to `sentry.io` carrying + * IP + User-Agent PII, with no way to turn it off. objectui fixed the half it + * owns — a build that never opts in now issues no third-party request at all — + * and documented the half it could not, verbatim from its shipped + * `app-shell/src/observability/sentry.ts`: *"a build that DID opt in still has + * no post-build off switch, because the only server-to-SPA channel is + * `/api/v1/runtime/config` and a telemetry key on that payload is an + * objectstack contract change, not objectui's to make."* This is that key. + * + * Ruled Option A (maintainer, 2026-08-22): server-authoritative, fail-closed, + * and the composed / air-gap posture defaults telemetry off so an operator who + * has never heard of Sentry is safe with zero configuration. + * + * Three properties, and each is a decision rather than a detail — see + * `telemetry-posture.ts` for the vocabulary reasoning: + * + * 1. **A permission, not a kill switch.** Only a positive grant sends. A + * negative `disabled` key would read falsy on every server too old to know + * it, on every malformed payload and on every failed fetch — i.e. it would + * be vacuous on the runtimes that are leaking today. + * 2. **Denied on EVERY posture until granted**, not only on the air-gapped + * one. Deriving "connected therefore allowed" would have left the reported + * injury class open one deployment over: an internet-connected on-prem EE + * box runs the SAME build artifact as the hosted console, so the DSN + * cannot distinguish them and its customer has equally never heard of + * Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, + * and satisfies it without having to identify the posture correctly — + * which matters, because a posture predicate that is wrong in the ALLOW + * direction is this card's own defect. + * 3. **A declared-off control plane REFUSES the grant** — see + * {@link declinesControlPlane}. That is the one place + * the posture is load-bearing rather than decorative. + * + * ## Where the deployment posture is read from, since there was no signal + * + * Triage flagged this as uninvestigated and the ruling handed it here. There + * is no general deployment-posture signal in this repo, and the two nearest + * candidates are both a different axis: `OS_TENANCY_POSTURE` + * (`single | group | isolated`, ADR-0105) is about organization walls, and + * `OS_MODE` (`standalone | cloud`) is a kernel boot mode read only inside the + * CLI, defaulting to `standalone` for every `objectstack dev`. + * + * What DOES exist is exactly one network-posture declaration, and it is + * already this package's: the `OS_CLOUD_URL` decline spellings, whose contract + * the README states in as many words — *"`OS_CLOUD_URL=off` disables every + * remote call; air-gapped installs keep working via inline manifests"*. It is + * the right signal precisely because the air-gapped operator ALREADY sets it + * without being told: measured and recorded in `cli/commands/serve.ts`, the EE + * image's compose file reads `OS_CLOUD_URL: ${OS_CLOUD_URL:-off}`, so `off` is + * not an unusual choice on that posture but the SHIPPED DEFAULT. A new + * `OS_DEPLOYMENT_POSTURE` var would have failed the ruling's actual + * requirement — zero configuration — by requiring the operator to learn it + * exists. + * + * So no posture source is introduced. What is introduced is one opt-in + * permission (`OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED`), and the existing + * decline declaration is honoured as a ceiling on it. + * * ## Feature seam (open-core boundary — cloud ADR-0012) * * This open package owns the **mechanism**: serve a per-request `features` @@ -126,7 +188,13 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; -import { resolveCloudUrl } from './cloud-url.js'; +import { resolveCloudUrl, isControlPlaneDeclined } from './cloud-url.js'; +import { + CLIENT_ERROR_REPORTING_ENV, + CLIENT_ERROR_REPORTING_SPELLINGS, + readClientErrorReportingGrant, + type RuntimeTelemetryPosture, +} from './telemetry-posture.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; /** @@ -292,6 +360,34 @@ function someRoutePattern(rawApp: unknown, matches: (pattern: string) => boolean } +/** + * Did this deployment declare that it has no control plane? (#10805) + * + * BOTH doors are asked, and the env one is not redundant: the CLI hands + * `controlPlaneUrl: ''` to this plugin on its cloud-connected arm AND on its + * air-gapped arm (`Serve.RUNTIME_CONFIG_OPTIONS`, measured on `main`), so on + * the product path the constructor argument carries no posture information at + * all and only `OS_CLOUD_URL` does. A host that instead spells the decline in + * its own argument is caught by the first call. + * + * The correspondence with the CLI's own arm selection is exact rather than + * approximate, which is what makes this a sound posture read: + * `resolveCloudUrl()` maps an unset env var to the PUBLIC default (truthy), so + * the offline arm is taken if and only if `OS_CLOUD_URL` is one of the decline + * spellings — the same condition this asks about. + * + * ⛔ A module-level function and NOT a static method, which it was for one + * commit. Referencing the class from inside its own body makes esbuild rename + * it (`class _RuntimeConfigPlugin { … }; RuntimeConfigPlugin = _…`), and + * `RuntimeConfigPlugin.name` is load-bearing in the BUILT artifact: the CLI's + * host-precedence detection reads `p?.constructor?.name` against + * `Serve.RUNTIME_CONFIG_IDENTITIES`, so the rename silently stops recognising + * a host's own mount. Caught by that drift pin, not by review. + */ +function declinesControlPlane(explicit: string | undefined): boolean { + return isControlPlaneDeclined(explicit) || isControlPlaneDeclined(); +} + /** * Product lifecycle stage — drives the Console's top-bar preview/beta chip * (#9252). @@ -428,6 +524,27 @@ export interface RuntimeConfigPluginConfig { pwaDescription?: string; /** PWA theme color hex. Falls back to OS_PWA_THEME_COLOR env var. Default: brandColor or '#4f46e5'. */ pwaThemeColor?: string; + /** + * Grant the SPA permission to send client error reports to the sink its + * build was compiled with (#10805). Falls back to the + * `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED` env var. + * + * ⛔ Default **false**, on every posture. This is the post-build off + * switch cloud#1508 asked for, so it fails closed: a deployment that says + * nothing sends nothing, and the operator who has never heard of Sentry + * needs no configuration to be safe. + * + * It is a PERMISSION, not a source — the server supplies no DSN and cannot + * turn telemetry on for a build that carries none. `true` says only "this + * deployment does not object to the sink you were built with". + * + * ⛔ It cannot be granted on a runtime that declared its control plane off + * (`OS_CLOUD_URL=off` / `none` / `local` / `disabled`). That declaration + * IS a runtime declining outbound calls, and the ruling this key + * implements is that a declining runtime wins. Refused loudly at mount + * time, never silently. + */ + allowClientErrorReporting?: boolean; /** * Distribution feature-policy hook (open-core seam — cloud ADR-0012). * Called with `undefined` for the static default (no environment resolved @@ -476,6 +593,12 @@ export class RuntimeConfigPlugin implements Plugin { private readonly brandColor: string | undefined; private readonly pwaDescription: string; private readonly pwaThemeColor: string; + /** The resolved permission served as `telemetry.allowClientErrorReporting`. */ + private readonly allowClientErrorReporting: boolean; + /** An unrecognised switch spelling, kept so `start()` can name it once. */ + private readonly refusedTelemetryGrant: string | undefined; + /** True when a real grant was overruled by the declined control plane. */ + private readonly telemetryGrantRefusedByPosture: boolean; private readonly resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides; constructor(config: RuntimeConfigPluginConfig = {}) { @@ -514,6 +637,24 @@ export class RuntimeConfigPlugin implements Plugin { this.brandColor = config.brandColor ?? envBrandColor; this.pwaDescription = config.pwaDescription ?? envPwaDescription ?? `${this.productName} — runtime console`; this.pwaThemeColor = config.pwaThemeColor ?? envPwaThemeColor ?? this.brandColor ?? '#4f46e5'; + + // Telemetry permission (#10805). Same precedence as every branding key + // — the HOST's explicit option wins, the env var is the operator's + // fallback — but the BASE is denial rather than a default value, and + // the posture can only lower the result. + const envGrant = readClientErrorReportingGrant( + typeof process !== 'undefined' ? process.env?.[CLIENT_ERROR_REPORTING_ENV] : undefined, + ); + this.refusedTelemetryGrant = envGrant.refused; + // `=== true`, not `??` then coerce: a JS host outside this type can + // hand us any value, and only the literal boolean grants. + const requested = config.allowClientErrorReporting !== undefined + ? config.allowClientErrorReporting === true + : envGrant.allowed; + const declined = declinesControlPlane(config.controlPlaneUrl); + this.allowClientErrorReporting = requested && !declined; + // Only worth a diagnostic when something was actually taken away. + this.telemetryGrantRefusedByPosture = requested && declined; } init = async (_ctx: PluginContext): Promise => {}; @@ -566,6 +707,38 @@ export class RuntimeConfigPlugin implements Plugin { ); } + // Telemetry switch outside the closed vocabulary (#10805). Same + // shape and same reason as the stage refusal above: the operator + // meant to GRANT something, the value was not understood, and the + // permission stays denied. Denial is the safe direction, so this + // is `warn` — but it must not be silent, or the operator reads a + // console with no error reporting and no explanation. + if (this.refusedTelemetryGrant !== undefined) { + ctx.logger?.warn?.( + `[RuntimeConfigPlugin] ignoring unrecognised telemetry switch ` + + `${JSON.stringify(this.refusedTelemetryGrant)} (${CLIENT_ERROR_REPORTING_ENV}) — ` + + `telemetry.allowClientErrorReporting stays false and the Console will not send client ` + + `error reports. Accepted values: ${CLIENT_ERROR_REPORTING_SPELLINGS.join(', ')}.`, + ); + } + + // A real, well-spelled grant overruled by the deployment's own + // declaration (#10805). This is the copied-env-file shape that + // cloud#1508 reported: a hosted configuration landing on an + // air-gapped box. The grant loses — a runtime that declined + // outbound calls has declined this one too — and `warn` rather + // than silence because the operator's explicit request is the + // thing being refused. + if (this.telemetryGrantRefusedByPosture) { + ctx.logger?.warn?.( + `[RuntimeConfigPlugin] refusing the client-error-reporting grant ` + + `(${CLIENT_ERROR_REPORTING_ENV} / the \`allowClientErrorReporting\` option): this runtime ` + + `declared its control plane off via OS_CLOUD_URL, which disables every remote call. ` + + `telemetry.allowClientErrorReporting stays false. Point OS_CLOUD_URL at a control plane ` + + `if this deployment is not air-gapped.`, + ); + } + // A multi-tenant runtime serves many subdomains, each mapped to // one environment. Telling the SPA *which* environment it is // attached to (per-request) lets the App Marketplace skip the @@ -679,6 +852,24 @@ export class RuntimeConfigPlugin implements Plugin { pwaDescription: this.pwaDescription, pwaThemeColor: this.pwaThemeColor, }, + // Its OWN namespace, deliberately not a member of + // `features` (#10805). That map is open-ended and a host's + // `resolveFeatures` hook merges arbitrary keys into it + // verbatim, so a distribution could grant this permission + // by returning one boolean from code whose subject is + // billing tiers. A security permission has exactly one + // author. Pinned in runtime-config-telemetry.test.ts. + // + // Always present, unlike `branding.stage` above: absence + // is reserved for payloads that did NOT come from a + // runtime that knows this key (older ObjectStack, third + // party, 404, network error), and every one of those must + // read as denial. Emitting an explicit `false` keeps that + // meaning unambiguous and leaves the state diagnosable + // with one curl. + telemetry: { + allowClientErrorReporting: this.allowClientErrorReporting, + } satisfies RuntimeTelemetryPosture, }); }; rawApp.get('/api/v1/runtime/config', handler); diff --git a/packages/cloud-connection/src/runtime-config-telemetry.test.ts b/packages/cloud-connection/src/runtime-config-telemetry.test.ts new file mode 100644 index 0000000000..adcc85985d --- /dev/null +++ b/packages/cloud-connection/src/runtime-config-telemetry.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `telemetry.allowClientErrorReporting` — the post-build off switch (#10805, + * upstream half of cloud#1508). + * + * The measured injury: an air-gapped on-prem EE Console sent 14 Sentry + * envelopes per session to `sentry.io` carrying IP + User-Agent PII, and the + * customer had no way to stop it — every knob was a Vite build-time variable + * frozen into the bundle. This file pins the server half of the fix. + * + * Two subjects, and they are different claims: + * + * - `RuntimeConfigPlugin` — does the runtime SAY the right thing? + * - `isClientErrorReportingAllowed` — does the documented reading of what it + * said (and of what a runtime that never heard of the key said) come out + * fail-closed? + * + * The second is the one that carries the guarantee, which is why the producer + * owns it: "absent means do not send" is a claim about consumer code, and a + * consumer left to write its own `?.` chain is one `!== false` away from + * re-opening the leak on exactly the legacy payloads the guarantee is for. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js'; +import { + isClientErrorReportingAllowed, + readClientErrorReportingGrant, + CLIENT_ERROR_REPORTING_ENV, +} from './telemetry-posture.js'; + +interface Served { + body: any; + warnings: string[]; +} + +/** + * Mount the plugin on a Hono-shaped raw app and serve one request, keeping the + * warnings it emitted at mount time. Same harness as + * `runtime-config-stage.test.ts` — the refusal paths are only observable there. + * + * The default `controlPlaneUrl: ''` is load-bearing, not boilerplate: it is + * what the CLI passes on BOTH its arms, so every test below that does not + * mention the env var is running in the "same origin, not declined" posture. + */ +async function serve(pluginConfig: RuntimeConfigPluginConfig = {}): Promise { + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + routes: [] as Array<{ method: string; path: string }>, + get(path: string, h: (c: any) => Promise) { + this.routes.push({ method: 'GET', path }); + if (path === '/api/v1/runtime/config') handler = h; + }, + }; + const warnings: string[] = []; + const ctx: any = { + logger: { info() {}, warn: (m: string) => { warnings.push(String(m)); } }, + getService: (n: string) => { + if (n === 'http-server') return { getRawApp: () => rawApp }; + throw new Error(`no ${n}`); + }, + hooks: [] as Array<() => Promise>, + hook(_e: string, cb: () => Promise) { this.hooks.push(cb); }, + }; + const plugin = new RuntimeConfigPlugin({ controlPlaneUrl: '', singleEnvironment: true, ...pluginConfig }); + await plugin.start(ctx); + for (const cb of ctx.hooks) await cb(); + if (!handler) throw new Error('handler not mounted'); + const body = await handler({ req: { header: () => undefined }, json: (b: any) => b }); + return { body, warnings }; +} + +/** + * The consumer rule this key exists to make expressible, written out once. + * + * The DSN is the build-time conjunct objectui already owns; the permission is + * the runtime conjunct this repo just added. Neither alone sends. Spelling the + * conjunction here is what lets the "a declining server beats a DSN" pins be + * about the composed decision rather than about a boolean in isolation. + */ +function wouldSendToThirdParty(buildTimeDsn: string | undefined, runtimeConfig: unknown): boolean { + return Boolean(buildTimeDsn) && isClientErrorReportingAllowed(runtimeConfig); +} + +/** A build that DID opt in at build time — the artifact cloud#1508 measured. */ +const OPTED_IN_BUILD = 'https://abc123@o1.ingest.sentry.io/42'; + +describe('RuntimeConfigPlugin — telemetry.allowClientErrorReporting (#10805)', () => { + const savedGrant = process.env[CLIENT_ERROR_REPORTING_ENV]; + const savedCloudUrl = process.env.OS_CLOUD_URL; + + beforeEach(() => { + delete process.env[CLIENT_ERROR_REPORTING_ENV]; + delete process.env.OS_CLOUD_URL; + }); + afterEach(() => { + if (savedGrant === undefined) delete process.env[CLIENT_ERROR_REPORTING_ENV]; + else process.env[CLIENT_ERROR_REPORTING_ENV] = savedGrant; + if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL; + else process.env.OS_CLOUD_URL = savedCloudUrl; + }); + + describe('fail-closed by default — a deployment that says nothing sends nothing', () => { + it('denies with zero configuration (the air-gapped operator who never heard of Sentry)', async () => { + const { body, warnings } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + // Nothing was refused, so nothing is reported: a default is not a + // diagnostic, and a warning on every boot is a muted warning. + expect(warnings).toEqual([]); + }); + + it('denies on a CONNECTED posture too, not only on the air-gapped one', async () => { + // The internet-connected on-prem EE box runs the SAME build + // artifact as the hosted console, so "connected therefore allowed" + // would have left the reported injury class open one deployment + // over, for a customer equally unaware of Sentry. + process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai'; + const { body } = await serve({ controlPlaneUrl: 'https://cloud.objectos.ai' }); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + }); + + it('the key is ALWAYS present, so absence can mean exactly one thing', async () => { + const { body } = await serve(); + expect(Object.prototype.hasOwnProperty.call(body, 'telemetry')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(body.telemetry, 'allowClientErrorReporting')).toBe(true); + // ...and it survives the wire as a real boolean, not as a dropped + // `undefined` — the failure `branding.stage` had to be spelled + // around, pointing the other way. + const parsed = JSON.parse(JSON.stringify(body)); + expect(parsed.telemetry).toEqual({ allowClientErrorReporting: false }); + }); + }); + + describe('direction 1 — an explicit grant reaches the payload', () => { + it.each(['1', 'true', 'on', 'yes', 'TRUE', ' yes '])('grants on %j', async (raw) => { + process.env[CLIENT_ERROR_REPORTING_ENV] = raw; + const { body, warnings } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(true); + expect(warnings).toEqual([]); + }); + + it('the host option grants with no env var set at all', async () => { + const { body } = await serve({ allowClientErrorReporting: true }); + expect(body.telemetry.allowClientErrorReporting).toBe(true); + }); + + it('the host option wins over the env var — in BOTH directions', async () => { + process.env[CLIENT_ERROR_REPORTING_ENV] = 'false'; + expect((await serve({ allowClientErrorReporting: true })).body.telemetry.allowClientErrorReporting) + .toBe(true); + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + expect((await serve({ allowClientErrorReporting: false })).body.telemetry.allowClientErrorReporting) + .toBe(false); + }); + + it('a same-origin runtime (controlPlaneUrl: "") is NOT a declined control plane', async () => { + // The conflation this key must not inherit: `resolveCloudUrl()` + // returns '' both for "this runtime IS the cloud" and for + // `OS_CLOUD_URL=off`. Reading the posture off that would deny the + // hosted console — the one deployment that legitimately grants. + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + const { body, warnings } = await serve({ controlPlaneUrl: '' }); + expect(body.telemetry.allowClientErrorReporting).toBe(true); + expect(warnings).toEqual([]); + }); + }); + + describe('direction 2 — an explicit denial, and the vocabulary is closed', () => { + it.each(['0', 'false', 'off', 'no'])('denies on %j, silently (deliberate, not a typo)', async (raw) => { + process.env[CLIENT_ERROR_REPORTING_ENV] = raw; + const { body, warnings } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + expect(warnings).toEqual([]); + }); + + it.each(['enable', 'enabled', 'y', 'sure', '2'])('refuses %j rather than guessing', async (raw) => { + process.env[CLIENT_ERROR_REPORTING_ENV] = raw; + const { body } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + }); + + it('names the refused value AND the accepted set, so the operator can fix it', async () => { + process.env[CLIENT_ERROR_REPORTING_ENV] = 'enable'; + const { warnings } = await serve(); + const warning = warnings.find((w) => w.includes('telemetry switch')); + expect(warning).toBeDefined(); + expect(warning).toContain('"enable"'); + expect(warning).toContain(CLIENT_ERROR_REPORTING_ENV); + for (const accepted of ['1', 'true', 'on', 'yes', '0', 'false', 'off', 'no']) { + expect(warning).toContain(accepted); + } + }); + + it('an EMPTY env var reads as unset — denied, and silent', async () => { + process.env[CLIENT_ERROR_REPORTING_ENV] = ' '; + const { body, warnings } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + expect(warnings).toEqual([]); + }); + }); + + describe('the declared-off control plane refuses the grant (the posture ceiling)', () => { + it.each(['off', 'none', 'local', 'disabled', 'OFF', ' off '])( + 'OS_CLOUD_URL=%j overrules a well-spelled grant', + async (raw) => { + process.env.OS_CLOUD_URL = raw; + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + const { body } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + }, + ); + + it('overrules the HOST option too, not only the env var', async () => { + process.env.OS_CLOUD_URL = 'off'; + const { body } = await serve({ allowClientErrorReporting: true }); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + }); + + it('catches the decline spelled in the host argument, with no env var at all', async () => { + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + const { body } = await serve({ controlPlaneUrl: 'off' }); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + }); + + it('says so — an explicit request refused in silence is the defect this card is about', async () => { + process.env.OS_CLOUD_URL = 'off'; + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + const { warnings } = await serve(); + const warning = warnings.find((w) => w.includes('client-error-reporting grant')); + expect(warning).toBeDefined(); + expect(warning).toContain('OS_CLOUD_URL'); + expect(warning).toContain(CLIENT_ERROR_REPORTING_ENV); + }); + + it('stays silent when there was no grant to refuse', async () => { + process.env.OS_CLOUD_URL = 'off'; + const { body, warnings } = await serve(); + expect(body.telemetry.allowClientErrorReporting).toBe(false); + expect(warnings.some((w) => w.includes('client-error-reporting grant'))).toBe(false); + }); + }); + + describe('the permission has exactly one author — resolveFeatures cannot grant it', () => { + it('a distribution feature hook returning the key does not move the permission', async () => { + const { body } = await serve({ + resolveFeatures: () => ({ allowClientErrorReporting: true } as any), + }); + // It may land in the open-ended feature map — that map is the + // distribution's — but the permission is a sibling of it, not a + // member, so billing-tier code cannot reach it. + expect(body.telemetry.allowClientErrorReporting).toBe(false); + expect(isClientErrorReportingAllowed(body)).toBe(false); + }); + }); +}); + +describe('isClientErrorReportingAllowed — the fail-closed reading (#10805)', () => { + describe('a declining server beats a build-time DSN', () => { + it('an opted-in BUILD sends nothing when the runtime declines', () => { + const declining = { telemetry: { allowClientErrorReporting: false } }; + expect(wouldSendToThirdParty(OPTED_IN_BUILD, declining)).toBe(false); + }); + + it('...and sends when the same build meets a runtime that grants', () => { + // The control that makes the line above a reading rather than a + // function that always answers false. + const granting = { telemetry: { allowClientErrorReporting: true } }; + expect(wouldSendToThirdParty(OPTED_IN_BUILD, granting)).toBe(true); + }); + + it('a granting runtime cannot START telemetry on a build with no DSN', () => { + // The permission is a conjunct, never a source: this side supplies + // no sink and must not be able to open one. + const granting = { telemetry: { allowClientErrorReporting: true } }; + expect(wouldSendToThirdParty(undefined, granting)).toBe(false); + }); + }); + + describe('absent reads as do-not-send', () => { + it('a payload from a runtime that never heard of the key', () => { + // Every field a pre-#10805 runtime really serves, and no telemetry. + const legacy = { + cloudUrl: '', + singleEnvironment: true, + features: { installLocal: true, marketplace: false, aiStudio: true }, + branding: { productName: 'ObjectOS' }, + }; + expect(Object.prototype.hasOwnProperty.call(legacy, 'telemetry')).toBe(false); + expect(isClientErrorReportingAllowed(legacy)).toBe(false); + expect(wouldSendToThirdParty(OPTED_IN_BUILD, legacy)).toBe(false); + }); + + it.each([ + ['an empty telemetry block', { telemetry: {} }], + ['a present-but-undefined permission', { telemetry: { allowClientErrorReporting: undefined } }], + ['a null telemetry block', { telemetry: null }], + ['a telemetry block that is not an object', { telemetry: 'on' }], + ['an empty payload', {}], + ])('%s', (_label, payload) => { + expect(isClientErrorReportingAllowed(payload)).toBe(false); + }); + + it.each([ + ['the STRING "true"', 'true'], + ['the number 1', 1], + ['the string "yes"', 'yes'], + ['a truthy object', {}], + ])('does not accept %s as the permission', (_label, value) => { + // `=== true`, not truthiness. A consumer should not be taught that + // any truthy shape on this key opens a third-party data flow. + expect(isClientErrorReportingAllowed({ telemetry: { allowClientErrorReporting: value } })).toBe(false); + }); + }); + + describe('a failed or erroring config fetch reads as do-not-send', () => { + it.each([ + ['the fetch threw and the caller has nothing', undefined], + ['the response body was null', null], + ['a 404 handed back an error envelope', { error: { code: 'NOT_FOUND' } }], + ['the body was not JSON at all', ''], + ['the endpoint answered with an array', []], + ])('%s', (_label, payload) => { + expect(isClientErrorReportingAllowed(payload)).toBe(false); + expect(wouldSendToThirdParty(OPTED_IN_BUILD, payload)).toBe(false); + }); + + it('the real thing: a rejected fetch, caught, read through the same function', async () => { + const fetchRuntimeConfig = async (): Promise => { + try { + throw new TypeError('Failed to fetch'); + } catch { + // The whole point of accepting `unknown`: the error path + // is the absent path, so there is no second reading to + // forget to write. + return undefined; + } + }; + expect(wouldSendToThirdParty(OPTED_IN_BUILD, await fetchRuntimeConfig())).toBe(false); + }); + }); + + describe('the served payload round-trips through JSON into the same verdict', () => { + it('granting', async () => { + process.env[CLIENT_ERROR_REPORTING_ENV] = 'true'; + const { body } = await serve(); + delete process.env[CLIENT_ERROR_REPORTING_ENV]; + expect(isClientErrorReportingAllowed(JSON.parse(JSON.stringify(body)))).toBe(true); + }); + + it('declining', async () => { + const { body } = await serve(); + expect(isClientErrorReportingAllowed(JSON.parse(JSON.stringify(body)))).toBe(false); + }); + }); +}); + +describe('readClientErrorReportingGrant — the closed switch vocabulary (#10805)', () => { + it('unset is unset: denied, and not a refusal', () => { + expect(readClientErrorReportingGrant(undefined)).toEqual({ allowed: false }); + }); + + it('an unrecognised spelling is denied AND held for reporting', () => { + expect(readClientErrorReportingGrant('enable')).toEqual({ allowed: false, refused: 'enable' }); + }); + + it('keeps the operator original spelling, untrimmed, so the diagnostic quotes what they typed', () => { + expect(readClientErrorReportingGrant(' Enable ').refused).toBe(' Enable '); + }); +}); diff --git a/packages/cloud-connection/src/telemetry-posture.ts b/packages/cloud-connection/src/telemetry-posture.ts new file mode 100644 index 0000000000..f449785ff8 --- /dev/null +++ b/packages/cloud-connection/src/telemetry-posture.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `telemetry.allowClientErrorReporting` — the runtime's post-build permission + * for SPA client telemetry (#10805, upstream half of cloud#1508). + * + * ## The injury this exists to end + * + * An air-gapped on-premises EE Console was measured sending 14 Sentry + * envelopes per session to `sentry.io`, carrying IP + User-Agent PII, with no + * way for the customer to turn it off. objectui closed one half (a build that + * never opts in now issues no third-party request at all), and could not close + * the other: every knob there is a Vite build-time variable that is inlined + * into the bundle as a frozen literal, so a build that DID opt in — the hosted + * console, and the identical artifact shipped on-prem — has no post-build off + * switch. Editing env vars on the deployed host does nothing. The only + * server-to-SPA channel is `GET /api/v1/runtime/config`, which this package + * owns, so the switch has to be a key on that payload. + * + * ## The permission is a CONJUNCT, never a source + * + * The server never supplies a DSN and cannot turn telemetry ON for a build + * that carries none. `allowClientErrorReporting: true` means only "this + * deployment does not object to the sink your build was compiled with"; the + * consumer still needs its own build-time DSN. Deliberate: a server that could + * *start* a third-party data flow in someone's browser is a strictly worse + * surface than the one being fixed. So the composed decision is + * `Boolean(buildTimeDsn) && isClientErrorReportingAllowed(payload)`, and this + * side owns the second conjunct only. + * + * ## Why a positive permission and not a negative kill switch + * + * Spelling it `telemetry: { disabled: true }` would have read as `undefined` + * on every server too old to know the key, on every malformed payload, and on + * every failed fetch — falsy, therefore "not disabled", therefore SEND. The + * gate would be vacuous exactly where it is needed: the legacy runtimes that + * are leaking today. Phrased as a permission that must be positively granted, + * every one of those states collapses onto "not `true`" and denies. The + * fail-closed reading is then a property of the VOCABULARY rather than a + * discipline each consumer has to remember. + * + * The boolean is chosen over a `'allowed' | 'denied'` union for the same + * reason: the shortest expression a consumer can write — `if (allowed)` — is + * already the safe one, whereas the laziest string test (`!== 'denied'`) fails + * OPEN on absence. + * + * ## Absence + * + * `RuntimeConfigPlugin` always emits the key, so absence never means "this + * server had no opinion" — it means the payload did not come from a runtime + * that knows about it (an older ObjectStack, a third-party host, a 404, a + * network error). {@link isClientErrorReportingAllowed} answers `false` for + * every one of those, and that reading is the contract, not an implementation + * detail: see its own note for why the producer owns it. + */ + +/** + * Operator switch that grants the permission. Boolean feature flag, + * default-off / opt-in, per the `OS_{DOMAIN}_{FEATURE}_ENABLED` rule. + * + * Named for the narrow thing it grants rather than for "telemetry": a later + * sibling permission (session replay, product analytics) must be a SEPARATE + * grant, and an operator who set a var called `OS_CONSOLE_TELEMETRY_ENABLED` + * would reasonably read it as having already granted those too. + */ +export const CLIENT_ERROR_REPORTING_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED'; + +/** + * The truthy vocabulary the whole repo's opt-in flags answer to + * (`resolveAllowDevPlugin` in `@objectstack/types` states the reasoning: a + * strict `=== '1'` fails closed on `=true`, which is safe but reads to an + * operator as the flag being broken). + */ +const GRANT_SPELLINGS: readonly string[] = ['1', 'true', 'on', 'yes']; + +/** The matching falsy vocabulary — a DELIBERATE denial, not a typo, so silent. */ +const DENY_SPELLINGS: readonly string[] = ['0', 'false', 'off', 'no']; + +/** Every spelling the switch accepts, in the order a diagnostic lists them. */ +export const CLIENT_ERROR_REPORTING_SPELLINGS: readonly string[] = [ + ...GRANT_SPELLINGS, + ...DENY_SPELLINGS, +]; + +/** What an operator's raw switch value resolved to. */ +export interface TelemetryGrantReading { + /** The permission. Fail-closed: only a recognised grant spelling is `true`. */ + readonly allowed: boolean; + /** + * The rejected spelling, when the operator said something outside the + * closed set. Held rather than warned about here so the caller can report + * it once, at mount time, where it has a logger. + */ + readonly refused?: string; +} + +const DENIED: TelemetryGrantReading = { allowed: false }; +const GRANTED: TelemetryGrantReading = { allowed: true }; + +/** + * Read the operator's switch through a CLOSED vocabulary. + * + * An unrecognised spelling is REFUSED and reported, never coerced — the same + * discipline `asPlatformStage` applies to `branding.stage`, and for the same + * reason: a knob that appears to work while doing nothing is how this whole + * family of defects reaches production. Here the refusal also happens to be + * the safe direction, but the diagnostic is what the operator needs, because + * their intent (`=enable`, `=Y`) was to grant and nothing would have said + * otherwise. + * + * Unset, empty, or whitespace-only is UNSET, not a typo: denied, and silent. + */ +export function readClientErrorReportingGrant(raw: string | undefined): TelemetryGrantReading { + if (raw === undefined) return DENIED; + const value = raw.trim().toLowerCase(); + if (value === '') return DENIED; + if (GRANT_SPELLINGS.includes(value)) return GRANTED; + if (DENY_SPELLINGS.includes(value)) return DENIED; + return { allowed: false, refused: raw }; +} + +/** + * The telemetry block served on `/api/v1/runtime/config`. + * + * A namespace of its own — deliberately NOT a member of `features`. That map + * is open-ended and a host's `resolveFeatures` hook merges arbitrary keys into + * it verbatim, so a distribution's plan policy could grant this permission by + * returning one boolean, from code whose subject is billing tiers. A security + * permission must have exactly one author. The separation is pinned by test. + */ +export interface RuntimeTelemetryPosture { + /** + * May the SPA send client error reports to the sink its build was + * compiled with? `false` unless a runtime positively granted it. + */ + readonly allowClientErrorReporting: boolean; +} + +/** + * The canonical fail-closed reading of a `/api/v1/runtime/config` payload. + * + * ## Why the producer owns the consumer's test + * + * "Absent reads as do-not-send" is the whole guarantee, and it is a claim + * about code that does NOT live here — every consumer writing its own `?.` + * chain is one `!== false` away from re-opening the leak, silently, on exactly + * the legacy payloads the guarantee is for. So the reading ships with the + * contract: one strict function, pinned in both directions here, rather than N + * dialects accumulating in the consumers (Prime Directive #12). + * + * Accepts `unknown` on purpose. Callers hand it a parsed HTTP body, and the + * "the fetch failed" case is spelled by passing `undefined` or `null` — so the + * error path and the absent-key path reach the same answer through the same + * function, instead of the error path being a `catch` block someone forgot to + * write. + * + * The test is `=== true`, not truthiness: the string `'true'`, `1`, and + * `'yes'` are payloads a consumer should not be teaching itself to accept. On + * the wire the value is produced by `RuntimeConfigPlugin` as a real boolean. + */ +export function isClientErrorReportingAllowed(runtimeConfig: unknown): boolean { + if (typeof runtimeConfig !== 'object' || runtimeConfig === null) return false; + const telemetry = (runtimeConfig as { telemetry?: unknown }).telemetry; + if (typeof telemetry !== 'object' || telemetry === null) return false; + return (telemetry as { allowClientErrorReporting?: unknown }).allowClientErrorReporting === true; +}