Skip to content

Commit 18588cd

Browse files
committed
fix(plugin-hono-server): resolve the /auth/me/localization regional defaults instead of answering null
The handler read `currency` / `timezone` off the request ExecutionContext, citing ADR-0053 — but `makeExecutionContextResolver`, the resolver that serves this surface, is a hand-rolled envelope that assigns neither. Both were therefore `undefined` on every request and the `?? null` answered `null` to every authenticated caller, whatever the `localization` settings said. All three values now come from ONE reading of `resolveLocalizationContext`, the same cascade the dispatcher's shared assembler fills `execCtx` from, so the two faces agree by construction rather than by comment. `locale` keeps its three #14788 rungs and its answers are unchanged; what changed underneath is that the cascade is read even when rung 1 or 2 wins, because the other two values need it whichever rung answers the language. The identity read and the settings read are independent and now run concurrently — the console races this endpoint against a 500 ms budget on a first visit. The #14788 pin asserted `timezone: null` as the contract; it was pinning the defect, and it now asserts the corrected one. `currency: null` in that fixture is UNCHANGED and still correct: the cascade gives `timezone` a floor (`UTC`) and `currency` none. The platform checklist's "nulls legal" clause is rewritten to that asymmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent 09124e7 commit 18588cd

4 files changed

Lines changed: 120 additions & 31 deletions

File tree

‎.changeset/great-clouds-repair.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
`GET /auth/me/localization` answers the deployment's resolved `currency` and `timezone` instead of `null`
6+
7+
The handler read both off the request `ExecutionContext`, citing ADR-0053, but the resolver serving this surface is a hand-rolled envelope that never carried them — so every authenticated caller was answered `currency: null, timezone: null` whatever the `localization` settings said, and the console's regional-formatting seed was fed nulls. All three values now come from one reading of the same `resolveLocalizationContext` cascade the dispatcher's shared assembler uses. `locale` resolution is unchanged. `timezone` now always answers (cascade floor `UTC`); `currency` still answers `null` when the deployment configures none — that value has no floor.

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,9 +2572,9 @@
25722572
"evidence": "the probe trace"
25732573
},
25742574
{
2575-
"clause": "localization rides the ExecutionContext without a setup gate: an ordinary member's /auth/me/localization answers 200 with currency/locale/timezone keys (nulls legal) — the SETTINGS surface is setup-gated, the resolved defaults deliberately are not",
2575+
"clause": "localization is RESOLVED without a setup gate: an ordinary member's /auth/me/localization answers 200 with currency/locale/timezone keys carrying the deployment cascade's own answers — the SETTINGS surface is setup-gated, the resolved defaults deliberately are not. \"Nulls legal\" no longer holds for all three (#15387, which repaired a resolver that carried none of them and made the endpoint answer null for currency AND timezone to every authenticated caller): locale and timezone ALWAYS answer, on cascade floors en-US / UTC, so a null for either is a FAIL and a regression of that repair. currency is the one key with no floor — null there is legal, and only when the deployment configures no localization.currency",
25762576
"oracle": "api",
2577-
"verify": "member trace carries authenticated:true plus the three keys",
2577+
"verify": "member trace carries authenticated:true plus the three keys, with timezone and locale non-null; configure localization.currency and localization.timezone and re-trace — both must move to the configured values (an unmoved trace is the #15387 defect, not a pass)",
25782578
"evidence": "the trace"
25792579
}
25802580
],

‎packages/plugins/plugin-hono-server/src/current-user-endpoints-localization.test.ts‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,30 @@
2020
// served" clause; the narrowed-rule case is the "no second parser" clause
2121
// (the answer moves with the registry's rule, not with a built-in regex).
2222
//
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.
23+
// Before #14788 the resolver behind these endpoints assembled NO localization
24+
// at all — `execCtx.locale` here was always `undefined` and the endpoint
25+
// answered `locale: null` for every authenticated caller — so the rung-3 cases
26+
// are also the first pins that this surface answers a language at all.
27+
//
28+
// #15387 — WHAT THIS FILE PINS CHANGED, deliberately and not silently.
29+
// `currency` / `timezone` were out of #14788's scope and kept coming off that
30+
// same resolver, so the rung-1 case below asserted `timezone: null` as the
31+
// contract. It was pinning the DEFECT: the endpoint answered null for both to
32+
// every authenticated caller whatever the deployment configured. The
33+
// assertion is now the corrected contract, and the `#15387` block at the foot
34+
// of this file is what makes the two values falsifiable rather than merely
35+
// present:
36+
//
37+
// * `timezone` — the deployment cascade's answer, floor `UTC`. An
38+
// authenticated caller can no longer be answered `null` for it, which is
39+
// why the rung-1 fixture (which configures no time zone) now reads `UTC`.
40+
// * `currency` — the deployment cascade's answer, and the one value with NO
41+
// floor. `null` there is still legal and still correct for a deployment
42+
// that configures no currency, so the rung-1 expectation for it is
43+
// UNCHANGED. Keeping that asymmetry visible is the point: the two keys do
44+
// not have the same nullability contract.
45+
//
46+
// The response SHAPE objectui reads is unchanged — same four keys.
3047

3148
import { describe, it, expect } from 'vitest';
3249
import { Hono } from 'hono';
@@ -144,7 +161,9 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs
144161
const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8');
145162
expect(status).toBe(200);
146163
// The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged.
147-
expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null });
164+
// `timezone: 'UTC'` is the cascade floor for a fixture that configures
165+
// no time zone (#15387); `currency: null` is the no-floor value.
166+
expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: 'UTC' });
148167
// The identity row is read under a SYSTEM context by the caller's own
149168
// id (the `tryFind` shape core uses for the same row) — never routed
150169
// through the caller's own RLS wall.

‎packages/plugins/plugin-hono-server/src/current-user-endpoints.ts‎

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ async function storedSignedInUserLocale(
716716
return rules.every((re) => re.test(value)) ? value : undefined;
717717
}
718718

719-
/** Input of {@link resolveSignedInUserLocale}. */
719+
/** Input of {@link resolveCurrentUserLocalization} and {@link resolveSignedInUserLocale}. */
720720
export interface ResolveSignedInUserLocaleInput {
721721
/** The locator of the kernel that OWNS the request (see `withRequestContext`). */
722722
ctx: CurrentUserEndpointsContext;
@@ -728,9 +728,28 @@ export interface ResolveSignedInUserLocaleInput {
728728
acceptLanguage?: string | null;
729729
}
730730

731+
/** Everything `/auth/me/localization` answers a signed-in caller. */
732+
interface CurrentUserLocalization {
733+
/**
734+
* The reference currency, or `undefined` when the deployment configures
735+
* none. The ONE value here with no floor, deliberately: a deployment that
736+
* has stated no currency has none, and inventing one would be a WRONG
737+
* answer where `undefined` is merely a missing one (the renderer's
738+
* documented degradation is a plain number).
739+
*/
740+
currency?: string;
741+
/** The signed-in user's language. Always a string — rung 3 has a floor. */
742+
locale: string;
743+
/** The reference time zone. Always a string — the cascade's floor is `UTC`. */
744+
timezone: string;
745+
}
746+
731747
/**
732-
* The signed-in user's language — the ONE read face (#14788, maintainer
733-
* ruling 2026-09-03, option D):
748+
* The endpoint's whole answer, resolved from ONE reading of the deployment
749+
* cascade (#15387).
750+
*
751+
* `locale` keeps the three rungs of #14788 (maintainer ruling 2026-09-03,
752+
* option D):
734753
*
735754
* 1. `sys_user.locale` when set and shaped like the column's own rule says
736755
* ({@link storedSignedInUserLocale});
@@ -742,24 +761,62 @@ export interface ResolveSignedInUserLocaleInput {
742761
* `execCtx.locale` already derives from on the dispatcher
743762
* (`localization.locale` settings → tenant `sys_setting` rows → `en-US`).
744763
*
745-
* Always answers a string for an authenticated caller: rung 3 has a floor.
746-
* Exported for the serverless host path that composes the resolver directly
747-
* (cloud#924) and for the pin that asserts the precedence.
764+
* `currency` and `timezone` come off that same cascade reading, which is the
765+
* repair #15387 names: they used to be read off the request
766+
* `ExecutionContext`, and the resolver serving THIS surface
767+
* ({@link makeExecutionContextResolver}) is a hand-rolled envelope that never
768+
* carried them — so the endpoint answered `null` for both to every
769+
* authenticated caller, whatever the `localization` settings said. The
770+
* dispatcher's shared assembler fills them from this very function
771+
* (`core/security/assemble-execution-context.ts` ⇒ `resolveLocalizationContext`),
772+
* so reading the cascade here makes the two faces agree by construction
773+
* instead of by comment.
774+
*
775+
* ONE reading, not two: rungs 1–2 no longer short-circuit it, because
776+
* `currency` / `timezone` are needed whichever rung answers the language. That
777+
* is one `sys_setting` read added to the requests where the caller's own
778+
* column or header already decided the locale, and it replaces the second
779+
* reading the obvious alternative (resolve again for the other two values)
780+
* would have cost on every request. The two reads it does issue are
781+
* INDEPENDENT — the caller's identity row and the deployment's settings — and
782+
* run concurrently: the console races this endpoint against a 500 ms budget on
783+
* a device's first visit (objectui `seedTenantLanguage`), so a needless serial
784+
* round-trip here is a language flash there. Neither read throws by its own
785+
* documented contract, which is what makes the concurrent form safe.
748786
*/
749-
export async function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise<string> {
787+
async function resolveCurrentUserLocalization(
788+
input: ResolveSignedInUserLocaleInput,
789+
): Promise<CurrentUserLocalization> {
750790
const { ctx, userId, tenantId } = input;
751791
const ql = (() => {
752792
try { return ctx.getService<IDataEngine>('objectql'); } catch { return undefined; }
753793
})();
754-
const stored = await storedSignedInUserLocale(ql, userId, ctx.logger);
755-
if (stored) return stored;
756-
const requested = preferredLocaleFromHeader(input.acceptLanguage);
757-
if (requested) return requested;
758794
const settings = (() => {
759795
try { return ctx.getService<unknown>('settings'); } catch { return undefined; }
760796
})();
761-
const localization = await resolveLocalizationContext({ ql, settings, tenantId, userId });
762-
return localization.locale;
797+
const [stored, deployment] = await Promise.all([
798+
storedSignedInUserLocale(ql, userId, ctx.logger),
799+
resolveLocalizationContext({ ql, settings, tenantId, userId }),
800+
]);
801+
return {
802+
currency: deployment.currency,
803+
locale: stored ?? preferredLocaleFromHeader(input.acceptLanguage) ?? deployment.locale,
804+
timezone: deployment.timezone,
805+
};
806+
}
807+
808+
/**
809+
* The signed-in user's language — the ONE read face (#14788), the `locale`
810+
* rung of {@link resolveCurrentUserLocalization} on its own.
811+
*
812+
* Always answers a string for an authenticated caller: rung 3 has a floor.
813+
* Exported for the serverless host path that composes the resolver directly
814+
* (cloud#924) and for the pin that asserts the precedence. Its ANSWER is
815+
* unchanged by #15387; what changed underneath is that the deployment cascade
816+
* is now read even when rung 1 or 2 wins (see above).
817+
*/
818+
export async function resolveSignedInUserLocale(input: ResolveSignedInUserLocaleInput): Promise<string> {
819+
return (await resolveCurrentUserLocalization(input)).locale;
763820
}
764821

765822
/**
@@ -1002,8 +1059,7 @@ export function registerCurrentUserEndpoints(
10021059
// language (`locale`), exposed to EVERY authenticated user. The
10031060
// `localization` SETTINGS are gated to `setup.access`, but the resolved
10041061
// defaults are needed by every renderer to format currency/dates/numbers —
1005-
// so they ride on the request ExecutionContext (ADR-0053) and are surfaced
1006-
// here without that gate.
1062+
// so they are surfaced here without that gate.
10071063
//
10081064
// [#14788] `locale` is the ONE read face for "this user's language"
10091065
// (maintainer ruling 2026-09-03, option D, which also retired the dead
@@ -1014,24 +1070,31 @@ export function registerCurrentUserEndpoints(
10141070
// 2. the request's `Accept-Language` preference;
10151071
// 3. the deployment default (`localization.locale`, the same cascade
10161072
// that feeds `execCtx.locale` on the dispatcher).
1017-
// See {@link resolveSignedInUserLocale}. `currency` / `timezone` and the
1018-
// unauthenticated answer are unchanged by that ruling.
1073+
//
1074+
// [#15387] All three come from {@link resolveCurrentUserLocalization} —
1075+
// ONE reading of that same cascade. `currency` / `timezone` used to be read
1076+
// off `execCtx` instead, citing ADR-0053; the resolver this surface uses
1077+
// ({@link makeExecutionContextResolver}) never carried them, so the
1078+
// endpoint answered `null` for both to every authenticated caller no matter
1079+
// what the deployment had configured. The `?? null` on `currency` is not
1080+
// that fallback returning: it is the cascade's own shape, which gives
1081+
// `timezone` a floor and `currency` none.
10191082
rawApp.get(`${prefix}/auth/me/localization`, withRequestContext(async (c, ctx, resolveCtx) => {
10201083
const execCtx = await resolveCtx(c);
10211084
if (!execCtx?.userId) {
10221085
return c.json({ authenticated: false });
10231086
}
1024-
const locale = await resolveSignedInUserLocale({
1087+
const localization = await resolveCurrentUserLocalization({
10251088
ctx,
10261089
userId: execCtx.userId,
10271090
tenantId: execCtx.tenantId ?? undefined,
10281091
acceptLanguage: c.req.raw?.headers?.get?.('accept-language') ?? undefined,
10291092
});
10301093
return c.json({
10311094
authenticated: true,
1032-
currency: execCtx.currency ?? null,
1033-
locale,
1034-
timezone: execCtx.timezone ?? null,
1095+
currency: localization.currency ?? null,
1096+
locale: localization.locale,
1097+
timezone: localization.timezone,
10351098
});
10361099
}));
10371100

0 commit comments

Comments
 (0)