From f4d11a60965c7b4a4617d51aa63a2d4427c8e0d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 05:47:12 +0000 Subject: [PATCH] =?UTF-8?q?fix(service-settings):=20localization's=20decla?= =?UTF-8?q?red=20standards=20are=20the=20enforcement=20boundary=20?= =?UTF-8?q?=E2=80=94=20valueDomain=20enforced=20on=20both=20doors=20(#5712?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-06 ruling (reading 1): the curated options tables on localization.timezone / localization.currency are UI convenience lists; the enforcement boundary is the standard domain. The spec half shipped as #6515 (SpecifierValueDomainSchema); this is the services half. - manifest: timezone declares iana_time_zone, currency iso_4217_currency, default_country iso_3166_alpha2 (third case of the same hole — ZZ passed ^[A-Za-z]{2}$) - both doors judge a declared domain at their one decision point: validatePatch (after pattern — shape first, membership second) and effectiveEnvOverride (loud error + fallback, #5204 contract unchanged) - membership follows the spec's pinned definitions: DateTimeFormat probe / supportedValuesOf('currency') / explicit 249-code alpha-2 list - a specifier without valueDomain is byte-for-byte unchanged (#5131 exhaustive options), pinned by regression tests on both doors - breach code is invalid_value with constraint { valueDomain } (ADR-0114 slot for a breach no member names — the #6199 precedent); invalid_option would misname the set consulted Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei --- .../localization-value-domain-enforced.md | 44 +++ .../manifests/localization.manifest.test.ts | 24 ++ .../src/manifests/localization.manifest.ts | 13 + .../src/settings-routes.test.ts | 48 +++ .../src/settings-service.test.ts | 325 ++++++++++++++++++ .../service-settings/src/settings-service.ts | 137 +++++++- .../src/value-domains.test.ts | 134 ++++++++ .../service-settings/src/value-domains.ts | 193 +++++++++++ 8 files changed, 905 insertions(+), 13 deletions(-) create mode 100644 .changeset/localization-value-domain-enforced.md create mode 100644 packages/services/service-settings/src/value-domains.test.ts create mode 100644 packages/services/service-settings/src/value-domains.ts diff --git a/.changeset/localization-value-domain-enforced.md b/.changeset/localization-value-domain-enforced.md new file mode 100644 index 0000000000..5297548f48 --- /dev/null +++ b/.changeset/localization-value-domain-enforced.md @@ -0,0 +1,44 @@ +--- +"@objectstack/service-settings": patch +--- + +fix(service-settings): localization's declared standards are the enforcement boundary — `valueDomain` enforced on both doors (#5712) + +`localization.timezone` promised "IANA zone" and `localization.currency` promised +"ISO 4217 code", but since #5131 the write path treated their curated 17/9-entry +`options` tables as exhaustive, and since #5204 the env path agreed — so +`PUT /api/settings/localization` with `timezone: 'Europe/Zurich'` (or +`currency: 'CHF'`) was refused with `invalid_option`, and +`OS_LOCALIZATION_TIMEZONE=Europe/Zurich` was ignored, despite both being values +every `Intl`-based consumer downstream handles. Maintainer ruling (2026-08-06, +reading 1): the curated tables are UI convenience lists; the boundary is the +standard's membership. + +The manifest now declares the merged spec vocabulary (#5933 / `SpecifierValueDomainSchema`) +on the three keys that promised a standard all along — `timezone: 'iana_time_zone'`, +`currency: 'iso_4217_currency'`, and `default_country: 'iso_3166_alpha2'` (third +case of the same hole: `^[A-Za-z]{2}$` admits `ZZ`) — and `SettingsService` +enforces a declared domain at the one decision point per door: + +- **Write door** (`validatePatch`): a domain-bearing specifier skips the + exhaustive-options check and judges the standard's membership instead, after + `pattern` (shape and membership narrow independently; the shape breach is the + coarser fact and speaks first). A breach is `invalid_value` with + `constraint: { valueDomain }` — no `FieldErrorCode` member names a + standard-domain breach, and `invalid_option` would misname the set that was + consulted. +- **Env door** (`effectiveEnvOverride`): the same membership judgment, so a + garbage override is loudly reported and ignored (falls back down the cascade, + pins nothing — #5204's contract, unchanged) while a legal one wins the cascade + and locks the key. + +Membership definitions follow the spec's pinned TSDoc: `iana_time_zone` is the +`Intl.DateTimeFormat` probe (NOT `Intl.supportedValuesOf('timeZone')`, whose +CLDR subset omits `UTC`, `Asia/Kolkata` and `Europe/Kyiv`); `iso_4217_currency` +is `Intl.supportedValuesOf('currency')`; `iso_3166_alpha2` is an explicit list +of the 249 officially assigned codes (no standard-library oracle exists — +`Intl.DisplayNames` names `ZZ` and `UK`). + +A specifier that declares no `valueDomain` is byte-for-byte unchanged: #5131's +exhaustive-options semantics stay in force for registry-backed tables such as +`mail.provider` / `sms.provider`, pinned by regression tests on both doors. diff --git a/packages/services/service-settings/src/manifests/localization.manifest.test.ts b/packages/services/service-settings/src/manifests/localization.manifest.test.ts index bb0a038929..1008a9f12c 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.test.ts @@ -30,6 +30,30 @@ describe('localizationSettingsManifest', () => { expect(byKey('fiscal_year_start').default).toBe('january'); }); + it('timezone / currency / default_country declare the standard value domain (#5712)', () => { + // The 2026-08-06 ruling, reading 1: the curated options are UI convenience + // lists and the STANDARD domain is the enforcement boundary. The manifest + // says so via `valueDomain` (#5933's vocabulary); `SettingsService` + // enforces it on both doors. The descriptions promised these domains all + // along — this declaration is what makes the promise true. + const specs = localizationSettingsManifest.specifiers as any[]; + const byKey = (k: string) => specs.find((s) => s.key === k); + expect(byKey('timezone').valueDomain).toBe('iana_time_zone'); + expect(byKey('currency').valueDomain).toBe('iso_4217_currency'); + expect(byKey('default_country').valueDomain).toBe('iso_3166_alpha2'); + // The registry-backed selects stay UNDECLARED on purpose: their tables ARE + // the supported sets (#5131 exhaustive semantics), not standards. + for (const key of ['locale', 'date_format', 'time_format', 'number_format', + 'first_day_of_week', 'fiscal_year_start']) { + expect(byKey(key).valueDomain, `${key} must not declare a domain`).toBeUndefined(); + } + // And the declaration round-trips the spec parse (the enum is closed — + // a misspelt member would throw here, not silently strip). + const parsed = SettingsManifestSchema.parse(localizationSettingsManifest) as any; + const parsedTz = parsed.specifiers.find((s: any) => s.key === 'timezone'); + expect(parsedTz.valueDomain).toBe('iana_time_zone'); + }); + it('every timezone option is a valid IANA zone', () => { const tz = (localizationSettingsManifest.specifiers as any[]).find((s) => s.key === 'timezone'); for (const opt of tz.options) { diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index 748b782022..5aa14a8cf8 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -33,6 +33,11 @@ export const localizationSettingsManifest: SettingsManifest = { { type: 'select', key: 'timezone', label: 'Default timezone', required: false, default: 'UTC', description: 'IANA zone used to resolve today()/daysFromNow, analytics date buckets, and rendered datetimes.', + // The description has always promised the IANA domain; since #5712 the + // declaration matches it: any valid IANA zone is accepted on the write + // and env doors, and the curated options below are a UI convenience + // list, not an exhaustive statement of what is legal. + valueDomain: 'iana_time_zone', options: [ { value: 'UTC', label: 'UTC' }, { value: 'America/Los_Angeles', label: '(UTC−08/−07) Los Angeles' }, @@ -66,7 +71,11 @@ export const localizationSettingsManifest: SettingsManifest = { { type: 'text', key: 'default_country', label: 'Default country', required: false, default: 'US', description: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN). Used for address and phone defaults.', + // Third case of the same hole #5712 closed on timezone/currency: the + // pattern constrains SHAPE only, and `ZZ` is a shape-valid code assigned + // to nobody. The domain constrains membership; both still apply. pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2, + valueDomain: 'iso_3166_alpha2', }, // ── Formats ─────────────────────────────────────────────────────────── @@ -118,6 +127,10 @@ export const localizationSettingsManifest: SettingsManifest = { // 'USD', which surfaced an unwanted "$"/"US$" on every code-less amount). // A workspace can still pick a default to apply org-wide. description: 'ISO 4217 code applied when a currency field omits its own. Leave unset to render code-less amounts as plain numbers.', + // As with `timezone`: the description promises ISO 4217, and since #5712 + // the declaration delivers it — any ISO 4217 code is accepted, the + // curated options are a UI convenience list. + valueDomain: 'iso_4217_currency', options: [ { value: 'USD', label: 'USD — US Dollar' }, { value: 'EUR', label: 'EUR — Euro' }, diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index 1c821dc5de..1a399b4085 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -5,6 +5,7 @@ import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@ob import { SettingsService } from './settings-service.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { brandingSettingsManifest } from './manifests/branding.manifest.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; class MockHttp implements IHttpServer { routes = new Map(); @@ -213,4 +214,51 @@ describe('settings-routes', () => { await write(r2.req, r2.res); expect(r2.state.status).toBe(403); // has setup.access, lacks setup.write }); + + // ── #5712 — the declared valueDomain, as it lands on the HTTP surface ───── + // The card's repros, asserted with the full envelope: status AND code, per + // the rejection-test contract — a bare "it threw" carries one bit where the + // defect has two. + + it('PUT /api/settings/localization accepts Europe/Zurich + CHF (the #5712 repro)', async () => { + const http = new MockHttp(); + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); + + const h = http.routes.get('PUT /api/settings/:namespace')!; + const { req, res, state } = makeReqRes({ + params: { namespace: 'localization' }, + body: { timezone: 'Europe/Zurich', currency: 'CHF' }, + }); + await h(req, res); + expect(state.status).toBe(200); + expect(state.body.error).toBeUndefined(); + expect(state.body.data.values.timezone.value).toBe('Europe/Zurich'); + expect(state.body.data.values.currency.value).toBe('CHF'); + }); + + it('PUT /api/settings/localization rejects garbage with 400 + SETTINGS_VALIDATION + invalid_value', async () => { + const http = new MockHttp(); + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); + + const h = http.routes.get('PUT /api/settings/:namespace')!; + const { req, res, state } = makeReqRes({ + params: { namespace: 'localization' }, + body: { timezone: 'Mars/Olympus' }, + }); + await h(req, res); + expect(state.status).toBe(400); + expect(state.body.error.code).toBe('SETTINGS_VALIDATION'); + expect(state.body.error.details.fields).toEqual([ + expect.objectContaining({ + field: 'timezone', + code: 'invalid_value', + constraint: { valueDomain: 'iana_time_zone' }, + value: 'Mars/Olympus', + }), + ]); + }); }); diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index e2019c7f3e..90d2afc9d0 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -7,6 +7,7 @@ import { NoopCryptoAdapter } from './crypto-adapter.js'; import { mailSettingsManifest, mailTestActionHandler } from './manifests/mail.manifest.js'; import { aiSettingsManifest } from './manifests/ai.manifest.js'; import { authSettingsManifest } from './manifests/auth.manifest.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; import { brandingSettingsManifest } from './manifests/branding.manifest.js'; import { featureFlagsSettingsManifest } from './manifests/feature-flags.manifest.js'; import { SettingsManifestSchema } from '@objectstack/spec/system'; @@ -1962,3 +1963,327 @@ describe('SettingsService — Phase 3 sys_secret + crypto provider + audit', () expect(await provider.decrypt(h2, ctx)).toBe('hello'); }); }); + +/** + * #5712 — a declared `valueDomain` moves the enforcement boundary onto the + * standard's membership, save-time half. + * + * The 2026-08-06 ruling (reading 1): the curated `options` tables on + * `localization.timezone` / `localization.currency` are UI convenience lists; + * the boundary is the STANDARD domain (IANA / ISO 4217). #5131's + * exhaustive-options semantics survive untouched wherever no domain is + * declared — that regression pin lives in this block too, because the + * registry-backed tables (`mail.provider`, `sms.provider`) are exactly the + * shape that must NOT loosen. + */ +describe('SettingsService — a declared valueDomain is the save-time boundary (#5712)', () => { + const localizationService = () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + return svc; + }; + + it("accepts the card's own repro: Europe/Zurich and CHF through the write door", async () => { + const svc = localizationService(); + await expect( + svc.setMany('localization', { timezone: 'Europe/Zurich', currency: 'CHF' }), + ).resolves.toBeDefined(); + expect((await svc.get('localization', 'timezone')).value).toBe('Europe/Zurich'); + expect((await svc.get('localization', 'currency')).value).toBe('CHF'); + }); + + it('accepts the probe edges supportedValuesOf would have rejected', async () => { + // The #5933 trap, exercised end to end: `UTC` is the manifest's own + // default, `Asia/Kolkata` a curated option, `Europe/Kyiv` the current + // IANA spelling ICU still lists under its old name. + const svc = localizationService(); + for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv']) { + await expect( + svc.setMany('localization', { timezone: tz }), + `${tz} is a legal IANA zone and must be accepted`, + ).resolves.toBeDefined(); + } + }); + + it('still accepts every curated option value on both keys', async () => { + // Degrading `options` to a suggestion list must not reject anything the + // dropdown itself offers. + const svc = localizationService(); + const specs = localizationSettingsManifest.specifiers as any[]; + for (const key of ['timezone', 'currency']) { + for (const opt of specs.find((s) => s.key === key).options) { + await expect( + svc.setMany('localization', { [key]: opt.value }), + `curated ${key} option ${opt.value} must stay accepted`, + ).resolves.toBeDefined(); + } + } + }); + + it('refuses garbage loudly, with the code and the domain in the constraint', async () => { + const svc = localizationService(); + for (const tz of ['Mars/Olympus', 'ZZ']) { + await expect(svc.setMany('localization', { timezone: tz })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [ + { + field: 'timezone', + // No `FieldErrorCode` member names a standard-domain breach, so it + // takes `invalid_value` — the catalog's slot for "rejected for a + // reason no other member names" (ADR-0114), the #6199 precedent. + // NOT `invalid_option`: the declared options are exactly the list + // a domain-bearing value may legitimately be outside of. + code: 'invalid_value', + label: 'Default timezone', + constraint: { valueDomain: 'iana_time_zone' }, + value: tz, + }, + ], + }); + } + await expect(svc.setMany('localization', { currency: 'XYZ' })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [ + { field: 'currency', code: 'invalid_value', constraint: { valueDomain: 'iso_4217_currency' } }, + ], + }); + // Atomic: nothing landed. + expect((await svc.get('localization', 'timezone')).source).toBe('default'); + }); + + it('default_country: the domain refuses what the pattern admits (#5712 third case)', async () => { + const svc = localizationService(); + // In the domain, outside any curated list — accepted. + await expect(svc.setMany('localization', { default_country: 'CH' })).resolves.toBeDefined(); + // Shape-valid, assigned to nobody: `ZZ` passes `^[A-Za-z]{2}$`, the domain + // refuses it. `UK` is the CLDR alias that is not an ISO 3166-1 code. + for (const cc of ['ZZ', 'UK']) { + await expect(svc.setMany('localization', { default_country: cc })).rejects.toMatchObject({ + fields: [ + { field: 'default_country', code: 'invalid_value', constraint: { valueDomain: 'iso_3166_alpha2' } }, + ], + }); + } + // Shape breach still speaks FIRST, in `pattern`'s own vocabulary — the + // coarser, more actionable fact (the window-before-grid ordering argument). + await expect(svc.setMany('localization', { default_country: 'ZZZ' })).rejects.toMatchObject({ + fields: [{ field: 'default_country', code: 'invalid_format' }], + }); + }); + + it('a specifier WITHOUT valueDomain keeps exhaustive options — the #5131 regression pin', async () => { + // The registry-backed shape (`mail.provider`, `sms.provider`): its table + // IS the supported set, and declaring no domain must keep it that way, + // byte-for-byte. Pinned on the mail manifest itself plus a localization + // key that deliberately declares no domain. + const svc = new SettingsService({ env: {} }); + svc.registerManifest(mailSettingsManifest); + await expect(svc.setMany('mail', { provider: 'sendgrid' })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [{ field: 'provider', code: 'invalid_option' }], + }); + + const loc = localizationService(); + await expect(loc.setMany('localization', { first_day_of_week: 'thursday' })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [{ field: 'first_day_of_week', code: 'invalid_option' }], + }); + }); + + it('an unenforceable domain on a hand-built manifest falls back to #5131, not to accept-everything', async () => { + // `registerManifest` takes manifests as given (no Zod pass). A misspelt + // domain must leave the exhaustive-options check in force — the safe side + // of the fork — never open the select to arbitrary strings. + const svc = new SettingsService({ env: {} }); + svc.registerManifest({ + namespace: 'typo', version: 1, label: 'Typo', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'select', key: 'zone', label: 'Zone', required: false, + valueDomain: 'iana_timezone', // not a vocabulary member + options: [{ value: 'UTC', label: 'UTC' }] }, + ], + } as any); + await expect(svc.setMany('typo', { zone: 'UTC' })).resolves.toBeDefined(); + await expect(svc.setMany('typo', { zone: 'Europe/Zurich' })).rejects.toMatchObject({ + fields: [{ field: 'zone', code: 'invalid_option' }], + }); + }); + + it('judges a multiselect element-wise against the domain', async () => { + // No shipped manifest authors this shape yet; covered anyway because the + // alternative is that the first one to do so re-opens the hole (the same + // reason OPTION_BEARING_TYPES covers radio/multiselect). + const svc = new SettingsService({ env: {} }); + svc.registerManifest({ + namespace: 'multi', version: 1, label: 'Multi', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'multiselect', key: 'currencies', label: 'Currencies', required: false, + valueDomain: 'iso_4217_currency', + options: [{ value: 'USD', label: 'USD' }] }, + ], + } as any); + await expect(svc.setMany('multi', { currencies: ['USD', 'CHF'] })).resolves.toBeDefined(); + await expect(svc.setMany('multi', { currencies: ['USD', 'XYZ'] })).rejects.toMatchObject({ + fields: [{ field: 'currencies', code: 'invalid_value', value: 'XYZ' }], + }); + }); + + it('never echoes the rejected value for an encrypted specifier', async () => { + // Same redaction rule as `invalid_option` and the #6199 grid, same reason. + const svc = new SettingsService({ env: {} }); + svc.registerManifest({ + namespace: 'vaultdom', version: 1, label: 'Vault domain', scope: 'tenant', + readPermission: 'setup.access', writePermission: 'setup.access', + specifiers: [ + { type: 'text', key: 'region_code', label: 'Region code', encrypted: true, + valueDomain: 'iso_3166_alpha2' }, + ], + } as any); + const err = await svc.setMany('vaultdom', { region_code: 'ZZ' }).catch((e) => e); + expect(err.code).toBe('SETTINGS_VALIDATION'); + expect(err.fields[0]).toMatchObject({ field: 'region_code', code: 'invalid_value' }); + expect(err.fields[0].value).toBeUndefined(); + expect(err.message).not.toContain('ZZ'); + // The domain still travels, so the caller learns what to do. + expect(err.fields[0].constraint).toMatchObject({ valueDomain: 'iso_3166_alpha2' }); + }); + + it('checks the domain only when the patch TOUCHES the key', async () => { + // The #5131/#5932/#6199 gate, inherited by construction: a stored value + // that pre-dates enforcement must not lock the workspace out of its + // unrelated settings. + const svc = new SettingsService({ env: {} }); + // Register a domain-less shape of the manifest, store a value the domain + // would refuse, then swap the real (domain-bearing) manifest in. + svc.registerManifest({ + ...localizationSettingsManifest, + specifiers: (localizationSettingsManifest.specifiers as any[]).map((s) => + s.key === 'timezone' ? { ...s, valueDomain: undefined, options: [...s.options, { value: 'Mars/Olympus', label: 'Mars' }] } : s, + ), + } as any); + await svc.setMany('localization', { timezone: 'Mars/Olympus' }); + svc.registerManifest(localizationSettingsManifest); + + // The stale value is still there … + expect((await svc.get('localization', 'timezone')).value).toBe('Mars/Olympus'); + // … and a patch that never mentions it is not rejected on its account. + await expect(svc.setMany('localization', { currency: 'CHF' })).resolves.toBeDefined(); + // Only re-writing the key itself is refused. + await expect(svc.setMany('localization', { timezone: 'Mars/Olympus' })).rejects.toMatchObject({ + fields: [{ field: 'timezone', code: 'invalid_value' }], + }); + }); +}); + +/** + * #5712, env half — the domain is judged at the ONE decision point + * (`effectiveEnvOverride`), for the reason #5204 is on file: the same + * comparison in two places is how the env half came to disagree with the save + * half in the first place. Loud error + fallback, never silent (#5204's + * contract, unchanged). + */ +describe('SettingsService — env overrides are judged against the declared valueDomain (#5712)', () => { + const spyLogger = () => { + const errors: string[] = []; + return { errors, logger: { error: (m: string) => void errors.push(m) } }; + }; + + it("honors the card's env repro: OS_LOCALIZATION_TIMEZONE=Europe/Zurich wins the cascade", async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_TIMEZONE: 'Europe/Zurich' }, logger }); + svc.registerManifest(localizationSettingsManifest); + + const r = await svc.get('localization', 'timezone'); + expect(r.value).toBe('Europe/Zurich'); + expect(r.source).toBe('env'); + expect(r.locked).toBe(true); + expect(errors).toHaveLength(0); + }); + + it('honors OS_LOCALIZATION_CURRENCY=CHF the same way', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_CURRENCY: 'CHF' }, logger }); + svc.registerManifest(localizationSettingsManifest); + + const r = await svc.get('localization', 'currency'); + expect(r.value).toBe('CHF'); + expect(r.source).toBe('env'); + expect(errors).toHaveLength(0); + }); + + it('ignores garbage loudly and resolves the next cascade layer instead', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_TIMEZONE: 'Mars/Olympus' }, logger }); + svc.registerManifest(localizationSettingsManifest); + + const r = await svc.get('localization', 'timezone'); + expect(r.value).toBe('UTC'); // the manifest default, not the garbage + expect(r.source).toBe('default'); + // Not in force, so it pins nothing either — read and write agree (#5204). + expect(r.locked).toBe(false); + expect(r.cascadeChain?.some((e) => e.scope === 'env')).toBe(false); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_LOCALIZATION_TIMEZONE'); + expect(errors[0]).toContain('is not a valid IANA time zone identifier'); + expect(errors[0]).toContain("Rejected value: 'Mars/Olympus'"); + expect(errors[0]).toContain('IGNORED'); + expect(errors[0]).toContain('does NOT take effect'); + }); + + it('rejects OS_LOCALIZATION_CURRENCY=XYZ loudly, once, at registration', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_CURRENCY: 'XYZ' }, logger }); + expect(errors).toHaveLength(0); + svc.registerManifest(localizationSettingsManifest); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('ISO 4217'); + for (let i = 0; i < 5; i++) await svc.get('localization', 'currency'); + expect(errors).toHaveLength(1); // said ONCE (#5204 dedupe) + }); + + it('a REJECTED override pins nothing — the key stays editable', async () => { + const { logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_TIMEZONE: 'Mars/Olympus' }, logger }); + svc.registerManifest(localizationSettingsManifest); + + expect((await svc.get('localization', 'timezone')).locked).toBe(false); + await expect(svc.setMany('localization', { timezone: 'Europe/Zurich' })).resolves.toBeDefined(); + expect((await svc.get('localization', 'timezone')).value).toBe('Europe/Zurich'); + }); + + it('env door for a domain-less select keeps exhaustive options — the #5131/#5204 regression pin', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ env: { OS_LOCALIZATION_DATE_FORMAT: 'DD>MM>YYYY' }, logger }); + svc.registerManifest(localizationSettingsManifest); + + const r = await svc.get('localization', 'date_format'); + expect(r.value).toBe('YYYY-MM-DD'); // the default — the override is not in force + expect(r.source).toBe('default'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('is not a declared option for'); + }); + + it('env door checks default_country membership too — the third case, both doors', async () => { + const { errors, logger } = spyLogger(); + const svc = new SettingsService({ + env: { OS_LOCALIZATION_DEFAULT_COUNTRY: 'ZZ' }, logger, + }); + svc.registerManifest(localizationSettingsManifest); + + const r = await svc.get('localization', 'default_country'); + expect(r.value).toBe('US'); // the manifest default + expect(r.source).toBe('default'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('ISO 3166-1'); + + // And a legal non-default is honored. + const { logger: okLogger, errors: okErrors } = spyLogger(); + const ok = new SettingsService({ env: { OS_LOCALIZATION_DEFAULT_COUNTRY: 'CH' }, logger: okLogger }); + ok.registerManifest(localizationSettingsManifest); + expect((await ok.get('localization', 'default_country')).value).toBe('CH'); + expect(okErrors).toHaveLength(0); + }); +}); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 7a92cec274..cca1ffa94e 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -7,6 +7,7 @@ import type { SettingsNamespacePayload, SettingsActionResult, SpecifierScope, + SpecifierValueDomain, SettingsChangeEvent, SettingsChangeHandler, SettingsUnsubscribe, @@ -29,6 +30,11 @@ import { UnknownKeyError, UnknownNamespaceError, } from './settings-service.types.js'; +import { + firstRejectedDomainMember, + knownValueDomain, + valueDomainPhrasing, +} from './value-domains.js'; import { evaluateVisibility, referencedKeys } from './visibility-eval.js'; const DEFAULT_OBJECT = 'sys_setting'; @@ -405,6 +411,18 @@ interface RegisteredManifest { * than "an empty window", which would reject everything. */ bounds: Map; + /** + * Declared standard value domains (#5712), keyed by specifier key — + * recorded only for the domains this side can enforce (see + * `knownValueDomain`). + * + * Precomputed for the same reason and read the same way as `optionTables`: + * an ABSENT key means "no domain declared" — for an option-bearing type the + * #5131 exhaustive-options check stays the boundary, byte-for-byte unchanged + * behaviour — while a PRESENT key moves the enforcement boundary onto the + * standard's membership and degrades `options` to a UI convenience list. + */ + valueDomains: Map; } /** @@ -558,6 +576,7 @@ export class SettingsService { const defaults = new Map(); const optionTables = new Map(); const bounds = new Map(); + const valueDomains = new Map(); const defaultScope = manifest.scope ?? 'tenant'; for (const spec of manifest.specifiers) { if (!spec.key || LAYOUT_ONLY_TYPES.has(spec.type)) continue; @@ -570,6 +589,14 @@ export class SettingsService { // a finite positive spacing records no grid at all (#6199). const declared = declaredBounds(spec); if (declared) bounds.set(spec.key, declared); + // A declared standard value domain (#5712). `knownValueDomain` filters to + // the members this side can enforce: `registerManifest` takes manifests + // as given (no Zod pass), and a misspelt domain on a hand-built manifest + // must fall back to unchanged behaviour — for an option-bearing type + // that is the #5131 exhaustive table — rather than either an + // unenforceable claim or an accept-everything hole. + const domain = knownValueDomain((spec as { valueDomain?: unknown }).valueDomain); + if (domain) valueDomains.set(spec.key, domain); 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 @@ -590,6 +617,7 @@ export class SettingsService { actions, optionTables, bounds, + valueDomains, }); this.auditEnvOverrides(manifest.namespace); } @@ -615,10 +643,15 @@ export class SettingsService { const reg = this.registry.get(namespace); if (!reg) return; // Only the keys that declare something enforceable can be rejected, so only - // they are worth walking: an option table (#5131/#5204) or a value window - // (#5932). `effectiveEnvOverride` does the judging (and the reporting); the - // value it returns is of no interest here. - const enforceable = new Set([...reg.optionTables.keys(), ...reg.bounds.keys()]); + // they are worth walking: an option table (#5131/#5204), a value window + // (#5932) or a standard value domain (#5712). `effectiveEnvOverride` does + // the judging (and the reporting); the value it returns is of no interest + // here. + const enforceable = new Set([ + ...reg.optionTables.keys(), + ...reg.bounds.keys(), + ...reg.valueDomains.keys(), + ]); for (const key of enforceable) { this.effectiveEnvOverride(reg, namespace, key); } @@ -668,18 +701,40 @@ export class SettingsService { 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) { - const rejected = firstRejectedOption(allowed, value); + // A declared standard value domain (#5712) REPLACES the option table as + // the membership boundary: the standard's membership is what the override + // is judged against, and `options` is a UI convenience list this door does + // not consult. Judged here — the ONE decision point — for the reason #5204 + // is on file: the same comparison in two places is how the env half came to + // disagree with the save half in the first place. + const domain = reg.valueDomains.get(key); + if (domain) { + const rejected = firstRejectedDomainMember(domain, value); if (rejected) { + const { member, example } = valueDomainPhrasing(domain); this.reportRejectedEnvOverride(reg, namespace, key, envName, rejected.value, { - what: 'is not a declared option for', - detail: `Allowed values: ${allowed.join(', ')}.`, - fix: 'one of the allowed values', + what: `is not a valid ${member} for`, + detail: `Allowed values: any ${member} (e.g. '${example}').`, + fix: `a valid ${member}`, }); return null; } + } else { + // A key with no declared table has nothing to enforce — unchanged + // behaviour (#5131's exhaustive-options semantics, untouched when no + // domain is declared). + const allowed = reg.optionTables.get(key); + if (allowed) { + const rejected = firstRejectedOption(allowed, value); + if (rejected) { + this.reportRejectedEnvOverride(reg, namespace, key, envName, rejected.value, { + what: 'is not a declared option for', + detail: `Allowed values: ${allowed.join(', ')}.`, + fix: 'one of the allowed values', + }); + return null; + } + } } // Likewise a key with no declared window (#5932). @@ -1213,7 +1268,16 @@ export class SettingsService { * - `required` + visible + empty → rejected. * - `pattern` (text fields) + non-empty value that mismatches → rejected. * - `options` (`select`/`radio`/`multiselect`) + non-empty value outside - * the declared table → rejected (`invalid_option`). + * the declared table → rejected (`invalid_option`) — unless the specifier + * declares a `valueDomain`, which moves the boundary (next bullet). + * - `valueDomain` (#5712) + non-empty value that is not a member of the + * declared standard → rejected (`invalid_value`). The domain REPLACES the + * option table as the membership boundary: `options` degrades to a UI + * convenience list, so a value outside `options` but inside the domain is + * accepted. Judged AFTER `pattern` — shape and membership narrow + * independently and a value must satisfy both, but the shape breach is the + * coarser, more actionable fact (same one-error-per-key ordering argument + * as window-before-grid). * - `min` / `max` / `minLength` / `maxLength` + non-empty value outside the * declared window → rejected (`min_value` / `max_value` / `min_length` / * `max_length`, #5932). @@ -1304,6 +1368,15 @@ export class SettingsService { continue; } + // A declared standard value domain (#5712) moves the enforcement + // boundary off the option table: `options` is a UI convenience list for + // a domain-bearing specifier, so the exhaustive check below is skipped + // and the domain's membership is judged instead (after `pattern`, in its + // own branch). `knownValueDomain` filters to enforceable members, so a + // misspelt domain on a hand-built manifest leaves the #5131 semantics + // in force rather than opening an accept-everything hole. + const domain = knownValueDomain(spec.valueDomain); + // A `select`/`radio`/`multiselect` value must be a member of the option // table the manifest declares. Until this check existed the `options` // list was a front-end convention only — the console dropdown emitted @@ -1311,7 +1384,7 @@ export class SettingsService { // so a script, a migration or AI-authored bootstrap code could write // `provider: 'sendgrid'` into a namespace that has no such provider and // the write would succeed silently, leaving each consumer to improvise. - if (!empty && OPTION_BEARING_TYPES.has(type)) { + if (!empty && OPTION_BEARING_TYPES.has(type) && !domain) { const allowed = declaredOptionValues(spec.options); // A manifest with no option table cannot say what is legal. The spec // refuses that shape at parse time, but `registerManifest` takes @@ -1369,6 +1442,44 @@ export class SettingsService { } } + // A declared standard value domain is enforced at save time (#5712). + // `pattern` has already spoken above — shape and membership narrow + // independently and a value must satisfy both — so what arrives here is + // shape-valid, and the question is purely whether the standard's + // membership admits it (`Mars/Olympus` is a shape-valid time zone that + // does not exist; `ZZ` matches `^[A-Za-z]{2}$` and is assigned to + // nobody). No `FieldErrorCode` member names a standard-domain breach, so + // it takes `invalid_value` — the catalog's declared slot for "rejected + // for a reason no other member names" (ADR-0114), the same verdict the + // step grid reached in #6199. `invalid_option` would be a lie about + // which set was consulted: the declared options are exactly the list a + // domain-bearing value may legitimately be outside of. + if (!empty && domain) { + const rejected = firstRejectedDomainMember(domain, value); + if (rejected) { + const offending = rejected.value; + const { member, example } = valueDomainPhrasing(domain); + // Same redaction rule as `invalid_option`, same reason: a domain + // member is not a secret, but `encrypted` is authorable on any + // specifier and this message travels back through the API and into + // logs. + const secret = reg.encryptedKeys.has(key); + const got = secret ? '' : ` Received '${String(offending)}'.`; + errors.push({ + field: key, + code: 'invalid_value', + message: `${label} must be a valid ${member} (e.g. '${example}').${got}`, + label, + // The declared domain, spelled by the property it comes from + // (`FieldError.constraint`, ADR-0114), so a client can branch on + // WHICH membership refused without parsing the sentence. + constraint: { valueDomain: domain }, + ...(secret ? {} : { value: String(offending) }), + }); + continue; + } + } + // A declared value window is enforced at save time (#5932). Until this // branch existed `min`/`max`/`minLength`/`maxLength` were, like the // `options` table before #5131, a front-end convention: the console diff --git a/packages/services/service-settings/src/value-domains.test.ts b/packages/services/service-settings/src/value-domains.test.ts new file mode 100644 index 0000000000..a4237973fe --- /dev/null +++ b/packages/services/service-settings/src/value-domains.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Membership pins for the enforcement half of `Specifier.valueDomain` (#5712). + * + * The DEFINITIONS (probe vs. enumeration, and why `Intl.supportedValuesOf + * ('timeZone')` / `Intl.DisplayNames` are the wrong oracles) are pinned where + * they are declared — `packages/spec/src/system/settings-manifest.test.ts` + * measures the traps themselves. What is pinned HERE is that this side + * implements those definitions: the values the spec's TSDoc names as legal are + * admitted, the ones it names as the reason each trap matters are refused. + */ + +import { describe, it, expect } from 'vitest'; +import { SpecifierValueDomainSchema } from '@objectstack/spec/system'; +import { + firstRejectedDomainMember, + ISO_3166_ALPHA2_CODES, + knownValueDomain, + valueDomainPhrasing, +} from './value-domains.js'; + +describe('value domains — vocabulary parity with the spec', () => { + it('enforces exactly the members SpecifierValueDomainSchema declares', () => { + // A spec-side vocabulary change must go red HERE rather than becoming a + // declared-but-unenforced member (Prime Directive #10). Every declared + // member resolves to an enforcer, and nothing beyond the vocabulary does. + for (const member of SpecifierValueDomainSchema.options) { + expect(knownValueDomain(member), `${member} must be enforceable`).toBe(member); + // …and each has phrasing, so neither door can hit an undefined sentence. + const p = valueDomainPhrasing(member); + expect(p.member.length).toBeGreaterThan(0); + expect(p.example.length).toBeGreaterThan(0); + } + }); + + it('records nothing for a domain it cannot enforce', () => { + // A hand-built manifest with a misspelt domain must fall back to + // unchanged behaviour, not to accept-everything. + expect(knownValueDomain('iana_timezone')).toBeNull(); // the plausible typo + expect(knownValueDomain('bcp47_locale')).toBeNull(); // deliberately not in the vocabulary (#5933) + expect(knownValueDomain('')).toBeNull(); + expect(knownValueDomain(undefined)).toBeNull(); + expect(knownValueDomain(42)).toBeNull(); + // Prototype-chain names must not read as members (`'toString' in obj`). + expect(knownValueDomain('toString')).toBeNull(); + expect(knownValueDomain('constructor')).toBeNull(); + }); +}); + +describe('iana_time_zone — the Intl.DateTimeFormat probe', () => { + const ok = (v: unknown) => firstRejectedDomainMember('iana_time_zone', v); + + it('admits every zone the supportedValuesOf trap would have rejected', () => { + // The six measured omissions from `Intl.supportedValuesOf('timeZone')` + // (#5933's TSDoc): each is accepted by every Intl-based consumer + // downstream, so each MUST be accepted here — `UTC` is the manifest's own + // default and `Asia/Kolkata` a curated option shipped today. + for (const tz of ['UTC', 'Asia/Kolkata', 'Europe/Kyiv', 'Asia/Ho_Chi_Minh', 'US/Eastern', 'GMT']) { + expect(ok(tz), `${tz} is a legal IANA zone`).toBeNull(); + } + // And the card's own repro value. + expect(ok('Europe/Zurich')).toBeNull(); + }); + + it('refuses shape-valid garbage loudly', () => { + expect(ok('Mars/Olympus')).toEqual({ value: 'Mars/Olympus' }); + expect(ok('ZZ')).toEqual({ value: 'ZZ' }); + expect(ok('Not A Zone')).toEqual({ value: 'Not A Zone' }); + }); +}); + +describe('iso_4217_currency — Intl.supportedValuesOf("currency")', () => { + const ok = (v: unknown) => firstRejectedDomainMember('iso_4217_currency', v); + + it('admits CHF and every curated localization option', () => { + for (const code of ['CHF', 'USD', 'EUR', 'GBP', 'JPY', 'CNY', 'INR', 'AUD', 'CAD', 'BRL']) { + expect(ok(code), `${code} is a legal ISO 4217 code`).toBeNull(); + } + }); + + it('refuses XYZ (and lowercase spellings — the standard is uppercase)', () => { + expect(ok('XYZ')).toEqual({ value: 'XYZ' }); + expect(ok('usd')).toEqual({ value: 'usd' }); + }); +}); + +describe('iso_3166_alpha2 — the explicit code list the spec says this side must carry', () => { + const ok = (v: unknown) => firstRejectedDomainMember('iso_3166_alpha2', v); + + it('is structurally the officially assigned set: 249 unique uppercase pairs', () => { + expect(ISO_3166_ALPHA2_CODES.size).toBe(249); + for (const code of ISO_3166_ALPHA2_CODES) { + expect(code).toMatch(/^[A-Z]{2}$/); + } + }); + + it('admits assigned codes, including the manifest default', () => { + for (const code of ['US', 'GB', 'CN', 'CH', 'DE', 'JP', 'BR', 'IN', 'UA']) { + expect(ok(code), `${code} is officially assigned`).toBeNull(); + } + }); + + it('refuses exactly the values the DisplayNames non-oracle admits', () => { + // `ZZ` maps to "Unknown Region" and is the value #5933 cites as slipping + // past `^[A-Za-z]{2}$`; `UK` is a CLDR alias, not an ISO 3166-1 code + // (GB is). `XX` is user-assigned. All three are shape-valid. + expect(ok('ZZ')).toEqual({ value: 'ZZ' }); + expect(ok('UK')).toEqual({ value: 'UK' }); + expect(ok('XX')).toEqual({ value: 'XX' }); + // Membership is exact uppercase, as the standard spells its codes. + expect(ok('us')).toEqual({ value: 'us' }); + }); +}); + +describe('firstRejectedDomainMember — the firstRejectedOption mirror contract', () => { + it('judges arrays element-wise and names the first offender', () => { + expect(firstRejectedDomainMember('iso_4217_currency', ['USD', 'CHF'])).toBeNull(); + expect(firstRejectedDomainMember('iso_4217_currency', ['USD', 'XYZ', 'ABC'])) + .toEqual({ value: 'XYZ' }); + }); + + it('wraps the offender so a rejected `undefined` stays distinguishable', () => { + const rejected = firstRejectedDomainMember('iana_time_zone', undefined); + expect(rejected).not.toBeNull(); + expect(rejected).toEqual({ value: undefined }); + }); + + it('compares in string form — the value has been through JSON and a form post', () => { + // A number is stringified before membership is asked, same as + // `declaredOptionValues` compares options; `String(5)` is no zone. + expect(firstRejectedDomainMember('iana_time_zone', 5)).toEqual({ value: 5 }); + }); +}); diff --git a/packages/services/service-settings/src/value-domains.ts b/packages/services/service-settings/src/value-domains.ts new file mode 100644 index 0000000000..78cc621b35 --- /dev/null +++ b/packages/services/service-settings/src/value-domains.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Standard value-domain membership — the enforcement half of + * `Specifier.valueDomain` (#5712; the declaration half is #5933, + * `SpecifierValueDomainSchema` in `@objectstack/spec/system`). + * + * A specifier that declares `valueDomain` says: the legal values for this key + * are the members of this published standard, and that membership — not the + * curated `options` table — is the enforcement boundary. The spec deliberately + * only DECLARES the domains (Prime Directive #2 — no business logic, no data + * tables in `packages/spec`); each domain's definition of membership is pinned + * in `SpecifierValueDomainSchema`'s TSDoc and implemented here, on the + * enforcing side. Keep the two in sync — the spec-side TSDoc names the traps + * each definition was measured against, and `value-domains.test.ts` re-measures + * them so drift goes red rather than rotting. + */ + +import type { SpecifierValueDomain } from '@objectstack/spec/system'; + +/** + * `iana_time_zone` membership — the `Intl.DateTimeFormat` probe. + * + * NOT `Intl.supportedValuesOf('timeZone')`: measured on the repo's Node 22 + * baseline it returns 418 CLDR *canonical* names and omits `UTC` (the + * localization manifest's own declared default), `Asia/Kolkata` (a curated + * option shipped today), `Europe/Kyiv`, `US/Eastern` and `GMT` — ICU keeps the + * old spellings (`Asia/Calcutta`, `Europe/Kiev`) as its canonical names, so + * testing membership against that list rejects values every runtime accepts + * (#5712's own env repro, re-measured in #5933). The probe is the definition + * the platform's consumers already use: `isValidTimeZone` in + * `packages/core/src/security/resolve-authz-context.ts` (module-private there, + * hence re-stated rather than imported) and the IANA assertion in + * `localization.manifest.test.ts`. Note the probe is case-insensitive + * (`europe/zurich` constructs fine) — that IS the pinned definition: the + * accepted domain equals what every `Intl`-based consumer downstream accepts. + */ +function isIanaTimeZone(value: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return true; + } catch { + return false; + } +} + +/** + * `iso_4217_currency` membership — `Intl.supportedValuesOf('currency')`. + * + * Here the enumeration IS usable (measured 162 entries on the Node 22 + * baseline: admits `CHF` and all nine curated localization options, rejects + * `XYZ`). Known gaps are the recently assigned `VED` and the metal/fund codes + * (`XAU`, `XDR`, …) — widen deliberately if a deployment needs one, never by + * falling back to a regex. Membership is exact (uppercase, as ISO 4217 spells + * codes); computed once and cached, since the set is a property of the runtime + * and `validatePatch` runs per write. + */ +let iso4217Cache: ReadonlySet | undefined; +function iso4217Codes(): ReadonlySet { + // The cast exists because the repo's root tsconfig `lib` is ES2020 while + // `Intl.supportedValuesOf` is typed in lib.es2022.intl — the RUNTIME is + // guaranteed (engines >= 22; the spec's own vocabulary test calls it bare). + // Delete the cast when the root `lib` moves to ES2022+; do not widen it. + const intl = Intl as typeof Intl & { supportedValuesOf(key: 'currency'): string[] }; + iso4217Cache ??= new Set(intl.supportedValuesOf('currency')); + return iso4217Cache; +} + +/** + * `iso_3166_alpha2` — the officially assigned ISO 3166-1 alpha-2 codes, + * carried explicitly because there is NO standard-library oracle for this + * domain (measured in #5933): `Intl.DisplayNames(…, { type: 'region' })` + * returns a distinct display name for `ZZ` ("Unknown Region" — the exact value + * this domain exists to reject) and for `UK` (a CLDR alias that is not an + * ISO 3166-1 code), so "the name differs from the input" is not a membership + * test. The spec's TSDoc says the enforcing side must carry the list; this is + * that list — the 249 officially assigned codes. User-assigned and reserved + * elements (`ZZ`, `XX`, `UK`, `AA`, `QM`–`QZ`, …) are deliberately absent. + * Membership is exact uppercase, as the standard spells the codes; nothing in + * this repo writes lowercase country values, and one strict spelling is the + * shape AI-authored metadata cannot get subtly wrong. + */ +const ISO_3166_ALPHA2 = new Set( + ( + 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ ' + + 'BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ ' + + 'CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ ' + + 'DE DJ DK DM DO DZ ' + + 'EC EE EG EH ER ES ET ' + + 'FI FJ FK FM FO FR ' + + 'GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY ' + + 'HK HM HN HR HT HU ' + + 'ID IE IL IM IN IO IQ IR IS IT ' + + 'JE JM JO JP ' + + 'KE KG KH KI KM KN KP KR KW KY KZ ' + + 'LA LB LC LI LK LR LS LT LU LV LY ' + + 'MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ ' + + 'NA NC NE NF NG NI NL NO NP NR NU NZ ' + + 'OM ' + + 'PA PE PF PG PH PK PL PM PN PR PS PT PW PY ' + + 'QA ' + + 'RE RO RS RU RW ' + + 'SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ ' + + 'TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ ' + + 'UA UG UM US UY UZ ' + + 'VA VC VE VG VI VN VU ' + + 'WF WS ' + + 'YE YT ' + + 'ZA ZM ZW' + ).split(' '), +); + +/** Exported for the structural pins in `value-domains.test.ts` only. */ +export const ISO_3166_ALPHA2_CODES: ReadonlySet = ISO_3166_ALPHA2; + +/** + * The closed set of domains this side knows how to enforce — kept equal to + * `SpecifierValueDomainSchema`'s members (`value-domains.test.ts` pins the + * equality, so a spec-side vocabulary change goes red here instead of becoming + * a declared-but-unenforced member, the Prime Directive #10 shape). + */ +const DOMAIN_MEMBERSHIP: Record boolean> = { + iana_time_zone: isIanaTimeZone, + iso_4217_currency: (value) => iso4217Codes().has(value), + iso_3166_alpha2: (value) => ISO_3166_ALPHA2.has(value), +}; + +/** + * The declared `valueDomain`, when it is one this side can enforce; else null. + * + * `registerManifest` and `validatePatch` take manifests as given (no Zod pass — + * a Zod-parsed manifest can never carry an unknown member, the enum is closed), + * so a hand-built manifest with a misspelt domain records NOTHING here rather + * than an unenforceable claim: the specifier behaves exactly as if the key were + * absent — for an option-bearing type that means the #5131 exhaustive-options + * check stays in force — which is the same "record nothing rather than an empty + * table" leniency the option-table registration takes, and strictly safer than + * accepting everything on the strength of a typo. + */ +export function knownValueDomain(declared: unknown): SpecifierValueDomain | null { + // Own-property, not `in`: the record is an object literal, so `'toString' in + // DOMAIN_MEMBERSHIP` is true via the prototype chain and would hand a + // hand-built manifest a "domain" whose enforcer is not a membership test. + // (`hasOwnProperty.call` rather than `Object.hasOwn` only because the root + // tsconfig `lib` is ES2020 — same story as the `supportedValuesOf` cast.) + return typeof declared === 'string' && + Object.prototype.hasOwnProperty.call(DOMAIN_MEMBERSHIP, declared) + ? (declared as SpecifierValueDomain) + : null; +} + +/** + * The first member of `value` the domain does not admit, or `null` when every + * member is admissible. + * + * Mirrors `firstRejectedOption` deliberately, member for member: element-wise + * over arrays (a `multiselect` stores one), scalar wrapped rather than + * rejected (shape is `invalid_type`'s business, not membership's), compared in + * string form (a stored value has been through JSON and a form post), and + * returning a wrapper so "nothing rejected" and "the rejected member WAS + * `undefined`" stay distinguishable. + */ +export function firstRejectedDomainMember( + domain: SpecifierValueDomain, + value: unknown, +): { value: unknown } | null { + const member = DOMAIN_MEMBERSHIP[domain]; + const picked = Array.isArray(value) ? value : [value]; + const at = picked.findIndex((v) => !member(String(v))); + return at === -1 ? null : { value: picked[at] }; +} + +/** + * The three prose fragments a rejection message needs, per domain — one + * definition feeding BOTH doors (`validatePatch`'s `FieldError.message` and + * `reportRejectedEnvOverride`'s log line), so the two never describe the same + * domain in different words. + */ +export function valueDomainPhrasing(domain: SpecifierValueDomain): { + /** What a legal member is called, e.g. "IANA time zone identifier". */ + member: string; + /** A representative legal value that is NOT in the curated options today. */ + example: string; +} { + switch (domain) { + case 'iana_time_zone': + return { member: 'IANA time zone identifier', example: 'Europe/Zurich' }; + case 'iso_4217_currency': + return { member: 'ISO 4217 currency code', example: 'CHF' }; + case 'iso_3166_alpha2': + return { member: 'ISO 3166-1 alpha-2 country code', example: 'CH' }; + } +}