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
44 changes: 44 additions & 0 deletions .changeset/localization-value-domain-enforced.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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' },
Expand Down
48 changes: 48 additions & 0 deletions packages/services/service-settings/src/settings-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RouteHandler>();
Expand Down Expand Up @@ -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',
}),
]);
});
});
Loading
Loading