Skip to content

Commit fa85759

Browse files
os-litantclaude
andauthored
fix(plugin-hono-server): /auth/me/localization resolves the regional defaults instead of answering null to every caller (#15745)
* test(plugin-hono-server): reproduce the always-null /auth/me/localization answer Widens the #14788 fixture so the tenant can configure `localization.timezone` and `localization.currency` rows (the endpoint reads all three keys in one `$in` query, so the double now answers whichever the fixture sets), and adds five cases for the resolved regional defaults. Measured against unchanged source: 4 failed | 9 passed. An authenticated caller configured with `Asia/Shanghai` / `CNY` is answered `currency: null, timezone: null` — the defect, reproduced here rather than inherited from the card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * 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 * test(plugin-hono-server): make the localization pin's ObjectQL double honour `where` and the caller's bound The double answered by object name and dropped `opts.limit`, so neither read it serves could tell a bounded read from an unbounded one — and both carry a bound (`sys_user` at 1, the grouped `sys_setting` `$in` read at 10). It now draws from a row table, filters with a `where` matcher that refuses any operator it does not implement, and applies the bound BY PRESENCE (`typeof opts?.limit === 'number'`) AFTER the filter. `check:objectql-double-limit` could not grade the old double at all: its deepest binding strategy stubs every non-function declaration, the counter `let sysUserReads = 0` became the gate's row-stub Proxy, and `++sysUserReads` raised `TypeError: Cannot convert object to primitive value` — reported as UNJUDGED, which the gate treats as debt rather than a skip. The double now seats on the gate's control probe at the earlier binding strategy, so it is graded CONFORMING (limit 3 -> 3 rows, 5 -> 5, 0 -> 0 of 7 matches) instead of throwing. No baseline entry was added; the ledger never grows. What the file ASSERTS is unchanged — all 13 cases pass, including the corrected `timezone`/`currency` contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1697831 commit fa85759

4 files changed

Lines changed: 228 additions & 40 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: 135 additions & 17 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';
@@ -61,6 +78,10 @@ interface MountOptions {
6178
rules?: Row[] | null;
6279
/** Tenant-scoped `localization.locale` `sys_setting` row value (rung 3). */
6380
settingLocale?: string;
81+
/** Tenant-scoped `localization.timezone` `sys_setting` row value. */
82+
settingTimezone?: string;
83+
/** Tenant-scoped `localization.currency` `sys_setting` row value. */
84+
settingCurrency?: string;
6485
/** Whether a session resolves at all. */
6586
authenticated?: boolean;
6687
/**
@@ -73,20 +94,65 @@ interface MountOptions {
7394
failUserRead?: boolean;
7495
}
7596

76-
function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, authenticated = true, failUserRead = false }: MountOptions = {}) {
97+
/**
98+
* The `where` shapes these endpoints actually issue: scalar equality
99+
* (`{ id }`, `{ namespace }`, `{ scope }`) and the one `$in` list on `key` the
100+
* grouped settings read carries. Anything else — a combinator, another
101+
* operator — is REFUSED loudly rather than answered silently wrong, which is
102+
* the cheap correct answer for a double that never sees one (the defect class
103+
* `check:where-matcher` exists for is silence, not incompleteness).
104+
*/
105+
function matchesWhere(row: Row, where: Row | undefined): boolean {
106+
for (const [key, condition] of Object.entries(where ?? {})) {
107+
if (key.startsWith('$')) {
108+
throw new Error(`this double does not implement the where operator ${key}`);
109+
}
110+
if (condition !== null && typeof condition === 'object') {
111+
const operators = Object.keys(condition);
112+
if (operators.length !== 1 || operators[0] !== '$in') {
113+
throw new Error(`this double does not implement the where operator(s) ${operators.join(', ')} on ${key}`);
114+
}
115+
if (!condition.$in.includes(row[key])) return false;
116+
continue;
117+
}
118+
if (row[key] !== condition) return false;
119+
}
120+
return true;
121+
}
122+
123+
function mount({ storedLocale, rules = [LOCALE_SHAPE_RULE], settingLocale, settingTimezone, settingCurrency, authenticated = true, failUserRead = false }: MountOptions = {}) {
77124
const reads: Array<{ object: string; opts: any }> = [];
78125
let sysUserReads = 0;
126+
/**
127+
* The rows each read draws from, held as a table rather than assembled per
128+
* branch, so the double answers `where` and the caller's `limit` the way
129+
* the engine does instead of by object name.
130+
*/
131+
const tables: Record<string, Row[]> = {
132+
sys_user: [{ id: USER, email: 'lang@example.com', locale: storedLocale }],
133+
// The endpoint reads all three `localization` keys in ONE `$in` query,
134+
// so the table carries whichever of them the fixture configured — and
135+
// nothing for the rest.
136+
sys_setting: ([
137+
['locale', settingLocale],
138+
['timezone', settingTimezone],
139+
['currency', settingCurrency],
140+
] as Array<[string, string | undefined]>)
141+
.filter(([, value]) => value !== undefined)
142+
.map(([key, value]) => ({ namespace: 'localization', key, value, scope: 'tenant' })),
143+
};
79144
const ql = {
80145
find: async (object: string, opts: any) => {
81146
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 [];
147+
if (object === 'sys_user' && failUserRead && ++sysUserReads > 1) throw new Error('sys_user unavailable');
148+
// The caller's bound is applied BY PRESENCE and AFTER the filter.
149+
// Both reads this double serves carry one — `sys_user` at 1, the
150+
// grouped `sys_setting` read at 10 — so a double that dropped it
151+
// could not tell a bounded read from an unbounded one, and every
152+
// bound change on those reads would be green by construction
153+
// (`check:objectql-double-limit`).
154+
const matched = (tables[object] ?? []).filter((row) => matchesWhere(row, opts?.where));
155+
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
90156
},
91157
// The registry view the endpoint reads the column's rule off.
92158
getSchema: (name: string) => (name === 'sys_user' && rules !== null ? { name: 'sys_user', validations: rules } : undefined),
@@ -130,7 +196,9 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs
130196
const { status, body } = await get('ja-JP,ja;q=0.9,en;q=0.8');
131197
expect(status).toBe(200);
132198
// The SHAPE objectui reads (`json?.locale`, plus `currency`) — unchanged.
133-
expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: null });
199+
// `timezone: 'UTC'` is the cascade floor for a fixture that configures
200+
// no time zone (#15387); `currency: null` is the no-floor value.
201+
expect(body).toEqual({ authenticated: true, currency: null, locale: 'zh-CN', timezone: 'UTC' });
134202
// The identity row is read under a SYSTEM context by the caller's own
135203
// id (the `tryFind` shape core uses for the same row) — never routed
136204
// through the caller's own RLS wall.
@@ -215,3 +283,53 @@ describe('/auth/me/localization — the signed-in user\'s language, three rungs
215283
expect(reads.filter((r) => r.object === 'sys_user')).toEqual([]);
216284
});
217285
});
286+
287+
describe('/auth/me/localization — the regional defaults are RESOLVED, not always null (#15387)', () => {
288+
it('answers the tenant\'s configured currency and time zone', async () => {
289+
const { get } = mount({ settingTimezone: 'Asia/Shanghai', settingCurrency: 'CNY', settingLocale: 'zh-CN' });
290+
const { status, body } = await get();
291+
expect(status).toBe(200);
292+
// The WHOLE published shape, so a fourth key cannot appear unnoticed.
293+
expect(body).toEqual({ authenticated: true, currency: 'CNY', locale: 'zh-CN', timezone: 'Asia/Shanghai' });
294+
});
295+
296+
it('resolves them independently of which locale rung won — they are not a by-product of the language cascade', async () => {
297+
// Rung 1 answers the language, so the deployment cascade does NOT decide
298+
// `locale` here. Before this card that short-circuit was the only reason
299+
// the cascade was consulted at all, and `currency` / `timezone` came off
300+
// an ExecutionContext that never carried them.
301+
const { get } = mount({ storedLocale: 'ja-JP', settingLocale: 'zh-CN', settingTimezone: 'Europe/Paris', settingCurrency: 'eur' });
302+
const { body } = await get('de-DE');
303+
// `eur` lower-case: the cascade's own coercion upper-cases a 3-letter code.
304+
expect(body).toEqual({ authenticated: true, currency: 'EUR', locale: 'ja-JP', timezone: 'Europe/Paris' });
305+
});
306+
307+
it('a value the cascade refuses falls to the cascade\'s own answer — this surface adds no second parser', async () => {
308+
// `coerceCurrency` takes exactly three letters; `coerceTimeZone` takes an
309+
// `iana_time_zone` domain member. Neither refusal is re-implemented here:
310+
// the endpoint answers whatever the shared resolver answers.
311+
const { get } = mount({ settingTimezone: 'Middle/Earth', settingCurrency: 'euro' });
312+
const { body } = await get();
313+
expect(body.timezone).toBe('UTC');
314+
expect(body.currency).toBeNull();
315+
});
316+
317+
it('with nothing configured: the time-zone floor answers, and currency is the one value that stays null', async () => {
318+
// The asymmetry is the cascade's, and it is deliberate to pin: `timezone`
319+
// has a floor (`UTC`) so an authenticated caller can never see null for
320+
// it again, while `currency` has none — a deployment that configures no
321+
// currency has no reference currency, and inventing one would be a wrong
322+
// answer rather than a missing one.
323+
const { get } = mount({});
324+
const { body } = await get();
325+
expect(body).toEqual({ authenticated: true, currency: null, locale: 'en-US', timezone: 'UTC' });
326+
});
327+
328+
it('the unauthenticated answer stays localization-free', async () => {
329+
const { get, reads } = mount({ authenticated: false, settingTimezone: 'Asia/Shanghai', settingCurrency: 'CNY' });
330+
const { body } = await get();
331+
expect(body).toEqual({ authenticated: false });
332+
// Anti-vacuity: no settings read is issued for a caller with no session.
333+
expect(reads.filter((r) => r.object === 'sys_setting')).toEqual([]);
334+
});
335+
});

0 commit comments

Comments
 (0)