From 7b525303a13b49a8c9c9e56e451b9ee27856c3aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 06:16:52 +0000 Subject: [PATCH] fix(service-settings): company.country adopts the iso_3166_alpha2 value domain (#6579) The fourth case of the hole #5712 closed on localization: the description promised ISO 3166-1 while pattern '^[A-Za-z]{2}$' constrained shape only, so ZZ (assigned to nobody) and UK (a CLDR alias, not an ISO 3166-1 code) passed the write door. One manifest line adopts the domain; both doors now judge membership (invalid_value + constraint: { valueDomain } on save, loud ignore on OS_COMPANY_COUNTRY). Pattern stays: a shape breach still speaks first as invalid_format. Deliberate tightening pinned: lowercase 'us' moves from pattern-accept to domain-reject. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei --- .changeset/company-country-value-domain.md | 5 + .../src/manifests/company.manifest.test.ts | 21 +++++ .../src/manifests/company.manifest.ts | 4 + .../src/settings-service.test.ts | 93 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 .changeset/company-country-value-domain.md diff --git a/.changeset/company-country-value-domain.md b/.changeset/company-country-value-domain.md new file mode 100644 index 0000000000..6e39bdb17a --- /dev/null +++ b/.changeset/company-country-value-domain.md @@ -0,0 +1,5 @@ +--- +'@objectstack/service-settings': patch +--- + +`company.country` adopts the `iso_3166_alpha2` value domain (#6579), the fourth case of the hole #5712 closed on localization: `pattern: '^[A-Za-z]{2}$'` constrains shape only, so `ZZ` (assigned to nobody) and `UK` (a CLDR alias, not an ISO 3166-1 code) passed the write door while the description promised ISO 3166-1. Both doors now judge membership against the explicit 249-code list (`invalid_value` with `constraint: { valueDomain: 'iso_3166_alpha2' }` on save; loud ignore on `OS_COMPANY_COUNTRY`). The pattern stays — a shape breach still speaks first as `invalid_format`. Deliberate tightening, same as #5712: membership is exact uppercase, so lowercase spellings like `us` are now refused. diff --git a/packages/services/service-settings/src/manifests/company.manifest.test.ts b/packages/services/service-settings/src/manifests/company.manifest.test.ts index 45b8704f98..50c0ebf5a0 100644 --- a/packages/services/service-settings/src/manifests/company.manifest.test.ts +++ b/packages/services/service-settings/src/manifests/company.manifest.test.ts @@ -30,6 +30,27 @@ describe('companySettingsManifest', () => { expect(byKey('country').pattern).toBe('^[A-Za-z]{2}$'); }); + it('country declares the iso_3166_alpha2 value domain (#6579)', () => { + // #5712's fourth case: the description promised ISO 3166-1 all along while + // `^[A-Za-z]{2}$` constrained shape only (`ZZ` and `UK` passed the write + // door). The declaration is what makes the promise true; `SettingsService` + // enforces it on both doors. The pattern STAYS — shape still speaks first. + const specs = companySettingsManifest.specifiers as any[]; + const byKey = (k: string) => specs.find((s) => s.key === k); + expect(byKey('country').valueDomain).toBe('iso_3166_alpha2'); + expect(byKey('country').pattern).toBe('^[A-Za-z]{2}$'); + // Every other key stays UNDECLARED on purpose: free-text legal-identity + // fields, not published standards. + for (const s of specs.filter((x) => x.key && x.key !== 'country')) { + expect(s.valueDomain, `${s.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(companySettingsManifest) as any; + expect(parsed.specifiers.find((s: any) => s.key === 'country').valueDomain) + .toBe('iso_3166_alpha2'); + }); + it('has no required fields — every key is optional for v1', () => { const specs = companySettingsManifest.specifiers as any[]; for (const s of specs.filter((x) => x.key)) { diff --git a/packages/services/service-settings/src/manifests/company.manifest.ts b/packages/services/service-settings/src/manifests/company.manifest.ts index 2bdf7b6254..a8737fea6f 100644 --- a/packages/services/service-settings/src/manifests/company.manifest.ts +++ b/packages/services/service-settings/src/manifests/company.manifest.ts @@ -54,7 +54,11 @@ export const companySettingsManifest: SettingsManifest = { { type: 'text', key: 'country', label: 'Country', required: false, description: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).', + // Fourth case of the hole #5712 closed on timezone/currency (#6579): 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', }, // ── Contact ─────────────────────────────────────────────────────────── diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 90d2afc9d0..5215b79e90 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -8,6 +8,7 @@ import { mailSettingsManifest, mailTestActionHandler } from './manifests/mail.ma import { aiSettingsManifest } from './manifests/ai.manifest.js'; import { authSettingsManifest } from './manifests/auth.manifest.js'; import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { companySettingsManifest } from './manifests/company.manifest.js'; import { brandingSettingsManifest } from './manifests/branding.manifest.js'; import { featureFlagsSettingsManifest } from './manifests/feature-flags.manifest.js'; import { SettingsManifestSchema } from '@objectstack/spec/system'; @@ -2287,3 +2288,95 @@ describe('SettingsService — env overrides are judged against the declared valu expect(okErrors).toHaveLength(0); }); }); + +/** + * #6579 — `company.country` adopts `iso_3166_alpha2`, the fourth case of the + * hole #5712 closed on localization: the description promised ISO 3166-1 all + * along while `^[A-Za-z]{2}$` constrained shape only, so `ZZ` (assigned to + * nobody) and `UK` (a CLDR alias, not an ISO 3166-1 code) passed the write + * door. Same enforcement machinery, same verdicts — these pins only cover the + * adoption, not the machinery (that lives in the #5712 blocks above). + */ +describe('SettingsService — company.country adopts iso_3166_alpha2 (#6579)', () => { + const companyService = () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(companySettingsManifest); + return svc; + }; + + it('write door: admits an assigned code, refuses ZZ/UK with the domain in the constraint', async () => { + const svc = companyService(); + await expect(svc.setMany('company', { country: 'CH' })).resolves.toBeDefined(); + expect((await svc.get('company', 'country')).value).toBe('CH'); + for (const cc of ['ZZ', 'UK']) { + await expect(svc.setMany('company', { country: cc })).rejects.toMatchObject({ + code: 'SETTINGS_VALIDATION', + fields: [ + { + field: 'country', + code: 'invalid_value', + label: 'Country', + constraint: { valueDomain: 'iso_3166_alpha2' }, + value: cc, + }, + ], + }); + } + }); + + it('shape breach still speaks first, in the pattern vocabulary', async () => { + // `pattern` stays on the specifier: shape and membership narrow + // independently, and the shape verdict is the coarser, more actionable + // fact (the same window-before-grid ordering argument as #5712). + const svc = companyService(); + await expect(svc.setMany('company', { country: 'ZZZ' })).rejects.toMatchObject({ + fields: [{ field: 'country', code: 'invalid_format' }], + }); + }); + + it('pins the deliberate tightening: lowercase `us` moves from pattern-accept to domain-reject', async () => { + // Before the adoption `^[A-Za-z]{2}$` admitted `us`; domain membership is + // exact uppercase, as ISO 3166-1 spells its codes — the same tightening + // #5712 recorded for `localization.default_country`. No in-repo runtime + // reader consumes `company.country` today, so the risk grade matches. + const svc = companyService(); + await expect(svc.setMany('company', { country: 'us' })).rejects.toMatchObject({ + fields: [ + { field: 'country', code: 'invalid_value', constraint: { valueDomain: 'iso_3166_alpha2' } }, + ], + }); + }); + + it('env door judges OS_COMPANY_COUNTRY against the domain too — both doors move together', async () => { + // Domain membership ONLY: the env door does not enforce `pattern` for + // company keys — that gap is #6580's card, deliberately untouched here. + const errors: string[] = []; + const svc = new SettingsService({ + env: { OS_COMPANY_COUNTRY: 'ZZ' }, + logger: { error: (m: string) => void errors.push(m) }, + }); + svc.registerManifest(companySettingsManifest); + + const r = await svc.get('company', 'country'); + // Not in force: `country` declares no default, so the read resolves to the + // empty default layer rather than the rejected override. + expect(r.source).toBe('default'); + expect(r.value).toBeNull(); + expect(r.locked).toBe(false); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('OS_COMPANY_COUNTRY'); + expect(errors[0]).toContain('ISO 3166-1'); + + // And a legal member is honored. + const okErrors: string[] = []; + const ok = new SettingsService({ + env: { OS_COMPANY_COUNTRY: 'CH' }, + logger: { error: (m: string) => void okErrors.push(m) }, + }); + ok.registerManifest(companySettingsManifest); + const okR = await ok.get('company', 'country'); + expect(okR.value).toBe('CH'); + expect(okR.source).toBe('env'); + expect(okErrors).toHaveLength(0); + }); +});