Skip to content

Commit 5f7fa1d

Browse files
os-justinclaude
andauthored
feat(spec,hono-server): retire SessionUser.language — /auth/me/localization is the one read face for the user's language (#14788) (#15386)
* wip: retire SessionUser.language; /auth/me/localization user-column-first locale (#14788) * feat(spec,hono-server): retire SessionUser.language; /auth/me/localization locale = sys_user.locale → Accept-Language → deployment default (#14788) * fix(spec): tombstone prescription cites the ADR, not an issue id (check:doc-authoring) * chore(spec): regenerate the migration registry and references after merging main (#15299 + this card) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 097a01c commit 5f7fa1d

12 files changed

Lines changed: 697 additions & 16 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/plugin-hono-server": minor
3+
---
4+
5+
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)
6+
7+
Maintainer ruling 2026-09-03 (option D on #14788): this endpoint is the ONE
8+
read face for "what language is this user", now that `sys_user.locale` is a
9+
user-stated preference (#13881 / #14787) and the never-produced
10+
`SessionUser.language` is retired from the session contract
11+
(`@objectstack/spec`, same release).
12+
13+
What changed, for an authenticated caller:
14+
15+
- `locale` resolves **the user's own `sys_user.locale`** first — read under a
16+
system context by the caller's own id and accepted only when it passes the
17+
column's OWN `locale_bcp47_shape` rule as the registry declares it (the
18+
endpoint evaluates that rule; it carries no second locale parser). A
19+
malformed, blank or unverifiable value falls through, it is never served.
20+
- then **the request's `Accept-Language`** preference (`preferredLocaleFromHeader`,
21+
the same parse REST and the runtime dispatcher feed `execCtx.locale` from);
22+
- then **the deployment default** (`resolveLocalizationContext` — the
23+
`localization.locale` settings cascade, floor `en-US`).
24+
25+
Before, the resolver behind this endpoint assembled no localization at all, so
26+
`locale` was `null` for every authenticated caller; it is now always a string
27+
for an authenticated caller. The response shape is unchanged
28+
(`{ authenticated, currency, locale, timezone }`), `currency` / `timezone`
29+
are untouched, and the unauthenticated answer (`{ authenticated: false }`) is
30+
unchanged. `resolveSignedInUserLocale` is exported for hosts that compose the
31+
current-user endpoints directly.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049)
6+
7+
<!-- adr-0087: registered session-user-language-retired -->
8+
9+
**BREAKING** key removal on a published session type, landing after the
10+
v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the
11+
prescription is registered under protocol major 18 — `api/SessionUser:language`
12+
in `RETIRED_KEYS_BY_MAJOR[18]` plus the D3 semantic entry
13+
`session-user-language-retired` — where `os migrate meta` users will look).
14+
15+
`SessionUserSchema.language` (`api/auth.zod.ts`) was declared
16+
`z.string().default('en')` and described as "Preferred language", and had no
17+
producer and no consumer anywhere: no session endpoint ever wrote it, no client
18+
ever read it (objectui measured at its pinned sha: zero readers; the only
19+
in-repo mentions were the schema's own unit test). A reader trusting the
20+
published contract got a constant that was not the user's language — while the
21+
user's real preference had just landed as the first-class column
22+
`sys_user.locale` (#13881), which the session type could not see. Three
23+
spellings of one concept on the published surface, none of them right. The
24+
maintainer ruled option D (2026-09-03): retire the dead key under ADR-0049
25+
enforce-or-remove and make `GET /auth/me/localization` the ONE read face for
26+
the signed-in user's language. No replacement field joins the session contract
27+
until a session endpoint really produces one — no dual-spelling window.
28+
29+
FROM → TO:
30+
31+
- `SessionUser.language` / `SessionUserParsed.language`*(removed)*. Read
32+
the signed-in user's language from `GET /auth/me/localization``locale`,
33+
which now resolves the user's own `sys_user.locale` when set → the request's
34+
`Accept-Language` → the deployment default (`@objectstack/plugin-hono-server`
35+
in the same release).
36+
37+
One-line fix: delete the key. A producer still writing it fails `tsc`
38+
(`never` input type) and fails to parse with this prescription; a reader still
39+
keying on it now reads `undefined` instead of a permanent `'en'`, and should
40+
read `locale` off `/auth/me/localization` instead.
41+
42+
The retirement kit:
43+
44+
- **`retiredKey()` tombstone** (the schema is a non-strict `z.object`, so a bare
45+
delete would have stripped the key silently — ADR-0104): writing `language`
46+
is a `tsc` error and a parse error carrying the prescription, on
47+
`SessionUserSchema` and through both envelopes that embed it
48+
(`SessionResponse.data.user`, `UserProfileResponse.data`).
49+
- **ADR-0087 registration**: `api/SessionUser:language` under major 18 plus
50+
the D3 semantic entry `session-user-language-retired`. A RESPONSE surface —
51+
the server mints a `SessionUser`, nobody authors or persists one — so there
52+
is no source for a D2 conversion to rewrite (the
53+
`api/AuthFeaturesConfig:passkeys` disposition).
54+
- **generated baselines**: `authorable-surface/api.json` carries the
55+
`[RETIRED]` row; `authorable-defaults/api.json` drops the `= "en"` default;
56+
`spec-changes.json`, the upgrade guide and `content/docs/references/api/auth.mdx`
57+
regenerated.
58+
- **pins** in `api/auth.test.ts`: the prescription on parse, absence (no default
59+
minted) on a clean parse, both envelopes refusing the key, and a
60+
`packages/spec/src`-scoped scan for any reader of `.language` off a
61+
`SessionUser`.
62+
- zero in-tree producers or readers, so no in-repo source changes ride along
63+
beyond the endpoint change shipped with it.

content/docs/references/api/auth.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ const result = AuthProvider.parse(data);
159159
| **username** | `string` | optional | Username (optional) |
160160
| **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs |
161161
| **tenantId** | `string` | optional | Current tenant ID |
162-
| **language** | `string` | optional (default: `"en"`) | Preferred language |
162+
| **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). |
163163
| **timezone** | `string` | optional | Preferred timezone |
164164
| **createdAt** | `string` | optional | |
165165
| **updatedAt** | `string` | optional | |
@@ -203,7 +203,7 @@ const result = AuthProvider.parse(data);
203203
| **username** | `string` | optional | Username (optional) |
204204
| **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs |
205205
| **tenantId** | `string` | optional | Current tenant ID |
206-
| **language** | `string` | optional (default: `"en"`) | Preferred language |
206+
| **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). |
207207
| **timezone** | `string` | optional | Preferred timezone |
208208
| **createdAt** | `string` | optional | |
209209
| **updatedAt** | `string` | optional | |
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14788 — `GET /auth/me/localization` is the ONE read face for the signed-in
4+
// user's language (maintainer ruling 2026-09-03, option D, which also retired
5+
// the never-produced `SessionUser.language` from the session contract).
6+
// `locale` resolves, in order:
7+
//
8+
// 1. the user's own `sys_user.locale` when set — accepted only when it passes
9+
// the column's OWN shape rule (`locale_bcp47_shape`, read off the
10+
// registered object; never a second parser here);
11+
// 2. the request's `Accept-Language` preference (`preferredLocaleFromHeader`,
12+
// the same parse the dispatcher feeds `execCtx.locale` from);
13+
// 3. the deployment default (`resolveLocalizationContext` — the
14+
// `localization.locale` cascade, floor `en-US`).
15+
//
16+
// Every case below pins one rung's precedence over the rungs beneath it by
17+
// supplying ALL the lower rungs at once — a case that only supplied the rung
18+
// under test would pass in a world where the other rungs were never consulted.
19+
// The malformed-column case is the ruling's "malformed ⇒ next rung, never
20+
// served" clause; the narrowed-rule case is the "no second parser" clause
21+
// (the answer moves with the registry's rule, not with a built-in regex).
22+
//
23+
// Before this card the resolver behind these endpoints assembled NO
24+
// localization at all — `execCtx.locale` here was always `undefined` and the
25+
// endpoint answered `locale: null` for every authenticated caller — so the
26+
// rung-3 cases are also the first pins that this surface answers a language
27+
// at all. `currency` / `timezone` are deliberately untouched by the ruling and
28+
// still come off that resolver (i.e. `null` in these fixtures); the response
29+
// SHAPE objectui reads is pinned unchanged.
30+
31+
import { describe, it, expect } from 'vitest';
32+
import { Hono } from 'hono';
33+
import { registerCurrentUserEndpoints } from './current-user-endpoints';
34+
35+
const ME_LOCALIZATION = '/api/v1/auth/me/localization';
36+
const USER = 'usr_lang';
37+
const ORG = 'org_lang';
38+
39+
type Row = Record<string, any>;
40+
41+
/**
42+
* The column's own rule, as the registry hands it to the endpoint — the
43+
* `validations[]` entry `sys-user.object.ts` declares (`SYS_USER_LOCALE_TAG_
44+
* PATTERN`; byte-parity with service-messaging's read-side regex is pinned in
45+
* `recipient-locale-shape-parity.test.ts`). Fixture data here: what is under
46+
* test is that the endpoint EVALUATES whatever rule the registry declares.
47+
*/
48+
const LOCALE_SHAPE_RULE: Row = {
49+
type: 'format',
50+
name: 'locale_bcp47_shape',
51+
field: 'locale',
52+
regex: '^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$',
53+
severity: 'error',
54+
message: 'Locale must be a BCP-47 language tag, such as zh-CN or ja-JP.',
55+
};
56+
57+
interface MountOptions {
58+
/** `sys_user.locale` on the caller's row; `undefined` = column unset. */
59+
storedLocale?: unknown;
60+
/** The `sys_user` object's `validations[]` as the registry reports them; `null` = no schema at all. */
61+
rules?: Row[] | null;
62+
/** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */
63+
settingLocale?: string;
64+
/** Whether a session resolves at all. */
65+
authenticated?: boolean;
66+
/**
67+
* Make the ENDPOINT's own `sys_user` read throw (the courtesy-never-fails-
68+
* the-answer case). The session resolver reads the same row first, once,
69+
* through core's fail-LOUD `tryFind` (#13279) — a throw there is a
70+
* different contract (the whole answer is refused), so only the read
71+
* after it fails here.
72+
*/
73+
failUserRead?: boolean;
74+
}
75+
76+
function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authenticated = true, failUserRead = false }: MountOptions = {}) {
77+
const reads: Array<{ object: string; opts: any }> = [];
78+
let sysUserReads = 0;
79+
const ql = {
80+
find: async (object: string, opts: any) => {
81+
reads.push({ object, opts });
82+
if (object === 'sys_user') {
83+
if (failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable');
84+
return opts?.where?.id === USER ? [{ id: USER, email: 'lang@example.com', locale: storedLocale }] : [];
85+
}
86+
if (object === 'sys_setting' && settingLocale !== undefined) {
87+
return [{ namespace: 'localization', key: 'locale', value: settingLocale, scope: 'tenant' }];
88+
}
89+
return [];
90+
},
91+
// The registry view the endpoint reads the column's rule off.
92+
getSchema: (name: string) => (name === 'sys_user' && rules !== null ? { name: 'sys_user', validations: rules } : undefined),
93+
registry: { getAllApps: () => [], getAllObjects: () => [] },
94+
};
95+
const services: Record<string, unknown> = {
96+
auth: {
97+
api: {
98+
getSession: async () => (authenticated
99+
? { user: { id: USER }, session: { activeOrganizationId: ORG } }
100+
: null),
101+
},
102+
},
103+
objectql: ql,
104+
metadata: { list: async () => [] },
105+
};
106+
const app = new Hono();
107+
registerCurrentUserEndpoints({
108+
rawApp: app,
109+
ctx: {
110+
logger: { debug() {}, warn() {} },
111+
// Throws for an unclaimed slot, like the real kernel locator.
112+
getService: <T,>(name: string): T => {
113+
if (!(name in services)) throw new Error(`[Kernel] Service '${name}' not found`);
114+
return services[name] as T;
115+
},
116+
},
117+
});
118+
const get = async (acceptLanguage?: string) => {
119+
const res = await app.request(`http://localhost${ME_LOCALIZATION}`, {
120+
headers: acceptLanguage === undefined ? {} : { 'accept-language': acceptLanguage },
121+
});
122+
return { status: res.status, body: await res.json() as any };
123+
};
124+
return { get, reads };
125+
}
126+
127+
describe('/auth/me/localization — the signed-in user\'s language, three rungs (#14788)', () => {
128+
it('rung 1: the user\'s own sys_user.locale wins over the request AND the deployment default', async () => {
129+
const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR' });
130+
const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8');
131+
expect(status).toBe(200);
132+
// The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged.
133+
expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null });
134+
// The identity row is read under a SYSTEM context by the caller's own
135+
// id (the `tryFind` shape core uses for the same row) — never routed
136+
// through the caller's own RLS wall.
137+
const userReads = reads.filter((r) => r.object === 'sys_user');
138+
expect(userReads.length).toBeGreaterThan(0);
139+
for (const r of userReads) {
140+
expect(r.opts?.context?.isSystem).toBe(true);
141+
expect(r.opts?.where?.id).toBe(USER);
142+
}
143+
});
144+
145+
it('rung 2: with the column unset, the request\'s Accept-Language preference wins over the deployment default', async () => {
146+
const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' });
147+
expect((await get('ja-JP,ja;q=0.9,en;q=0.8')).body.locale).toBe('ja-JP');
148+
// An EMPTY column is "unset", not a preference for the empty string.
149+
const blank = mount({ storedLocale: ' ', settingLocale: 'fr-FR' });
150+
expect((await blank.get('ja-JP')).body.locale).toBe('ja-JP');
151+
});
152+
153+
it('rung 3: with neither, the deployment default answers — and it has a floor', async () => {
154+
const { get } = mount({ storedLocale: undefined, settingLocale: 'fr-FR' });
155+
expect((await get()).body.locale).toBe('fr-FR');
156+
// `*` is "any language" — no preference expressed, so rung 3 again.
157+
expect((await get('*')).body.locale).toBe('fr-FR');
158+
// Nothing configured anywhere: the cascade's own floor, never `null`.
159+
const bare = mount({ storedLocale: undefined });
160+
expect((await bare.get()).body.locale).toBe('en-US');
161+
});
162+
163+
it('a malformed column value falls to the next rung — it is never served', async () => {
164+
// The shape a lossy producer leaves at rest (the hotcrm dead-letter
165+
// shape service-messaging refuses on the delivery side); and a value
166+
// a user typed before the write rule existed.
167+
for (const stored of ['Chinese (Simplified)', 'undefined', 'zh_CN!', 42]) {
168+
const { get } = mount({ storedLocale: stored, settingLocale: 'fr-FR' });
169+
expect((await get('ja-JP')).body.locale, `stored=${String(stored)}`).toBe('ja-JP');
170+
expect((await get()).body.locale, `stored=${String(stored)}, no header`).toBe('fr-FR');
171+
}
172+
});
173+
174+
it('the column\'s OWN rule is what governs — narrow the registry\'s rule and the answer moves with it', async () => {
175+
// A second parser hard-coded here would keep accepting `zh-CN`. The
176+
// endpoint evaluates the rule the registry declares, so a narrower
177+
// rule refuses what the real rule accepts, and vice versa.
178+
const narrow = { ...LOCALE_SHAPE_RULE, regex: '^[a-z]{2}$' };
179+
const refused = mount({ storedLocale: 'zh-CN', rules: [narrow] });
180+
expect((await refused.get('ja-JP')).body.locale).toBe('ja-JP');
181+
const accepted = mount({ storedLocale: 'zh', rules: [narrow] });
182+
expect((await accepted.get('ja-JP')).body.locale).toBe('zh');
183+
// A rule objectql would skip as malformed is skipped here too, which
184+
// leaves NO usable rule — the unverifiable case below, not a bypass.
185+
const broken = mount({ storedLocale: 'zh-CN', rules: [{ ...LOCALE_SHAPE_RULE, regex: '[' }] });
186+
expect((await broken.get('ja-JP')).body.locale).toBe('ja-JP');
187+
});
188+
189+
it('an unverifiable column (no shape rule declared) is not trusted — it falls through', async () => {
190+
// Fail direction pinned on purpose: a registry that declares no
191+
// `format` rule for `locale` cannot vouch for the stored value, and an
192+
// unvouched value reads as "unset", the same as a malformed one.
193+
const noRule = mount({ storedLocale: 'zh-CN', rules: [] });
194+
expect((await noRule.get('ja-JP')).body.locale).toBe('ja-JP');
195+
const noSchema = mount({ storedLocale: 'zh-CN', rules: null });
196+
expect((await noSchema.get('ja-JP')).body.locale).toBe('ja-JP');
197+
});
198+
199+
it('a failed identity read is a courtesy lost, never a failed answer', async () => {
200+
const { get, reads } = mount({ storedLocale: 'zh-CN', settingLocale: 'fr-FR', failUserRead: true });
201+
const { status, body } = await get('ja-JP');
202+
expect(status).toBe(200);
203+
expect(body.authenticated).toBe(true);
204+
expect(body.locale).toBe('ja-JP');
205+
// Anti-vacuity: the endpoint's own read really was issued (and threw).
206+
expect(reads.filter((r) => r.object === 'sys_user').length).toBeGreaterThan(1);
207+
});
208+
209+
it('the unauthenticated answer is unchanged', async () => {
210+
const { get, reads } = mount({ authenticated: false, storedLocale: 'zh-CN' });
211+
const { status, body } = await get('ja-JP');
212+
expect(status).toBe(200);
213+
expect(body).toEqual({ authenticated: false });
214+
// No identity row is read for an anonymous caller.
215+
expect(reads.filter((r) => r.object === 'sys_user')).toEqual([]);
216+
});
217+
});

0 commit comments

Comments
 (0)