Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/runtime-config-client-error-reporting-dsn.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
---
"@objectstack/cloud-connection": minor
---

**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`).

```json
{
"telemetry": {
"errorReporting": {
"dsn": "https://PUBLIC_KEY@o1.ingest.sentry.io/42",
"sendDefaultPii": false,
"environment": "production",
"tracesSampleRate": 0.1,
"replaysOnErrorSampleRate": 0
}
}
}
```

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:

> 「我是一个开发平台呀,我的用户并不会去构建我的前端,我理解这种应该在服务端传进去。」

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.

**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.

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.

**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.

**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.

**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.

**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.

### Breaking: `telemetry.allowClientErrorReporting` is REPLACED, not paralleled

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.

| FROM | TO |
|:--|:--|
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true` | `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN=https://PUBLIC_KEY@HOST/PROJECT_ID` |
| `new RuntimeConfigPlugin({ allowClientErrorReporting: true })` | `new RuntimeConfigPlugin({ clientErrorReporting: { dsn: '…' } })` |
| `telemetry.allowClientErrorReporting: boolean` on the payload | `telemetry.errorReporting?: { dsn, sendDefaultPii, environment?, tracesSampleRate, replaysOnErrorSampleRate }` |
| `isClientErrorReportingAllowed(payload): boolean` | `readClientErrorReporting(payload): ClientErrorReportingConfig \| null` |
| `CLIENT_ERROR_REPORTING_ENV` | `CLIENT_ERROR_REPORTING_DSN_ENV` (plus `..._PII_ENV`, `..._ENVIRONMENT_ENV`, `..._TRACES_RATE_ENV`, `..._REPLAY_RATE_ENV`) |

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.

**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.

<!-- 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. -->
25 changes: 0 additions & 25 deletions .changeset/runtime-config-telemetry-posture.md

This file was deleted.

6 changes: 5 additions & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,7 +339,11 @@ 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. |
| `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`). |
| `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. |
| `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. |
| `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. |
| `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. |

---

Expand Down
52 changes: 27 additions & 25 deletions packages/cli/test/serve-marketplace-offline-runtime-config.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -431,11 +431,12 @@ describe('#8389: the identities and options the arm mounts with are the real one
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
* #12681 — the same offline arm must also refuse to serve the telemetry sink.
*
* 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.
* This is cloud#1508's acceptance expressed on the server side, now that the
* DSN itself travels on the payload: on a composed / air-gapped posture, the
* Console must be given no sink at all, and an operator who configured one
* anyway must be told it was refused — with no rebuild involved on either side.
*
* 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
Expand All@@ -451,12 +452,13 @@ describe('#8389: the identities and options the arm mounts with are the real one
* 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';
describe('#12681: the offline arm serves no client telemetry sink on a real OS_CLOUD_URL=off boot', () => {
const DSN_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN';
const DSN = 'https://abc123@o1.ingest.sentry.io/42';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
Expand All@@ -465,52 +467,52 @@ describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_UR
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
it('...and refuses even an explicit DSN, because this runtime declared its control plane off', async () => {
process.env[DSN_ENV] = DSN;
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
const { readClientErrorReporting } = await import('@objectstack/cloud-connection');
expect(body.telemetry.errorReporting).toBeUndefined();
// 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);
// with this payload is the thing under test, not the raw key.
expect(readClientErrorReporting(body)).toBeNull();
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
it('POSITIVE CONTROL — the same DSN on the CLOUD arm is served', 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.
// that serves nothing unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const savedDsn = process.env[DSN_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
process.env[DSN_ENV] = DSN;
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);
expect(body.telemetry.errorReporting.dsn).toBe(DSN);
} 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;
if (savedDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = savedDsn;
}
});
});
Loading
Loading