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
25 changes: 25 additions & 0 deletions .changeset/runtime-config-telemetry-posture.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
---
"@objectstack/cloud-connection": minor
---

**Security (p0, upstream half):** `GET /api/v1/runtime/config` now carries a `telemetry` block, giving the Console SPA a **post-build off switch** for client error reporting (#10805, upstream half of `cloud#1508`).

An air-gapped on-premises EE Console was measured sending **14 Sentry envelopes per session** to `sentry.io`, carrying IP and User-Agent PII, with no way for the customer to turn it off. objectui closed the half it owns — a build that never opts in now issues no third-party request at all — and could not close the other: every telemetry knob there is a Vite build-time variable inlined into the bundle as a frozen literal, so a build that **did** opt in (the hosted console, and the identical artifact shipped on-prem) had no switch that editing env vars on the deployed host could reach. The only server-to-SPA channel is this endpoint.

```json
{ "telemetry": { "allowClientErrorReporting": false } }
```

Operators grant it with `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true`, or hosts with `new RuntimeConfigPlugin({ allowClientErrorReporting: true })`. The switch answers to the repo's usual truthy vocabulary (`1` / `true` / `on` / `yes`); an unrecognised spelling is refused and named at mount time rather than coerced.

**Denied by default on every posture, not only the air-gapped one.** Deriving "connected therefore allowed" would have left the reported injury class open one deployment over: an internet-connected on-prem box runs the *same build artifact* as the hosted console, so the DSN cannot tell them apart, and its customer has equally never heard of Sentry. A universal opt-in satisfies "air-gap defaults off" strictly, and satisfies it without having to identify the posture correctly — which matters, because a posture predicate that is wrong in the *allow* direction is this card's own defect.

**A permission, never a source.** The server supplies no DSN and cannot start telemetry for a build that carries none; `true` means only "this deployment does not object to the sink you were compiled with". The composed decision stays `Boolean(buildTimeDsn) && isClientErrorReportingAllowed(payload)`.

**A runtime that declared its control plane off cannot grant it.** `OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and says so in the boot log — the copied-hosted-config-onto-an-air-gapped-box shape. That declaration is the repo's one existing network-posture signal and needs no new knob: the EE image's compose file already defaults `OS_CLOUD_URL` to `off`, so the operator this failed is safe with zero configuration.

**Absence is denial, and the reading ships with the contract.** A new export, `isClientErrorReportingAllowed(payload)`, is the canonical fail-closed reader: an older runtime's payload, a malformed body, a 404 and a failed fetch (pass `undefined`) all answer `false`. It is exported rather than left to consumers because "absent means do not send" is a claim about *their* code, and a hand-written `?.` chain is one `!== false` away from re-opening the leak on exactly the legacy payloads the guarantee is for. The key is spelled as a permission for the same reason: a negative `disabled` flag would have read falsy — therefore "send" — on every one of those states.

`isControlPlaneDeclined()` is factored out of `cloud-url.ts` so "what counts as off" has one definition shared by the URL resolution and the telemetry refusal. No behaviour change to `resolveCloudUrl()`.

The consumer half (reading the key and gating `initSentry`) is objectui's and is filed separately.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -340,6 +340,7 @@ the hosted ObjectOS Cloud control plane.
| `OS_OTLP_ENDPOINT` | url | — | OTLP/HTTP collector endpoint. Required when `OS_OBS_EXPORTER=otlp`. |
| `OS_OTLP_HEADERS` | csv | — | Comma-separated `key=value` pairs added to every OTLP export. |
| `OS_OTLP_FLUSH_MS` | number | `10000` | OTLP batch flush interval. |
| `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED` | boolean | `false` | Permits the Console SPA to send client error reports to the sink its build was compiled with, via `telemetry.allowClientErrorReporting` on `/api/v1/runtime/config`. Opt-in on every posture: an unset switch, an unrecognised value, or a runtime that declared its control plane off (`OS_CLOUD_URL=off`) all deny. It is a permission, not a source — it cannot start telemetry for a build that carries no DSN. |

---

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -429,3 +429,88 @@ describe('#8389: the identities and options the arm mounts with are the real one
expect(Object.isFrozen(Serve.RUNTIME_CONFIG_OPTIONS)).toBe(true);
});
});

/**
* #10805 — the same offline arm must also serve the SPA telemetry refusal.
*
* This is cloud#1508's acceptance expressed on the server side: on a
* composed / air-gapped posture, a Console build that DOES carry a Sentry DSN
* must be told not to send, through a switch that needs no rebuild.
*
* It belongs here rather than only in the plugin's own suite because of one
* measured property of this wiring: `Serve.RUNTIME_CONFIG_OPTIONS` hands the
* plugin `controlPlaneUrl: ''` on BOTH arms, so on the real product path the
* constructor argument carries no posture information whatsoever and only
* `OS_CLOUD_URL` does. A posture read built on the resolved URL would pass
* every hand-built fixture and be wrong exactly here. The correspondence this
* pins is exact: `resolveCloudUrl()` maps an unset env var to the PUBLIC
* default (truthy), so this arm is reached if and only if `OS_CLOUD_URL` is
* one of the decline spellings — the same condition the refusal reads.
*
* The env var is set for real, not simulated by `marketplaceUrl: ''` as the
* blocks above do, because that simulation is precisely the half that would
* hide the defect.
*/
describe('#10805: the offline arm refuses client telemetry on a real OS_CLOUD_URL=off boot', () => {
const GRANT_ENV = 'OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED';

async function bootAirGapped(run: (body: any) => void | Promise<void>): Promise<void> {
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
const dir = tempStorageDir();
try {
process.env.OS_CLOUD_URL = 'off';
const { app } = await bootOfflineArm({ storageDir: dir });
await run(await readConfig(app));
} finally {
if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL;
else process.env.OS_CLOUD_URL = savedCloudUrl;
if (savedGrant === undefined) delete process.env[GRANT_ENV];
else process.env[GRANT_ENV] = savedGrant;
rmSync(dir, { recursive: true, force: true });
}
}

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

it('...and refuses even an explicit grant, because this runtime declared its control plane off', async () => {
process.env[GRANT_ENV] = 'true';
await bootAirGapped(async (body) => {
const { isClientErrorReportingAllowed } = await import('@objectstack/cloud-connection');
expect(body.telemetry.allowClientErrorReporting).toBe(false);
// Read through the exported contract too: what the SPA will actually do
// with this payload is the thing under test, not the raw boolean.
expect(isClientErrorReportingAllowed(body)).toBe(false);
});
});

it('POSITIVE CONTROL — the same grant on the CLOUD arm is honoured', async () => {
// Without this, the refusal above could be an artifact of the fixture
// rather than a posture reading, and the pin would stay green on a build
// that denies everything unconditionally.
const savedCloudUrl = process.env.OS_CLOUD_URL;
const savedGrant = process.env[GRANT_ENV];
try {
process.env.OS_CLOUD_URL = 'https://cloud.objectos.ai';
process.env[GRANT_ENV] = 'true';
const { RuntimeConfigPlugin } = await import('@objectstack/cloud-connection');
const app = createApp();
// The cloud arm's own mount, verbatim — same shared frozen options.
await startOn(app, new RuntimeConfigPlugin({ ...Serve.RUNTIME_CONFIG_OPTIONS }));
const body = await readConfig(app);
expect(body.telemetry.allowClientErrorReporting).toBe(true);
} finally {
if (savedCloudUrl === undefined) delete process.env.OS_CLOUD_URL;
else process.env.OS_CLOUD_URL = savedCloudUrl;
if (savedGrant === undefined) delete process.env[GRANT_ENV];
else process.env[GRANT_ENV] = savedGrant;
}
});
});
43 changes: 42 additions & 1 deletion packages/cloud-connection/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,46 @@ const plugins = [
];
```

## SPA telemetry is denied unless a runtime grants it

`GET /api/v1/runtime/config` carries a `telemetry` block:

```json
{ "telemetry": { "allowClientErrorReporting": false } }
```

It is the Console's **post-build off switch**. Every telemetry knob in the SPA
is a build-time variable frozen into the bundle, so a build that opted in has
no other way to be turned off on a deployed host — and an air-gapped
deployment measurably shipped one that could not be (`cloud#1508`: 14 Sentry
envelopes per session carrying IP and User-Agent PII).

It is **denied by default on every posture**. Grant it explicitly:

```bash
OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true # or: new RuntimeConfigPlugin({ allowClientErrorReporting: true })
```

Three properties worth knowing before you build on it:

- **A permission, not a source.** The server supplies no DSN and cannot start
telemetry for a build that carries none. `true` means only "this deployment
does not object to the sink you were compiled with".
- **A runtime that declared its control plane off cannot grant it.**
`OS_CLOUD_URL=off` (or `none` / `local` / `disabled`) refuses the grant and
says so in the boot log, so an air-gapped box stays silent even if a hosted
configuration is copied onto it.
- **Absence means denied.** An older runtime, a third-party host, a 404 or a
failed fetch all read the same way. Consumers should use the reading that
ships with the contract rather than writing their own:

```ts
import { isClientErrorReportingAllowed } from '@objectstack/cloud-connection';

// `payload` may be the parsed body, or undefined when the fetch failed.
if (buildTimeDsn && isClientErrorReportingAllowed(payload)) initErrorReporting();
```

## Boundary (open mechanism, closed intelligence)

This package is **mechanism**: proxying a catalog, installing into the local
Expand All@@ -65,7 +105,8 @@ rules. Plan-derived feature flags are injected by the host via
`RuntimeConfigPluginConfig.resolvePlanFeatures`.

`OS_CLOUD_URL=off` disables every remote call; air-gapped installs keep
working via inline manifests handed to `install-local`.
working via inline manifests handed to `install-local`, and the SPA telemetry
permission above cannot be granted.

See `docs/adr` in the cloud repository (ADR-0008) for the full architecture
decision.
49 changes: 47 additions & 2 deletions packages/cloud-connection/src/cloud-url.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,24 @@
*/
export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai';

/**
* The spellings by which a deployment declares "this runtime has no control
* plane" — the repo's one existing, documented, network-posture declaration.
*
* ONE definition, read in two directions: `resolveCloudUrl()` turns it into an
* empty URL, and {@link isControlPlaneDeclined} answers whether it was said at
* all. They were a single inline list until the telemetry posture (#10805)
* needed the second question; keeping two copies would let "what counts as
* off" drift between "no cloud calls" and "no client telemetry", which is
* precisely the pair that must never disagree.
*/
const CLOUD_DECLINED_SPELLINGS: readonly string[] = ['off', 'none', 'local', 'disabled'];

/** Is this raw declaration one of the documented "no control plane" spellings? */
function isDeclinedSpelling(raw: string): boolean {
return CLOUD_DECLINED_SPELLINGS.includes(raw.trim().toLowerCase());
}

/**
* Resolve the effective control-plane URL from an explicit constructor
* value, the OS_CLOUD_URL env var, or the default. Returns an empty
Expand All@@ -20,10 +38,37 @@ export const DEFAULT_CLOUD_URL = 'https://cloud.objectos.ai';
*/
export function resolveCloudUrl(explicit?: string | null): string {
const raw = (explicit ?? process.env.OS_CLOUD_URL ?? '').trim();
const lower = raw.toLowerCase();
if (lower === 'off' || lower === 'none' || lower === 'local' || lower === 'disabled') {
if (isDeclinedSpelling(raw)) {
return '';
}
const picked = raw || DEFAULT_CLOUD_URL;
return picked.replace(/\/+$/, '');
}

/**
* Did this deployment DECLARE that it has no control plane (#10805)?
*
* ## Why this is not `resolveCloudUrl(...) === ''`
*
* That test conflates two opposite deployments, and the conflation is not
* theoretical — it is what every CLI-served runtime looks like. `''` is also
* how a host says **"this runtime IS the cloud"** (same origin), which
* `RuntimeConfigPlugin`'s constructor special-cases before it ever calls the
* resolver. Measured on `main`: `Serve.RUNTIME_CONFIG_OPTIONS` passes
* `controlPlaneUrl: ''` on **both** the cloud-connected arm and the air-gapped
* arm of the CLI's marketplace wiring, so the resolved URL carries no posture
* information whatsoever on the product path. A posture read built on it would
* report every hosted console as air-gapped and every air-gapped box as
* hosted — in the second direction, silently.
*
* This asks the different, answerable question: was one of the documented
* decline spellings actually said? An empty string is not one of them, an
* unset env var is not one of them, and `https://…` is not one of them.
*
* Pass the host's explicit argument to ask about that argument; pass nothing to
* ask about the deployment's environment. Callers that must catch both doors
* ask twice — see `RuntimeConfigPlugin.declinesControlPlane()`.
*/
export function isControlPlaneDeclined(explicit?: string | null): boolean {
return isDeclinedSpelling(explicit ?? process.env.OS_CLOUD_URL ?? '');
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -481,8 +481,8 @@ describe('plugin-route door — MarketplaceInstallLocalPlugin error exits (#9267
/**
* `GET /api/v1/runtime/config` answers a BARE payload —
* `{ cloudUrl, singleEnvironment, defaultOrgId, defaultEnvironmentId, features,
* branding}` — with no `success` flag and six top-level keys the envelope does
* not declare.
* branding, telemetry }` — with no `success` flag and seven top-level keys the
* envelope does not declare.
*
* ⚠️ This is NOT blessed, and this pin is not an assertion that the shape is
* right. It is the honest record of measured drift, in the same spirit as the
Expand DownExpand Up@@ -527,6 +527,11 @@ describe('plugin-route door — RuntimeConfigPlugin is NOT enveloped (recorded,
'stray top-level key `defaultEnvironmentId` — the payload belongs under `data`',
'stray top-level key `features` — the payload belongs under `data`',
'stray top-level key `branding` — the payload belongs under `data`',
// #10805 added the seventh: the SPA telemetry permission. Recorded
// here for the same reason as the six above — this route is read
// bare by the Console before first paint, so the drift grows with
// the payload until #9364 envelopes it.
'stray top-level key `telemetry` — the payload belongs under `data`',
]);
});
});
7 changes: 7 additions & 0 deletions packages/cloud-connection/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,6 +55,13 @@ export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-conn
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures, PlatformStage } from './runtime-config-plugin.js';
// #10805 — the SPA telemetry permission carried on that payload, and the
// canonical fail-closed way to read it. The reader is exported deliberately:
// "an absent key means do not send" is a claim about consumer code, and a
// consumer writing its own `?.` chain is one `!== false` away from re-opening
// the PII leak on exactly the legacy payloads the guarantee is for.
export { isClientErrorReportingAllowed, CLIENT_ERROR_REPORTING_ENV } from './telemetry-posture.js';
export type { RuntimeTelemetryPosture } from './telemetry-posture.js';
// ADR-0008 consumption side — the self-hosted credential ledger (bind
// persists the oscc_ bearer here; forwards present it to the control plane).
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';
Expand Down
Loading
Loading