Skip to content

Commit 21075ce

Browse files
feat(cli,cloud-connection): OS_CONSOLE_MAP_STYLE_URL names the deployment's default map style (#26162)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9ndwPy9fuaXy5hbjAwpdcG)_ Fixes #26001 Clause-②: no Metadata-protocol: none The server half of #26001 under ruling 6413991421 (letter A, operator env only). One environment variable names a deployment's default map style, so an intranet deployment, or one that forbids a third-party tile host, can stop depending on the public demo style host: ```sh OS_CONSOLE_MAP_STYLE_URL=https://tiles.example.com/styles/basic/style.json ``` ## What the one value does 1. **Runtime-config.** `RuntimeConfigPlugin` serves it verbatim as the top-level `mapStyleUrl` on `GET /api/v1/runtime/config` (and its legacy alias). Unset or empty, the key is absent, the same default-absent shape as `branding.stage` and `upgradeUrl`, and every other key is byte-identical. The payload's doc block declares the field. 2. **Console CSP.** The Console mount adds the URL's origin to the document's `connect-src`, read once at boot beside `OS_CONSOLE_CSP_CONNECT_SRC`. The default demo host stays allowed when the env is set: a view may still name it in its own `mapStyle`, and a Console build that does not read `mapStyleUrl` yet still loads its built-in default. 3. **Refusals.** The value must be an absolute `https:` URL on a host name, with no user name, password or whitespace. Anything else refuses the boot with a message that names `OS_CONSOLE_MAP_STYLE_URL` (credentials are left out of the message). `RuntimeConfigPlugin` refuses from `start()`, not the constructor: the CLI's cloud wiring in `serve.ts` wraps the construction in a try/catch that turns a throw into a warning and a missing door. No Setup setting, no settings-manifest key, no spec key, no per-request CSP, no `ui/**`, no `docs/adr/**`. Only the operator can widen the Console's `connect-src`. **Name.** `OS_CONSOLE_MAP_STYLE_URL` follows AGENTS.md Prime Directive #9 for a config value (`OS_{DOMAIN}_{NAME}`), in the `CONSOLE` domain beside `OS_CONSOLE_CSP_CONNECT_SRC`. The payload key is `mapStyleUrl`: the renderer sub-card reads it as its fallback before the demo style. ## One contract, two readers The order asked for one value read by both sides. They cannot share code: `packages/cli/src/utils/console.ts` is published as `@objectstack/cli/console` and imports Node built-ins only, which `test/published-subpath-console.pin.test.ts` pins against the packed tarball (an import of `@objectstack/cloud-connection` reds it with `ERR_MODULE_NOT_FOUND`, measured on the first push). So the contract lives in `packages/cloud-connection/src/console-map-style.ts` and the Console mount restates it as `readMapStyleOrigin`. `console.content-security-policy.test.ts` holds the two readers to the same verdict, refusal text included, on 11 values (4 accepted, 7 refused), and asserts that the CSP names the origin of the very URL the door serves. ## Two processes - Single `os start`: the parent spawns `os serve` with its env, and both `RuntimeConfigPlugin` (CLI-wired or the host config's own) and the Console static plugin run in that child, reading the same `process.env` at start. They agree. - `--no-console` / `OS_DISABLE_CONSOLE`: the door serves the URL and there is no Console document, so nothing can disagree. - A runtime-host kernel (`ObjectOSEnvironmentPlugin` present): the CLI wires no `RuntimeConfigPlugin`, the host wires its own. They disagree only when the process that serves the Console document is not the one that serves `/api/v1/runtime/config` and the two carry different env. Then the CSP can name an origin the door does not serve (harmless), or the door can serve a URL the CSP blocks (a grey map). No such split exists in this repository. ## Tests (red first) - `packages/cloud-connection/src/runtime-config-map-style.test.ts` (new): on `main` 10 failed / 3 passed (the 3 absence controls pass there, as they should); with the fix 13/13. - `packages/cli/src/utils/console.content-security-policy.test.ts` (extended): on `main` 7 failed / 21 passed; with the fix 39/39, including the 11 parity cases. - Ablation (one-off, not kept): let the Console reader accept `http:` (`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `912ed07b6027` to `aac41d764ec4`): 2 red (the boot refusal and the parity case for `http:`); restored, blob back to HEAD's, `git diff HEAD` empty. - At head `ef163a1bb`: `pnpm --filter @objectstack/cloud-connection test` 59 files / 745 tests passed; `typecheck` exit 0. `pnpm --filter @objectstack/cli exec vitest run --project unit` 333 files / 4873 tests passed (the integration tier is declared to CI); `typecheck` exit 0. eslint over `packages/cli` and `packages/cloud-connection` with `--no-inline-config`: exit 0. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff, run at head `ef163a1bb`: 99 derived, 99 run, all exit 0. `--ran` reconciliation: 99 accounted for, 0 NOT MEASURED, 0 unrun. Four first exited 3 on prerequisites and were re-run once those were met: `check-plugin-teardown-shape --self-test` (the positive-control commit fetched on this shallow clone), `check:i18n-coverage` (the example closure built), `check:skill-examples` and `check:dual-build-cjs-loads` (a full `pnpm build`, 119/119 tasks). ## Live probe `pnpm dev:crm -- --fresh` after a full build and `pnpm console:build`, each server on its own port and stopped afterwards: - `OS_CONSOLE_MAP_STYLE_URL=http://tiles.example.com/style.json`: the boot exits 1 with `Plugin com.objectstack.runtime.runtime-config failed to start - rollback complete: OS_CONSOLE_MAP_STYLE_URL is "http://tiles.example.com/style.json", which uses http: rather than https: ...`. - `OS_CONSOLE_MAP_STYLE_URL=https://tiles.example.com/styles/basic/style.json`: `GET /api/v1/runtime/config` serves `mapStyleUrl` with that value (keys `cloudUrl, mapStyleUrl, singleEnvironment, features, branding, telemetry`), and `GET /_console/` answers 200 with `connect-src 'self' https://demotiles.maplibre.org https://tiles.example.com`. - Env unset (control): no `mapStyleUrl` key, and `connect-src 'self' https://demotiles.maplibre.org`, as on `main`. ## Changeset `.changeset/26001-console-map-style-url.md`, `patch` for `@objectstack/cloud-connection` and `@objectstack/cli`. The order said `minor`, but `Metadata-protocol: none` grades every entry `patch` under the level axis of `check-changeset-no-major.mjs` (`none-minor` is exit 1), and the change adds no authorable key. ## Acceptance notes - Not done here, by scope: the `ui/packages/plugin-map` renderer reading `mapStyleUrl` before its demo default. Until that lands, map views keep the built-in style. The seat files it as a `domain:ui` sub-card blocked by this card. - A style whose tiles, sprites or glyphs live on other hosts still needs those hosts in `OS_CONSOLE_CSP_CONNECT_SRC`; the env reference row says so. - `content/docs/deployment/environment-variables.mdx` gains the `OS_CONSOLE_MAP_STYLE_URL` row, and the `OS_CONSOLE_CSP_CONNECT_SRC` row now lists its origin among the sources already allowed. --- _Generated by [Claude Code](https://claude.ai/code/session_011ApYCfy4gzYfuYeXX38EcT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f79b9d5 commit 21075ce

7 files changed

Lines changed: 453 additions & 5 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/cloud-connection': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
feat: `OS_CONSOLE_MAP_STYLE_URL` names a deployment's default map style, served on runtime-config and allowed by the Console's content-security-policy
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A map view that names no `mapStyle` falls back to a built-in style on a public demo host, so a deployment without public internet, or one whose policy forbids a third-party tile host, shows a grey map. An operator can now name the deployment's own style document once:
12+
13+
```sh
14+
OS_CONSOLE_MAP_STYLE_URL=https://tiles.example.com/styles/basic/style.json
15+
```
16+
17+
That one value does two things, both read at boot:
18+
19+
- `GET /api/v1/runtime/config` serves it verbatim as the top-level `mapStyleUrl`, the deployment's default map style. Unset, the key is absent and every other key is unchanged.
20+
- The Console document's `connect-src` gains the URL's origin. The default demo host stays allowed beside it.
21+
22+
The value must be an absolute `https:` URL on a host name, with no user name, password or whitespace. Anything else stops the server at boot with a message naming the variable, the same way a malformed `OS_CONSOLE_CSP_CONNECT_SRC` entry does. The runtime-config door is anonymous, so the URL is public once set.
23+
24+
It is an operator setting only: there is no Setup setting and no metadata key for it, so only the person who runs the deployment can widen the Console's content-security-policy. A style whose tiles, sprites or glyphs live on other hosts still needs those hosts in `OS_CONSOLE_CSP_CONNECT_SRC`.
25+
26+
The Console's map renderer reads `mapStyleUrl` in a separate change; until then the served value and the policy are in place, and map views keep the built-in style.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
2828
| `OS_BOOT_EMPTY` | flag | `0` | When `1`, boot the kernel with no metadata artifact (CLI internal). |
2929
| `OS_MIGRATE_AND_EXIT` | flag | `0` | When `1`, run pending migrations and exit (suitable for one-shot jobs). |
3030
| `OS_DISABLE_CONSOLE` | flag | `0` | When `1`, do not mount the Console portal under `/_console`. |
31-
| `OS_CONSOLE_CSP_CONNECT_SRC` | list | — | Extra `connect-src` sources for the Console document's content-security-policy, separated by spaces: a scheme (`https:`) or a scheme with a host (`https://tiles.example.com`). Name the host of any map view `mapStyle` that is not the default demo style; the server itself, the default map style host and a configured `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN` origin are already allowed. A malformed entry refuses the boot. |
31+
| `OS_CONSOLE_CSP_CONNECT_SRC` | list | — | Extra `connect-src` sources for the Console document's content-security-policy, separated by spaces: a scheme (`https:`) or a scheme with a host (`https://tiles.example.com`). Name the host of any map view `mapStyle` that is not the default demo style, and the tile, sprite and glyph hosts of a style that keeps them elsewhere; the server itself, the default map style host, the `OS_CONSOLE_MAP_STYLE_URL` origin and a configured `OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN` origin are already allowed. A malformed entry refuses the boot. |
32+
| `OS_CONSOLE_MAP_STYLE_URL` | URL | — | The deployment's default map style document (for example `https://tiles.example.com/styles/basic/style.json`). Served as `mapStyleUrl` on `GET /api/v1/runtime/config`, which is anonymous, so the URL is public; its origin joins the Console's `connect-src`. Unset, no `mapStyleUrl` is served. Must be an absolute `https:` URL on a host name, with no user name or password; anything else refuses the boot. |
3233
| `OS_EAGER_SCHEMAS` | flag | `0` | When `1`, evaluate all lazily-built Zod spec schemas eagerly at module load instead of on first use — an emergency rollback of the lazy-schema memory optimization (higher startup RSS). |
3334
| `OS_SKIP_SCHEMA_SYNC` | flag | `0` | When `1`, skip the implicit `db:sync` on boot. Use after running migrations manually. |
3435

‎packages/cli/src/utils/console.content-security-policy.test.ts‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const ENV = [
4242
'OS_PAGE_REACT_ENABLED',
4343
'OS_PAGE_REACT',
4444
'OS_CONSOLE_CSP_CONNECT_SRC',
45+
'OS_CONSOLE_MAP_STYLE_URL',
4546
'OS_CLOUD_URL',
4647
'OS_TELEMETRY_CLIENT_ERROR_REPORTING_DSN',
4748
];
@@ -305,4 +306,85 @@ describe('connect-src', () => {
305306
process.env.OS_CONSOLE_CSP_CONNECT_SRC = `https://ok.example.com ${value}`;
306307
await expect(mount()).rejects.toThrow(/OS_CONSOLE_CSP_CONNECT_SRC names .*not a connect-src source/);
307308
});
309+
310+
it('adds the origin of the deployment\'s default map style, named in OS_CONSOLE_MAP_STYLE_URL', async () => {
311+
process.env.OS_CONSOLE_MAP_STYLE_URL = 'https://tiles.example.com:8443/styles/basic/style.json?key=public';
312+
const request = await mount();
313+
const res = await request('/_console/');
314+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
315+
"'self'",
316+
'https://demotiles.maplibre.org',
317+
'https://tiles.example.com:8443',
318+
]);
319+
});
320+
321+
it('names no map style origin of its own when OS_CONSOLE_MAP_STYLE_URL is unset or empty', async () => {
322+
process.env.OS_CONSOLE_MAP_STYLE_URL = ' ';
323+
const request = await mount();
324+
const res = await request('/_console/');
325+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
326+
"'self'",
327+
'https://demotiles.maplibre.org',
328+
]);
329+
});
330+
331+
it('keeps OS_CONSOLE_CSP_CONNECT_SRC working beside it, after the map style origin', async () => {
332+
process.env.OS_CONSOLE_MAP_STYLE_URL = 'https://tiles.example.com/style.json';
333+
process.env.OS_CONSOLE_CSP_CONNECT_SRC = 'https://glyphs.example.com https://tiles.example.com';
334+
const request = await mount();
335+
const res = await request('/_console/');
336+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toEqual([
337+
"'self'",
338+
'https://demotiles.maplibre.org',
339+
'https://tiles.example.com',
340+
'https://glyphs.example.com',
341+
]);
342+
});
343+
344+
it('allows the very origin RuntimeConfigPlugin serves as the default map style, from one env var', async () => {
345+
process.env.OS_CONSOLE_MAP_STYLE_URL = 'https://tiles.example.com/styles/basic/style.json';
346+
const request = await mount({ runtimeConfig: {} });
347+
const served = await (await request('/api/v1/runtime/config')).json();
348+
expect(served.mapStyleUrl).toBe('https://tiles.example.com/styles/basic/style.json');
349+
const res = await request('/_console/');
350+
expect(directives(res.headers.get('content-security-policy')).get('connect-src')).toContain(
351+
new URL(served.mapStyleUrl).origin,
352+
);
353+
});
354+
355+
it.each([
356+
['http://tiles.example.com/style.json'],
357+
['/styles/basic/style.json'],
358+
['tiles.example.com'],
359+
["https://tiles.example.com/style.json 'unsafe-inline'"],
360+
])('refuses the boot when OS_CONSOLE_MAP_STYLE_URL is %j', async (value) => {
361+
process.env.OS_CONSOLE_MAP_STYLE_URL = value;
362+
await expect(mount()).rejects.toThrow(/^OS_CONSOLE_MAP_STYLE_URL is .*absolute https: URL/s);
363+
});
364+
365+
// The Console mount restates RuntimeConfigPlugin's reader rather than
366+
// importing it (this module imports Node built-ins only), so the two are held
367+
// here to one verdict, refusal text included, on every value named.
368+
it.each([
369+
['https://tiles.example.com/styles/basic/style.json'],
370+
['https://Tiles.Example.com:8443/style.json?key=public'],
371+
['https://192.0.2.10/style.json'],
372+
[' '],
373+
['http://tiles.example.com/style.json'],
374+
['/styles/basic/style.json'],
375+
['tiles.example.com'],
376+
['javascript:alert(1)'],
377+
['https://user:secret@tiles.example.com/style.json'],
378+
['https://[::1]/style.json'],
379+
['https://a.example.com/style.json https://b.example.com/style.json'],
380+
])('gives the same verdict as RuntimeConfigPlugin on OS_CONSOLE_MAP_STYLE_URL=%j', async (value) => {
381+
process.env.OS_CONSOLE_MAP_STYLE_URL = value;
382+
const verdict = (start: () => Promise<unknown>) => start().then(() => 'accepted', (e: Error) => e.message);
383+
const runtimeConfig = await verdict(() => new RuntimeConfigPlugin({}).start({
384+
hook: () => {},
385+
getService: (name: string) => { throw new Error(`no service ${name}`); },
386+
logger: { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} },
387+
} as any));
388+
expect(await verdict(() => mount())).toBe(runtimeConfig);
389+
});
308390
});

‎packages/cli/src/utils/console.ts‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -720,10 +720,72 @@ function readExtraConnectSources(env: NodeJS.ProcessEnv): string[] {
720720
return sources;
721721
}
722722

723+
/**
724+
* The env var an operator names the deployment's default map style document
725+
* in: an absolute `https:` URL, such as
726+
* `https://tiles.example.com/styles/basic/style.json`. `RuntimeConfigPlugin`
727+
* serves the same value as `mapStyleUrl` on `GET /api/v1/runtime/config`, and
728+
* the Console document's `connect-src` names its origin, so the map view can
729+
* fetch the style the door hands it.
730+
*/
731+
const CONSOLE_MAP_STYLE_URL_ENV = 'OS_CONSOLE_MAP_STYLE_URL';
732+
733+
/**
734+
* A DNS host name as the WHATWG parser leaves it. An IP literal in brackets is
735+
* left out: a content-security-policy host source cannot name one.
736+
*/
737+
const MAP_STYLE_HOST = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/;
738+
739+
/**
740+
* The origin of the map style named in {@link CONSOLE_MAP_STYLE_URL_ENV}, or
741+
* `undefined` when it is unset or empty; anything that is not an absolute
742+
* `https:` URL on a host name, without credentials or whitespace, refuses the
743+
* boot.
744+
*
745+
* The same contract, and the same refusal, as the reader `RuntimeConfigPlugin`
746+
* serves the value through (`@objectstack/cloud-connection`,
747+
* `readConsoleMapStyleUrl`). It is restated here rather than imported because
748+
* this module, published as `@objectstack/cli/console`, imports Node built-ins
749+
* only (`test/published-subpath-console.pin.test.ts`).
750+
* `console.content-security-policy.test.ts` holds the two to the same verdict
751+
* on every value it names, so the policy allows exactly the URL the door
752+
* serves, and a value one side refuses cannot boot past the other.
753+
*/
754+
function readMapStyleOrigin(env: NodeJS.ProcessEnv): string | undefined {
755+
const value = String(env[CONSOLE_MAP_STYLE_URL_ENV] ?? '').trim();
756+
if (value === '') return undefined;
757+
const refuse = (why: string, shown: string = value): never => {
758+
throw new Error(
759+
`${CONSOLE_MAP_STYLE_URL_ENV} is ${JSON.stringify(shown)}, which ${why}. Name the map style document `
760+
+ 'as an absolute https: URL on a host name, for example '
761+
+ 'https://tiles.example.com/styles/basic/style.json, or unset it to keep the built-in default style.',
762+
);
763+
};
764+
if (/\s/.test(value)) return refuse('contains whitespace');
765+
let parsed: URL;
766+
try {
767+
parsed = new URL(value);
768+
} catch {
769+
return refuse('is not an absolute URL');
770+
}
771+
if (parsed.protocol !== 'https:') return refuse(`uses ${parsed.protocol} rather than https:`);
772+
if (parsed.username !== '' || parsed.password !== '') {
773+
// Named without the credentials: this message lands in the boot log.
774+
return refuse(
775+
'carries a user name or password, which the anonymous runtime-config door would publish',
776+
`${parsed.protocol}//${parsed.host}${parsed.pathname}`,
777+
);
778+
}
779+
if (!MAP_STYLE_HOST.test(parsed.hostname)) return refuse('names a host that a content-security-policy cannot allow');
780+
return parsed.origin;
781+
}
782+
723783
/**
724784
* The default map style the Console's map view loads when the view names none
725785
* (objectui `plugin-map`, `DEFAULT_MAP_STYLE`). Its style, tiles and glyphs are
726-
* all on this host.
786+
* all on this host. It stays allowed when the operator names the deployment's
787+
* own default in `OS_CONSOLE_MAP_STYLE_URL`: a view may still name it, and a
788+
* Console build that does not read the served default yet still loads it.
727789
*/
728790
const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
729791

@@ -760,9 +822,12 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
760822
* values, and images inside an email template preview.
761823
* - `connect-src`: the server itself (`'self'`, which also covers the
762824
* same-origin realtime WebSocket and the metadata event stream), the default
763-
* map style's host, the error-reporting endpoint when the runtime serves a
764-
* DSN (`telemetry.errorReporting.dsn` from `GET /api/v1/runtime/config`), and
765-
* the hosts an operator names in `OS_CONSOLE_CSP_CONNECT_SRC`.
825+
* map style's host, the origin of the deployment's own default map style
826+
* when the operator names one in `OS_CONSOLE_MAP_STYLE_URL` (the URL
827+
* `GET /api/v1/runtime/config` serves as `mapStyleUrl`), the
828+
* error-reporting endpoint when the runtime serves a DSN
829+
* (`telemetry.errorReporting.dsn` from the same door), and the hosts an
830+
* operator names in `OS_CONSOLE_CSP_CONNECT_SRC`.
766831
* - `default-src 'self'` covers the rest the Console uses: the module workers
767832
* maplibre starts from its own file, the PWA manifest, and the same-origin
768833
* frames (the Studio's live canvas). The build ships no web fonts.
@@ -776,12 +841,14 @@ function consoleContentSecurityPolicy(input: {
776841
nonce: string;
777842
reactPages: boolean;
778843
errorReportingOrigin: string | undefined;
844+
mapStyleOrigin: string | undefined;
779845
extraConnectSources: readonly string[];
780846
embeddable: boolean;
781847
}): string {
782848
const scriptSrc = ["'self'", `'nonce-${input.nonce}'`, "'wasm-unsafe-eval'"];
783849
if (input.reactPages) scriptSrc.push("'unsafe-eval'");
784850
const connectSrc = ["'self'", DEFAULT_MAP_STYLE_ORIGIN];
851+
if (input.mapStyleOrigin !== undefined) connectSrc.push(input.mapStyleOrigin);
785852
if (input.errorReportingOrigin !== undefined) connectSrc.push(input.errorReportingOrigin);
786853
connectSrc.push(...input.extraConnectSources);
787854
const directives: string[][] = [
@@ -912,6 +979,9 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
912979
// Read once, at boot: a malformed value refuses here, loudly, rather
913980
// than reaching the header of every document.
914981
const extraConnectSources = readExtraConnectSources(process.env);
982+
// The deployment's default map style (#26001), on the contract the
983+
// runtime-config door serves it under, so the policy allows that URL.
984+
const mapStyleOrigin = readMapStyleOrigin(process.env);
915985

916986
const indexPath = path.join(absoluteDist, 'index.html');
917987
if (!fs.existsSync(indexPath)) {
@@ -997,6 +1067,7 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
9971067
nonce,
9981068
reactPages,
9991069
errorReportingOrigin: shell?.errorReportingOrigin,
1070+
mapStyleOrigin,
10001071
extraConnectSources,
10011072
embeddable: reqPath.startsWith(`${PUBLIC_FORM_PAGE}/`),
10021073
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The deployment's default map style: one operator env var, read the same way
5+
* by both of its readers (#26001, ruling A: operator env only).
6+
*
7+
* A map view that names no `mapStyle` of its own falls back to a built-in
8+
* default whose style, tiles and glyphs all live on a public demo host. A
9+
* deployment without public internet, or one whose policy forbids a
10+
* third-party tile host, gets a grey canvas there, and before this env var
11+
* the only remedy was editing every map view of every app. The operator now
12+
* names the deployment's own style document once:
13+
*
14+
* OS_CONSOLE_MAP_STYLE_URL=https://tiles.example.com/styles/basic/style.json
15+
*
16+
* Two readers act on that one value, on one contract:
17+
*
18+
* - `RuntimeConfigPlugin` serves it, through {@link readConsoleMapStyleUrl},
19+
* as `mapStyleUrl` on `GET /api/v1/runtime/config`, the deployment's
20+
* default map style;
21+
* - the CLI's Console mount adds its origin to the Console document's
22+
* `connect-src`, computed once at boot (`readMapStyleOrigin` in
23+
* `packages/cli/src/utils/console.ts`). That module is published as
24+
* `@objectstack/cli/console` and imports Node built-ins only, so it
25+
* restates this contract instead of importing it; the CLI's
26+
* `console.content-security-policy.test.ts` holds the two readers to the
27+
* same verdict on every value it names.
28+
*
29+
* It is an operator posture and nothing else: no Setup setting, no settings
30+
* key, no spec key, and no per-request policy. Widening the Console's
31+
* `connect-src` is the operator's act alone, so a tenant admin has no door to
32+
* it. The value is public once set (the runtime-config door is anonymous), so
33+
* it must not carry credentials.
34+
*
35+
* Contract, strict on purpose: the value is an ABSOLUTE `https:` URL whose
36+
* host is a DNS name, with no user name, password or whitespace. Empty or
37+
* whitespace-only reads as unset. Anything else refuses the boot, naming the
38+
* variable, the same way a malformed `OS_CONSOLE_CSP_CONNECT_SRC` entry does:
39+
* a map style the Console could not load, or a policy that names the wrong
40+
* origin, is a misconfiguration the operator must see at once, never a
41+
* quietly grey map.
42+
*/
43+
44+
/** The variable an operator names the deployment's default map style document in. */
45+
export const CONSOLE_MAP_STYLE_URL_ENV = 'OS_CONSOLE_MAP_STYLE_URL';
46+
47+
/**
48+
* A DNS host name as the WHATWG parser leaves it (lower-cased, punycoded). An
49+
* IP literal in brackets is left out: a content-security-policy host source
50+
* cannot name one, so the origin could never be allowed.
51+
*/
52+
const DNS_HOST = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/;
53+
54+
/**
55+
* The deployment's default map style URL, or `undefined` when the operator set
56+
* none. The value is returned as written (trimmed), never re-serialised by the
57+
* parser, so what the operator declared is byte for byte what the Console
58+
* loads.
59+
*
60+
* @throws Error naming {@link CONSOLE_MAP_STYLE_URL_ENV} when the value is set
61+
* but is not an absolute `https:` URL on a DNS host without credentials.
62+
*/
63+
export function readConsoleMapStyleUrl(env: Readonly<Record<string, string | undefined>>): string | undefined {
64+
const value = String(env[CONSOLE_MAP_STYLE_URL_ENV] ?? '').trim();
65+
if (value === '') return undefined;
66+
const refuse = (why: string, shown: string = value): never => {
67+
throw new Error(
68+
`${CONSOLE_MAP_STYLE_URL_ENV} is ${JSON.stringify(shown)}, which ${why}. Name the map style document `
69+
+ 'as an absolute https: URL on a host name, for example '
70+
+ 'https://tiles.example.com/styles/basic/style.json, or unset it to keep the built-in default style.',
71+
);
72+
};
73+
// The parser would percent-encode it into the path; it is a second value
74+
// or a typo, never part of one style URL.
75+
if (/\s/.test(value)) return refuse('contains whitespace');
76+
let parsed: URL;
77+
try {
78+
parsed = new URL(value);
79+
} catch {
80+
return refuse('is not an absolute URL');
81+
}
82+
if (parsed.protocol !== 'https:') return refuse(`uses ${parsed.protocol} rather than https:`);
83+
if (parsed.username !== '' || parsed.password !== '') {
84+
// Named without the credentials: this message lands in the boot log.
85+
return refuse(
86+
'carries a user name or password, which the anonymous runtime-config door would publish',
87+
`${parsed.protocol}//${parsed.host}${parsed.pathname}`,
88+
);
89+
}
90+
if (!DNS_HOST.test(parsed.hostname)) return refuse('names a host that a content-security-policy cannot allow');
91+
return value;
92+
}

0 commit comments

Comments
 (0)