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
14 changes: 14 additions & 0 deletions .changeset/30455-manifest-list-serves-sourced-options.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
5 changes: 3 additions & 2 deletions packages/services/service-settings/src/settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 24 additions & 9 deletions packages/services/service-settings/src/settings-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}. */
Expand Down
11 changes: 11 additions & 0 deletions packages/spec/src/system/settings-manifest.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading