Uh oh!
There was an error while loading. Please reload this page.
cloud-connection: serve a fail-closed SPA telemetry permission on /api/v1/runtime/config (#10805) - #11382
Conversation
…i/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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b15effe2c652f23a575dbb9368222337e534ac20 && git checkout b15effe2c652f23a575dbb9368222337e534ac20
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 82b65ec90dfeba9f0006807c32672547e9e57a0f 8682f4b02b702597bfade67ffa1d2cd231d64491 && git checkout -B drift-repro 82b65ec90dfeba9f0006807c32672547e9e57a0f && git merge --no-ff 8682f4b02b702597bfade67ffa1d2cd231d64491
node scripts/docs-audit/affected-docs.mjs --json 82b65ec90dfeba9f0006807c32672547e9e57a0f
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10805
Upstream half of
objectstack-ai/cloud#1508(p0 / security). An air-gapped on-premises EE Console was measured sending 14 Sentry envelopes per session tosentry.io, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns (objectui#5559 — a build that never opts in now issues no third-party request at all) and documented the half it could not, in its shippedapp-shell/src/observability/sentry.ts:This is that key. Implemented against maintainer ruling Option A (2026-08-22): server-authoritative, fail-closed, composed / air-gap defaults off.
The contract
GET /api/v1/runtime/configgains one top-level namespace, alongsidefeaturesandbranding:{ "telemetry": { "allowClientErrorReporting": false } }Operators grant it with
OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true; hosts withnew RuntimeConfigPlugin({ allowClientErrorReporting: true }). The switch answers to the repo's established truthy vocabulary (1/true/on/yes), an explicit falsy spelling is a deliberate silent denial, and an unrecognised spelling is refused and named at mount time rather than coerced — theasPlatformStagediscipline this file already applies tobranding.stage.Four decisions worth a reviewer's attention:
telemetry: { disabled: true }would readundefinedon 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 on the runtimes leaking today. Phrased as a permission, every one of those states collapses onto "nottrue". A boolean rather than anallowed/deniedunion for the same reason: the laziest consumer expression,if (allowed), is already the safe one, while the laziest string test (!== 'denied') fails open on absence.truemeans only "this deployment does not object to the sink you were compiled with". A server that could start a third-party data flow in someone's browser would be a worse surface than the one being fixed.branding.stagediscipline applied rather than copied:stageis absent when unset because the consumer owns its default, whereas here absence must be unambiguous in one direction only, so the server states its answer and never leaves a falsy placeholder or apresent-and-undefinedproperty behind.Where the deployment posture is read from
Triage flagged this as uninvestigated and the ruling handed it to the implementer. No posture source is introduced.
There is no general deployment-posture signal in this repo, and the two nearest candidates are a different axis:
OS_TENANCY_POSTURE(single/group/isolated, ADR-0105) is about organization walls, andOS_MODE(standalone/cloud) is a kernel boot mode read only inside the CLI, defaulting tostandalonefor everyobjectstack dev.What does exist is exactly one network-posture declaration, and it is already this package's: the
OS_CLOUD_URLdecline spellings (off/none/local/disabled), whose contract the package README states in as many words — "OS_CLOUD_URL=offdisables 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: as measured and recorded inpackages/cli/src/commands/serve.ts, the EE image's compose file readsOS_CLOUD_URL: ${OS_CLOUD_URL:-off}, sooffthere is the shipped default rather than an unusual choice. A newOS_DEPLOYMENT_POSTUREvariable would have failed the ruling's actual requirement — zero configuration — by making the operator learn it exists.So a runtime that declared its control plane off refuses the grant, loudly, at mount time. Two properties make that a sound read rather than a guess:
resolveCloudUrl(...) === ''. That test conflates two opposite deployments:''is also how a host says "this runtime IS the cloud" (same origin), andServe.RUNTIME_CONFIG_OPTIONShands the plugincontrolPlaneUrl: ''on both arms of the CLI's marketplace wiring — so on the product path the constructor argument carries no posture information at all. A read built on it would report every hosted console as air-gapped and every air-gapped box as hosted, the second silently.isControlPlaneDeclined()asks the different, answerable question: was a decline spelling actually said, at either door.resolveCloudUrl()maps an unset variable to the public default (truthy), so the offline arm is taken if and only ifOS_CLOUD_URLis a decline spelling — the same condition the refusal reads.isControlPlaneDeclined()is factored out ofcloud-url.tsso "what counts as off" has one definition shared by URL resolution and the telemetry refusal.resolveCloudUrl()is unchanged in behaviour.The fail-closed reading ships with the contract
New export
isClientErrorReportingAllowed(payload). "Absent means do not send" is a claim about consumer code, and a consumer writing its own optional-chain is one!== falseaway from re-opening the leak on exactly the legacy payloads the guarantee is for. It acceptsunknownso that "the fetch failed" is spelled by passingundefinedand reaches the same answer through the same function, rather than being acatchblock someone forgot to write. The test is=== true, not truthiness.The permission is also deliberately not a member of
features: that map is open-ended and a host'sresolveFeatureshook merges arbitrary keys into it verbatim, so a distribution's billing-tier code could otherwise grant it by returning one boolean. Pinned.Verification
All readings below are from commit
8682f4b0, which is this branch's head.pnpm --filter @objectstack/cloud-connection testTest Files 25 passed (25)·Tests 268 passed (268)pnpm --filter @objectstack/cli exec vitest run test/serve-marketplace-offline-runtime-config.test.tsTest Files 1 passed (1)·Tests 14 passed (14)pnpm --filter @objectstack/cli typechecktsc --noEmit, exit 0pnpm check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured, 1897 raw tsc error(s) total, none above its recorded numberpnpm lint(whole repo,eslint . --no-inline-config)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack)check:route-envelopeandcheck:published-readme-exportsare in that set and both pass;packages/cloud-connection's entry in the type-check DEBT ledger did not move.Anti-vacuity
A fail-closed gate that never actually closes is the defect class this card exists to fix, so every pin was shown failing without the behaviour it pins. Four mutations, each applied to the committed tree, proved on disk (removed text at 0 hits, injected marker at 1 hit), run, then restored under an
EXITtrap — the restore verified by the same greps. No rebuild is involved and none is owed: this suite imports its subjects by relative source path, so vitest resolvessrc/, notdist/— and the mutations changing the result with no build is itself the control proving that.?.telemetry?.allowClientErrorReporting !== falsePost-fix, restored: 58 / 58 pass in that file, 268 / 268 in the package.
The CLI acceptance test carries its own positive control rather than an ablation: the same grant on the cloud arm is honoured, so the air-gapped refusal cannot be the fixture answering
falseto everything.Acceptance
cloud#1508's criterion is a composed / air-gap posture producing zero third-party envelopes for a build that carries a DSN, via an off-switch that needs no rebuild. The server half is pinned end to end in
packages/cli/test/serve-marketplace-offline-runtime-config.test.ts: a realOS_CLOUD_URL=offboot through the CLI's actual offline-arm wiring servesallowClientErrorReporting: falsewith zero configuration, and still servesfalsewhen an explicit grant is present. The remaining half — the browser making no request — is objectui's and is out of scope here.Scope
initSentry) is objectui's, filed separately with aBlocked-by:on Runtime config has no telemetry key, so an opted-in console build still has no post-build off switch (upstream half of objectui#5522, cloud#1508 p0) #10805. objectui is untouched.cloudrepo is untouched.content/docs/releases/is untouched..changeset/runtime-config-telemetry-posture.md(minor,@objectstack/cloud-connection).Clause ②
This widens a published payload. The PR stays draft,
needs:contract-reviewstays on both the card and this PR, and this seat neither reviews nor enqueues its own dispatch. Not armed for auto-merge, not queued.One question is left open for that review, stated rather than decided here: should a runtime that declared its control plane off be able to opt in anyway? This PR says no — the declaration is a runtime declining outbound calls and the ruling is that a declining runtime wins, and the deployment it protects (a hosted configuration copied onto an air-gapped box) is cloud#1508's own shape. The counter-case is an on-prem box that disables the ObjectStack control plane while its browsers do have internet; no such deployment is measured today. The strict direction was chosen because relaxing a refusal later is additive, while tightening a permission later breaks deployments.
Generated by Claude Code