diff --git a/.changeset/30286-default-language-follows-deployment.md b/.changeset/30286-default-language-follows-deployment.md new file mode 100644 index 00000000000..002767e5d90 --- /dev/null +++ b/.changeset/30286-default-language-follows-deployment.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-settings': patch +--- + +fix(service-settings)!: Setup's default language (`localization.locale`) offers and accepts the languages the deployment supports, not four hard-coded ones (#30286) + +Clause-②: yes (narrowing) — `SettingsService` gains `registerOptionSource(namespace, key, source)` and the package exports the `SettingsOptionSource`, `SettingsOptionSourceAnswer` and `SettingsServedOption` types; `getNamespace` takes an optional third `display` argument. The save of `localization.locale` (`PUT /api/settings/localization`, `SettingsService.setMany`) and an `OS_LOCALIZATION_LOCALE` override now accept the i18n service's locale list instead of the declared `en-US`, `zh-CN`, `ja-JP`, `es-ES`: a language the deployment supports is accepted where it was refused (`fr-FR`, `vi-VN`), and one of the four the deployment does not support is refused where it was accepted (`ja-JP` on an app that declares `i18n.supportedLocales: ['en', 'zh-CN']`). +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing on the `localization.locale` settings save (Setup → Localization → Default language, `PUT /api/settings/localization`, `SettingsService.setMany`) and on an `OS_LOCALIZATION_LOCALE` override, on a deployment whose language list leaves out one of `en-US`, `zh-CN`, `ja-JP`, `es-ES`. + +- **Before.** Default language offered English (US), 简体中文, 日本語 and Español (España), and the save accepted exactly those four, whatever the deployment shipped. A French pack, or a Vietnamese translation item the user's language menu already offered, could not be chosen as the organization's default. +- **Now.** The options and the accepted values are the i18n service's locale list, the one `GET /api/v1/i18n/locales` and the user's language menu read (the app's declared `i18n.supportedLocales`, else every locale a bundle or translation item has loaded), asked at each save and page read, so a translation item added at runtime is offered at once. Each option is named in its own language (the platform locale table's name, else `Intl.DisplayNames` in that language, else the code). A stored spelling of an offered language is still accepted and shown chosen (`en-US` where `en` is offered, `zh-CN` where `zh-Hans` is). A value outside the list is refused `400 SETTINGS_VALIDATION` with `invalid_option` at `locale`, listing the offered values. With no i18n service in the composition the declared four stand. +- **Unchanged.** A value stored before this release that the deployment no longer offers stays in effect and readable: the page offers it, named as no longer offered in the reader's language, and a save of other keys passes it by; only a save that writes it again is judged. Every reader of the setting (formatting, auth email and SMS, seeds, activity titles) reads it as before. + +**The remedy.** To keep offering a language the deployment refuses, ship it: add the language to the app's `i18n.supportedLocales` (and its translations), then pick it in Setup. diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 0494fab5868..ccdbc9177de 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -54,6 +54,10 @@ export { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + // The options a select key serves from the running system, not its manifest. + type SettingsOptionSource, + type SettingsOptionSourceAnswer, + type SettingsServedOption, // ADR-0144 D4 — the write-side rule a package registers on one key. type SettingsWritePredicate, type SettingsWritePredicateInput, diff --git a/packages/services/service-settings/src/localization-locale-options.test.ts b/packages/services/service-settings/src/localization-locale-options.test.ts new file mode 100644 index 00000000000..43fae8e381b --- /dev/null +++ b/packages/services/service-settings/src/localization-locale-options.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup's 默认语言 (`localization.locale`) offers and accepts the languages + * the deployment supports: the i18n service's locale list, the one the user's + * language menu reads (`GET /api/v1/i18n/locales`), not four codes written + * into the manifest. + * + * Pinned on a real `LiteKernel` boot of `SettingsServicePlugin`, through the + * settings routes the Localization page reads and saves through, so the + * wiring, the served manifest and the save's acceptance are one path. The + * i18n service is a stand-in whose locale list the cases change at runtime, + * the way an added translation item grows it. + */ + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SettingsServicePlugin } from './settings-service-plugin.js'; +import { SettingsService } from './settings-service.js'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { localizationSettingsManifest } from './manifests/localization.manifest.js'; +import { localeSelfName, localizationLocaleOptionSource, notOfferedCopy } from './localization-locale-options.js'; + +/** An i18n service whose reported locale list each case sets. */ +class LocaleListI18nPlugin implements Plugin { + name = 'test.locale-list-i18n'; + version = '0.0.0'; + locales: string[] = []; + async init(ctx: PluginContext): Promise { + ctx.registerService('i18n', { + loadTranslations: () => {}, + getLocales: () => [...this.locales], + }); + } +} + +class MockHttp implements IHttpServer { + routes = new Map(); + get(path: string, h: RouteHandler) { this.routes.set(`GET ${path}`, h); return this as any; } + post(path: string, h: RouteHandler) { this.routes.set(`POST ${path}`, h); return this as any; } + put(path: string, h: RouteHandler) { this.routes.set(`PUT ${path}`, h); return this as any; } + delete(path: string, h: RouteHandler) { this.routes.set(`DELETE ${path}`, h); return this as any; } + patch(path: string, h: RouteHandler) { this.routes.set(`PATCH ${path}`, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +async function call( + http: MockHttp, + route: string, + opts: { body?: unknown; headers?: Record } = {}, +): Promise<{ status: number; body: any }> { + const state: { status: number; body?: any } = { status: 200 }; + const res = { + json: vi.fn((data: unknown) => { state.body = data; }), + send: vi.fn(), + status: vi.fn((code: number) => { state.status = code; return res; }), + header: vi.fn(() => res), + } as unknown as IHttpResponse; + const req = { + params: { namespace: 'localization' }, + query: {}, + body: opts.body, + headers: opts.headers ?? {}, + method: route.split(' ')[0], + path: '/api/settings/localization', + } as IHttpRequest; + await http.routes.get(route)!(req, res); + return { status: state.status, body: state.body }; +} + +const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' }); + +async function boot(env: Record = {}): Promise<{ + kernel: LiteKernel; + i18n: LocaleListI18nPlugin; + service: SettingsService; + http: MockHttp; +}> { + const i18n = new LocaleListI18nPlugin(); + const kernel = new LiteKernel({ logger: { level: 'error' } as never }); + kernel.use(i18n); + kernel.use(new SettingsServicePlugin({ registerRoutes: false, env })); + await kernel.bootstrap(); + const service = kernel.getService('settings'); + const http = new MockHttp(); + registerSettingsRoutes(http, service, { contextFromRequest: admin }); + return { kernel, i18n, service, http }; +} + +const servedLocaleOptions = (body: any): Array<{ value: string; label: string }> => + body.data.manifest.specifiers.find((s: { key?: string }) => s.key === 'locale').options; + +const getPage = (http: MockHttp, headers?: Record) => + call(http, 'GET /api/settings/:namespace', { headers }); +const save = (http: MockHttp, body: unknown) => call(http, 'PUT /api/settings/:namespace', { body }); + +describe('localization.locale serves the deployment\'s locale list', () => { + let booted: Awaited>; + beforeAll(async () => { + booted = await boot(); + }); + afterAll(async () => { + await booted.kernel.shutdown(); + }); + afterEach(async () => { + await booted.service.setMany('localization', { locale: null }); + }); + + it('offers what the i18n service reports, each named in its own language', async () => { + booted.i18n.locales = ['en', 'zh-CN', 'fr', 'vi-VN']; + const page = await getPage(booted.http); + expect(page.status).toBe(200); + const options = servedLocaleOptions(page.body); + // The manifest default `en-US` is in effect, a second spelling of `en`: + // that option is offered under the stored spelling, so it shows chosen. + 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. + 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('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' }); + expect(saved.status).toBe(200); + expect(saved.body.data.values.locale.value).toBe('fr-FR'); + expect((await booted.service.get('localization', 'locale')).value).toBe('fr-FR'); + }); + + it('follows the list at runtime: a translation item added later is accepted without a reboot', async () => { + booted.i18n.locales = ['en', 'zh-CN']; + const before = await save(booted.http, { locale: 'vi-VN' }); + expect(before.status).toBe(400); + booted.i18n.locales = ['en', 'zh-CN', 'vi-VN']; + const after = await save(booted.http, { locale: 'vi-VN' }); + expect(after.status).toBe(200); + expect((await booted.service.get('localization', 'locale')).value).toBe('vi-VN'); + }); + + it('refuses a language the deployment does not support, as any value outside a table is refused', async () => { + booted.i18n.locales = ['en', 'zh-CN']; + const refused = await save(booted.http, { locale: 'ja-JP' }); + expect(refused.status).toBe(400); + expect(refused.body.error.code).toBe('SETTINGS_VALIDATION'); + expect(refused.body.error.details.fields).toEqual([ + expect.objectContaining({ field: 'locale', code: 'invalid_option', value: 'ja-JP', constraint: { allowed: 'en, zh-CN' } }), + ]); + // Nothing was written. + expect((await booted.service.get('localization', 'locale')).source).toBe('default'); + }); + + it('accepts the spelling stored values carry for an offered language (`en-US` where `en` is offered)', async () => { + booted.i18n.locales = ['en', 'zh-Hans']; + expect((await save(booted.http, { locale: 'zh-CN' })).status).toBe(200); + expect((await save(booted.http, { locale: 'en-US' })).status).toBe(200); + // A code the platform locale table does not alias is accepted only as spelled. + booted.i18n.locales = ['en', 'fr']; + expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400); + }); + + it('keeps a stored value the deployment no longer offers readable, and serves it named as such', async () => { + booted.i18n.locales = ['en', 'zh-CN', 'ja-JP']; + expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(200); + booted.i18n.locales = ['en', 'zh-CN']; + + const page = await getPage(booted.http, { 'accept-language': 'zh-CN' }); + expect(page.body.data.values.locale.value).toBe('ja-JP'); + expect(servedLocaleOptions(page.body)).toEqual([ + { value: 'en', label: 'English' }, + { value: 'zh-CN', label: '简体中文' }, + { value: 'ja-JP', label: `日本語 (${notOfferedCopy('zh-CN')})` }, + ]); + // It keeps being the value in effect, and a save of another key passes it by. + expect((await save(booted.http, { fiscal_year_start: 'april' })).status).toBe(200); + expect((await booted.service.get('localization', 'locale')).value).toBe('ja-JP'); + // Written again, it is judged like any other value. + expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(400); + }); + + it('stands on the declared four when the i18n service reports no locale', async () => { + booted.i18n.locales = []; + const page = await getPage(booted.http); + expect(servedLocaleOptions(page.body).map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']); + expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400); + expect((await save(booted.http, { locale: 'es-ES' })).status).toBe(200); + }); +}); + +describe('an OS_LOCALIZATION_LOCALE override is judged against the same list, when it is read', () => { + it('is in force for a language the deployment supports, and not reported at boot', async () => { + const booted = await boot({ OS_LOCALIZATION_LOCALE: 'vi-VN' }); + try { + booted.i18n.locales = ['en', 'vi-VN']; + const resolved = await booted.service.get('localization', 'locale'); + expect(resolved.value).toBe('vi-VN'); + expect(resolved.source).toBe('env'); + booted.i18n.locales = ['en']; + // No longer offered: the override is not in force, as any rejected one. + expect((await booted.service.get('localization', 'locale')).source).not.toBe('env'); + } finally { + await booted.kernel.shutdown(); + } + }); +}); + +describe('the registration-time audit of OS_* overrides', () => { + it('leaves a sourced key to be judged when read, and still judges a key with only a declared table', () => { + const error = vi.fn(); + const svc = new SettingsService({ + env: { OS_LOCALIZATION_LOCALE: 'vi-VN', OS_LOCALIZATION_FISCAL_YEAR_START: 'smarch' }, + logger: { warn: vi.fn(), error } as never, + }); + // The i18n service is not up yet at this point of a boot. + svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => undefined)); + svc.registerManifest(localizationSettingsManifest); + const reported = error.mock.calls.map((c) => String(c[0])); + expect(reported.some((line) => line.includes('OS_LOCALIZATION_LOCALE'))).toBe(false); + expect(reported.some((line) => line.includes('OS_LOCALIZATION_FISCAL_YEAR_START'))).toBe(true); + }); +}); + +describe('a service with no option source, or a failing one', () => { + it('judges the declared table', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(localizationSettingsManifest); + await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' }); + await svc.setMany('localization', { locale: 'ja-JP' }); + expect((await svc.get('localization', 'locale')).value).toBe('ja-JP'); + }); + + it('falls back to the declared table when the source throws, and says so once', async () => { + const warn = vi.fn(); + const svc = new SettingsService({ env: {}, logger: { warn, error: vi.fn() } as never }); + svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => { + throw new Error('i18n exploded'); + })); + svc.registerManifest(localizationSettingsManifest); + await svc.setMany('localization', { locale: 'ja-JP' }); + await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' }); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain("'localization.locale'"); + }); +}); + +describe('localeSelfName: a locale named in its own language, else its code', () => { + it('takes the platform locale table\'s name for the language a code means', () => { + expect(localeSelfName('en')).toBe('English'); + expect(localeSelfName('en-US')).toBe('English'); + expect(localeSelfName('zh-TW')).toBe('繁體中文'); + expect(localeSelfName('ja-JP')).toBe('日本語'); + }); + + it('answers the code for a tag nothing names, a malformed tag, and a runtime without Intl.DisplayNames', () => { + expect(localeSelfName('xx-YY')).toBe('xx-YY'); + expect(localeSelfName('not a tag')).toBe('not a tag'); + const original = Intl.DisplayNames; + try { + (Intl as { DisplayNames?: unknown }).DisplayNames = undefined; + expect(localeSelfName('vi-VN')).toBe('vi-VN'); + } finally { + (Intl as { DisplayNames?: unknown }).DisplayNames = original; + } + }); + + it('names a language off the table through Intl.DisplayNames where the runtime\'s ICU data has it', () => { + // Read from the runtime rather than written down: ICU builds differ, and + // the pin is that the name comes from it, capitalized, or is the code. + let expected = 'vi-VN'; + try { + const name = new Intl.DisplayNames(['vi-VN'], { type: 'language', fallback: 'none' }).of('vi-VN'); + if (name && name !== 'vi-VN') expected = name.charAt(0).toLocaleUpperCase('vi-VN') + name.slice(1); + } catch { + // keep the code + } + expect(localeSelfName('vi-VN')).toBe(expected); + }); +}); diff --git a/packages/services/service-settings/src/localization-locale-options.ts b/packages/services/service-settings/src/localization-locale-options.ts new file mode 100644 index 00000000000..8a1305e41ab --- /dev/null +++ b/packages/services/service-settings/src/localization-locale-options.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The options of `localization.locale` (Setup's 默认语言): the languages the + * deployment supports, not a list written into the manifest. + * + * The manifest used to declare four (`en-US`, `zh-CN`, `ja-JP`, `es-ES`), and + * that table was the enforcement boundary. The user's language menu offers + * something else: what `GET /api/v1/i18n/locales` reports, which is the i18n + * service's `getLocales()` (the app's declared `i18n.supportedLocales`, else + * every locale a bundle or an authored translation item has loaded). So a + * deployment with a French pack, or a Vietnamese translation item, offered + * French or Vietnamese to each user and refused it as the organization's + * default. This source makes both answers one: the options and the accepted + * values are what `getLocales()` reports at the moment they are asked, so a + * translation item added at runtime is offered here too. + * + * What it answers, for the i18n service's codes: + * + * - **Options.** One per code, in the service's order, each named in its own + * language: the platform locale table's name for the language the code + * means (`PLATFORM_LOCALES`, through its read aliases, so `zh-CN` is + * 简体中文), else `Intl.DisplayNames` in the code itself (`vi-VN` is + * "Tiếng Việt (Việt Nam)"), else the code. The language menu names them + * the same way. + * - **Accepted values.** Each code, and a second spelling of one: a stored + * code that means the same table language (`en-US` where `en` is offered, + * `zh-CN` where `zh-Hans` is). Those are the spellings this setting has + * stored all along, and its manifest default is `en-US`; the read aliases + * exist so that such values stay valid. A code the table does not name is + * accepted only as spelled (`fr-FR` is not `fr`). + * - **A stored value in effect.** Offered under its own spelling when it is a + * second spelling of an offered code, so the page shows it selected; and, + * when the deployment no longer offers it, offered as itself, named as no + * longer offered, so the page shows what is in effect rather than a blank. + * It is not accepted again: only a write that changes the key is judged, so + * the stored value keeps reading and keeps working until someone picks + * another language. + * + * No i18n service in the composition, or one that reports no locale: the + * source answers nothing and the manifest's declared four stand. + */ + +import { resolveLocale } from '@objectstack/core'; +import { PLATFORM_LOCALES, platformLocaleTagOf } from '@objectstack/spec/system'; +import type { SettingsOptionSource, SettingsServedOption } from './settings-service.types.js'; + +/** The i18n service surface this source reads: the locale list the language menu reads. */ +export interface LocaleListService { + getLocales(): readonly string[]; +} + +const TABLE_LABEL = new Map(PLATFORM_LOCALES.map((row) => [row.tag, row.label])); + +/** + * A locale's name in its own language: the platform locale table's name for + * the language the code means, else `Intl.DisplayNames` in the code itself, + * capitalized as the table's names are, else the code. Every failure answers + * the code, which is still an honest, selectable name: `Intl.DisplayNames` + * may be absent, throws `RangeError` on a malformed tag, and names no language + * its ICU data does not have. + */ +export function localeSelfName(code: string): string { + const tag = platformLocaleTagOf(code); + const fromTable = tag ? TABLE_LABEL.get(tag) : undefined; + if (fromTable) return fromTable; + try { + // `fallback: 'none'`: a language the ICU data does not name answers + // `undefined`, not a made-up name built from the subtags (`Xx (YY)`). + const name = new Intl.DisplayNames([code], { type: 'language', fallback: 'none' }).of(code); + if (!name || name === code) return code; + return name.charAt(0).toLocaleUpperCase(code) + name.slice(1); + } catch { + return code; + } +} + +/** "no longer offered", in the reader's language. */ +export const NOT_OFFERED_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', string> = { + en: 'no longer offered', + 'zh-CN': '已不再提供', + 'ja-JP': '現在は提供されていません', + 'es-ES': 'ya no disponible', +}; + +const COPY_LOCALES = Object.keys(NOT_OFFERED_COPY); + +/** The "no longer offered" words for a reader's locale: exact, then by language, else English. */ +export function notOfferedCopy(locale: string | undefined): string { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return NOT_OFFERED_COPY[(picked ?? 'en') as keyof typeof NOT_OFFERED_COPY]; +} + +/** The locale codes the i18n service reports, de-duplicated, in its order. */ +function offeredCodes(i18n: LocaleListService | undefined): string[] { + const reported = i18n && typeof i18n.getLocales === 'function' ? i18n.getLocales() : undefined; + if (!Array.isArray(reported)) return []; + const seen = new Set(); + for (const code of reported) { + if (typeof code === 'string' && code.trim()) seen.add(code.trim()); + } + return [...seen]; +} + +/** + * The option source of `localization.locale`. `i18n` is asked at each use + * (the i18n service registers after this plugin's `init`); it answers + * `undefined` when the composition carries none. + */ +export function localizationLocaleOptionSource( + i18n: () => LocaleListService | undefined, +): SettingsOptionSource { + return ({ current, locale } = {}) => { + const codes = offeredCodes(i18n()); + if (codes.length === 0) return undefined; + + // The table language each offered code means, where the table names one. + const offeredTags = new Set(codes.map((code) => platformLocaleTagOf(code)).filter(Boolean)); + const admits = (value: string): boolean => { + if (codes.includes(value)) return true; + const tag = platformLocaleTagOf(value); + return tag !== undefined && offeredTags.has(tag); + }; + + const options: SettingsServedOption[] = codes.map((code) => ({ value: code, label: localeSelfName(code) })); + + if (typeof current === 'string' && current.trim() && !codes.includes(current)) { + const tag = platformLocaleTagOf(current); + const sameLanguage = tag === undefined ? -1 : codes.findIndex((code) => platformLocaleTagOf(code) === tag); + if (sameLanguage !== -1) { + // Stored as a second spelling (`en-US` where `en` is offered): offer + // that language under the stored spelling, so the page shows it chosen. + options[sameLanguage] = { value: current, label: options[sameLanguage].label }; + } else { + options.push({ value: current, label: `${localeSelfName(current)} (${notOfferedCopy(locale)})` }); + } + } + + return { options, admits }; + }; +} + +/** The settings service surface {@link registerLocalizationLocaleOptions} needs. */ +export interface LocaleOptionsHost { + registerOptionSource(namespace: string, key: string, source: SettingsOptionSource): void; +} + +/** + * Serve `localization.locale`'s options from the i18n service. Called before + * the manifest registers, so the manifest's registration-time audit of + * `OS_LOCALIZATION_LOCALE` leaves the key to be judged when it is read. + */ +export function registerLocalizationLocaleOptions( + host: LocaleOptionsHost, + i18n: () => LocaleListService | undefined, +): void { + host.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(i18n)); +} diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts index 766ae3c3896..300aaa5ae49 100644 --- a/packages/services/service-settings/src/manifests/localization.manifest.ts +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -83,6 +83,13 @@ export const localizationSettingsManifest: SettingsManifest = { { type: 'select', key: 'locale', label: 'Default language', required: false, default: 'en-US', description: 'BCP-47 locale for message catalogs and number/date formatting.', + // [#30286] What a running deployment offers and accepts here is NOT this + // table: `SettingsServicePlugin` registers an option source for this key + // (`localization-locale-options.ts`) that serves the i18n service's + // locale list, the one the user's language menu reads, each language + // named in its own language. These four are what a composition without + // an i18n service gets; the translation bundles name none of them, for + // the same reason. options: [ { value: 'en-US', label: 'English (US)' }, { value: 'zh-CN', label: '简体中文' }, diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 88b6ace1807..676c060ec62 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -289,7 +289,8 @@ export function registerSettingsRoutes( mountFamilyRoute('get', `${base}/:namespace`, { async serve(req, res, ctx) { const ns = req.params.namespace; - const payload = await service.getNamespace(ns, ctx); + // The request's language names a served option no longer offered. + const payload = await service.getNamespace(ns, ctx, { locale: requestLocaleOf(req) }); // The page states what is LIVE, not only what is stored: the owning // package's statement heads the specifiers as an `info_banner`, asked on // this read (see `SettingsService.liveStatusOf`). Added here and not in diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index b389b0dbf7c..e37885c7d38 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -39,6 +39,7 @@ import { } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js'; +import { registerLocalizationLocaleOptions, type LocaleListService } from './localization-locale-options.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { registerSettingsReadDoor } from './settings-read-door.js'; @@ -231,6 +232,18 @@ export class SettingsServicePlugin implements Plugin { // `console.error` fallback instead. logger: ctx.logger, }); + // Setup's default language offers the languages the deployment supports: + // the i18n service's locale list, the one the language menu reads, asked + // at each use (the i18n service registers after this `init`). Registered + // before the manifests, so `localization`'s registration-time audit of + // `OS_LOCALIZATION_LOCALE` leaves that key to be judged when it is read. + registerLocalizationLocaleOptions(this.service, () => { + try { + return ctx.getService('i18n'); + } catch { + return undefined; + } + }); for (const m of this.opts.manifests ?? []) { if (UNWALLED_ONLY_NAMESPACES.has(m.namespace)) { this.heldUntilPostureKnown.push(m); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 556051f9a85..8eb5aa98a40 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -22,6 +22,8 @@ import { type SettingsActionHandler, type SettingsLiveStatus, type SettingsLiveStatusProvider, + type SettingsOptionSource, + type SettingsOptionSourceAnswer, type SettingsWritePredicate, type SettingsWritePredicateOptions, type SettingsRequestTransport, @@ -895,6 +897,10 @@ export class SettingsService { private organizationSource?: SettingsOrganizationSource; /** Live-status providers by namespace — see {@link registerLiveStatus}. */ private readonly liveStatusProviders = new Map(); + /** Option sources by namespace, then key — see {@link registerOptionSource}. */ + private readonly optionSources = new Map>(); + /** Option-source failures already said, `namespace.key:cause` — see {@link optionSourceAnswer}. */ + private readonly reportedOptionSourceFaults = new Set(); /** Write predicates by namespace, then key — see {@link registerWritePredicate}. */ private readonly writePredicates = new Map< string, @@ -1317,7 +1323,10 @@ export class SettingsService { // keys are found by type. `effectiveEnvOverride` does the judging (and the // reporting); the value it returns is of no interest here. const enforceable = new Set([ - ...reg.optionTables.keys(), + // A key whose options a source serves is judged when it is read: the + // source reads services that register later in the boot, so a verdict + // here would be drawn from the declared table it replaces. + ...[...reg.optionTables.keys()].filter((key) => !this.optionSources.get(namespace)?.has(key)), ...reg.bounds.keys(), ...reg.valueDomains.keys(), ...reg.patterns.keys(), @@ -1396,9 +1405,9 @@ export class SettingsService { // A key with no declared table has nothing to enforce — unchanged // behaviour (#5131's exhaustive-options semantics, untouched when no // domain is declared). - const allowed = reg.optionTables.get(key); - if (allowed) { - const rejected = firstRejectedOption(allowed, value); + const { allowed, judge } = this.optionTableOf(namespace, key, reg.optionTables.get(key) ?? []); + if (allowed.length > 0) { + const rejected = judge(value); if (rejected) { this.reportRejectedEnvOverride(reg, namespace, key, envName, rejected.value, { what: 'is not a declared option for', @@ -1661,6 +1670,104 @@ export class SettingsService { this.liveStatusProviders.set(namespace, provider); } + /** + * Register (or replace) the source that serves one option-bearing key's + * options from the running system, in place of its manifest's declared + * table. See {@link SettingsOptionSource}. The last registration wins, the + * same override rule as {@link registerAction}. + * + * Unlike the other registrations it may come BEFORE the namespace's + * manifest: registered first, the manifest's registration-time audit of + * `OS_*` overrides already leaves the key's options to be judged when read, + * and draws no verdict from the declared table the source replaces. + */ + registerOptionSource(namespace: string, key: string, source: SettingsOptionSource): void { + let byKey = this.optionSources.get(namespace); + if (!byKey) this.optionSources.set(namespace, (byKey = new Map())); + byKey.set(key, source); + } + + /** + * The source's answer for one key, or `undefined` when no source is + * registered or it has none. A source that throws is answered `undefined` + * and said once per cause: the declared table then stands, the same answer a + * composition without the source's service gets. + */ + private optionSourceAnswer( + namespace: string, + key: string, + input: Parameters[0] = {}, + ): SettingsOptionSourceAnswer | undefined { + const source = this.optionSources.get(namespace)?.get(key); + if (!source) return undefined; + try { + return source(input) ?? undefined; + } catch (err: any) { + const cause = String(err?.message ?? err); + const dedupeAt = `${namespace}.${key}:${cause}`; + if (!this.reportedOptionSourceFaults.has(dedupeAt)) { + this.reportedOptionSourceFaults.add(dedupeAt); + this.logger?.warn?.( + `SettingsService: the options of '${namespace}.${key}' could not be read from the running system; ` + + `the manifest's declared options apply instead. Cause: ${cause}`, + ); + } + return undefined; + } + } + + /** + * The option table one key is judged against, as the values it offers and + * the judgment of a written value: a registered source's answer when it has + * one, else `declared`, judged by {@link firstRejectedOption}. Both doors ask + * here, the save (`validatePatch`) and the `OS_*` override + * (`effectiveEnvOverride`), so a source moves both at once. + */ + private optionTableOf( + namespace: string, + key: string, + declared: string[], + ): { allowed: string[]; judge: (value: unknown) => { value: unknown } | null } { + const answer = this.optionSourceAnswer(namespace, key); + if (!answer) return { allowed: declared, judge: (value) => firstRejectedOption(declared, value) }; + return { + allowed: answer.options.map((opt) => opt.value), + judge: (value) => { + // Element-wise and in string form, as `firstRejectedOption` judges. + const picked = Array.isArray(value) ? value : [value]; + const at = picked.findIndex((v) => !answer.admits(String(v))); + return at === -1 ? null : { value: picked[at] }; + }, + }; + } + + /** + * 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. + */ + private servedManifest( + reg: RegisteredManifest, + namespace: string, + values: Record, + display: SettingsDisplayOptions, + ): SettingsManifest { + const sources = this.optionSources.get(namespace); + if (!sources || sources.size === 0) return reg.manifest; + let changed = false; + const specifiers = reg.manifest.specifiers.map((spec) => { + if (!spec.key || !sources.has(spec.key)) return spec; + const answer = this.optionSourceAnswer(namespace, spec.key, { + current: values[spec.key]?.value, + locale: display.locale, + }); + if (!answer) return spec; + changed = true; + return { ...spec, options: answer.options.map((opt) => ({ ...opt })) } as typeof spec; + }); + return changed ? { ...reg.manifest, specifiers } : reg.manifest; + } + /** * Register (or replace) the write predicate of one manifest key — a rule * over engine state that may refuse a CHANGE to the key. See @@ -2087,6 +2194,7 @@ export class SettingsService { async getNamespace( namespace: string, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise { const reg = this.registry.get(namespace); if (!reg) throw new UnknownNamespaceError(namespace); @@ -2097,7 +2205,9 @@ export class SettingsService { // [#10826] One grouped row load instead of one per key — same resolution // per key by construction (getMany shares get()'s extracted cascade). const values = await this.getMany(namespace, Array.from(reg.scopes.keys()), ctx); - return { manifest: reg.manifest, values }; + // A key whose options a source serves is offered what the running system + // offers, for the value in effect (`registerOptionSource`). + return { manifest: this.servedManifest(reg, namespace, values, display), values }; } /** @@ -2935,7 +3045,9 @@ export class SettingsService { // `provider: 'sendgrid'` into a namespace that has no such provider and // the write would succeed silently, leaving each consumer to improvise. if (!empty && OPTION_BEARING_TYPES.has(type) && !domain) { - const allowed = declaredOptionValues(spec.options); + // The table a registered option source serves, else the declared one + // (see `optionTableOf`). + const { allowed, judge } = this.optionTableOf(namespace, key, declaredOptionValues(spec.options)); // A manifest with no option table cannot say what is legal. The spec // refuses that shape at parse time, but `registerManifest` takes // manifests as given (no Zod pass), so skip rather than reject every @@ -2944,7 +3056,7 @@ export class SettingsService { if (allowed.length > 0) { // Shared with the env path (#5204) — see `firstRejectedOption` for the // array/scalar handling and why the result is wrapped. - const rejected = firstRejectedOption(allowed, value); + const rejected = judge(value); if (rejected) { const offending = rejected.value; // An option value is not a secret, but `encrypted` is authorable on diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index b1234bdcd4c..6bc2048c577 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -474,6 +474,49 @@ export type SettingsLiveStatusProvider = (input: { locale?: string; }) => Promise | SettingsLiveStatus | undefined; +/** One option a {@link SettingsOptionSource} serves: the stored value and its name. */ +export interface SettingsServedOption { + value: string; + label: string; +} + +/** What a {@link SettingsOptionSource} answers for one select key. */ +export interface SettingsOptionSourceAnswer { + /** The options the page offers, in order. */ + options: SettingsServedOption[]; + /** + * Whether a write of `value` is accepted. Holds for every offered value, and + * may hold for a second spelling of one (a stored `en-US` where `en` is + * offered). A value it refuses is answered `invalid_option`, as a value + * outside a declared table is. + */ + admits(value: string): boolean; +} + +/** + * Serves the options of one option-bearing key from the running system, in + * place of the manifest's declared `options`. The declared table stays the + * answer whenever the source answers `undefined` (the service it reads is + * 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 + * 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. + */ +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. + */ + current?: unknown; + /** The language the page is read in, {@link SettingsDisplayOptions.locale}. */ + locale?: string; +}) => SettingsOptionSourceAnswer | undefined; + /** * The `info_banner` id that carries a namespace's live status. Reserved: a * manifest must not declare it. It has no entry in the translation bundles on diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 2f375fd245f..b870d261a56 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -188,12 +188,8 @@ export const zhCN: PlatformTranslationData = { locale: { label: '默认语言', help: '用于消息文案和数字/日期格式的 BCP-47 语言。', - options: { - 'en-US': '英语(美国)', - 'zh-CN': '简体中文', - 'ja-JP': '日语', - 'es-ES': '西班牙语(西班牙)', - }, + // No `options`: each language is named in its own language, as the + // server serves it (`localization-locale-options.ts`). }, default_country: { label: '默认国家/地区',