diff --git a/.changeset/30455-manifest-list-serves-sourced-options.md b/.changeset/30455-manifest-list-serves-sourced-options.md new file mode 100644 index 00000000000..761edb66c96 --- /dev/null +++ b/.changeset/30455-manifest-list-serves-sourced-options.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-settings': patch +'@objectstack/spec': patch +--- + +fix(service-settings): the settings manifest list serves a sourced select's options, the table the save judges against, as the namespace read does + +Clause-②: no — the manifest list now states the table the save already enforces; nothing a caller sends is refused differently. +Metadata-protocol: none + +A select key whose options come from the running system (`localization.locale`: the languages the deployment's i18n service reports) was served its live options only by the namespace read (`GET /api/settings/:namespace`). `SettingsService.getManifest()`, `SettingsService.listManifests()` and `GET /api/settings` still carried the declared table (the four codes `en-US`, `zh-CN`, `ja-JP`, `es-ES`), so a reader of the manifest list (the Setup hub, an SDK caller) was told a table that is not the one a save is judged against. + +- **Now.** All three are served through the same resolution as the namespace read: a sourced key carries its source's options; every other key, and a sourced key whose source has no answer (no i18n service in the composition), carries the declared table as before. `getManifest(namespace, display?)` and `listManifests(ctx?, display?)` take an optional display language; `GET /api/settings` passes the request's. The registered manifest is never changed. +- **Spec.** The `valueDomain` description in `settings-manifest.zod.ts` names the runtime option source beside `valueDomain` as the other mover of a select's enforcement boundary: where a source answers, the declared `options` are the fallback. diff --git a/packages/services/service-settings/src/localization-locale-options.test.ts b/packages/services/service-settings/src/localization-locale-options.test.ts index 43fae8e381b..1b800eafc2e 100644 --- a/packages/services/service-settings/src/localization-locale-options.test.ts +++ b/packages/services/service-settings/src/localization-locale-options.test.ts @@ -121,11 +121,48 @@ describe('localization.locale serves the deployment\'s locale list', () => { expect(options.map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'fr', 'vi-VN']); expect(options.slice(0, 3).map((o) => o.label)).toEqual(['English', '简体中文', 'Français']); expect(options[3].label).toBe(localeSelfName('vi-VN')); - // The registered manifest is left as declared. + // The registered manifest is left as declared: once the source has no + // answer, the manifest read is the declared four again. + booted.i18n.locales = []; const declared = (booted.service.getManifest('localization').specifiers as any[]).find((s) => s.key === 'locale'); expect(declared.options.map((o: { value: string }) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']); }); + it('the manifest list serves the table the namespace read serves and the save judges against', async () => { + booted.i18n.locales = ['en', 'zh-CN', 'fr-FR', 'vi-VN']; + // An offered value in effect, so the namespace read offers the list as reported. + expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(200); + + const list = await call(booted.http, 'GET /api/settings'); + expect(list.status).toBe(200); + const listed = (list.body.data.manifests as any[]) + .find((m) => m.namespace === 'localization') + .specifiers.find((s: { key?: string }) => s.key === 'locale').options as Array<{ value: string; label: string }>; + const page = servedLocaleOptions((await getPage(booted.http)).body); + expect(listed).toEqual(page); + expect(listed.map((o) => o.value)).toEqual(['en', 'zh-CN', 'fr-FR', 'vi-VN']); + // In-process readers are told the same table. + const read = (booted.service.getManifest('localization').specifiers as any[]).find((s) => s.key === 'locale'); + expect(read.options).toEqual(listed); + const inProcess = booted.service.listManifests().find((m) => m.namespace === 'localization')!; + expect((inProcess.specifiers as any[]).find((s) => s.key === 'locale').options).toEqual(listed); + + // The save's boundary is that table: every listed value saves, the + // declared `ja-JP` the list leaves out is refused naming the listed values. + for (const { value } of listed) { + expect((await save(booted.http, { locale: value })).status).toBe(200); + } + const refused = await save(booted.http, { locale: 'ja-JP' }); + expect(refused.status).toBe(400); + expect(refused.body.error.details.fields).toEqual([ + expect.objectContaining({ + field: 'locale', + code: 'invalid_option', + constraint: { allowed: listed.map((o) => o.value).join(', ') }, + }), + ]); + }); + it('accepts a language the deployment supports that the declared four never named', async () => { booted.i18n.locales = ['en', 'zh-CN', 'fr-FR']; const saved = await save(booted.http, { locale: 'fr-FR' }); diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 676c060ec62..4939ccf4986 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -273,8 +273,9 @@ export function registerSettingsRoutes( }; mountFamilyRoute('get', base, { - async serve(_req, res, ctx) { - const manifests = service.listManifests(ctx); + async serve(req, res, ctx) { + // Served as the namespace read serves them: a sourced key's options. + const manifests = service.listManifests(ctx, { locale: requestLocaleOf(req) }); sendOk(res, { manifests }); }, refuse(res, err) { diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 8eb5aa98a40..0d3acb6be38 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1572,11 +1572,16 @@ export class SettingsService { else console.error(message); } - /** Look up a manifest, or throw `UnknownNamespaceError`. */ - getManifest(namespace: string): SettingsManifest { + /** + * Look up a manifest, or throw `UnknownNamespaceError`. A key with a + * registered option source carries the source's options, as the namespace + * read serves them ({@link servedManifest}); every other key carries its + * declared table. `display.locale` is the language the options are named in. + */ + getManifest(namespace: string, display: SettingsDisplayOptions = {}): SettingsManifest { const reg = this.registry.get(namespace); if (!reg) throw new UnknownNamespaceError(namespace); - return reg.manifest; + return this.servedManifest(reg, namespace, {}, display); } /** @@ -1605,10 +1610,16 @@ export class SettingsService { } } - /** List all registered manifests, optionally filtered by permission. */ - listManifests(ctx: SettingsContext = {}): SettingsManifest[] { + /** + * List all registered manifests, optionally filtered by permission. Each is + * served as {@link getManifest} serves it: a sourced key carries the options + * the save judges against, not the declared table the source replaces. + */ + listManifests(ctx: SettingsContext = {}, display: SettingsDisplayOptions = {}): SettingsManifest[] { const perms = new Set(ctx.permissions ?? []); - const all = Array.from(this.registry.values()).map((r) => r.manifest); + const all = Array.from(this.registry.entries()).map(([namespace, reg]) => + this.servedManifest(reg, namespace, {}, display), + ); // Empty permissions ⇒ pass-through ONLY for a trusted (non-enforced) // in-process caller. An ENFORCED HTTP caller with no capabilities sees only // the manifests it may read — never the whole set (Finding-1: an @@ -1742,9 +1753,13 @@ export class SettingsService { } /** - * The manifest a page is served: the registered one, with each sourced - * key's `options` replaced by its source's answer for the value in effect. - * The registered manifest itself is never changed. + * The manifest a reader is served: the registered one, with each sourced + * key's `options` replaced by its source's answer. The one resolution every + * manifest read goes through: the namespace read passes the values in + * effect, so the answer can offer the stored value as stored; + * {@link getManifest} and {@link listManifests} pass none, and are served + * the options a write is judged against. The registered manifest itself is + * never changed. */ private servedManifest( reg: RegisteredManifest, diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 6bc2048c577..ab12f36baf9 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -500,7 +500,9 @@ export interface SettingsOptionSourceAnswer { * absent from the composition). * * Asked at the moment it is used, never recorded: on each save (the write's - * acceptance), on each read of an `OS_*` override, and on each page read. It + * acceptance), on each read of an `OS_*` override, on each page read, and on + * each manifest read (`getManifest`, `listManifests`, `GET /api/settings`), + * so every reader of the key's options is told the table a save judges. It * is not asked when the manifest registers, because what it reads registers * later in the boot, so an `OS_*` override of such a key is judged when it is * read. @@ -510,7 +512,8 @@ export type SettingsOptionSource = (input: { * The key's value in effect on the page being served, so the answer can * offer it as stored: under its own spelling when it is a second spelling * of an offered value, and named as no longer offered when it is not - * admitted. Absent when a write or an override is judged. + * admitted. Absent when a write or an override is judged, and when a + * manifest is read outside a page (`getManifest`, `listManifests`). */ current?: unknown; /** The language the page is read in, {@link SettingsDisplayOptions.locale}. */ diff --git a/packages/spec/src/system/settings-manifest.zod.ts b/packages/spec/src/system/settings-manifest.zod.ts index cc1a1859110..70de2665b7f 100644 --- a/packages/spec/src/system/settings-manifest.zod.ts +++ b/packages/spec/src/system/settings-manifest.zod.ts @@ -499,6 +499,17 @@ export const SpecifierSchema = lazySchema(() => z.object({ * `sms.provider` — where "legal" means "this deployment ships an adapter for * it", not "some registrar published it". Do not add `valueDomain` to those. * + * **The one other boundary mover is a runtime option source.** A select whose + * legal values are what the running deployment supports (`localization.locale`: + * the languages its i18n service reports) has its table served by the + * settings service from that system, not from this declaration. Where a + * source answers, its options are what a write is judged against and what + * every manifest read serves (the namespace read and the manifest list + * alike); the declared `options` are the fallback, standing only while the + * source has no answer (the system it reads is absent from the composition). + * That is a runtime registration, not an authorable key: a manifest cannot + * declare one. + * * `pattern` / `minLength` / `maxLength` still apply and still narrow: they * constrain the *shape* of the string, the domain constrains its * *membership*. A value must satisfy both. Reach for `valueDomain` precisely