Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/hono-me-localization-user-locale.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions .changeset/session-user-language-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
"@objectstack/spec": minor
---

feat(spec): retire `SessionUser.language` — the session contract's never-produced "preferred language" (#14788, ADR-0049)

<!-- adr-0087: registered session-user-language-retired -->

**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.
4 changes: 2 additions & 2 deletions content/docs/references/api/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down Expand Up @@ -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 | |
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any>;

/**
* 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<string, unknown> = {
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: <T,>(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([]);
});
});
Loading
Loading