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
38 changes: 38 additions & 0 deletions .changeset/env-settings-option-table-gate.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/services/service-settings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export {
type SettingsActionHandler,
type SettingsAuditSink,
type SettingsContext,
type SettingsDiagnosticsLogger,
type SettingsEngine,
type SettingsRow,
type SettingsServiceOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {})) {
Expand Down
Loading
Loading