Skip to content

Commit 9cbe507

Browse files
fix(core, plugin-auth): a failed organization probe reads as readFailed, and an auth mail treats a failed currency read as transient (#28088)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DDUsM7i5cZHXRCUW51mVsp4)_ domain:server#3 · os-elon-musk · session_016WV4czs2fGS6UiDUUbdGrf Fixes #27733 Fixes #27710 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`resolveLocalizationContext` 在调用方未指明组织、且组织探针(`sys_organization`)因存储故障抛 `AuthzStoreUnavailableError` 时,也返回 `readFailed: true`(与 #27730 的设置读失败同义),且这种结果两种缓存都不写,下一次请求重新探。plugin-auth 的认证邮件币种读取在 `readFailed` 时拒绝;若渲染因此只缺默认币种而被拒,先以 `XXX` 试渲染一次(不发送),能过就抛一条点名"币种读取失败、读成功后可再发"的瞬时错误,不再是 `TEMPLATE_INVALID` 加"去设置 localization.currency"的误导性补救。 - 为什么改:探针故障时原先读成"没有组织 → 没有默认",消息投递被判永久进死信;认证邮件把一切读失败都当"没有默认币种"。分诊 6509207804、6506866652:读失败是瞬时的,只有读成功且无默认才是永久的。 - 风险与回滚:不改任何公开类型(`readFailed` 已存在,只多一个触发条件;新函数不在包入口导出),`Clause-②: no`。探针故障时不再缓存,故障期间每次请求多一次探针查询。无法解析的 posture(配置错误)不算存储故障,行为不变。回滚即 revert。 - 先看:`packages/core/src/security/resolve-authz-context.ts` 的 `localizationFallbackReach` 与 `resolveLocalizationContext` 中 `probeFailed` 分支;`packages/plugins/plugin-auth/src/mail-tenant-currency.ts` 的 `refusalAfterCurrencyReadFault`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/mail-tenant-currency.test.ts`,预期 17 条全过(其中 10 条为本 PR 新增:读失败两条腿判瞬时、读成功无默认原样拒绝、无法用默认治愈的拒绝原样保留)。 - 需要你做的:无。#27710 的 service-automation 一腿需要动热文件 `engine.ts` 的解析器契约,已移交 #28056,本 PR 不含。 ## What changes **Core: the organization probe is a read too (#27733).** For a caller that names no organization where no wall stands, the direct `sys_setting` fallback first asks `sys_organization` (through `resolveGuestOrganization`) whose rows it may read. `localizationFallbackReach` turned a throw there into `unowned`, so no `sys_setting` read ran and the answer had no `readFailed`. It now returns `{ reach, probeFailed }`, where `probeFailed` is `isAuthzStoreUnavailableError(err)`, and `resolveLocalizationContext` sets `readFailed: true` on it. Per triage 6509207804, such an answer writes **no** failure memo; it is not stored in the success cache either (it holds no value to keep), and the dead entry it supersedes is dropped, so the next request probes again. Any other throw (an `OS_TENANCY_POSTURE` that cannot be read, a configuration error, not transient) still answers `unowned` with no `readFailed`. The settings-read memo and the settings-service legs are unchanged. The `readFailed` doc comment now names both triggers. **plugin-auth: the auth mails' tenant currency (#27710).** `withMailTenantCurrency` (`mail-tenant-currency.ts`) wraps the email service the plugin hands the AuthManager. Its resolver used to return `.currency` for every answer, so a failed read added no `currency` and the render refused a no-argument `{{ amount | currency }}` hole `TEMPLATE_INVALID` with the "set localization.currency" remedy. Now: - The resolver moved from an inline closure in `auth-plugin.ts` to `localizationMailCurrencyResolver` (same file as the wrapper, not exported from the package entry). It rejects when `readFailed` is set, as messaging's `getTenantCurrency` does since #27730. - On a rejected read the wrapper sends without a currency. When that send throws `TEMPLATE_INVALID`, it renders the template once more without sending (`renderTemplate`, with the ISO no-currency code `XXX`). If that still refuses `TEMPLATE_INVALID`, a default cannot cure the template (an `@path` value that is not a code) and that refusal stands. If it renders, or the service has no `renderTemplate`, the mail fails with `the mail '<template>' was not rendered: the organization's default currency could not be read (<cause>); it can be sent once that read succeeds`, which carries no `TEMPLATE_*` code. The auth mail has no retry ladder of its own: the caller (a resend endpoint, a background send) sees a transient error it can retry, instead of a permanent one. - Every other failure, a successful read with no default, and a template with no such hole are unchanged. **service-automation: not in this PR.** #27710's notify-run leg moved to #28056 (seat 3). `plugin.ts` alone cannot express it: the resolver's rejection and its `undefined` are swallowed alike in `run-resume.ts` (run setup and `withTenantCurrency`); the resolver contract `FlowTenantCurrencyResolver` that says "throws, tolerated" is documented in `engine.ts`, a held hot file (#27951); and the missing-default refusal is a `FlowTextTemplateError` branded a guard refusal in `builtin/template.ts`, with no `AutomationContext` member (a `packages/spec` contract) to carry "the read failed" to it. ## Why Triage 6509207804 (#27733) and 6506866652 (#27257 retriage, which graded `readFailed` and filed #27710): a failed read learned nothing and is transient; only a read that succeeded and found no default is permanent. #27730 covered the `sys_setting` leg and messaging; this covers the probe leg one step earlier and the plugin-auth caller. ## Tests - `packages/core/src/security/resolve-localization-read-failed.test.ts` (5 new): a store fault on the probe answers the built-ins with `readFailed: true` and reads no `sys_setting`; no memo of either kind (an engine with the write-epoch and middleware seams, so a success entry would be kept if written): the next request probes again, and once the store answers it reads `CNY` with no `readFailed`; a probe that succeeds and finds no organization has no `readFailed`; an unreadable posture has none; a caller naming its organization is not probed. - `packages/services/service-messaging/src/template-tenant-currency.test.ts` (1 new, the email channel through `MessagingServicePlugin`'s own wiring): a delivery with no organization whose probe fails renders without `currency`, trial-renders with `XXX`, sends nothing, and is graded `retryable` with no `TEMPLATE_` text; once the probe answers, the next delivery formats in `CNY`. - `packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts` (10 new): with a failed read, a missing-default refusal becomes the transient error (exact text, no `TEMPLATE_`, one `XXX` trial, nothing sent); a service with no `renderTemplate` gets it too; an `@path` refusal, alone or behind the missing default, stands; `TEMPLATE_NOT_FOUND` stands with no trial; a template needing no default is sent; a successful read with no default keeps the render's refusal with no trial. Through `localizationMailCurrencyResolver` over the real `resolveLocalizationContext`: a failed `sys_setting` read and a failed `sys_organization` probe (identity mail) both reach the mail as the transient error; a read with no default reaches it as the refusal; a read that finds `CNY` formats in it. ## Acceptance notes - **Re-measured on `main` @ `bc496bf7`:** `localizationFallbackReach` still mapped the probe's throw to `UNOWNED_ORGANIZATION_REACH` with no `readFailed`; `auth-plugin.ts` and `service-automation/src/plugin.ts` still returned `.currency` without reading `readFailed`. Both premises held. - **Clause-②: no**, confirmed from the diff: no exported type changes (`readFailed?: true` already exists); `localizationMailCurrencyResolver` lives in `plugin-auth/src/mail-tenant-currency.ts`, which no package entry re-exports. The contract review is therefore not expected, as the dispatch said. - **Changeset:** `@objectstack/core` and `@objectstack/plugin-auth`, `patch`. `service-messaging` moved no code (one new test), so it has none; `service-automation` has no change here. - **Skills-docs:** none. `git grep -n "readFailed\|no tenant default\|could not be read" -- skills docs content` names no text about this resolver or these mails. `content/docs/automation/email-templates.mdx` and `content/docs/kernel/runtime-services/email-service.mdx` describe only the "no default currency reaches the render" case, which is unchanged. - **Branch name:** the card thread's harness-pinned branch `claude/project-thread-abzzym` names no issue; claims 6520779833 / 6520770423 (amended) are the identity record. - **Open PRs:** none of the 20 open PRs touches these files. #27862 (seat 3) edits other hunks of `plugin-auth/src`. ## Verification **Head `eb1c3932`** (one commit on `main` @ `bc496bf7`). - **Ablations** (`scripts/ablation-replace.mjs`; each leg red, then restored with blob == HEAD): - A1, the probe fault sets no kind (`probeFailed: false`): core 2 of 10 red; with core rebuilt, messaging 1 red (the probe pin) and plugin-auth 1 red (the end-to-end probe pin). - A2, the no-memo branch skipped (the answer falls into the success cache): core 1 red (the no-memo pin). - A3, every derivation throw counts (`probeFailed: true`): core 1 red (the unreadable-posture pin). - A4, the auth resolver never rejects: plugin-auth 2 red (both end-to-end read-fault pins); rerun after the last fixture change, still 2 red. - A5, the wrapper never re-grades (`throw err`): plugin-auth 5 red. - A6, the trial render's standing refusal ignored: plugin-auth 2 red (both `@path` pins). - **Tests:** `@objectstack/core` 128 files / 2878 passed; `@objectstack/plugin-auth` 204 / 3896 passed (10 skipped); `@objectstack/service-messaging` 62 / 756 passed. - **Types:** `typecheck` exit 0 for `core`, `plugin-auth`, `service-messaging` (the first messaging run caught an ES2022 `Array.prototype.at` in the new pin; replaced by an index). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 families, after a whole-tree `turbo run build`. 68 exited 0 on the first run; `check:objectql-double-limit` flagged the new plugin-auth `find` double as unjudged, and after rewriting it on the generic where-matcher it exits 0. That gate and the eight others that read test files (`where-matcher`, `engine-double-contract`, `cross-package-test-inputs`, `test-source-alias`, `nul-bytes`, `issue-citations`, `undeclared-dep-imports`, `query-options-erasure`) were rerun at `eb1c3932`, all exit 0; the derived list at `eb1c3932` is the same 69. `--ran`: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. - **Lint:** `pnpm lint` exit 0; ESLint on the two test files changed afterwards, 0 errors. - **Dogfood battery:** not run; the title has no `!` and there is no `(narrowing)` arm. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (Two sibling cards, one dispatch.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4czs2fGS6UiDUUbdGrf)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czs2fGS6UiDUUbdGrf Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5954705 commit 9cbe507

7 files changed

Lines changed: 487 additions & 40 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
fix(core, plugin-auth): a failed organization probe says `readFailed` too, and an auth mail no longer reads a failed settings read as "no default currency" (#27733, #27710)
7+
8+
Clause-②: no — `resolveLocalizationContext`'s result keeps its shape; the existing `readFailed?: true` member gains one more trigger. Nothing new is exported from a package entry.
9+
Metadata-protocol: none
10+
11+
- **Before.** For a caller that names no organization where no wall stands, `resolveLocalizationContext` first asks `sys_organization` which organization's rows it may read. A store fault on that probe answered the built-ins with no `readFailed`, so messaging graded a mail whose `{{ amount | currency }}` hole then had no default as permanent (`TEMPLATE_INVALID`) and dead-lettered it. And the auth mails (verification, password reset, invitations, magic link, notices) read every failed settings read as "the organization has no default currency": such a mail failed with `TEMPLATE_INVALID` and its "set localization.currency" remedy during a transient fault.
12+
- **Now.** A probe that throws `AuthzStoreUnavailableError` answers `readFailed: true`, the same as a failed `sys_setting` read, so messaging retries the delivery. That answer is cached in neither memo, since no value was read: the next request asks again. A posture that cannot be read is not a store fault and still answers no `readFailed`. The auth mail's currency read rejects on `readFailed`; when the render then refuses for want of a default, the mail is rendered once more without sending with the no-currency code `XXX`: if that renders, the mail fails with an error that names the failed read and carries no `TEMPLATE_*` code, so sending it again once the read succeeds works; if it still refuses, that refusal stands.
13+
- **Unchanged.** A read that succeeds and finds no default keeps today's refusal and remedy, on both paths. A template with no no-argument `currency` hole is sent during a read fault. The failure memo for a failed `sys_setting` read is unchanged.

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

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
* `tryFind`'s `catch` — read that before changing anything on this path.
3939
*/
4040

41-
import { AuthzStoreUnavailableError } from './authz-store-unavailable.js';
41+
import { AuthzStoreUnavailableError, isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
4242
// [commit 6a180e42d, ruled 2026-08-30] `isMissingTableError` is the one "was this READ
4343
// failure just an unprovisioned table?" predicate. It was `@objectstack/metadata`'s
4444
// until this resolver needed it; metadata depends on core, so the ruling relocated
@@ -2216,14 +2216,17 @@ type LocalizationResult = {
22162216
locale: string;
22172217
currency?: string;
22182218
/**
2219-
* Present only when the direct `sys_setting` read THREW (a backend fault),
2220-
* on a fresh read and on a memoized one alike: every value above is then the
2221-
* built-in default, not a stored answer. Absent, a missing `currency` means
2222-
* the read succeeded and the organization has no default, which is permanent
2223-
* until someone sets one; present, it means nothing was learned, and a caller
2224-
* that would refuse work for want of a default retries instead. A
2225-
* settings-service refusal never sets it (#11877), and it does not lengthen
2226-
* the failure memo. (#27257)
2219+
* Present only when the direct `sys_setting` read could not be answered
2220+
* because the store failed: the read itself THREW (a backend fault), on a
2221+
* fresh read and on a memoized one alike (#27257), or the `sys_organization`
2222+
* probe that decides whose rows it may read threw
2223+
* `AuthzStoreUnavailableError`, so it never ran (#27733). Every value above
2224+
* is then the built-in default, not a stored answer. Absent, a missing
2225+
* `currency` means the read succeeded and the organization has no default,
2226+
* which is permanent until someone sets one; present, it means nothing was
2227+
* learned, and a caller that would refuse work for want of a default retries
2228+
* instead. A settings-service refusal never sets it (#11877), and it does
2229+
* not lengthen the failure memo; a probe fault writes no memo at all.
22272230
*/
22282231
readFailed?: true;
22292232
};
@@ -2647,7 +2650,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE
26472650
* every request (#10221). A settings-service refusal is not a backend fault
26482651
* and never populates that memo (#11877). Such an answer carries
26492652
* `readFailed: true`, fresh and memoized alike, so a caller can tell "the read
2650-
* failed" from "the read found no default" (#27257).
2653+
* failed" from "the read found no default" (#27257). So does an answer whose
2654+
* `sys_organization` probe failed and so read no row (#27733); that one is
2655+
* cached in neither memo, since it holds no value to keep.
26512656
*
26522657
* A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only
26532658
* when the engine carries the write-epoch and middleware seams, and only until
@@ -2684,12 +2689,20 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput
26842689
}
26852690
}
26862691

2687-
const { value: read, backendFailed } = await resolveLocalizationContextUncached(input);
2692+
const { value: read, backendFailed, probeFailed } = await resolveLocalizationContextUncached(input);
26882693
// [#27257] The kind travels ON the answer, and the memo stores that same
26892694
// answer, so a hit inside the failure TTL still says the read failed.
2690-
const value: LocalizationResult = backendFailed ? { ...read, readFailed: true } : read;
2695+
const value: LocalizationResult = backendFailed || probeFailed ? { ...read, readFailed: true } : read;
26912696
if (!cacheable) return value;
26922697

2698+
if (probeFailed) {
2699+
// [#27733] No read ran, so there is no value to memoise: the next request
2700+
// asks again. Only the entry this answer superseded is dropped (no LIVE
2701+
// one was found above), so no earlier answer outlives it either.
2702+
localizationCache.get(ql)?.delete(cacheKey);
2703+
return value;
2704+
}
2705+
26932706
if (backendFailed) {
26942707
putLocalizationEntry(ql, cacheKey, {
26952708
value,
@@ -2734,32 +2747,41 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput
27342747
* ({@link resolveGuestOrganization}) under that same posture.
27352748
*
27362749
* A failed derivation reads nothing (`unowned`): no organization's row is read
2737-
* on a guess, and the built-in defaults answer. This fallback is reached only
2750+
* on a guess, and the built-in defaults answer. When it failed because the
2751+
* store did (`AuthzStoreUnavailableError` from the `sys_organization` probe),
2752+
* `probeFailed` says so, and the answer carries `readFailed` (#27733); any
2753+
* other throw (an unreadable posture) is not transient and says nothing more.
2754+
* This fallback is reached only
27382755
* when the settings service is absent or has just failed, so it takes no
27392756
* inputs that service would supply.
27402757
*
27412758
* ⛔ Kept by design, not drift: the requested posture where the service reads
27422759
* the one in force, and a failed derivation degraded where the service
27432760
* propagates it — both make this fallback the stricter reader.
27442761
*/
2745-
async function localizationFallbackReach(ql: any, organizationId: string | undefined): Promise<OrganizationReach> {
2762+
async function localizationFallbackReach(
2763+
ql: any,
2764+
organizationId: string | undefined,
2765+
): Promise<{ reach: OrganizationReach; probeFailed: boolean }> {
27462766
try {
2747-
return await resolveOrganizationReach({
2767+
const reach = await resolveOrganizationReach({
27482768
callerOrganizationId: organizationId,
27492769
wallStands: () => postureEnforcesWall(resolveTenancyPosture()),
27502770
deriveOrganization: () => resolveGuestOrganization(ql, resolveTenancyPosture()),
27512771
});
2752-
} catch {
2753-
// The organization could not be derived (`AuthzStoreUnavailableError`):
2754-
// no organization's row is read on a guess. Not the direct read's own
2755-
// backend fault, so it does not populate the failure memo (#10221).
2756-
return UNOWNED_ORGANIZATION_REACH;
2772+
return { reach, probeFailed: false };
2773+
} catch (err) {
2774+
// The organization could not be derived: no organization's row is read on
2775+
// a guess. Not the direct read's own backend fault, so it does not
2776+
// populate the failure memo (#10221); a store fault is still a read that
2777+
// learned nothing (#27733).
2778+
return { reach: UNOWNED_ORGANIZATION_REACH, probeFailed: isAuthzStoreUnavailableError(err) };
27572779
}
27582780
}
27592781

27602782
async function resolveLocalizationContextUncached(
27612783
input: ResolveLocalizationInput,
2762-
): Promise<{ value: LocalizationResult; backendFailed: boolean }> {
2784+
): Promise<{ value: LocalizationResult; backendFailed: boolean; probeFailed: boolean }> {
27632785
const { ql, settings, organizationId, userId } = input;
27642786
// ONLY the direct `sys_setting` read below sets this. The settings-service
27652787
// legs deliberately do not — see the cache doc above (#11877).
@@ -2816,7 +2838,7 @@ async function resolveLocalizationContextUncached(
28162838
const locale = coerceLocale(localeRes?.value);
28172839
const currency = coerceCurrency(currencyRes?.value);
28182840
if (tz || locale || currency) {
2819-
return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, backendFailed: false };
2841+
return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, backendFailed: false, probeFailed: false };
28202842
}
28212843
}
28222844
} catch {
@@ -2826,7 +2848,7 @@ async function resolveLocalizationContextUncached(
28262848
// [ADR-0131 D8] The organization this read may draw on — the reach the
28272849
// settings service itself reads with (#25226): never another organization's
28282850
// row, and a row with no organization only where no organization can exist.
2829-
const reach = await localizationFallbackReach(ql, organizationId);
2851+
const { reach, probeFailed } = await localizationFallbackReach(ql, organizationId);
28302852
// One read for all three keys instead of a query per key (`$in` on `key`).
28312853
// Inlined (rather than the shared `tryFind`) so a genuine backend fault —
28322854
// as opposed to a legitimate empty result — is visible to the caller above,
@@ -2866,5 +2888,6 @@ async function resolveLocalizationContextUncached(
28662888
currency: coerceCurrency(valueOf('currency')),
28672889
},
28682890
backendFailed,
2891+
probeFailed,
28692892
};
28702893
}

‎packages/core/src/security/resolve-localization-read-failed.test.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
// `readFailed: true` exactly when the direct `sys_setting` read threw, on the
88
// fresh read AND on the hit the failure memo serves inside its TTL, and never
99
// otherwise.
10+
//
11+
// [#27733] The read can also fail one step earlier: a caller that names no
12+
// organization, where no wall stands, reads the rows of the organization the
13+
// `sys_organization` probe derives. A store fault there means no row was read,
14+
// so the answer carries `readFailed: true` too, and writes no memo of either
15+
// kind, since it holds no value to keep.
1016

1117
import { describe, it, expect, vi, afterEach } from 'vitest';
1218
import { resolveLocalizationContext } from './resolve-authz-context.js';
@@ -46,6 +52,7 @@ function makeBrokenQl() {
4652

4753
afterEach(() => {
4854
vi.useRealTimers();
55+
vi.unstubAllEnvs();
4956
});
5057

5158
describe('resolveLocalizationContext — a failed read says so (#27257)', () => {
@@ -104,3 +111,86 @@ describe('resolveLocalizationContext — a failed read says so (#27257)', () =>
104111
expect(broken.reads.length + healthy.reads.length).toBe(2);
105112
});
106113
});
114+
115+
/**
116+
* An engine whose `sys_organization` probe throws while `probeBroken` is set
117+
* (an outage, not a missing table), and otherwise holds the one organization
118+
* `org_a`. It carries the write-epoch and middleware seams, so a success-cache
119+
* entry WOULD be kept if the resolver wrote one.
120+
*/
121+
function makeProbeQl(rows: Array<Record<string, unknown>>) {
122+
const reads: string[] = [];
123+
const state = { probeBroken: true };
124+
const ql = {
125+
reads,
126+
state,
127+
writeEpoch: { current: 0, bump: () => 0, subscribe: () => () => {} },
128+
registerMiddleware: () => {},
129+
async find(object: string, o: any) {
130+
reads.push(object);
131+
if (object === 'sys_organization') {
132+
if (state.probeBroken) throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
133+
return [{ id: 'org_a' }].slice(0, o?.limit ?? 1);
134+
}
135+
const where = o?.where ?? {};
136+
const matched = rows.filter((r) =>
137+
Object.entries(where).every(([k, v]) => {
138+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
139+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
140+
return r[k] === v;
141+
}),
142+
);
143+
// Hold the caller's bound, after the filter and by presence.
144+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
145+
},
146+
};
147+
return ql;
148+
}
149+
150+
describe('resolveLocalizationContext — a failed organization probe says so too (#27733)', () => {
151+
const CNY = { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'org_a', value: 'CNY' };
152+
153+
it('a store fault on the `sys_organization` probe answers the built-ins WITH `readFailed: true`, reading no row', async () => {
154+
vi.stubEnv('OS_TENANCY_POSTURE', 'single');
155+
const ql = makeProbeQl([CNY]);
156+
const answer = await resolveLocalizationContext({ ql });
157+
expect(answer).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true });
158+
expect(ql.reads).toEqual(['sys_organization']);
159+
});
160+
161+
it('writes no memo: the next request probes again, and once the store answers it reads the stored default', async () => {
162+
vi.stubEnv('OS_TENANCY_POSTURE', 'single');
163+
const ql = makeProbeQl([CNY]);
164+
expect((await resolveLocalizationContext({ ql })).readFailed).toBe(true);
165+
expect((await resolveLocalizationContext({ ql })).readFailed).toBe(true);
166+
expect(ql.reads).toEqual(['sys_organization', 'sys_organization']);
167+
ql.state.probeBroken = false;
168+
const recovered = await resolveLocalizationContext({ ql });
169+
expect(recovered.currency).toBe('CNY');
170+
expect(recovered).not.toHaveProperty('readFailed');
171+
});
172+
173+
it('a probe that SUCCEEDS and finds no organization is a real "no default": no `readFailed`', async () => {
174+
vi.stubEnv('OS_TENANCY_POSTURE', 'single');
175+
const ql = makeQl([]);
176+
const answer = await resolveLocalizationContext({ ql });
177+
expect(answer.currency).toBeUndefined();
178+
expect(answer).not.toHaveProperty('readFailed');
179+
});
180+
181+
it('a derivation that fails for a reason other than the store (an unreadable posture) is not a failed read', async () => {
182+
vi.stubEnv('OS_TENANCY_POSTURE', 'not-a-posture');
183+
const ql = makeProbeQl([CNY]);
184+
const answer = await resolveLocalizationContext({ ql });
185+
expect(answer.currency).toBeUndefined();
186+
expect(answer).not.toHaveProperty('readFailed');
187+
});
188+
189+
it('a caller naming its organization is not probed: a broken probe changes nothing', async () => {
190+
const ql = makeProbeQl([CNY]);
191+
const answer = await resolveLocalizationContext({ ql, organizationId: 'org_a' });
192+
expect(answer.currency).toBe('CNY');
193+
expect(answer).not.toHaveProperty('readFailed');
194+
expect(ql.reads).toEqual(['sys_setting']);
195+
});
196+
});

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
// [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask.
4-
import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveLocalizationContext, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
4+
import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core';
55
import type { BetterAuthOptions } from 'better-auth';
66
import {
77
AuthConfig,
@@ -23,7 +23,7 @@ import {
2323
import { isHumanUserRow, OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js';
2424
// [#14157] The dev-admin seed's own precondition — "does a login exist?".
2525
import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js';
26-
import { withMailTenantCurrency } from './mail-tenant-currency.js';
26+
import { localizationMailCurrencyResolver, withMailTenantCurrency } from './mail-tenant-currency.js';
2727
import {
2828
// ADR-0048 — the Setup/Studio/Account apps moved to their own packages
2929
// (@objectstack/{setup,studio,account}); plugin-auth no longer registers them.
@@ -1118,13 +1118,14 @@ export class AuthPlugin implements Plugin {
11181118
// A mail template's no-argument `currency` hole formats in the
11191119
// tenant default: added once here for the six auth mail call sites
11201120
// (see mail-tenant-currency.ts), read the way a request reads it.
1121-
this.authManager.setEmailService(withMailTenantCurrency(emailSvc, async (organizationId) => {
1121+
// A failed read is not "no default" (#27710).
1122+
this.authManager.setEmailService(withMailTenantCurrency(emailSvc, localizationMailCurrencyResolver(() => {
11221123
let settings: unknown;
11231124
try { settings = ctx.getService('settings'); } catch { settings = undefined; }
11241125
let ql: unknown;
11251126
try { ql = ctx.getService<IDataEngine>('data'); } catch { ql = undefined; }
1126-
return (await resolveLocalizationContext({ ql, settings, organizationId })).currency;
1127-
}));
1127+
return { ql, settings };
1128+
})));
11281129
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
11291130
} else {
11301131
// No email service. The verification / password-reset callbacks now

0 commit comments

Comments
 (0)