diff --git a/.changeset/hono-me-localization-user-locale.md b/.changeset/hono-me-localization-user-locale.md new file mode 100644 index 0000000000..21d883db39 --- /dev/null +++ b/.changeset/hono-me-localization-user-locale.md @@ -0,0 +1,31 @@ +--- +"@objectstack/plugin-hono-server": minor +--- + +feat(hono-server): `GET /auth/me/localization` → `locale` is now the signed-in user's language — `sys_user.locale` when set, then the request's `Accept-Language`, then the deployment default (#14788) + +Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE +read face for "what language is this user", now that `sys_user.locale` is a +user-stated preference (#13881 / #14787) and the never-produced +`SessionUser.language` is retired from the session contract +(`@objectstack/spec`, same release). + +What changed, for an authenticated caller: + +- `locale` resolves **the user's own `sys_user.locale`** first — read under a + system context by the caller's own id and accepted only when it passes the + column's OWN `locale_bcp47_shape` rule as the registry declares it (the + endpoint evaluates that rule; it carries no second locale parser). A + malformed, blank or unverifiable value falls through, it is never served. +- then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`, + the same parse REST and the runtime dispatcher feed `execCtx.locale` from); +- then **the deployment default** (`resolveLocalizationContext` — the + `localization.locale` settings cascade, floor `en-US`). + +Before, the resolver behind this endpoint assembled no localization at all, so +`locale` was `null` for every authenticated caller; it is now always a string +for an authenticated caller. The response shape is unchanged +(`{ authenticated, currency, locale, timezone }`), `currency` / `timezone` +are untouched, and the unauthenticated answer (`{ authenticated: false }`) is +unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the +current-user endpoints directly. diff --git a/.changeset/session-user-language-retired.md b/.changeset/session-user-language-retired.md new file mode 100644 index 0000000000..36d9459e7d --- /dev/null +++ b/.changeset/session-user-language-retired.md @@ -0,0 +1,63 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049) + + + +**BREAKING** key removal on a published session type, landing after the +v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the +prescription is registered under protocol major 18 — `api/SessionUser:language` +in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry +`session-user-language-retired` — where `os migrate meta` users will look). + +`SessionUserSchema.language` (`api/auth.zod.ts`) was declared +`z.string().default('en')` and described as "Preferred language", and had no +producer and no consumer anywhere: no session endpoint ever wrote it, no client +ever read it (objectui measured at its pinned sha: zero readers; the only +in-repo mentions were the schema's own unit test). A reader trusting the +published contract got a constant that was not the user's language — while the +user's real preference had just landed as the first-class column +`sys_user.locale` (#13881), which the session type could not see. Three +spellings of one concept on the published surface, none of them right. The +maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049 +enforce-or-remove and make `GET /auth/me/localization` the ONE read face for +the signed-in user's language. No replacement field joins the session contract +until a session endpoint really produces one — no dual-spelling window. + +FROM → TO: + +- `SessionUser.language` / `SessionUserParsed.language` → *(removed)*. Read + the signed-in user's language from `GET /auth/me/localization` → `locale`, + which now resolves the user's own `sys_user.locale` when set → the request's + `Accept-Language` → the deployment default (`@objectstack/plugin-hono-server` + in the same release). + +One-line fix: delete the key. A producer still writing it fails `tsc` +(`never` input type) and fails to parse with this prescription; a reader still +keying on it now reads `undefined` instead of a permanent `'en'`, and should +read `locale` off `/auth/me/localization` instead. + +The retirement kit: + +- **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare + delete would have stripped the key silently — ADR-0104): writing `language` + is a `tsc` error and a parse error carrying the prescription, on + `SessionUserSchema` and through both envelopes that embed it + (`SessionResponse.data.user`, `UserProfileResponse.data`). +- **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus + the D3 semantic entry `session-user-language-retired`. A RESPONSE surface — + the server mints a `SessionUser`, nobody authors or persists one — so there + is no source for a D2 conversion to rewrite (the + `api/AuthFeaturesConfig:passkeys` disposition). +- **generated baselines**: `authorable-surface/api.json` carries the + `[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default; + `spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx` + regenerated. +- **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default + minted) on a clean parse, both envelopes refusing the key, and a + `packages/spec/src`-scoped scan for any reader of `.language` off a + `SessionUser`. +- zero in-tree producers or readers, so no in-repo source changes ride along + beyond the endpoint change shipped with it. diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx index 0efcbe7669..19414b421c 100644 --- a/content/docs/references/api/auth.mdx +++ b/content/docs/references/api/auth.mdx @@ -159,7 +159,7 @@ const result = AuthProvider.parse(data); | **username** | `string` | optional | Username (optional) | | **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs | | **tenantId** | `string` | optional | Current tenant ID | -| **language** | `string` | optional (default: `"en"`) | Preferred language | +| **language** | `never` | optional | [REMOVED] `SessionUser.language` was removed in @objectstack/spec 17.4.0 (ADR-0049 enforce-or-remove) — it was declared with a permanent default of `'en'` and never produced by any session endpoint nor read by any client, so a reader keying on it saw a constant, not the user's language. Delete the key. Read the signed-in user's language from `GET /auth/me/localization` (`locale`: the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default). | | **timezone** | `string` | optional | Preferred timezone | | **createdAt** | `string` | optional | | | **updatedAt** | `string` | optional | | @@ -203,7 +203,7 @@ const result = AuthProvider.parse(data); | **username** | `string` | optional | Username (optional) | | **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs | | **tenantId** | `string` | optional | Current tenant ID | -| **language** | `string` | optional (default: `"en"`) | Preferred language | +| **language** | `never` | optional | [REMOVED] `SessionUser.language` was removed in @objectstack/spec 17.4.0 (ADR-0049 enforce-or-remove) — it was declared with a permanent default of `'en'` and never produced by any session endpoint nor read by any client, so a reader keying on it saw a constant, not the user's language. Delete the key. Read the signed-in user's language from `GET /auth/me/localization` (`locale`: the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default). | | **timezone** | `string` | optional | Preferred timezone | | **createdAt** | `string` | optional | | | **updatedAt** | `string` | optional | | diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts new file mode 100644 index 0000000000..80c5957719 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #14788 — `GET /auth/me/localization` is the ONE read face for the signed-in +// user's language (maintainer ruling 2026-09-03, option D, which also retired +// the never-produced `SessionUser.language` from the session contract). +// `locale` resolves, in order: +// +// 1. the user's own `sys_user.locale` when set — accepted only when it passes +// the column's OWN shape rule (`locale_bcp47_shape`, read off the +// registered object; never a second parser here); +// 2. the request's `Accept-Language` preference (`preferredLocaleFromHeader`, +// the same parse the dispatcher feeds `execCtx.locale` from); +// 3. the deployment default (`resolveLocalizationContext` — the +// `localization.locale` cascade, floor `en-US`). +// +// Every case below pins one rung's precedence over the rungs beneath it by +// supplying ALL the lower rungs at once — a case that only supplied the rung +// under test would pass in a world where the other rungs were never consulted. +// The malformed-column case is the ruling's "malformed ⇒ next rung, never +// served" clause; the narrowed-rule case is the "no second parser" clause +// (the answer moves with the registry's rule, not with a built-in regex). +// +// Before this card the resolver behind these endpoints assembled NO +// localization at all — `execCtx.locale` here was always `undefined` and the +// endpoint answered `locale: null` for every authenticated caller — so the +// rung-3 cases are also the first pins that this surface answers a language +// at all. `currency` / `timezone` are deliberately untouched by the ruling and +// still come off that resolver (i.e. `null` in these fixtures); the response +// SHAPE objectui reads is pinned unchanged. + +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; + +const ME_LOCALIZATION = '/api/v1/auth/me/localization'; +const USER = 'usr_lang'; +const ORG = 'org_lang'; + +type Row = Record; + +/** + * The column's own rule, as the registry hands it to the endpoint — the + * `validations[]` entry `sys-user.object.ts` declares (`SYS_USER_LOCALE_TAG_ + * PATTERN`; byte-parity with service-messaging's read-side regex is pinned in + * `recipient-locale-shape-parity.test.ts`). Fixture data here: what is under + * test is that the endpoint EVALUATES whatever rule the registry declares. + */ +const LOCALE_SHAPE_RULE: Row = { + type: 'format', + name: 'locale_bcp47_shape', + field: 'locale', + regex: '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$', + severity: 'error', + message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.', +}; + +interface MountOptions { + /** `sys_user.locale` on the caller's row; `undefined` = column unset. */ + storedLocale?: unknown; + /** The `sys_user` object's `validations[]` as the registry reports them; `null` = no schema at all. */ + rules?: Row[] | null; + /** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */ + settingLocale?: string; + /** Whether a session resolves at all. */ + authenticated?: boolean; + /** + * Make the ENDPOINT's own `sys_user` read throw (the courtesy-never-fails- + * the-answer case). The session resolver reads the same row first, once, + * through core's fail-LOUD `tryFind` (#13279) — a throw there is a + * different contract (the whole answer is refused), so only the read + * after it fails here. + */ + failUserRead?: boolean; +} + +function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authenticated = true, failUserRead = false }: MountOptions = {}) { + const reads: Array<{ object: string; opts: any }> = []; + let sysUserReads = 0; + const ql = { + find: async (object: string, opts: any) => { + reads.push({ object, opts }); + if (object === 'sys_user') { + if (failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable'); + return opts?.where?.id === USER ? [{ id: USER, email: 'lang@example.com', locale: storedLocale }] : []; + } + if (object === 'sys_setting' && settingLocale !== undefined) { + return [{ namespace: 'localization', key: 'locale', value: settingLocale, scope: 'tenant' }]; + } + return []; + }, + // The registry view the endpoint reads the column's rule off. + getSchema: (name: string) => (name === 'sys_user' && rules !== null ? { name: 'sys_user', validations: rules } : undefined), + registry: { getAllApps: () => [], getAllObjects: () => [] }, + }; + const services: Record = { + auth: { + api: { + getSession: async () => (authenticated + ? { user: { id: USER }, session: { activeOrganizationId: ORG } } + : null), + }, + }, + objectql: ql, + metadata: { list: async () => [] }, + }; + const app = new Hono(); + registerCurrentUserEndpoints({ + rawApp: app, + ctx: { + logger: { debug() {}, warn() {} }, + // Throws for an unclaimed slot, like the real kernel locator. + getService: (name: string): T => { + if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`); + return services[name] as T; + }, + }, + }); + const get = async (acceptLanguage?: string) => { + const res = await app.request(`http://localhost${ME_LOCALIZATION}`, { + headers: acceptLanguage === undefined ? {} : { 'accept-language': acceptLanguage }, + }); + return { status: res.status, body: await res.json() as any }; + }; + return { get, reads }; +} + +describe('/auth/me/localization — the signed-in user\'s language, three rungs (#14788)', () => { + it('rung 1: the user\'s own sys_user.locale wins over the request AND the deployment default', async () => { + const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR' }); + const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8'); + expect(status).toBe(200); + // The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged. + expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null }); + // The identity row is read under a SYSTEM context by the caller's own + // id (the `tryFind` shape core uses for the same row) — never routed + // through the caller's own RLS wall. + const userReads = reads.filter((r) => r.object === 'sys_user'); + expect(userReads.length).toBeGreaterThan(0); + for (const r of userReads) { + expect(r.opts?.context?.isSystem).toBe(true); + expect(r.opts?.where?.id).toBe(USER); + } + }); + + it('rung 2: with the column unset, the request\'s Accept-Language preference wins over the deployment default', async () => { + const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' }); + expect((await get('ja-JP,ja;q=0.9,en;q=0.8')).body.locale).toBe('ja-JP'); + // An EMPTY column is "unset", not a preference for the empty string. + const blank = mount({ storedLocale: ' ', settingLocale: 'fr-FR' }); + expect((await blank.get('ja-JP')).body.locale).toBe('ja-JP'); + }); + + it('rung 3: with neither, the deployment default answers — and it has a floor', async () => { + const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' }); + expect((await get()).body.locale).toBe('fr-FR'); + // `*` is "any language" — no preference expressed, so rung 3 again. + expect((await get('*')).body.locale).toBe('fr-FR'); + // Nothing configured anywhere: the cascade's own floor, never `null`. + const bare = mount({ storedLocale: undefined }); + expect((await bare.get()).body.locale).toBe('en-US'); + }); + + it('a malformed column value falls to the next rung — it is never served', async () => { + // The shape a lossy producer leaves at rest (the hotcrm dead-letter + // shape service-messaging refuses on the delivery side); and a value + // a user typed before the write rule existed. + for (const stored of ['Chinese (Simplified)', 'undefined', 'zh_CN!', 42]) { + const { get } = mount({ storedLocale: stored, settingLocale: 'fr-FR' }); + expect((await get('ja-JP')).body.locale, `stored=${String(stored)}`).toBe('ja-JP'); + expect((await get()).body.locale, `stored=${String(stored)}, no header`).toBe('fr-FR'); + } + }); + + it('the column\'s OWN rule is what governs — narrow the registry\'s rule and the answer moves with it', async () => { + // A second parser hard-coded here would keep accepting `zh-CN`. The + // endpoint evaluates the rule the registry declares, so a narrower + // rule refuses what the real rule accepts, and vice versa. + const narrow = { ...LOCALE_SHAPE_RULE, regex: '^[a-z]{2}$' }; + const refused = mount({ storedLocale: 'zh-CN', rules: [narrow] }); + expect((await refused.get('ja-JP')).body.locale).toBe('ja-JP'); + const accepted = mount({ storedLocale: 'zh', rules: [narrow] }); + expect((await accepted.get('ja-JP')).body.locale).toBe('zh'); + // A rule objectql would skip as malformed is skipped here too, which + // leaves NO usable rule — the unverifiable case below, not a bypass. + const broken = mount({ storedLocale: 'zh-CN', rules: [{ ...LOCALE_SHAPE_RULE, regex: '[' }] }); + expect((await broken.get('ja-JP')).body.locale).toBe('ja-JP'); + }); + + it('an unverifiable column (no shape rule declared) is not trusted — it falls through', async () => { + // Fail direction pinned on purpose: a registry that declares no + // `format` rule for `locale` cannot vouch for the stored value, and an + // unvouched value reads as "unset", the same as a malformed one. + const noRule = mount({ storedLocale: 'zh-CN', rules: [] }); + expect((await noRule.get('ja-JP')).body.locale).toBe('ja-JP'); + const noSchema = mount({ storedLocale: 'zh-CN', rules: null }); + expect((await noSchema.get('ja-JP')).body.locale).toBe('ja-JP'); + }); + + it('a failed identity read is a courtesy lost, never a failed answer', async () => { + const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR', failUserRead: true }); + const { status, body } = await get('ja-JP'); + expect(status).toBe(200); + expect(body.authenticated).toBe(true); + expect(body.locale).toBe('ja-JP'); + // Anti-vacuity: the endpoint's own read really was issued (and threw). + expect(reads.filter((r) => r.object === 'sys_user').length).toBeGreaterThan(1); + }); + + it('the unauthenticated answer is unchanged', async () => { + const { get, reads } = mount({ authenticated: false, storedLocale: 'zh-CN' }); + const { status, body } = await get('ja-JP'); + expect(status).toBe(200); + expect(body).toEqual({ authenticated: false }); + // No identity row is read for an anonymous caller. + expect(reads.filter((r) => r.object === 'sys_user')).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index b6ae1346c0..262960af69 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -10,7 +10,8 @@ * These three are the platform's SOLE supply: `packages/rest` and * `packages/runtime` register no `/me/*` route at all, the objectui console * reads `/auth/me/permissions` for its whole permission layer and - * `/auth/me/localization` for regional defaults, and `core`'s auth gate + * `/auth/me/localization` for its regional defaults and the signed-in user's + * language (#14788 — the ONE read face for that), and `core`'s auth gate * allow-lists `/me/apps` + `/me/localization` as endpoints a gated user MUST * still reach to bootstrap the remediation UI. #4079 lifted them out from under * `registerStandardEndpoints`, which also gated a DUPLICATE `/data` CRUD + @@ -37,13 +38,14 @@ * round-trip to an auth service that does not implement these paths. */ -import { IDataEngine, resolveUserAuthzGrants } from '@objectstack/core'; +import { IDataEngine, resolveLocalizationContext, resolveUserAuthzGrants } from '@objectstack/core'; import { resolveEffectiveApiMethods, effectiveOperationsArray, type EnableLike, } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import type { IAuthService, IMetadataService, @@ -633,6 +635,133 @@ export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { return resolveCtx; } +/** The `sys_user` column that carries the user's own stated language (#13881). */ +const SYS_USER_LOCALE_FIELD = 'locale'; + +/** + * The column's OWN shape rule(s) for `sys_user.locale`, read off the + * registered object exactly as objectql's rule validator will apply them on a + * write (`format` rule → `new RegExp(rule.regex)`, no flags). This is + * deliberately NOT a second locale parser: the one spelling of "what a locale + * looks like" is the `locale_bcp47_shape` rule declared on the object + * (`@objectstack/platform-objects`, pinned byte-equal to service-messaging's + * read-side `LOCALE_TAG_SHAPE`), and this surface evaluates that declaration + * rather than restating it. A rule objectql would skip as malformed is skipped + * here too — the acceptance below can only ever be a SUBSET of the write + * side's. + */ +function sysUserLocaleShapeRules(ql: unknown): RegExp[] { + let schema: { validations?: unknown } | undefined; + try { + const getSchema = (ql as { getSchema?: (name: string) => unknown } | undefined)?.getSchema; + schema = typeof getSchema === 'function' ? (getSchema.call(ql, 'sys_user') as typeof schema) : undefined; + } catch { + return []; + } + const rules = Array.isArray(schema?.validations) ? (schema.validations as Array>) : []; + const out: RegExp[] = []; + for (const rule of rules) { + if (rule?.type !== 'format' || rule?.field !== SYS_USER_LOCALE_FIELD || typeof rule?.regex !== 'string') continue; + try { out.push(new RegExp(rule.regex)); } catch { /* objectql fail-opens a malformed rule; unusable as a shape here */ } + } + return out; +} + +/** + * Rung 1 of {@link resolveSignedInUserLocale}: the caller's own stored + * `sys_user.locale`, or `undefined` for every shape that cannot name a + * language — no engine, no row, no value, a value that fails the column's own + * shape rule, or a registry that declares no shape rule at all (then the value + * is UNVERIFIABLE, and unverifiable falls through rather than being trusted: + * the same fail direction as an unset column — the ruling's "malformed ⇒ next + * rung", never "malformed ⇒ served"). + * + * The row is read under a SYSTEM context by the caller's own id — the shape + * `resolveUserAuthzGrants` already uses for the identical row (its `tryFind`), + * and what plugin-auth's stored-locale reader does for a recipient: a + * user-context read here would route the caller's own identity row through + * the `org_user_ids` RLS wall for the one column the caller is entitled to see + * about themselves. Never throws — a failed read is logged and falls through; + * the language is a courtesy, the answer is not. + */ +async function storedSignedInUserLocale( + ql: unknown, + userId: string, + logger: CurrentUserEndpointsContext['logger'], +): Promise { + const find = (ql as { find?: (object: string, opts: unknown) => Promise } | undefined)?.find; + if (typeof find !== 'function') return undefined; + const rules = sysUserLocaleShapeRules(ql); + if (rules.length === 0) { + logger?.debug?.('[hono] /auth/me/localization: sys_user declares no locale shape rule — stored locale not read', { userId }); + return undefined; + } + let row: { locale?: unknown } | undefined; + try { + let rows = await find.call(ql, 'sys_user', { + where: { id: userId }, + limit: 1, + context: { isSystem: true }, + }); + if (rows && typeof rows === 'object' && 'value' in rows) rows = (rows as { value: unknown }).value; + row = Array.isArray(rows) ? (rows[0] as typeof row) : undefined; + } catch (err: any) { + logger?.warn?.('[hono] /auth/me/localization: sys_user read failed — stored locale skipped', { err: err?.message, userId }); + return undefined; + } + const raw = row?.[SYS_USER_LOCALE_FIELD]; + if (typeof raw !== 'string') return undefined; + const value = raw.trim(); + if (!value) return undefined; + return rules.every((re) => re.test(value)) ? value : undefined; +} + +/** Input of {@link resolveSignedInUserLocale}. */ +export interface ResolveSignedInUserLocaleInput { + /** The locator of the kernel that OWNS the request (see `withRequestContext`). */ + ctx: CurrentUserEndpointsContext; + /** The signed-in user — this surface never resolves a language for an anonymous caller. */ + userId: string; + /** Active org/tenant, for the deployment-default cascade. */ + tenantId?: string; + /** The request's raw `Accept-Language` header value, if any. */ + acceptLanguage?: string | null; +} + +/** + * The signed-in user's language — the ONE read face (#14788, maintainer + * ruling 2026-09-03, option D): + * + * 1. `sys_user.locale` when set and shaped like the column's own rule says + * ({@link storedSignedInUserLocale}); + * 2. the request's `Accept-Language` preference — the SAME parse REST and + * the runtime dispatcher feed `execCtx.locale` from + * (`preferredLocaleFromHeader`, #3957), so this surface and a rejection + * message cannot disagree about what the request asked for; + * 3. the deployment default — `resolveLocalizationContext`, the cascade + * `execCtx.locale` already derives from on the dispatcher + * (`localization.locale` settings → tenant `sys_setting` rows → `en-US`). + * + * Always answers a string for an authenticated caller: rung 3 has a floor. + * Exported for the serverless host path that composes the resolver directly + * (cloud#924) and for the pin that asserts the precedence. + */ +export async function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise { + const { ctx, userId, tenantId } = input; + const ql = (() => { + try { return ctx.getService('objectql'); } catch { return undefined; } + })(); + const stored = await storedSignedInUserLocale(ql, userId, ctx.logger); + if (stored) return stored; + const requested = preferredLocaleFromHeader(input.acceptLanguage); + if (requested) return requested; + const settings = (() => { + try { return ctx.getService('settings'); } catch { return undefined; } + })(); + const localization = await resolveLocalizationContext({ ql, settings, tenantId, userId }); + return localization.locale; +} + /** * Register the current-user endpoints — `/auth/me/permissions`, * `/auth/me/localization` and `/me/apps` — on `rawApp`. @@ -869,20 +998,39 @@ export function registerCurrentUserEndpoints( })); // GET /me/localization — the resolved regional defaults (currency / - // locale / timezone) for the current request's tenant, exposed to EVERY - // authenticated user. The `localization` SETTINGS are gated to - // `setup.access`, but the resolved defaults are needed by every renderer - // to format currency/dates/numbers — so they ride on the request - // ExecutionContext (ADR-0053) and are surfaced here without that gate. + // timezone) for the current request's tenant plus the signed-in user's + // language (`locale`), exposed to EVERY authenticated user. The + // `localization` SETTINGS are gated to `setup.access`, but the resolved + // defaults are needed by every renderer to format currency/dates/numbers — + // so they ride on the request ExecutionContext (ADR-0053) and are surfaced + // here without that gate. + // + // [#14788] `locale` is the ONE read face for "this user's language" + // (maintainer ruling 2026-09-03, option D, which also retired the dead + // `SessionUser.language` on the session contract). It resolves, in order: + // 1. the user's own `sys_user.locale` when set — a user-stated + // preference since the 2026-09-03 ruling on the column (#14787 B), + // accepted only when it passes the column's OWN shape rule; + // 2. the request's `Accept-Language` preference; + // 3. the deployment default (`localization.locale`, the same cascade + // that feeds `execCtx.locale` on the dispatcher). + // See {@link resolveSignedInUserLocale}. `currency` / `timezone` and the + // unauthenticated answer are unchanged by that ruling. rawApp.get(`${prefix}/auth/me/localization`, withRequestContext(async (c, ctx, resolveCtx) => { const execCtx = await resolveCtx(c); if (!execCtx?.userId) { return c.json({ authenticated: false }); } + const locale = await resolveSignedInUserLocale({ + ctx, + userId: execCtx.userId, + tenantId: execCtx.tenantId ?? undefined, + acceptLanguage: c.req.raw?.headers?.get?.('accept-language') ?? undefined, + }); return c.json({ authenticated: true, currency: execCtx.currency ?? null, - locale: execCtx.locale ?? null, + locale, timezone: execCtx.timezone ?? null, }); })); diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 759a0d8a3d..a0956ec1b2 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -178,7 +178,6 @@ "api/ScheduledExport:enabled = true", "api/ScheduledExport:format = \"csv\"", "api/SessionUser:emailVerified = false", - "api/SessionUser:language = \"en\"", "api/SessionUser:roles = []", "api/VersioningConfig:headerName = \"ObjectStack-Version\"", "api/VersioningConfig:includeInDiscovery = true", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index fddc7a4a9f..f141354046 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1632,7 +1632,7 @@ "api/SessionUser:emailVerified", "api/SessionUser:id", "api/SessionUser:image", - "api/SessionUser:language", + "api/SessionUser:language [RETIRED]", "api/SessionUser:name", "api/SessionUser:roles", "api/SessionUser:tenantId", diff --git a/packages/spec/src/api/auth.test.ts b/packages/spec/src/api/auth.test.ts index a1e9f9d0b0..b06a070321 100644 --- a/packages/spec/src/api/auth.test.ts +++ b/packages/spec/src/api/auth.test.ts @@ -45,7 +45,9 @@ describe('SessionUserSchema', () => { expect(user.id).toBe('usr_123'); expect(user.emailVerified).toBe(false); expect(user.roles).toEqual([]); - expect(user.language).toBe('en'); + // #14788 — `language` is retired: no default is minted any more, and the + // parsed user carries no such property at all (pinned again below). + expect(user).not.toHaveProperty('language'); }); it('should accept user with all optional fields', () => { @@ -58,14 +60,13 @@ describe('SessionUserSchema', () => { username: 'admin', roles: ['admin', 'editor'], tenantId: 'tenant_1', - language: 'fr', timezone: 'Europe/Paris', createdAt: '2025-01-01T00:00:00Z', updatedAt: '2025-06-01T00:00:00Z', }); expect(user.emailVerified).toBe(true); expect(user.roles).toEqual(['admin', 'editor']); - expect(user.language).toBe('fr'); + expect(user.timezone).toBe('Europe/Paris'); }); it('should reject invalid email', () => { @@ -84,6 +85,77 @@ describe('SessionUserSchema', () => { }); }); +describe('SessionUser.language retirement (#14788, ADR-0049 — maintainer ruling D, 2026-09-03)', () => { + const base = { id: 'usr_1', email: 'a@b.com', name: 'A' }; + + it('REJECTS a `language` value, with the prescription in the message', () => { + // Tombstoned, not deleted: SessionUserSchema is a non-strict `z.object`, + // so a plain deletion would have STRIPPED the key silently and a producer + // still writing it would never learn. `retiredKey()` makes the rejection + // carry the fix — that prescription is the payload, and what this pins. + expect(() => SessionUserSchema.parse({ ...base, language: 'fr' })) + .toThrow(/SessionUser\.language.*removed.*\/auth\/me\/localization/s); + }); + + it('parses cleanly without it, and mints NO default in its place', () => { + // The retired key used to default to `'en'` — a constant a reader took + // for the user's language. Absence of the key is the whole point: a + // reader now gets `undefined`, never a wrong-but-plausible value. + const user = SessionUserSchema.parse(base); + expect(user).not.toHaveProperty('language'); + expect(Object.keys(user)).not.toContain('language'); + // Positive control: the sibling preference key survives untouched. + expect(SessionUserSchema.parse({ ...base, timezone: 'Asia/Shanghai' }).timezone).toBe('Asia/Shanghai'); + }); + + it('the response envelopes carrying SessionUser refuse it too (both faces)', () => { + const rejects = (fn: () => unknown) => + expect(fn).toThrow(/SessionUser\.language.*removed/s); + rejects(() => SessionResponseSchema.parse({ + success: true, + data: { session: { id: 's', expiresAt: '2025-12-31T23:59:59Z', userId: 'usr_1' }, user: { ...base, language: 'fr' } }, + })); + rejects(() => UserProfileResponseSchema.parse({ success: true, data: { ...base, language: 'fr' } })); + }); + + it('nothing in packages/spec/src reads `.language` off a SessionUser any more', async () => { + // Tree-scoped over this package's own source (the radius a spec test may + // walk without a cross-package declaration — a repo-wide glob would put + // spec's whole suite on every PR, the radius `check:cross-package-test- + // inputs` exists to keep narrow). Beyond this tree the tombstone's `never` + // input type is the enforcement: writing the key anywhere in the monorepo + // fails `tsc`; objectui was measured at zero readers at dispatch. + // Excluded, with the reason: the tombstone itself names the key in its + // prescription; this pin names it in its own assertions; and the ADR-0087 + // ledger (`migrations/entries/**` + the generated `migrations/registry.ts`) + // is the retirement's own RECORD of the key, not a reader of it. + const fs = await import('node:fs'); + const path = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const self = fileURLToPath(import.meta.url); + const tombstone = path.join(srcRoot, 'api', 'auth.zod.ts'); + const ledger = path.join(srcRoot, 'migrations'); + const readers: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { if (full !== ledger) walk(full); } + else if (entry.name.endsWith('.ts') && full !== self && full !== tombstone) { + const src = fs.readFileSync(full, 'utf-8'); + if (/SessionUser/.test(src) && /\.language\b/.test(src)) readers.push(path.relative(srcRoot, full)); + } + } + }; + walk(srcRoot); + // Anti-vacuity: the walk must have seen the tombstone's own neighbours, + // and the excluded ledger really is where the key's record lives. + expect(fs.existsSync(tombstone)).toBe(true); + expect(fs.existsSync(path.join(ledger, 'entries', 'retired-keys', '18.api__SessionUser__language.ts'))).toBe(true); + expect(readers, 'a reader of SessionUser.language reappeared — the key is retired (#14788)').toEqual([]); + }); +}); + describe('SessionSchema', () => { it('should accept valid session', () => { const session = SessionSchema.parse({ diff --git a/packages/spec/src/api/auth.zod.ts b/packages/spec/src/api/auth.zod.ts index 2bb8b4a6f0..77ef298b81 100644 --- a/packages/spec/src/api/auth.zod.ts +++ b/packages/spec/src/api/auth.zod.ts @@ -15,6 +15,7 @@ import { BaseResponseSchema } from './contract.zod'; // ========================================== import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const AuthProvider = z.enum([ 'local', 'google', @@ -33,7 +34,33 @@ export const SessionUserSchema = lazySchema(() => z.object({ username: z.string().optional().describe('Username (optional)'), roles: z.array(z.string()).optional().default([]).describe('Assigned role IDs'), tenantId: z.string().optional().describe('Current tenant ID'), - language: z.string().default('en').describe('Preferred language'), + /** + * `language` RETIRED (#14788, ADR-0049 enforce-or-remove; maintainer ruling + * 2026-09-03, option D). It was declared with a permanent default of `'en'` + * and had no producer and no consumer anywhere: no session endpoint ever + * wrote it, no client ever read it, so a reader trusting the contract got a + * constant that was not the user's language. The signed-in user's language + * has ONE read face — `GET /auth/me/localization`, whose `locale` projects + * the user's own `sys_user.locale` when set, then the request's + * `Accept-Language`, then the deployment default. No replacement field is + * added here until a session endpoint really produces one. + * + * Tombstoned, not deleted: this schema is a non-strict `z.object`, so a + * plain deletion would let a producer still writing `language` parse clean + * and lose the key silently (the ADR-0104 silent-strip shape). `retiredKey()` + * makes writing it a `tsc` error and a parse error carrying the fix. + * Registered as `api/SessionUser:language` under protocol major 18 plus the + * D3 semantic entry `session-user-language-retired`; a RESPONSE surface, so + * there is no authored source for a D2 conversion to rewrite. + */ + language: retiredKey( + '`SessionUser.language` was removed in @objectstack/spec 17.4.0 (ADR-0049 enforce-or-remove) — ' + + 'it was declared with a permanent default of `\'en\'` and never produced by any session ' + + 'endpoint nor read by any client, so a reader keying on it saw a constant, not the ' + + "user's language. Delete the key. Read the signed-in user's language from " + + '`GET /auth/me/localization` (`locale`: the user\'s own `sys_user.locale` when set → ' + + "the request's `Accept-Language` → the deployment default).", + ), timezone: z.string().optional().describe('Preferred timezone'), createdAt: z.string().datetime().optional(), updatedAt: z.string().datetime().optional(), diff --git a/packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts b/packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts new file mode 100644 index 0000000000..401e45d0e3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.api__SessionUser__language.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #14788 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-03, option +// D). `SessionUserSchema.language` (`api/auth.zod.ts`) was declared with a +// permanent default of `'en'` and described as "Preferred language", and had +// no producer and no consumer anywhere: no session endpoint wrote it, no +// client read it (objectui measured at its pinned sha: zero readers of +// `SessionUser.language`; its only in-repo mentions were the schema's own +// unit test). A reader trusting the published contract received a constant +// that was not the user's language — the "declared ≠ honoured" shape, made +// worse by the fact that the user's real preference had just landed as a +// first-class column (`sys_user.locale`, #13881) the session type could not +// see. The ruling retires the dead key and makes `GET /auth/me/localization` +// the one read face for the signed-in user's language (`locale`: the user's +// own `sys_user.locale` when set → the request's `Accept-Language` → the +// deployment default); no replacement field joins the session contract until +// a session endpoint really produces one. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). +// The schema is a non-strict `z.object`, so the route is a `retiredKey()` +// tombstone (a bare delete would strip the key silently, ADR-0104). A RESPONSE +// surface — the server mints a `SessionUser` and nobody authors or persists +// one — so, like `api/AuthFeaturesConfig:passkeys`, there is no source for +// `os migrate meta` to rewrite and no D2 conversion; the prescription reaches +// consumers through this tombstone plus the D3 semantic entry +// `session-user-language-retired`. +export const entry = 'api/SessionUser:language'; diff --git a/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts b/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts new file mode 100644 index 0000000000..d19e86338b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.session-user-language-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'session-user-language-retired', + surface: 'api.session.user.language', + replacement: + '`GET /auth/me/localization` → `locale` (the user\'s own `sys_user.locale` when set → ' + + 'the request\'s `Accept-Language` → the deployment default)', + reason: + '`SessionUserSchema.language` was declared with a permanent default of `\'en\'` and ' + + 'described as "Preferred language", and had no producer and no consumer anywhere: no ' + + 'session endpoint wrote it, no client read it (objectui measured zero readers at its ' + + 'pinned sha), so a reader trusting the published contract received a constant that was ' + + 'not the user\'s language. Meanwhile the user\'s real preference landed as the ' + + 'first-class column `sys_user.locale` (#13881), which the session type could not see — ' + + 'three spellings of one concept on the published surface, none of them right. The ' + + 'maintainer ruled option D (2026-09-03, #14788): retire the dead key under ADR-0049 ' + + 'enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its ' + + '`locale` projecting the user column first. This is a RESPONSE surface — the server ' + + 'mints a `SessionUser` and nobody authors or persists one — so there is no source for ' + + 'the chain to rewrite; the schema tombstones the key via retiredKey() and consumers ' + + 'move their read to the endpoint. No replacement field joins the session contract ' + + 'until a session endpoint really produces one (no dual-spelling window, 不渐进). ' + + 'ADR-0049, ADR-0087, #14788.', + acceptanceCriteria: + 'No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a ' + + 'client that seeded its UI language from it now reads `locale` off ' + + '`GET /auth/me/localization`, where a user who set `sys_user.locale` sees that value, ' + + 'a user who did not sees the request\'s `Accept-Language` preference, and a request ' + + 'expressing none sees the deployment default. Constructing a `SessionUser` with ' + + '`language` fails to parse with its own prescription instead of being silently ' + + 'stripped, and assigning it is a `tsc` error at the authoring site.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4aa7a398e3..4a18956f43 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7937,6 +7937,37 @@ const step18: MigrationStep = { + '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay ' + 'semantics.', }, + { + id: 'session-user-language-retired', + surface: 'api.session.user.language', + replacement: + '`GET /auth/me/localization` → `locale` (the user\'s own `sys_user.locale` when set → ' + + 'the request\'s `Accept-Language` → the deployment default)', + reason: + '`SessionUserSchema.language` was declared with a permanent default of `\'en\'` and ' + + 'described as "Preferred language", and had no producer and no consumer anywhere: no ' + + 'session endpoint wrote it, no client read it (objectui measured zero readers at its ' + + 'pinned sha), so a reader trusting the published contract received a constant that was ' + + 'not the user\'s language. Meanwhile the user\'s real preference landed as the ' + + 'first-class column `sys_user.locale` (#13881), which the session type could not see — ' + + 'three spellings of one concept on the published surface, none of them right. The ' + + 'maintainer ruled option D (2026-09-03, #14788): retire the dead key under ADR-0049 ' + + 'enforce-or-remove and make `GET /auth/me/localization` the ONE read face, with its ' + + '`locale` projecting the user column first. This is a RESPONSE surface — the server ' + + 'mints a `SessionUser` and nobody authors or persists one — so there is no source for ' + + 'the chain to rewrite; the schema tombstones the key via retiredKey() and consumers ' + + 'move their read to the endpoint. No replacement field joins the session contract ' + + 'until a session endpoint really produces one (no dual-spelling window, 不渐进). ' + + 'ADR-0049, ADR-0087, #14788.', + acceptanceCriteria: + 'No client reads `user.language` off a `SessionResponse` / `UserProfileResponse`; a ' + + 'client that seeded its UI language from it now reads `locale` off ' + + '`GET /auth/me/localization`, where a user who set `sys_user.locale` sees that value, ' + + 'a user who did not sees the request\'s `Accept-Language` preference, and a request ' + + 'expressing none sees the deployment default. Constructing a `SessionUser` with ' + + '`language` fails to parse with its own prescription instead of being silently ' + + 'stripped, and assigning it is a `tsc` error at the authoring site.', + }, // Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the // D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` // precedent): an authored theme has no lossless target. `app.branding` holds @@ -8953,6 +8984,34 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // advertises. Its three ledger child rows collapse into the one `overrides` // row. Closes #14365's question about `overrides.*.operations` — no record left. 'api/RouteGenerationConfig:overrides', + // #14788 — ADR-0049 enforce-or-remove (maintainer ruling 2026-09-03, option + // D). `SessionUserSchema.language` (`api/auth.zod.ts`) was declared with a + // permanent default of `'en'` and described as "Preferred language", and had + // no producer and no consumer anywhere: no session endpoint wrote it, no + // client read it (objectui measured at its pinned sha: zero readers of + // `SessionUser.language`; its only in-repo mentions were the schema's own + // unit test). A reader trusting the published contract received a constant + // that was not the user's language — the "declared ≠ honoured" shape, made + // worse by the fact that the user's real preference had just landed as a + // first-class column (`sys_user.locale`, #13881) the session type could not + // see. The ruling retires the dead key and makes `GET /auth/me/localization` + // the one read face for the signed-in user's language (`locale`: the user's + // own `sys_user.locale` when set → the request's `Accept-Language` → the + // deployment default); no replacement field joins the session contract until + // a session endpoint really produces one. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). + // The schema is a non-strict `z.object`, so the route is a `retiredKey()` + // tombstone (a bare delete would strip the key silently, ADR-0104). A RESPONSE + // surface — the server mints a `SessionUser` and nobody authors or persists + // one — so, like `api/AuthFeaturesConfig:passkeys`, there is no source for + // `os migrate meta` to rewrite and no D2 conversion; the prescription reaches + // consumers through this tombstone plus the D3 semantic entry + // `session-user-language-retired`. + 'api/SessionUser:language', // #10414 — ADR-0049 enforce-or-remove (triage routed REMOVE; the #10298 shape // one level up). `filters` was a declared, authorable per-metric raw-SQL // filter (`filters: [{ sql: string }]`) with ZERO consumers, measured with a