Skip to content

Commit 65145d3

Browse files
refactor(core)!: ResolveLocalizationInput takes organizationId; the tenantId key is retired (#26078)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XUMfNCBcFEpzSy2vfiLtxwL)_ Fixes #25555 Clause-②: no (narrowing) Metadata-protocol: none ## What changes `ResolveLocalizationInput.tenantId` (`@objectstack/core`) is renamed `organizationId`. The rename is immediate: there is no alias and no dual spelling. The key always meant the organization id: the settings service stores `localization` under the organization (ADR-0131 D8), and the direct `sys_setting` fallback reads that organization's rows. An environment id passed under the old name type-checked, matched no organization, and resolved every non-UTC organization as `UTC`. Under the new name the mistake cannot be spelled. The old key is an excess-property error at every object-literal call, and the resolver reads no alias at run time. - `packages/core/src/security/resolve-authz-context.ts`: the interface key, with a doc comment saying it takes an organization id and never an environment id. The two internal readers (the cached entry point and the uncached resolve) and `localizationFallbackReach` read `organizationId`. The cache-key docs follow the rename. - The four in-repo callers are migrated: `core/security/request-execution-context.ts`, `mcp/src/plugin.ts`, `plugin-audit/src/audit-plugin.ts` and `plugin-hono-server/src/current-user-endpoints.ts`. A re-grep of `origin/main` at `bbf70614` (including `ui/`) found no others. hotcrm has none either. - The core localization tests are migrated: 100 call sites across 4 files, all mechanical key renames. - New pin `packages/core/src/security/resolve-localization-organization-id.pin.test.ts` has two halves: - Type: a `@ts-expect-error` on `tenantId`. `tsconfig.test.json` compiles this file, and the reverse check below shows it is live. - Runtime: the organization's timezone resolves through `organizationId` on both legs, the settings service and the direct fallback. A `tenantId` smuggled past the compiler reaches neither leg and resolves `UTC`. - A changeset: patch for core, mcp, plugin-audit and plugin-hono-server, with a BREAKING banner, the FROM / TO line, and ADR-0087 `not-required (runtime-export-retired @objectstack/core#ResolveLocalizationInput.tenantId)`. The gate verifies it. ## Not moved The settings service's own scope argument, `{ tenantId, userId }` on `get` / `getMany`, keeps its spelling. It is a separate public surface with its own callers across services, and renaming it is outside this card's acceptance, which is about the localization input. The resolver passes the organization id there unchanged, and the new pin asserts the scope it receives. ## External caller objectstack-ai/cloud is the named external caller: cloud#3150 repaired its three AI entry points. This session cannot read that repository, so its `@objectstack/core` pin is NOT MEASURED. The card says the callers pass an object literal. If that holds, the rename turns red at cloud's next core bump as a TS2353 excess-property error, and the fix is one key per call site. It does not fail silently. A non-literal call would drop the key without an error, so the changeset tells callers to grep for `tenantId` beside `resolveLocalizationContext`. ## Verification (head `bcf5c8adf`; source change at `4c667539`) - `pnpm --filter PKG typecheck` exit 0 for core, mcp, plugin-audit and plugin-hono-server. This includes core's test layer: `check:test-typecheck` OK, ledger unchanged at 4 files / 4 errors. - `pnpm --filter PKG test` exit 0: core 120 files / 2772 tests, mcp 43 / 538, plugin-audit 60 / 993, plugin-hono-server 35 / 448. - Reverse check of the type pin: with `tenantId?: string` added back to the interface (`git hash-object` of the restored file equals the HEAD blob), `check-test-typecheck` exits 1. It names exactly the new pin file with 1 error, the unused `@ts-expect-error`. - `eslint --no-inline-config` over the four packages' `src`: exit 0. - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: OK, 8 anchors judged, 0 lost. - Derived gates (`dispatch-gates.mjs --commands`, 76 commands): the non-zero exits are listed in the report on the card. `check:objectql-double-limit` caught the first version of the pin's engine double, which is fixed in `bcf5c8adf`. `check:dts-closure` and `check:dual-build-cjs-loads` need a full build and are NOT MEASURED locally; CI runs both. - Full dogfood battery (owed by the narrowing arm): result is in the report on the card. ## Acceptance notes - The card's acceptance: the name states it takes an organization id; an environment-shaped value cannot be spelled under the old key; in-repo callers are migrated; the changeset declares both lines. --- _Generated by [Claude Code](https://claude.ai/code/session_014xf92fjdDKjycyVbyxMKm8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b50f5cf commit 65145d3

11 files changed

Lines changed: 267 additions & 116 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/mcp': patch
4+
'@objectstack/plugin-audit': patch
5+
'@objectstack/plugin-hono-server': patch
6+
---
7+
8+
refactor(core)!: `resolveLocalizationContext` takes the organization as `organizationId`; the `tenantId` key is retired
9+
10+
Clause-②: no (narrowing)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/core#ResolveLocalizationInput.tenantId) the retired member is a key of a published runtime TypeScript input with no Zod schema, no spec declaration and no stored shape; the compiler reports it at every object-literal call that still spells it, and nothing exists for `objectstack migrate meta` to rewrite -->
14+
15+
**BREAKING** for a TypeScript caller of `resolveLocalizationContext` from `@objectstack/core`. The key that names the organization to localize for is renamed. There is no alias and no deprecation window.
16+
17+
`ResolveLocalizationInput.tenantId` always meant the **organization** id: the settings service stores and reads `localization` under the organization (ADR-0131 D8), and the direct `sys_setting` fallback reads that organization's rows. The name did not say so, and an environment id passed there type-checked. It matched no organization's settings, so every non-UTC organization silently resolved its timezone as `UTC`, and the date boundaries cut from it moved with it. The key is now `organizationId`, and the resolver reads no other spelling at run time.
18+
19+
- FROM `ResolveLocalizationInput.tenantId` TO `ResolveLocalizationInput.organizationId`: pass the caller's organization id under the new key, `resolveLocalizationContext({ ql, settings, organizationId, userId })`. Never pass an environment id.
20+
21+
**Who moves.** A caller that builds the input as an object literal with `tenantId` gets a compile error (excess property) and makes the one-key fix above. A caller that builds it some other way, such as spreading an object that holds `tenantId` or passing a variable typed wider than the input, does not get that error: its `tenantId` is ignored and the resolver localizes as for a caller with no organization, so grep for `tenantId` beside `resolveLocalizationContext`. In this repository the four callers (`@objectstack/core`'s request execution context, `@objectstack/mcp`, `@objectstack/plugin-audit` and `@objectstack/plugin-hono-server`) are migrated here and behave as before.
22+
23+
**Not moved.** The settings service's own scope argument, `{ tenantId, userId }` on `get` / `getMany`, keeps its spelling. It is a separate surface with its own callers. The resolver passes the organization id there as before.

‎packages/core/src/security/request-execution-context.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export async function resolveRequestExecutionContext(
271271
if (authz.userId) {
272272
if (input.localization !== 'withhold') {
273273
const settings = await Promise.resolve(input.localization.settings()).catch(() => undefined);
274-
localization = await resolveLocalizationContext({ ql, settings, tenantId: authz.tenantId, userId: authz.userId });
274+
localization = await resolveLocalizationContext({ ql, settings, organizationId: authz.tenantId, userId: authz.userId });
275275
}
276276
authGate = await decideAuthGate(input, authz, sessionApi);
277277
}

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
224224
{ namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'JPY' },
225225
],
226226
});
227-
const loc = await resolveLocalizationContext({ ql, tenantId: 'o1' });
227+
const loc = await resolveLocalizationContext({ ql, organizationId: 'o1' });
228228
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
229229
expect(ql.counts.sys_setting).toBe(1);
230230
});
@@ -256,7 +256,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
256256
},
257257
};
258258
const ql = makeCountingQl({ sys_setting: [] });
259-
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
259+
const loc = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
260260
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
261261
expect(getMany.calls).toBe(1);
262262
expect(ql.counts.sys_setting ?? 0).toBe(0); // service answered — no direct read
@@ -271,7 +271,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
271271
},
272272
};
273273
const ql = makeCountingQl({ sys_setting: [] });
274-
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
274+
const loc = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
275275
expect(loc).toEqual({ timezone: 'Asia/Tokyo', locale: 'ja-JP', currency: 'JPY' });
276276
expect(gets).toBe(3);
277277
});
@@ -286,7 +286,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
286286
{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Europe/Paris' },
287287
],
288288
});
289-
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
289+
const loc = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
290290
expect(loc.timezone).toBe('Europe/Paris');
291291
expect(ql.counts.sys_setting).toBe(1); // the batched $in fallback ran once
292292
});
@@ -372,7 +372,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
372372
it('a per-key occupant issues three namespace reads in ONE leg (the pre-#10826 cost)', async () => {
373373
const settings = makeSettingsRig(VALUES, { batched: false });
374374
const ql = makeCountingQl({ sys_setting: [] });
375-
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1', userId: 'u1' });
375+
const loc = await resolveLocalizationContext({ ql, settings, organizationId: 'o1', userId: 'u1' });
376376
expect(loc).toEqual(EXPECTED);
377377
expect(settings.stats).toEqual({ queries: 3, legs: 1 });
378378
// The settings path answered, so the direct `sys_setting` fallback is not
@@ -383,7 +383,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
383383
it('the batched occupant issues ONE namespace read, in the same ONE leg', async () => {
384384
const settings = makeSettingsRig(VALUES, { batched: true });
385385
const ql = makeCountingQl({ sys_setting: [] });
386-
const loc = await resolveLocalizationContext({ ql, settings, tenantId: 'o1', userId: 'u1' });
386+
const loc = await resolveLocalizationContext({ ql, settings, organizationId: 'o1', userId: 'u1' });
387387
expect(loc).toEqual(EXPECTED);
388388
// queries 3 -> 1, legs 1 -> 1. #10826 was correctly scheduled as a
389389
// query-count fix; the `legs` half is what a sequential-loop regression
@@ -397,7 +397,7 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
397397
await resolveLocalizationContext({
398398
ql: makeCountingQl({ sys_setting: [] }),
399399
settings,
400-
tenantId: 'o1',
400+
organizationId: 'o1',
401401
userId: 'u1',
402402
});
403403
expect(settings.calls).toEqual([
@@ -451,8 +451,8 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
451451

452452
it('does not re-query on a second call within the TTL window when the read fails (same tenant)', async () => {
453453
const ql = makeMissingTableQl();
454-
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
455-
const second = await resolveLocalizationContext({ ql, tenantId: 'o1' });
454+
const first = await resolveLocalizationContext({ ql, organizationId: 'o1' });
455+
const second = await resolveLocalizationContext({ ql, organizationId: 'o1' });
456456
// Functional behavior is unchanged — still the built-in defaults — the
457457
// second call just doesn't repeat the failing query (and its log line).
458458
expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
@@ -462,32 +462,32 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
462462

463463
it('re-queries once the TTL expires, so the cache self-heals once the table/migration lands', async () => {
464464
const ql = makeMissingTableQl();
465-
await resolveLocalizationContext({ ql, tenantId: 'o1' });
465+
await resolveLocalizationContext({ ql, organizationId: 'o1' });
466466
expect(ql.counts.sys_setting).toBe(1);
467467
await vi.advanceTimersByTimeAsync(30_001);
468-
await resolveLocalizationContext({ ql, tenantId: 'o1' });
468+
await resolveLocalizationContext({ ql, organizationId: 'o1' });
469469
expect(ql.counts.sys_setting).toBe(2);
470470
});
471471

472472
it('keys the failure cache per tenant, so a lookup for one tenant never reuses another tenant\'s entry', async () => {
473473
const qlA = makeMissingTableQl();
474474
// Reuse the SAME underlying table state across two tenant-scoped calls by
475-
// routing both through one `ql`, distinguished only by `tenantId` in the
475+
// routing both through one `ql`, distinguished only by `organizationId` in the
476476
// cache key (the direct-read fallback query itself is not tenant-scoped
477477
// in its `where`, matching the real resolver's existing behavior).
478-
await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-a' });
479-
await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-b' });
478+
await resolveLocalizationContext({ ql: qlA, organizationId: 'tenant-a' });
479+
await resolveLocalizationContext({ ql: qlA, organizationId: 'tenant-b' });
480480
expect(qlA.counts.sys_setting).toBe(2);
481481
// A repeat for the first tenant hits its own cache entry, not tenant-b's.
482-
await resolveLocalizationContext({ ql: qlA, tenantId: 'tenant-a' });
482+
await resolveLocalizationContext({ ql: qlA, organizationId: 'tenant-a' });
483483
expect(qlA.counts.sys_setting).toBe(2);
484484
});
485485

486486
it('keys the failure cache per `ql` instance, so two environments in one process never share a cached outcome', async () => {
487487
const qlA = makeMissingTableQl();
488488
const qlB = makeMissingTableQl();
489-
await resolveLocalizationContext({ ql: qlA, tenantId: 'o1' });
490-
await resolveLocalizationContext({ ql: qlB, tenantId: 'o1' });
489+
await resolveLocalizationContext({ ql: qlA, organizationId: 'o1' });
490+
await resolveLocalizationContext({ ql: qlB, organizationId: 'o1' });
491491
expect(qlA.counts.sys_setting).toBe(1);
492492
expect(qlB.counts.sys_setting).toBe(1);
493493
});
@@ -507,12 +507,12 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
507507
it('without an engine write-epoch seam, a successful read is never cached: a value change between two calls is visible on the very next call', async () => {
508508
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }];
509509
const ql = makeCountingQl({ sys_setting: rows });
510-
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
510+
const first = await resolveLocalizationContext({ ql, organizationId: 'o1' });
511511
expect(first.timezone).toBe('UTC');
512512
// Simulate a settings write landing between the two calls — no TTL
513513
// advance, so a cache would still be "fresh" if one existed.
514514
rows[0].value = 'America/Los_Angeles';
515-
const second = await resolveLocalizationContext({ ql, tenantId: 'o1' });
515+
const second = await resolveLocalizationContext({ ql, organizationId: 'o1' });
516516
expect(second.timezone).toBe('America/Los_Angeles');
517517
expect(ql.counts.sys_setting).toBe(2);
518518
});
@@ -525,10 +525,10 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
525525
it('without a seam, a legitimate empty result is not cached either: the first write for a previously-unconfigured tenant is visible immediately', async () => {
526526
const rows: Array<{ namespace: string; key: string; scope: string; organization_id: string; value: string }> = [];
527527
const ql = makeCountingQl({ sys_setting: rows });
528-
const first = await resolveLocalizationContext({ ql, tenantId: 'o1' });
528+
const first = await resolveLocalizationContext({ ql, organizationId: 'o1' });
529529
expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
530530
rows.push({ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' });
531-
const second = await resolveLocalizationContext({ ql, tenantId: 'o1' });
531+
const second = await resolveLocalizationContext({ ql, organizationId: 'o1' });
532532
expect(second.timezone).toBe('Asia/Tokyo');
533533
expect(ql.counts.sys_setting).toBe(2);
534534
});
@@ -608,14 +608,14 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
608608
const settings = makeBindWindowSettings();
609609
const ql = makeCountingQl({ sys_setting: [] });
610610

611-
const preBind = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
611+
const preBind = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
612612
expect(preBind).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
613613
expect(settings.state.getManyCalls).toBe(1);
614614

615615
settings.state.bound = true; // the settings engine binds
616616
await vi.advanceTimersByTimeAsync(1_000); // still deep inside the 30s window
617617

618-
const postBind = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
618+
const postBind = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
619619
// The re-read must REACH the service (a memo hit would never call it) and
620620
// must carry the configured values, which exist only behind the bind.
621621
expect(settings.state.getManyCalls).toBe(2);
@@ -630,11 +630,11 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
630630
const rows = [{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'UTC' }];
631631
const ql = makeCountingQl({ sys_setting: rows });
632632

633-
const first = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
633+
const first = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
634634
expect(first.timezone).toBe('UTC');
635635

636636
rows[0].value = 'America/Los_Angeles'; // a settings write lands, no TTL advance
637-
const second = await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
637+
const second = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
638638
expect(second.timezone).toBe('America/Los_Angeles');
639639
expect(ql.counts.sys_setting).toBe(2);
640640
});
@@ -652,7 +652,7 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
652652
};
653653
const ql = makeCountingQl({ sys_setting: [] });
654654

655-
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
655+
expect(await resolveLocalizationContext({ ql, settings, organizationId: 'o1' })).toEqual({
656656
timezone: 'UTC',
657657
locale: 'en-US',
658658
currency: undefined,
@@ -661,7 +661,7 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
661661

662662
state.bound = true;
663663
await vi.advanceTimersByTimeAsync(1_000);
664-
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
664+
expect(await resolveLocalizationContext({ ql, settings, organizationId: 'o1' })).toEqual({
665665
timezone: 'Asia/Shanghai',
666666
locale: 'zh-CN',
667667
currency: 'CNY',
@@ -684,15 +684,15 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
684684
};
685685
const ql = makeCountingQl({ sys_setting: [] });
686686

687-
expect(await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).toEqual({
687+
expect(await resolveLocalizationContext({ ql, settings, organizationId: 'o1' })).toEqual({
688688
timezone: 'UTC',
689689
locale: 'en-US',
690690
currency: undefined,
691691
});
692692

693693
state.bound = true;
694694
await vi.advanceTimersByTimeAsync(1_000);
695-
expect((await resolveLocalizationContext({ ql, settings, tenantId: 'o1' })).timezone).toBe('Asia/Shanghai');
695+
expect((await resolveLocalizationContext({ ql, settings, organizationId: 'o1' })).timezone).toBe('Asia/Shanghai');
696696
});
697697

698698
// ── the half that must be PRESERVED (#10221) ────────────────────────────
@@ -705,18 +705,18 @@ describe('resolveLocalizationContext — only a backend fault populates the memo
705705
const settings = makeBindWindowSettings(); // unbound → refuses
706706
const ql = makeMissingTableQl(); // and the direct read throws
707707

708-
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
708+
await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
709709
expect(ql.counts.sys_setting).toBe(1);
710710

711711
await vi.advanceTimersByTimeAsync(1_000);
712-
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
712+
await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
713713
// Memo hit: the failing query — and the driver's log line for it — did not
714714
// repeat. The settings refusal is re-attempted (it is free), but that is
715715
// not what #10221 was protecting.
716716
expect(ql.counts.sys_setting).toBe(1);
717717

718718
await vi.advanceTimersByTimeAsync(30_001);
719-
await resolveLocalizationContext({ ql, settings, tenantId: 'o1' });
719+
await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
720720
expect(ql.counts.sys_setting).toBe(2); // and it still self-heals on expiry
721721
});
722722
});

0 commit comments

Comments
 (0)