diff --git a/.changeset/env-settings-option-table-gate.md b/.changeset/env-settings-option-table-gate.md new file mode 100644 index 0000000000..ff191ccc1c --- /dev/null +++ b/.changeset/env-settings-option-table-gate.md @@ -0,0 +1,38 @@ +--- +'@objectstack/service-settings': patch +--- + +Settings: an `OS_*` env override is now checked against the specifier's declared `options` table (#5204) + +A manifest's `options` table has been enforced on the write path since #5131, but +`SettingsService.get()` produced an effective value by a second route that never +consulted it: an `OS_*` override was reshaped by the default's type and returned +straight from the top of the cascade with `locked: true`. So the providers #5094 and +#5133 retired from `mail.provider` could walk back in through the one door with no +gate on it — `OS_MAIL_PROVIDER=sendgrid` reached the mail plugin unchallenged — and a +plain typo such as `OS_BRANDING_THEME_MODE=drak` was served to every consumer as a +normal value with normal-looking provenance, each consumer left to improvise. + +An override whose value the table does not declare is now **ignored** rather than +repaired: the value falls through to the next layer of the cascade (a stored +global/tenant/user value, else the manifest default), and the read API reports that +layer honestly instead of claiming `source: 'env'` for a value not in force. The +rejection is logged once at `error`, naming the variable, the rejected value, the legal +value set and the consequence. The same audit runs at `registerManifest`, so a +misconfigured deployment learns at boot rather than whenever somebody first opens the +settings page. + +Registration **reports but never refuses**: option tables move, a pin that was legal +the day it was written must not turn an upgrade into a crash-on-start. + +Two behaviour notes for anyone relying on the old shape: + +- Keys with no declared option table are untouched — text, boolean, number and + password overrides behave exactly as before. The check applies only to + `select`/`radio`/`multiselect` specifiers that declare a non-empty table. +- A **rejected** override no longer pins its key against writes. `setMany` used to + refuse on the mere presence of the variable; judged by presence, an ignored value + would have left the key configurable by nothing at all — env value discarded, UI + refused with `SETTINGS_LOCKED`, and `get()` reporting `locked: false` to a settings + page whose save would then fail. An override that *is* in force still locks the key, + unchanged. diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 7ae63e73aa..541ee2cb67 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -27,6 +27,7 @@ export { type SettingsActionHandler, type SettingsAuditSink, type SettingsContext, + type SettingsDiagnosticsLogger, type SettingsEngine, type SettingsRow, type SettingsServiceOptions, diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index bc5d87e135..90826d0553 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -108,6 +108,13 @@ export class SettingsServicePlugin implements Plugin { this.service = new SettingsService({ crypto: this.opts.crypto, env: this.opts.env, + // #5204 — the service reports a rejected `OS_*` override at `error`, and + // it must land in the deployment's real log pipeline rather than raw + // stdout. Passed before `registerManifest` below, because that call is + // what audits this namespace's env overrides: constructing the service + // without the logger first would send the boot-time report to the + // `console.error` fallback instead. + logger: ctx.logger, }); for (const m of this.opts.manifests ?? []) this.service.registerManifest(m); for (const [ns, handlers] of Object.entries(this.opts.actionHandlers ?? {})) { diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 1e4e43d5d3..74f6095d94 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { SettingsService } from './settings-service.js'; import { SettingsLockedError, UnknownKeyError, UnknownNamespaceError, envKeyOf } from './settings-service.types.js'; import { NoopCryptoAdapter } from './crypto-adapter.js'; @@ -595,6 +595,356 @@ describe('SettingsService — save-time validation (declared options are enforce }); }); +/** + * #5204 — the option table is enforced on the ENV side too. + * + * The symmetric half of the suite above. `setMany` has checked the declared + * table since #5131, but `get()` produced an effective value by a second route + * that never consulted it: an `OS_*` override was reshaped by the default's type + * (`coerceEnvValue`) and returned straight out of the top of the cascade with + * `locked: true`. So the values #5094/#5133 retired from `mail.provider` could + * walk back in through the one door with no gate on it — + * `OS_MAIL_PROVIDER=sendgrid` reached the mail plugin unchallenged — and a plain + * typo (`OS_BRANDING_THEME_MODE=drak`) was served to every consumer as a normal + * value with a normal-looking provenance. + * + * Per the ruling on #5204 an offending override is IGNORED, not repaired: the + * value falls through to the next cascade layer, and the read API reports THAT + * layer honestly instead of claiming `source: 'env'` for a value not in force. + */ +describe('SettingsService — env overrides are checked against declared options (#5204)', () => { + /** Capture the loud channel without stubbing the console. */ + const spyLogger = () => { + const errors: string[] = []; + return { errors, logger: { error: (m: string) => void errors.push(m) } }; + }; + + it('ignores an out-of-table env value and resolves the manifest default instead', async () => { + const { errors, logger } = spyLogger(); + // The issue's own example: a typo, one transposition away from 'dark'. + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); + svc.registerManifest(brandingSettingsManifest); + + const r = await svc.get('branding', 'theme_mode'); + expect(r.value).toBe('system'); // the manifest default, not 'drak' + expect(r.source).toBe('default'); + // The override is not in force, so it does not lock anything either. + expect(r.locked).toBe(false); + expect(r.lockedReason).toBeUndefined(); + // And it contributes NO cascade entry — an `env` entry here would be read as + // a layer that supplied (and locked) the value. + expect(r.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_BRANDING_THEME_MODE'); + expect(errors[0]).toContain('drak'); + expect(errors[0]).toContain('light, dark, system'); // the legal value set + expect(errors[0]).toContain('IGNORED'); + }); + + it('falls back to the next cascade layer, not straight to the default', async () => { + // "Ignored" means the env layer is skipped, not that the whole cascade is. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); + svc.registerManifest(brandingSettingsManifest); + await svc.set('branding', 'theme_mode', 'dark'); + + const r = await svc.get('branding', 'theme_mode'); + expect(r.value).toBe('dark'); + expect(r.source).toBe('tenant'); + expect(r.locked).toBe(false); + expect(errors).toHaveLength(1); + }); + + it('still lets a value the table DOES declare win at the top of the cascade', async () => { + // The regression pin for the untouched path: a legal override keeps its + // precedence, its `locked: true`, and its reason string. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'dark' }, logger }); + svc.registerManifest(brandingSettingsManifest); + + const r = await svc.get('branding', 'theme_mode'); + expect(r.value).toBe('dark'); + expect(r.source).toBe('env'); + expect(r.locked).toBe(true); + expect(r.lockedReason).toContain('OS_BRANDING_THEME_MODE'); + expect(r.cascadeChain?.[0]).toMatchObject({ scope: 'env', effective: true }); + expect(errors).toHaveLength(0); + }); + + it('an IN-FORCE override still pins the key against writes', async () => { + // The other half of the `locked` contract, unchanged: what `get()` reports as + // locked, `setMany` refuses. Pinned here so the coherence fix below cannot be + // over-applied into "env never locks anything". + const { logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'dark' }, logger }); + svc.registerManifest(brandingSettingsManifest); + expect((await svc.get('branding', 'theme_mode')).locked).toBe(true); + await expect(svc.set('branding', 'theme_mode', 'light')).rejects.toBeInstanceOf( + SettingsLockedError, + ); + }); + + it('a REJECTED override pins nothing — read and write agree the key is editable', async () => { + // Both halves of `locked` are judged by the same rule, so they cannot + // disagree. Before this, `setMany` locked on the mere PRESENCE of the env + // var: the read side would have said `locked: false` while the save threw + // `SETTINGS_LOCKED`, leaving the key configurable by nothing at all — env + // value ignored, UI refused — a lockout only an env edit could clear. + const { logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); + svc.registerManifest(brandingSettingsManifest); + + expect((await svc.get('branding', 'theme_mode')).locked).toBe(false); + // …and the write the read surface just advertised as possible really is. + await expect(svc.set('branding', 'theme_mode', 'light')).resolves.toBeDefined(); + const after = await svc.get('branding', 'theme_mode'); + expect(after.value).toBe('light'); + expect(after.source).toBe('tenant'); + }); + + it('closes the #5094 door: OS_MAIL_PROVIDER cannot smuggle a retired provider back in', async () => { + // THE load-bearing case. `sendgrid` and `ses` left `mail.provider` in + // #5094/#5133 because this server cannot deliver through them; #5131 stopped + // them at the write path on the same day, and this is the other door. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_MAIL_PROVIDER: 'sendgrid' }, logger }); + svc.registerManifest(mailSettingsManifest); + + const r = await svc.get('mail', 'provider'); + expect(r.value).toBe('smtp'); // the manifest default + expect(r.source).toBe('default'); + expect(errors[0]).toContain('smtp, resend, postmark, log'); + // The consequence is spelled out, not left to the reader. + expect(errors[0]).toContain('does NOT take effect'); + expect(errors[0]).toContain('OS_MAIL_PROVIDER'); + }); + + it('leaves keys with no declared option table completely alone', async () => { + // The check must not widen past `select`/`radio`/`multiselect` with a table: + // a free-text, a boolean and a number env override behave exactly as before. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ + env: { + OS_BRANDING_WORKSPACE_NAME: 'EnvCorp', // text — any string is legal + OS_FEATURE_FLAGS_AI_ENABLED: 'true', // boolean — coerced, not enumerated + }, + logger, + }); + svc.registerManifest(brandingSettingsManifest); + svc.registerManifest(featureFlagsSettingsManifest); + + const name = await svc.get('branding', 'workspace_name'); + expect(name.value).toBe('EnvCorp'); + expect(name.source).toBe('env'); + expect(name.locked).toBe(true); + + const flag = await svc.get('feature_flags', 'ai_enabled'); + expect(flag.value).toBe(true); + expect(flag.source).toBe('env'); + + expect(errors).toHaveLength(0); + }); + + it('reports the misconfiguration at registration, before anything reads the key', async () => { + // An override that will never take effect is a misconfigured deployment, and + // the operator should learn at boot rather than whenever someone first opens + // the settings page (or never, for a key nothing reads this process). + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_MAIL_PROVIDER: 'ses' }, logger }); + expect(errors).toHaveLength(0); + + svc.registerManifest(mailSettingsManifest); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_MAIL_PROVIDER'); + expect(errors[0]).toContain('ses'); + }); + + it('registration REPORTS but never refuses — a stale pin must not block a boot', async () => { + // The upgrade trap: `sendgrid` was a legal value the day the deployment was + // written. Turning today's narrower table into a crash-on-start would punish + // exactly the operator the message is trying to help. + const { logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_MAIL_PROVIDER: 'sendgrid' }, logger }); + expect(() => svc.registerManifest(mailSettingsManifest)).not.toThrow(); + // The service is fully usable afterwards, including writes to the same key. + await expect( + svc.setMany('mail', { provider: 'smtp', smtp_host: 's.example.com', from_email: 'a@b.com' }), + ).resolves.toBeDefined(); + }); + + it('says it ONCE, not once per read', async () => { + // `getNamespace` resolves every specifier on every settings page load, so a + // per-read line would be a firehose — and AGENTS.md's "Degradation log + // levels" names training people to skim `error` as the mirror-image failure. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); + svc.registerManifest(brandingSettingsManifest); // one report here + for (let i = 0; i < 5; i++) await svc.get('branding', 'theme_mode'); + await svc.getNamespace('branding'); + await svc.getNamespace('branding'); + expect(errors).toHaveLength(1); + }); + + it('reports a DIFFERENT bad value that appears after registration', async () => { + // The dedupe is keyed on the value, not just the var name: `this.env` may be + // a live `process.env` reference, so a newly-set bad override must not + // inherit an earlier line's silence. + const { errors, logger } = spyLogger(); + const env: Record = { OS_BRANDING_THEME_MODE: 'drak' }; + const svc = new SettingsService({ env, logger }); + svc.registerManifest(brandingSettingsManifest); + expect(errors).toHaveLength(1); + + env.OS_BRANDING_THEME_MODE = 'lite'; + const r = await svc.get('branding', 'theme_mode'); + expect(r.source).toBe('default'); + expect(errors).toHaveLength(2); + expect(errors[1]).toContain('lite'); + }); + + it('the read surface reports the layer actually in force, never a phantom env', async () => { + // What `GET /api/settings/:ns` serves. Reporting `source: 'env'` with + // `locked: true` for a value that was discarded would tell an admin the + // field is pinned by the deployment and not editable — about a value nothing + // is using. + const { logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' }, logger }); + svc.registerManifest(brandingSettingsManifest); + await svc.set('branding', 'theme_mode', 'light'); + + const payload = await svc.getNamespace('branding'); + expect(payload.values.theme_mode).toMatchObject({ + value: 'light', + source: 'tenant', + locked: false, + }); + expect(payload.values.theme_mode.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); + // A sibling key with a legal env override is unaffected in the same payload. + expect(payload.values.workspace_name.source).toBe('default'); + }); + + it('falls back to console.error when no logger is injected', async () => { + // A service built without a kernel (unit tests, control-plane mock, boot + // before the logger exists) must still report — going silent there is the + // very failure #5204 is about. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const svc = new SettingsService({ env: { OS_BRANDING_THEME_MODE: 'drak' } }); + svc.registerManifest(brandingSettingsManifest); + expect(spy).toHaveBeenCalledTimes(1); + expect(String(spy.mock.calls[0]![0])).toContain('OS_BRANDING_THEME_MODE'); + } finally { + spy.mockRestore(); + } + }); + + it('rejects the WHOLE multiselect override when any one member is undeclared', async () => { + // No manifest ships a `multiselect` today, so this is the shape the first one + // to do so will meet. Partial acceptance is deliberately not on the table: it + // would synthesise a combination nobody configured. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ + // A JSON array, because that is what `coerceEnvValue` produces when the + // declared default is an array — verified, not assumed. + env: { OS_DIGEST_CHANNELS: '["email","carrier_pigeon"]' }, + logger, + }); + svc.registerManifest({ + namespace: 'digest', + version: 1, + label: 'Digest', + scope: 'tenant', + readPermission: 'setup.access', + writePermission: 'setup.access', + specifiers: [ + { + type: 'multiselect', + key: 'channels', + label: 'Channels', + required: false, + default: ['email'], + options: [ + { value: 'email', label: 'Email' }, + { value: 'sms', label: 'SMS' }, + ], + }, + ], + } as any); + + const r = await svc.get('digest', 'channels'); + // `email` was legal, but the override is dropped whole — not narrowed to it. + expect(r.value).toEqual(['email']); // the manifest default, which happens to match + expect(r.source).toBe('default'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('carrier_pigeon'); + expect(errors[0]).toContain('email, sms'); + }); + + it('accepts a multiselect override whose every member is declared', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_DIGEST_CHANNELS: '["email","sms"]' }, logger }); + svc.registerManifest({ + namespace: 'digest', version: 1, label: 'Digest', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'multiselect', key: 'channels', label: 'Channels', required: false, + default: ['email'], + options: [{ value: 'email', label: 'Email' }, { value: 'sms', label: 'SMS' }] }, + ], + } as any); + + const r = await svc.get('digest', 'channels'); + expect(r.value).toEqual(['email', 'sms']); + expect(r.source).toBe('env'); + expect(r.locked).toBe(true); + expect(errors).toHaveLength(0); + }); + + it('compares by string form, so a numeric option survives the env round-trip', async () => { + // An env var is always a string and `coerceEnvValue` turns it back into a + // number when the default is numeric. Comparing raw would reject the legal + // value `30`; the table is compared in string form for exactly this reason. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_RETENTION_WINDOW_DAYS: '30' }, logger }); + svc.registerManifest({ + namespace: 'retention', version: 1, label: 'Retention', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'select', key: 'window_days', label: 'Window', required: false, default: 7, + options: [{ value: 7, label: '7 days' }, { value: 30, label: '30 days' }] }, + ], + } as any); + + const r = await svc.get('retention', 'window_days'); + expect(r.value).toBe(30); + expect(r.source).toBe('env'); + expect(errors).toHaveLength(0); + }); + + it('never echoes the rejected value for an encrypted specifier', async () => { + // An option value is not a secret, but `encrypted` is authorable on ANY + // specifier, and this message lands in logs. Same rule as the save path. + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_VAULT_KEY_REF: 's3cr3t-handle' }, logger }); + svc.registerManifest({ + namespace: 'vault', version: 1, label: 'Vault', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'select', key: 'key_ref', label: 'Key reference', required: false, + encrypted: true, + options: [{ value: 'primary', label: 'Primary' }, { value: 'backup', label: 'Backup' }] }, + ], + } as any); + + expect(errors).toHaveLength(1); + expect(errors[0]).not.toContain('s3cr3t-handle'); + // …but it still names the var and the legal set, so the message stays useful. + expect(errors[0]).toContain('OS_VAULT_KEY_REF'); + expect(errors[0]).toContain('primary, backup'); + }); +}); + describe('SettingsService — user-scoped values', () => { it('isolates writes by ctx.userId', async () => { const svc = new SettingsService({ env: {} }); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 2e6c0dff19..bcc8e1eaed 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -83,6 +83,34 @@ function declaredOptionValues(options: unknown): string[] { return out; } +/** + * The first member of `value` that the declared table does not admit, or + * `null` when every member is admissible. + * + * ONE comparison, shared by both paths that produce an effective value — the + * save path ({@link SettingsService.validatePatch}) and the env path + * ({@link SettingsService.get} / `reportRejectedEnvOverride`). #5204 exists + * precisely because one of those two checked the option table and the other + * did not; two open-coded copies of this comparison would be the same defect + * deferred rather than fixed. + * + * `multiselect` stores an array, `select`/`radio` a scalar; both are checked + * element-wise against the one table. A scalar arriving at a multiselect is + * wrapped rather than rejected — policing the value's SHAPE is a different + * constraint (`invalid_type`) with a different owner, and inventing it here + * would reject writes this check was never asked to touch. + * + * Returns a WRAPPER, not the offending value itself: a bare return cannot + * distinguish "nothing was rejected" from "the rejected member WAS + * `undefined`", and the latter would slip through the check. Same reason the + * implementation uses `findIndex` rather than `find`. + */ +function firstRejectedOption(allowed: string[], value: unknown): { value: unknown } | null { + const picked = Array.isArray(value) ? value : [value]; + const at = picked.findIndex((v) => !allowed.includes(String(v))); + return at === -1 ? null : { value: picked[at] }; +} + interface RegisteredManifest { manifest: SettingsManifest; /** Resolved specifier scopes for fast lookup. */ @@ -93,6 +121,19 @@ interface RegisteredManifest { defaults: Map; /** Action handlers registered alongside this manifest. */ actions: Map; + /** + * Declared option values (string form) for every option-bearing specifier + * that actually declares a non-empty table, keyed by specifier key. + * + * Precomputed at registration because `get()` is the service's hottest path + * — `getNamespace` calls it once per specifier on every settings page load — + * and the alternative is re-scanning `manifest.specifiers` per read. A key + * ABSENT from this map is a key with nothing to enforce (not option-bearing, + * or an option-bearing type whose manifest declares no usable table), so + * absence means "unchanged behaviour" at both call sites rather than + * "check against an empty set", which would reject everything. + */ + optionTables: Map; } /** @@ -108,7 +149,14 @@ export class SettingsService { private auditWriter?: import('./settings-service.types.js').SettingsAuditWriter; private readonly env: Record; private readonly objectName: string; + private readonly logger?: import('./settings-service.types.js').SettingsDiagnosticsLogger; private readonly registry = new Map(); + /** + * `OS_*` overrides already reported as rejected (#5204), keyed + * `=`. See {@link reportRejectedEnvOverride} for why + * the value is part of the key and not just the var name. + */ + private readonly reportedEnvOverrides = new Set(); /** In-memory fallback when no engine is wired. */ private readonly memory: SettingsRow[] = []; /** Change subscribers, optionally scoped to a namespace. */ @@ -126,6 +174,7 @@ export class SettingsService { this.auditWriter = opts.auditWriter; this.env = opts.env ?? (typeof process !== 'undefined' ? process.env : {}); this.objectName = opts.objectName ?? DEFAULT_OBJECT; + this.logger = opts.logger; } /** @@ -223,21 +272,181 @@ export class SettingsService { // Manifest registry // --------------------------------------------------------------------- - /** Register (or replace) a manifest. Idempotent. */ + /** + * Register (or replace) a manifest. Idempotent. + * + * Registration is also where the deployment's `OS_*` overrides for this + * namespace are audited against the manifest's option tables (#5204) — see + * {@link auditEnvOverrides}. Registration REPORTS, it never rejects: a value + * that was legal yesterday and left the table today must not keep an + * existing deployment from booting. + */ registerManifest(manifest: SettingsManifest): void { const scopes = new Map(); const encryptedKeys = new Set(); const defaults = new Map(); + const optionTables = new Map(); const defaultScope = manifest.scope ?? 'tenant'; for (const spec of manifest.specifiers) { if (!spec.key || LAYOUT_ONLY_TYPES.has(spec.type)) continue; scopes.set(spec.key, spec.scope ?? defaultScope); if (spec.encrypted || spec.type === 'password') encryptedKeys.add(spec.key); if (typeof spec.default !== 'undefined') defaults.set(spec.key, spec.default); + if (OPTION_BEARING_TYPES.has(spec.type)) { + // A manifest with no option table cannot say what is legal. The spec + // refuses that shape at parse time, but `registerManifest` takes + // manifests as given (no Zod pass), so record nothing rather than + // record an empty table — same leniency the save path takes, and the + // reason the map's ABSENT key means "nothing to enforce". + const allowed = declaredOptionValues((spec as { options?: unknown }).options); + if (allowed.length > 0) optionTables.set(spec.key, allowed); + } } const prev = this.registry.get(manifest.namespace); const actions = prev?.actions ?? new Map(); - this.registry.set(manifest.namespace, { manifest, scopes, encryptedKeys, defaults, actions }); + this.registry.set(manifest.namespace, { + manifest, + scopes, + encryptedKeys, + defaults, + actions, + optionTables, + }); + this.auditEnvOverrides(manifest.namespace); + } + + /** + * #5204 — report every `OS_*` override in this namespace whose value the + * specifier's declared `options` table does not admit. + * + * Why at registration and not only on read: an override that will never take + * effect is a **misconfigured deployment**, and the operator should learn + * that at boot, next to the rest of the startup output — not the first time + * somebody happens to open the settings page, and not never (a key nobody + * reads during the process's life would otherwise stay silent forever). + * + * Why it does NOT refuse to boot: the option tables move. #5094 retired + * `sendgrid` and `ses` from `mail.provider`; a deployment pinning + * `OS_MAIL_PROVIDER=sendgrid` was correct the day it was written, and turning + * an upgrade into a crash-on-start would punish exactly the operator this + * message is trying to help. The value is ignored either way (see `get`); the + * difference is whether the rest of the platform still comes up around it. + */ + private auditEnvOverrides(namespace: string): void { + const reg = this.registry.get(namespace); + if (!reg || reg.optionTables.size === 0) return; + // Only the option-bearing keys can be rejected, so only they are worth + // walking. `effectiveEnvOverride` does the judging (and the reporting); + // the value it returns is of no interest here. + for (const key of reg.optionTables.keys()) { + this.effectiveEnvOverride(reg, namespace, key); + } + } + + /** + * The `OS_*` override for this key **if it is actually in force**, else null. + * + * THE one place that answers "does env win here?", for every site that used to + * ask in its own way. There were three, and #5204 is what having three costs: + * `get()` coerced the value and returned it, `setMany` locked the key on the + * mere PRESENCE of the variable, and neither consulted the `options` table + * that the save path had been enforcing since #5131. + * + * Routing all three through one judgment is what keeps `locked` coherent. A + * rejected override is not in force, so it must not pin the key either: + * reporting `locked: false` from `get()` while `setMany` still threw + * `SETTINGS_LOCKED` would leave the settings UI rendering the field as + * editable and then failing the save — and, worse, would leave that key + * configurable by NOTHING (env value rejected, UI refused), a lockout only an + * env edit could clear. That is strictly worse than the hole #5204 closes, + * and it is the same reasoning `validatePatch` already applies when it + * refuses to lock a workspace out over historical drift. + * + * Reporting lives here rather than at the call sites so no future fourth + * caller can read an override without the rejection being heard. + */ + private effectiveEnvOverride( + reg: RegisteredManifest, + namespace: string, + key: string, + ): { envName: string; value: unknown } | null { + const envName = envKeyOf(namespace, key); + const envRaw = this.env[envName]; + if (typeof envRaw !== 'string') return null; + + const value = coerceEnvValue(envRaw, reg.defaults.get(key)); + // A key with no declared table has nothing to enforce — unchanged behaviour. + const allowed = reg.optionTables.get(key); + if (!allowed) return { envName, value }; + + const rejected = firstRejectedOption(allowed, value); + if (!rejected) return { envName, value }; + + this.reportRejectedEnvOverride(reg, namespace, key, envName, allowed, rejected.value); + return null; + } + + /** + * Emit the one loud line a rejected `OS_*` override owes its operator, at + * most once per (env var, value) for the life of the service. + * + * **Level is `error`, deliberately.** AGENTS.md → "Degradation log levels" + * decides this by one question: afterwards, does the system still look normal + * from the outside while something it claims to honour has not landed? Here + * it does — the read API answers with a perfectly plausible value tagged with + * a perfectly plausible source, and nothing anywhere looks broken, while the + * operator's declared intent is simply not in force. #5152 reached the same + * verdict for `auth.membership_policy` in `bindAuthSettings` for the same + * reason, and that case shows the stakes: a typo'd `invite_only` read as + * `auto` leaves an operator believing the wall is up while every sign-up is + * auto-bound. + * + * **Once, not once per read.** Same section: "Say it once, at the first + * degradation, not once per failed write." `getNamespace` resolves every + * specifier in the namespace on every settings page load, so a per-read line + * would be a firehose — and training people to skim `error` is the + * mirror-image failure AGENTS.md names in the very next paragraph. Keying the + * dedupe on the VALUE as well as the var means a genuinely new bad value is + * still reported: `this.env` may be a live `process.env` reference, so an + * override can appear or change after registration and must not inherit an + * earlier line's silence. + * + * **No structured `meta`.** Everything an operator needs is in the sentence, + * and `Logger.redactSensitive` (`packages/core/src/logger.ts`) redacts any + * meta field whose name merely CONTAINS `key`/`token`/`secret`/`password` — + * so the obvious `{ key }` / `{ envKey }` field would arrive as + * `***REDACTED***` and delete the diagnostic while looking complete (#5573). + */ + private reportRejectedEnvOverride( + reg: RegisteredManifest, + namespace: string, + key: string, + envName: string, + allowed: string[], + offending: unknown, + ): void { + const dedupeAt = `${envName}=${String(offending)}`; + if (this.reportedEnvOverrides.has(dedupeAt)) return; + this.reportedEnvOverrides.add(dedupeAt); + + // An option value is not a secret, but `encrypted` is authorable on ANY + // specifier — so never echo the rejected value for a key whose contents are + // held encrypted, in a message that lands in logs. Same rule, same reason, + // as the save path's `invalid_option` error. + const secret = reg.encryptedKeys.has(key); + const rejected = secret ? '' : ` Rejected value: '${String(offending)}'.`; + const message = + `[SettingsService] env override ${envName} is not a declared option for ` + + `setting '${namespace}.${key}' — IGNORED.${rejected} Allowed values: ${allowed.join(', ')}. ` + + `Consequence: this override does NOT take effect and nothing else looks wrong — ` + + `'${namespace}.${key}' resolves from the next layer of the cascade instead ` + + `(a stored global/tenant/user value, else the manifest default), and reads report ` + + `THAT layer as the source rather than 'env'. ` + + `Fix: set ${envName} to one of the allowed values, or unset it and configure ` + + `'${namespace}.${key}' through the settings UI.`; + + if (this.logger?.error) this.logger.error(message); + else console.error(message); } /** Look up a manifest, or throw `UnknownNamespaceError`. */ @@ -306,12 +515,27 @@ export class SettingsService { if (!reg) throw new UnknownNamespaceError(namespace); if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); - // 1. OS_* env - const envName = envKeyOf(namespace, key); - const envRaw = this.env[envName]; - if (typeof envRaw === 'string') { - const def = reg.defaults.get(key); - const value = coerceEnvValue(envRaw, def); + // 1. OS_* env — but only when the override is actually in force. + // + // #5204: the `options` table is an ENFORCEMENT surface on this side too. + // `setMany` has checked it since #5131, yet an env override reached the + // effective value without passing through that path at all, and did so at + // the TOP of the cascade with `locked: true` — so `OS_MAIL_PROVIDER=sendgrid` + // handed the mail plugin the exact value #5094 had just retired, through the + // one door nobody was watching. `coerceEnvValue` only ever reshaped the + // string by the default's type; it never consulted the enumeration. + // + // A rejected value is IGNORED rather than repaired: there is no honest way to + // guess which declared option a typo meant, and guessing is worse than not + // applying it (#5152, on `invite_only` silently read as `auto`). Ignored + // means the env layer contributes NOTHING here — no value and, critically, + // no `cascadeChain` entry: an `env` entry carrying `locked: true` would be + // picked up by the `lockedEntry` scan below and reported as locking a value + // it is not even providing, which is the opposite of what the read API owes + // its caller. + const envOverride = this.effectiveEnvOverride(reg, namespace, key); + if (envOverride) { + const { envName, value } = envOverride; return { value: value as T, source: 'env', @@ -504,8 +728,16 @@ export class SettingsService { // Pre-flight: reject the whole batch if any key is locked or unknown. for (const key of Object.keys(patch)) { if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key); - const envRaw = this.env[envKeyOf(namespace, key)]; - if (typeof envRaw === 'string') throw new SettingsLockedError(namespace, key); + // An env override pins the key against writes — but only one that is IN + // FORCE (#5204). This used to trigger on the mere PRESENCE of the + // variable, which after the read-side gate would have produced a key + // configurable by nothing at all: the env value rejected and ignored, the + // UI refused with `SETTINGS_LOCKED`, and `get()` reporting `locked: false` + // to a settings page that would then fail its own save. See + // `effectiveEnvOverride`. + if (this.effectiveEnvOverride(reg, namespace, key)) { + throw new SettingsLockedError(namespace, key); + } // Phase 2 lock: a row at an upper scope marked locked=true // refuses writes at this (lower) scope. Writing AT the same @@ -743,19 +975,11 @@ export class SettingsService { // write to a hand-built manifest — same leniency the unparseable // `visible` and invalid `pattern` branches already take. if (allowed.length > 0) { - // `multiselect` stores an array, `select`/`radio` a scalar; both are - // checked element-wise against the one table. A scalar arriving at a - // multiselect is wrapped rather than rejected — policing the value's - // SHAPE is a different constraint (`invalid_type`) with a different - // owner, and inventing it here would reject writes this change was - // never asked to touch. - const picked = Array.isArray(value) ? value : [value]; - // `findIndex`, not `find`: a `find` returning `undefined` cannot say - // whether nothing was rejected or whether the rejected element WAS - // `undefined` — and the latter would slip through the check. - const at = picked.findIndex((v) => !allowed.includes(String(v))); - if (at !== -1) { - const offending = picked[at]; + // Shared with the env path (#5204) — see `firstRejectedOption` for the + // array/scalar handling and why the result is wrapped. + const rejected = firstRejectedOption(allowed, value); + if (rejected) { + const offending = rejected.value; // An option value is not a secret, but `encrypted` is authorable on // any specifier — so never echo the rejected value for a key whose // contents are held encrypted, in a message that lands in logs. diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 1ac690795e..24f110cfb8 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -180,6 +180,29 @@ export type SettingsActionHandler = (input: { ctx: SettingsContext; }) => Promise | SettingsActionResult; +/** + * Minimal logging surface the service needs: just the loud channel. + * + * Deliberately a structural minimum rather than the full `Logger` contract or + * `PluginContext['logger']` — this service is framework-agnostic by design (it + * must not learn about the plugin context, see the file header), and a caller + * can hand over `ctx.logger`, a spec `Logger`, or a two-line test double + * interchangeably. `error` is optional so the lean test kernels that already + * call `ctx.logger?.info?.()` defensively are assignable unchanged. + * + * One string parameter, and no `...rest`: the service deliberately passes no + * structured `meta` (a meta field name containing `key` would be redacted away + * — see `reportRejectedEnvOverride`), and a `...rest: unknown[]` tail would not + * even accept the spec `Logger` it is meant to accept — its `error(message, + * error?: Error, meta?)` is narrower than `unknown` in those positions, so the + * assignment fails contravariantly. Declaring only what is actually called + * keeps `Logger`, `ctx.logger`, `console.error` and a one-line spy all + * assignable. + */ +export interface SettingsDiagnosticsLogger { + error?: (message: string) => void; +} + export interface SettingsServiceOptions { /** Persistence engine. When undefined, an in-memory store is used. */ engine?: SettingsEngine; @@ -206,6 +229,15 @@ export interface SettingsServiceOptions { env?: Record; /** Object name backing the K/V store. Defaults to 'sys_setting'. */ objectName?: string; + /** + * Sink for the loud-but-non-fatal diagnostics the service emits — today + * exactly one: an `OS_*` override whose value the specifier's `options` + * table does not declare (#5204). Optional; falls back to `console.error` + * so a service built without a kernel (unit tests, control-plane mock, + * bootstrap before the logger exists) still reports rather than going + * silent, which is the failure mode #5204 is about. + */ + logger?: SettingsDiagnosticsLogger; } /**