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
5 changes: 5 additions & 0 deletions .changeset/company-country-value-domain.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand Down
93 changes: 93 additions & 0 deletions packages/services/service-settings/src/settings-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading