Skip to content

Commit 709ce17

Browse files
claude[bot]claude
andauthored
runtime-config: serve the client error-reporting DSN from the server (#12697)
ObjectStack's users consume a prebuilt Console and cannot set build-time keys, so the two-key gate shipped in #10805 -- a build-time VITE_SENTRY_DSN AND a runtime permission -- left a self-hosting operator unable to enable client error reporting at all. The DSN now travels on GET /api/v1/runtime/config together with the closed set of knobs that must accompany it, and the permission boolean it replaces is removed rather than paralleled. The DSN's presence IS the grant: no second boolean, so the two silent dead states the split shape had ("permission on, no DSN" / "DSN in, permission off") cannot exist. Fail-closed survives the collapse for free, because absence of a source is not a value that can be misread. Malformed values are refused at mount and never coerced, with every refusal landing on the safer value: a bad DSN withholds the whole block, a bad sample rate falls back to its default. A DSN carrying a secret after the public key is refused outright -- this payload is read by every browser that loads the Console. OS_CLOUD_URL=off still refuses to serve any sink. Claude-Session: https://claude.ai/code/session_01DKWDdUJ2XNRESVVWUvcpnh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e8f5b0 commit 709ce17

9 files changed

Lines changed: 1201 additions & 454 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
**Security (p0, upstream half):** `GET /api/v1/runtime/config` now serves the Console's client error-reporting **sink** — the DSN itself, plus the closed set of knobs that travel with it — so a self-hosting operator configures telemetry on the server, in one place, with no frontend rebuild (#12681, upstream half of `cloud#1508`).
6+
7+
```json
8+
{
9+
"telemetry": {
10+
"errorReporting": {
11+
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
12+
"sendDefaultPii": false,
13+
"environment": "production",
14+
"tracesSampleRate": 0.1,
15+
"replaysOnErrorSampleRate": 0
16+
}
17+
}
18+
}
19+
```
20+
21+
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. The first fix (#10805) served a runtime *permission* and left the *source* where it was — a build-time `VITE_SENTRY_DSN` inlined into the published bundle. That closed the leak and opened a different hole, which the maintainer named on 2026-08-27:
22+
23+
> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」
24+
25+
ObjectStack's users consume a **prebuilt** Console. They cannot set a build-time key, so under the two-key gate a self-hosting operator could not enable client error reporting at all: the permission was reachable and the source was not.
26+
27+
**The DSN's presence IS the grant.** There is no second boolean, and this is not shorthand — it removes the failure mode the two-key shape had. With a permission and a source configured in different places, "permission on, no DSN" and "DSN in, permission off" are two silent dead states that look identical from the browser. One knob cannot disagree with itself.
28+
29+
The fail-closed direction survives the collapse for free, and more robustly than the boolean managed: the grant is now "a non-empty DSN reached me", so an older runtime, a third-party host, a 404, a network error, a malformed body and a payload that has not arrived yet all carry no DSN and therefore deny. A boolean needed `=== true` plus a written argument about why `disabled: true` would have been vacuous; absence of a *source* is not a value that can be misread.
30+
31+
**Everything that must travel with the DSN travels with it.** `sendDefaultPii`, `environment`, `tracesSampleRate` and `replaysOnErrorSampleRate` were build-time `VITE_SENTRY_*` variables, which a prebuilt-console consumer could set none of — including the one deciding whether IP and User-Agent leave the network. This is not new surface; it is the same surface moved to the side that can operate it. One knob deliberately did **not** move: a release identifies *which bundle* produced a stack trace and must match that build's uploaded source maps, so `VITE_SENTRY_RELEASE` stays build-time in objectui and is the only `VITE_SENTRY_*` knob that does.
32+
33+
**Malformed is refused at mount, never coerced**, and every refusal lands on the safer value. A DSN that is not an `https://PUBLIC_KEY@HOST/PROJECT_ID` URL is refused and the whole block withheld — there is no safe default for a source. A DSN carrying a **secret** after the public key is refused for a different reason: this payload is read by every browser that loads the Console, so a legacy secret-bearing DSN would publish that secret to every visitor while looking entirely ordinary. A bad sample rate falls back to its documented default instead, because silencing error reporting over a typo in a volume knob would be strictness pointed away from the hazard. Quoted values are key-redacted: boot logs travel further than the configuration they quote.
34+
35+
**A runtime that declared its control plane off serves no sink.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the DSN 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.
36+
37+
**Absence is denial, and the reading ships with the contract.** `readClientErrorReporting(payload)` is the canonical fail-closed reader, returning the sink or `null`; a failed fetch is spelled by passing `undefined`, so the error path and the absent path reach the same answer through the same function. It is exported rather than left to consumers because "no DSN means do not send" is a claim about *their* code.
38+
39+
### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled
40+
41+
The #10805 permission boolean is removed in this same change — no dual-spelling window. It was added days ago, is **unreleased** (it appears in no published `CHANGELOG.md`), and no deployment consumes it; its pending changeset is superseded by this one rather than shipping a feature and its removal in the same release notes.
42+
43+
| FROM | TO |
44+
|:--|:--|
45+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
46+
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
47+
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
48+
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
49+
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |
50+
51+
One-line fix for an operator: replace the `..._ENABLED=true` line with a `..._DSN=` line carrying your DSN. One-line fix for a consumer: `if (readClientErrorReporting(payload)) …` in place of `if (buildTimeDsn && isClientErrorReportingAllowed(payload)) …` — the build-time conjunct is gone, because the server now supplies the source.
52+
53+
**Landing order is safe in both directions.** An old client meeting this server reads an absent `allowClientErrorReporting` and denies; a new client meeting an old server reads an absent DSN and stays off. Neither half can turn reporting on by itself, so the two repos' PRs can land in any order.
54+
55+
<!-- adr-0087: not-required (unpublished) the replaced boolean, its env var, its config option and its reader were added by #10805 and never shipped in a published release — `@objectstack/cloud-connection@17.2.0` carries no mention of them and their changeset was still pending in `.changeset/`, so there is no upgrader to reach. -->

.changeset/runtime-config-telemetry-posture.md

Lines changed: 0 additions & 25 deletions
This file was deleted.

content/docs/deployment/environment-variables.mdx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,11 @@ the hosted ObjectOS Cloud control plane.
339339
| `OS_OTLP_ENDPOINT` | url || OTLP/HTTP collector endpoint. Required when `OS_OBS_EXPORTER=otlp`. |
340340
| `OS_OTLP_HEADERS` | csv || Comma-separated `key=value` pairs added to every OTLP export. |
341341
| `OS_OTLP_FLUSH_MS` | number | `10000` | OTLP batch flush interval. |
342-
| `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. |
342+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN` | url || Error-reporting sink for the Console SPA, served as `telemetry.errorReporting.dsn` on `/api/v1/runtime/config`. **Presence is the grant** — unset means the Console sends nothing, and there is no separate permission flag. Set it here and nowhere else: the Console is consumed prebuilt, so this is the only place a self-hosting operator can configure it, and no frontend rebuild is involved. Refused loudly at mount when it is not an `https://PUBLIC_KEY@HOST/PROJECT_ID` URL, when it carries a secret after the public key (this payload is public to every browser), or when the runtime declared its control plane off (`OS_CLOUD_URL=off`). |
343+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_SEND_DEFAULT_PII` | boolean | `false` | Attach IP address and User-Agent to client error events. Opt-in; an unrecognised value is refused at mount and stays off. Inert without a DSN. |
344+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENVIRONMENT` | string || `environment` tag on client error events (`production`, `staging`, …). Unset lets the Console tag events with its own build mode. Inert without a DSN. |
345+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_TRACES_SAMPLE_RATE` | number | `0.1` | Fraction (`0``1`) of Console transactions sampled for performance tracing. Out-of-range or unparseable values are refused at mount and fall back to the default. Inert without a DSN. |
346+
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_REPLAY_SAMPLE_RATE` | number | `0` | Fraction (`0``1`) of Console **error** sessions recorded as session replays. Off by default — replay records what the user did, so it is the deliberate choice of the deployment that wants it. Inert without a DSN. |
343347

344348
---
345349

packages/cli/test/serve-marketplace-offline-runtime-config.test.ts

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
431431
});
432432

433433
/**
434-
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
434+
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
435435
*
436-
* This is cloud#1508's acceptance expressed on the server side: on a
437-
* composed / air-gapped posture, a Console build that DOES carry a Sentry DSN
438-
* must be told not to send, through a switch that needs no rebuild.
436+
* This is cloud#1508's acceptance expressed on the server side, now that the
437+
* DSN itself travels on the payload: on a composed / air-gapped posture, the
438+
* Console must be given no sink at all, and an operator who configured one
439+
* anyway must be told it was refused — with no rebuild involved on either side.
439440
*
440441
* It belongs here rather than only in the plugin's own suite because of one
441442
* measured property of this wiring: `Serve.RUNTIME_CONFIG_OPTIONS` hands the
@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
451452
* blocks above do, because that simulation is precisely the half that would
452453
* hide the defect.
453454
*/
454-
describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_URL=off boot', () => {
455-
const GRANT_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED';
455+
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
456+
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
457+
const DSN = 'https://abc123@o1.ingest.sentry.io/42';
456458

457459
async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
458460
const savedCloudUrl = process.env.OS_CLOUD_URL;
459-
const savedGrant = process.env[GRANT_ENV];
461+
const savedDsn = process.env[DSN_ENV];
460462
const dir = tempStorageDir();
461463
try {
462464
process.env.OS_CLOUD_URL = 'off';
@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
465467
} finally {
466468
if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL;
467469
else process.env.OS_CLOUD_URL = savedCloudUrl;
468-
if (savedGrant === undefined) delete process.env[GRANT_ENV];
469-
else process.env[GRANT_ENV] = savedGrant;
470+
if (savedDsn === undefined) delete process.env[DSN_ENV];
471+
else process.env[DSN_ENV] = savedDsn;
470472
rmSync(dir, { recursive: true, force: true });
471473
}
472474
}
473475

474-
it('THE ACCEPTANCE — an air-gapped boot tells the Console not to send, with zero configuration', async () => {
476+
it('THE ACCEPTANCE — an air-gapped boot hands the Console no sink, with zero configuration', async () => {
475477
await bootAirGapped((body) => {
476478
expect(
477-
body.telemetry.allowClientErrorReporting,
479+
body.telemetry.errorReporting,
478480
'an operator who has never heard of Sentry must be safe without configuring anything',
479-
).toBe(false);
481+
).toBeUndefined();
480482
});
481483
});
482484

483-
it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
484-
process.env[GRANT_ENV] = 'true';
485+
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
486+
process.env[DSN_ENV] = DSN;
485487
await bootAirGapped(async (body) => {
486-
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
487-
expect(body.telemetry.allowClientErrorReporting).toBe(false);
488+
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
489+
expect(body.telemetry.errorReporting).toBeUndefined();
488490
// Read through the exported contract too: what the SPA will actually do
489-
// with this payload is the thing under test, not the raw boolean.
490-
expect(isClientErrorReportingAllowed(body)).toBe(false);
491+
// with this payload is the thing under test, not the raw key.
492+
expect(readClientErrorReporting(body)).toBeNull();
491493
});
492494
});
493495

494-
it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
496+
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', async () => {
495497
// Without this, the refusal above could be an artifact of the fixture
496498
// rather than a posture reading, and the pin would stay green on a build
497-
// that denies everything unconditionally.
499+
// that serves nothing unconditionally.
498500
const savedCloudUrl = process.env.OS_CLOUD_URL;
499-
const savedGrant = process.env[GRANT_ENV];
501+
const savedDsn = process.env[DSN_ENV];
500502
try {
501503
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
502-
process.env[GRANT_ENV] = 'true';
504+
process.env[DSN_ENV] = DSN;
503505
const { RuntimeConfigPlugin } = await import('@objectstack/cloud-connection');
504506
const app = createApp();
505507
// The cloud arm's own mount, verbatim — same shared frozen options.
506508
await startOn(app, new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS }));
507509
const body = await readConfig(app);
508-
expect(body.telemetry.allowClientErrorReporting).toBe(true);
510+
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
509511
} finally {
510512
if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL;
511513
else process.env.OS_CLOUD_URL = savedCloudUrl;
512-
if (savedGrant === undefined) delete process.env[GRANT_ENV];
513-
else process.env[GRANT_ENV] = savedGrant;
514+
if (savedDsn === undefined) delete process.env[DSN_ENV];
515+
else process.env[DSN_ENV] = savedDsn;
514516
}
515517
});
516518
});

0 commit comments

Comments
 (0)