From eb1c3932176dc9aab7cce58b1558289d706e0640 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 10:54:38 +0000 Subject: [PATCH] fix(core, plugin-auth): a failed organization probe reads as readFailed, and an auth mail retries a failed currency read The localization resolver's sys_organization probe, which decides whose sys_setting rows a caller naming no organization may read, now answers readFailed: true on an AuthzStoreUnavailableError, with no memo of either kind. The auth mails' tenant currency reader rejects on readFailed, and a render refusal that only the missing default caused becomes a transient error that names the read instead of TEMPLATE_INVALID. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czs2fGS6UiDUUbdGrf --- ...3-27710-read-failed-probe-and-auth-mail.md | 13 ++ .../src/security/resolve-authz-context.ts | 69 ++++--- .../resolve-localization-read-failed.test.ts | 90 +++++++++ .../plugins/plugin-auth/src/auth-plugin.ts | 11 +- .../src/mail-tenant-currency.test.ts | 180 +++++++++++++++++- .../plugin-auth/src/mail-tenant-currency.ts | 108 ++++++++++- .../src/template-tenant-currency.test.ts | 56 +++++- 7 files changed, 487 insertions(+), 40 deletions(-) create mode 100644 .changeset/27733-27710-read-failed-probe-and-auth-mail.md diff --git a/.changeset/27733-27710-read-failed-probe-and-auth-mail.md b/.changeset/27733-27710-read-failed-probe-and-auth-mail.md new file mode 100644 index 00000000000..4c599d864ea --- /dev/null +++ b/.changeset/27733-27710-read-failed-probe-and-auth-mail.md @@ -0,0 +1,13 @@ +--- +'@objectstack/core': patch +'@objectstack/plugin-auth': patch +--- + +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) + +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. +Metadata-protocol: none + +- **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. +- **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. +- **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. diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 839cc0ff281..5936df4a883 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -38,7 +38,7 @@ * `tryFind`'s `catch` — read that before changing anything on this path. */ -import { AuthzStoreUnavailableError } from './authz-store-unavailable.js'; +import { AuthzStoreUnavailableError, isAuthzStoreUnavailableError } from './authz-store-unavailable.js'; // [commit 6a180e42d, ruled 2026-08-30] `isMissingTableError` is the one "was this READ // failure just an unprovisioned table?" predicate. It was `@objectstack/metadata`'s // until this resolver needed it; metadata depends on core, so the ruling relocated @@ -2216,14 +2216,17 @@ type LocalizationResult = { locale: string; currency?: string; /** - * Present only when the direct `sys_setting` read THREW (a backend fault), - * on a fresh read and on a memoized one alike: every value above is then the - * built-in default, not a stored answer. Absent, a missing `currency` means - * the read succeeded and the organization has no default, which is permanent - * until someone sets one; present, it means nothing was learned, and a caller - * that would refuse work for want of a default retries instead. A - * settings-service refusal never sets it (#11877), and it does not lengthen - * the failure memo. (#27257) + * Present only when the direct `sys_setting` read could not be answered + * because the store failed: the read itself THREW (a backend fault), on a + * fresh read and on a memoized one alike (#27257), or the `sys_organization` + * probe that decides whose rows it may read threw + * `AuthzStoreUnavailableError`, so it never ran (#27733). Every value above + * is then the built-in default, not a stored answer. Absent, a missing + * `currency` means the read succeeded and the organization has no default, + * which is permanent until someone sets one; present, it means nothing was + * learned, and a caller that would refuse work for want of a default retries + * instead. A settings-service refusal never sets it (#11877), and it does + * not lengthen the failure memo; a probe fault writes no memo at all. */ readFailed?: true; }; @@ -2647,7 +2650,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE * every request (#10221). A settings-service refusal is not a backend fault * and never populates that memo (#11877). Such an answer carries * `readFailed: true`, fresh and memoized alike, so a caller can tell "the read - * failed" from "the read found no default" (#27257). + * failed" from "the read found no default" (#27257). So does an answer whose + * `sys_organization` probe failed and so read no row (#27733); that one is + * cached in neither memo, since it holds no value to keep. * * A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only * when the engine carries the write-epoch and middleware seams, and only until @@ -2684,12 +2689,20 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput } } - const { value: read, backendFailed } = await resolveLocalizationContextUncached(input); + const { value: read, backendFailed, probeFailed } = await resolveLocalizationContextUncached(input); // [#27257] The kind travels ON the answer, and the memo stores that same // answer, so a hit inside the failure TTL still says the read failed. - const value: LocalizationResult = backendFailed ? { ...read, readFailed: true } : read; + const value: LocalizationResult = backendFailed || probeFailed ? { ...read, readFailed: true } : read; if (!cacheable) return value; + if (probeFailed) { + // [#27733] No read ran, so there is no value to memoise: the next request + // asks again. Only the entry this answer superseded is dropped (no LIVE + // one was found above), so no earlier answer outlives it either. + localizationCache.get(ql)?.delete(cacheKey); + return value; + } + if (backendFailed) { putLocalizationEntry(ql, cacheKey, { value, @@ -2734,7 +2747,11 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput * ({@link resolveGuestOrganization}) under that same posture. * * A failed derivation reads nothing (`unowned`): no organization's row is read - * on a guess, and the built-in defaults answer. This fallback is reached only + * on a guess, and the built-in defaults answer. When it failed because the + * store did (`AuthzStoreUnavailableError` from the `sys_organization` probe), + * `probeFailed` says so, and the answer carries `readFailed` (#27733); any + * other throw (an unreadable posture) is not transient and says nothing more. + * This fallback is reached only * when the settings service is absent or has just failed, so it takes no * inputs that service would supply. * @@ -2742,24 +2759,29 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput * the one in force, and a failed derivation degraded where the service * propagates it — both make this fallback the stricter reader. */ -async function localizationFallbackReach(ql: any, organizationId: string | undefined): Promise { +async function localizationFallbackReach( + ql: any, + organizationId: string | undefined, +): Promise<{ reach: OrganizationReach; probeFailed: boolean }> { try { - return await resolveOrganizationReach({ + const reach = await resolveOrganizationReach({ callerOrganizationId: organizationId, wallStands: () => postureEnforcesWall(resolveTenancyPosture()), deriveOrganization: () => resolveGuestOrganization(ql, resolveTenancyPosture()), }); - } catch { - // The organization could not be derived (`AuthzStoreUnavailableError`): - // no organization's row is read on a guess. Not the direct read's own - // backend fault, so it does not populate the failure memo (#10221). - return UNOWNED_ORGANIZATION_REACH; + return { reach, probeFailed: false }; + } catch (err) { + // The organization could not be derived: no organization's row is read on + // a guess. Not the direct read's own backend fault, so it does not + // populate the failure memo (#10221); a store fault is still a read that + // learned nothing (#27733). + return { reach: UNOWNED_ORGANIZATION_REACH, probeFailed: isAuthzStoreUnavailableError(err) }; } } async function resolveLocalizationContextUncached( input: ResolveLocalizationInput, -): Promise<{ value: LocalizationResult; backendFailed: boolean }> { +): Promise<{ value: LocalizationResult; backendFailed: boolean; probeFailed: boolean }> { const { ql, settings, organizationId, userId } = input; // ONLY the direct `sys_setting` read below sets this. The settings-service // legs deliberately do not — see the cache doc above (#11877). @@ -2816,7 +2838,7 @@ async function resolveLocalizationContextUncached( const locale = coerceLocale(localeRes?.value); const currency = coerceCurrency(currencyRes?.value); if (tz || locale || currency) { - return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, backendFailed: false }; + return { value: { timezone: tz ?? 'UTC', locale: locale ?? 'en-US', currency }, backendFailed: false, probeFailed: false }; } } } catch { @@ -2826,7 +2848,7 @@ async function resolveLocalizationContextUncached( // [ADR-0131 D8] The organization this read may draw on — the reach the // settings service itself reads with (#25226): never another organization's // row, and a row with no organization only where no organization can exist. - const reach = await localizationFallbackReach(ql, organizationId); + const { reach, probeFailed } = await localizationFallbackReach(ql, organizationId); // One read for all three keys instead of a query per key (`$in` on `key`). // Inlined (rather than the shared `tryFind`) so a genuine backend fault — // as opposed to a legitimate empty result — is visible to the caller above, @@ -2866,5 +2888,6 @@ async function resolveLocalizationContextUncached( currency: coerceCurrency(valueOf('currency')), }, backendFailed, + probeFailed, }; } diff --git a/packages/core/src/security/resolve-localization-read-failed.test.ts b/packages/core/src/security/resolve-localization-read-failed.test.ts index 5805ae16864..9e74cf2bc98 100644 --- a/packages/core/src/security/resolve-localization-read-failed.test.ts +++ b/packages/core/src/security/resolve-localization-read-failed.test.ts @@ -7,6 +7,12 @@ // `readFailed: true` exactly when the direct `sys_setting` read threw, on the // fresh read AND on the hit the failure memo serves inside its TTL, and never // otherwise. +// +// [#27733] The read can also fail one step earlier: a caller that names no +// organization, where no wall stands, reads the rows of the organization the +// `sys_organization` probe derives. A store fault there means no row was read, +// so the answer carries `readFailed: true` too, and writes no memo of either +// kind, since it holds no value to keep. import { describe, it, expect, vi, afterEach } from 'vitest'; import { resolveLocalizationContext } from './resolve-authz-context.js'; @@ -46,6 +52,7 @@ function makeBrokenQl() { afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); }); describe('resolveLocalizationContext — a failed read says so (#27257)', () => { @@ -104,3 +111,86 @@ describe('resolveLocalizationContext — a failed read says so (#27257)', () => expect(broken.reads.length + healthy.reads.length).toBe(2); }); }); + +/** + * An engine whose `sys_organization` probe throws while `probeBroken` is set + * (an outage, not a missing table), and otherwise holds the one organization + * `org_a`. It carries the write-epoch and middleware seams, so a success-cache + * entry WOULD be kept if the resolver wrote one. + */ +function makeProbeQl(rows: Array>) { + const reads: string[] = []; + const state = { probeBroken: true }; + const ql = { + reads, + state, + writeEpoch: { current: 0, bump: () => 0, subscribe: () => () => {} }, + registerMiddleware: () => {}, + async find(object: string, o: any) { + reads.push(object); + if (object === 'sys_organization') { + if (state.probeBroken) throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + return [{ id: 'org_a' }].slice(0, o?.limit ?? 1); + } + const where = o?.where ?? {}; + const matched = rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + // Hold the caller's bound, after the filter and by presence. + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + }; + return ql; +} + +describe('resolveLocalizationContext — a failed organization probe says so too (#27733)', () => { + const CNY = { namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'org_a', value: 'CNY' }; + + it('a store fault on the `sys_organization` probe answers the built-ins WITH `readFailed: true`, reading no row', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeProbeQl([CNY]); + const answer = await resolveLocalizationContext({ ql }); + expect(answer).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true }); + expect(ql.reads).toEqual(['sys_organization']); + }); + + it('writes no memo: the next request probes again, and once the store answers it reads the stored default', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeProbeQl([CNY]); + expect((await resolveLocalizationContext({ ql })).readFailed).toBe(true); + expect((await resolveLocalizationContext({ ql })).readFailed).toBe(true); + expect(ql.reads).toEqual(['sys_organization', 'sys_organization']); + ql.state.probeBroken = false; + const recovered = await resolveLocalizationContext({ ql }); + expect(recovered.currency).toBe('CNY'); + expect(recovered).not.toHaveProperty('readFailed'); + }); + + it('a probe that SUCCEEDS and finds no organization is a real "no default": no `readFailed`', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = makeQl([]); + const answer = await resolveLocalizationContext({ ql }); + expect(answer.currency).toBeUndefined(); + expect(answer).not.toHaveProperty('readFailed'); + }); + + it('a derivation that fails for a reason other than the store (an unreadable posture) is not a failed read', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'not-a-posture'); + const ql = makeProbeQl([CNY]); + const answer = await resolveLocalizationContext({ ql }); + expect(answer.currency).toBeUndefined(); + expect(answer).not.toHaveProperty('readFailed'); + }); + + it('a caller naming its organization is not probed: a broken probe changes nothing', async () => { + const ql = makeProbeQl([CNY]); + const answer = await resolveLocalizationContext({ ql, organizationId: 'org_a' }); + expect(answer.currency).toBe('CNY'); + expect(answer).not.toHaveProperty('readFailed'); + expect(ql.reads).toEqual(['sys_setting']); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 020b425ef2a..9a115cec870 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask. -import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveLocalizationContext, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core'; import type { BetterAuthOptions } from 'better-auth'; import { AuthConfig, @@ -23,7 +23,7 @@ import { import { isHumanUserRow, OPEN_POSTURE_VERIFICATION_OFF_WARNING } from './audience-posture.js'; // [#14157] The dev-admin seed's own precondition — "does a login exist?". import { decideDevAdminSeedGate } from './dev-admin-seed-gate.js'; -import { withMailTenantCurrency } from './mail-tenant-currency.js'; +import { localizationMailCurrencyResolver, withMailTenantCurrency } from './mail-tenant-currency.js'; import { // ADR-0048 — the Setup/Studio/Account apps moved to their own packages // (@objectstack/{setup,studio,account}); plugin-auth no longer registers them. @@ -1118,13 +1118,14 @@ export class AuthPlugin implements Plugin { // A mail template's no-argument `currency` hole formats in the // tenant default: added once here for the six auth mail call sites // (see mail-tenant-currency.ts), read the way a request reads it. - this.authManager.setEmailService(withMailTenantCurrency(emailSvc, async (organizationId) => { + // A failed read is not "no default" (#27710). + this.authManager.setEmailService(withMailTenantCurrency(emailSvc, localizationMailCurrencyResolver(() => { let settings: unknown; try { settings = ctx.getService('settings'); } catch { settings = undefined; } let ql: unknown; try { ql = ctx.getService('data'); } catch { ql = undefined; } - return (await resolveLocalizationContext({ ql, settings, organizationId })).currency; - })); + return { ql, settings }; + }))); ctx.logger.info('Auth: email service wired (transactional mail enabled)'); } else { // No email service. The verification / password-reset callbacks now diff --git a/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts b/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts index d11d0a9c447..1f5526457e4 100644 --- a/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts +++ b/packages/plugins/plugin-auth/src/mail-tenant-currency.test.ts @@ -7,9 +7,9 @@ * than being refused for a default the organization has. */ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts'; -import { withMailTenantCurrency } from './mail-tenant-currency.js'; +import { localizationMailCurrencyResolver, withMailTenantCurrency } from './mail-tenant-currency.js'; function emailDouble() { const sendTemplate = vi.fn(async (_input: SendTemplateInput) => ({ id: 'mail_1', status: 'sent' as const })); @@ -59,3 +59,179 @@ describe('withMailTenantCurrency', () => { expect(email.send).toHaveBeenCalledWith({ to: 'a@x.com' }); }); }); + +// [#27710] A read that FAILED learned nothing, so the refusal it causes is not +// the organization's missing default: the mail fails with a transient error +// that names the read, never `TEMPLATE_INVALID` and its remedy. A read that +// succeeded and found no default keeps the render's refusal, as before. +describe('withMailTenantCurrency: a failed read is not "no default"', () => { + const REFUSAL = + 'TEMPLATE_INVALID: email template auth.invitation (locale=en-US): the hole {{ amount | currency }} has no currency argument and the organization has no default currency; set localization.currency, or write currency:EUR'; + const PATH_REFUSAL = + "TEMPLATE_INVALID: email template auth.invitation (locale=en-US): the hole {{ amount | currency:@deal.code }} reads 'dollars' at deal.code, which is not an ISO 4217 code"; + + /** + * The render the real `email` service does. `missing-currency`: a + * no-argument hole needs a `currency`. `path-value`: an `@path` hole refuses + * whatever the default. `both`: the missing default first, then the `@path`. + */ + function renderingMail(opts: { refuses?: 'missing-currency' | 'path-value' | 'both' | 'not-found'; noRender?: boolean } = {}) { + const renders: any[] = []; + let sent = 0; + const render = (input: any) => { + renders.push(input); + if (opts.refuses === 'not-found') throw new Error('TEMPLATE_NOT_FOUND: email template auth.invitation'); + const needsDefault = opts.refuses === 'missing-currency' || opts.refuses === 'both'; + if (needsDefault && !input.currency) throw new Error(REFUSAL); + if (opts.refuses === 'path-value' || opts.refuses === 'both') throw new Error(PATH_REFUSAL); + return { subject: 's', html: '

x

', text: `${input.currency ?? ''}` }; + }; + const service: Record = { + async send() { return { id: 'mail_2', status: 'sent' as const }; }, + async sendTemplate(input: SendTemplateInput) { render(input); sent += 1; return { id: 'mail_1', status: 'sent' as const }; }, + }; + if (!opts.noRender) service.renderTemplate = async (input: unknown) => render(input); + return { renders, sent: () => sent, service: service as unknown as IEmailService }; + } + + const failedRead = async (): Promise => { throw new Error("the localization settings of organization 'org_cn' could not be read"); }; + const INPUT: SendTemplateInput = { template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn', data: { amount: 12 } }; + + it('a failed read whose only refusal is the missing default fails transient: the read named, no TEMPLATE_ code, nothing sent', async () => { + const mail = renderingMail({ refuses: 'missing-currency' }); + const error = await withMailTenantCurrency(mail.service, failedRead).sendTemplate(INPUT).then(() => undefined, (e: Error) => e); + expect(error?.message).toBe( + "the mail 'auth.invitation' was not rendered: the organization's default currency could not be read " + + "(the localization settings of organization 'org_cn' could not be read); it can be sent once that read succeeds", + ); + expect(error?.message).not.toMatch(/TEMPLATE_/); + // The send, then ONE trial render with the "no currency" code, never sent. + expect(mail.renders).toHaveLength(2); + expect(mail.renders[0]).not.toHaveProperty('currency'); + expect(mail.renders[1]).toMatchObject({ template: 'auth.invitation', data: { amount: 12 }, currency: 'XXX' }); + expect(mail.sent()).toBe(0); + }); + + it('a service with no renderTemplate cannot tell the causes apart: the read fault is transient', async () => { + const mail = renderingMail({ refuses: 'missing-currency', noRender: true }); + const error = await withMailTenantCurrency(mail.service, failedRead).sendTemplate(INPUT).then(() => undefined, (e: Error) => e); + expect(error?.message).toMatch(/default currency could not be read/); + expect(error?.message).not.toMatch(/TEMPLATE_/); + }); + + it.each([ + ['a refusal no default cures', 'path-value' as const], + ['the missing default AND a refusal no default cures', 'both' as const], + ])('a failed read leaves %s as it stands: that refusal, nothing sent', async (_label, refuses) => { + const mail = renderingMail({ refuses }); + await expect(withMailTenantCurrency(mail.service, failedRead).sendTemplate(INPUT)).rejects.toThrow(PATH_REFUSAL); + expect(mail.sent()).toBe(0); + }); + + it('a failed read leaves every other failure as it was: TEMPLATE_NOT_FOUND, with no trial render', async () => { + const mail = renderingMail({ refuses: 'not-found' }); + await expect(withMailTenantCurrency(mail.service, failedRead).sendTemplate(INPUT)).rejects.toThrow(/^TEMPLATE_NOT_FOUND: /); + expect(mail.renders).toHaveLength(1); + }); + + it('a failed read does not stop a template that needs no default: it is sent', async () => { + const mail = renderingMail(); + await expect(withMailTenantCurrency(mail.service, failedRead).sendTemplate(INPUT)).resolves.toMatchObject({ status: 'sent' }); + expect(mail.sent()).toBe(1); + }); + + it('a read that SUCCEEDS and finds no default keeps the render\'s refusal and its remedy, with no trial render', async () => { + const mail = renderingMail({ refuses: 'missing-currency' }); + await expect(withMailTenantCurrency(mail.service, async () => undefined).sendTemplate(INPUT)).rejects.toThrow(REFUSAL); + expect(mail.renders).toHaveLength(1); + }); +}); + +// [#27710] The resolver the plugin wires, over the real +// `resolveLocalizationContext`: both legs a read can fail on (#27257's +// `sys_setting` read, #27733's `sys_organization` probe) reach the mail as a +// transient failure; a read that found no default reaches it as the refusal. +describe('localizationMailCurrencyResolver: the plugin\'s read, end to end', () => { + const REFUSAL = 'TEMPLATE_INVALID: email template auth.invitation (locale=en-US): the hole {{ amount | currency }} has no currency argument and the organization has no default currency; set localization.currency, or write currency:EUR'; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + /** An engine: `fail` names the object whose read throws (an outage, not a missing table); `sys_setting` answers `rows`. */ + function engine(opts: { fail?: 'sys_setting' | 'sys_organization' }, rows: Array> = []) { + const reads: string[] = []; + return { + reads, + async find(object: string, o: any) { + reads.push(object); + if (object === opts.fail) throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + if (object === 'sys_organization') return [{ id: 'org_cn' }].slice(0, o?.limit ?? 1); + if (object !== 'sys_setting') return []; + const where = o?.where ?? {}; + const matched = rows.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]); + return r[k] === v; + }), + ); + // Hold the caller's bound, after the filter and by presence. + return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched; + }, + }; + } + + function mail() { + const renders: any[] = []; + const render = (input: any) => { + renders.push(input); + if (!input.currency) throw new Error(REFUSAL); + return { subject: 's', html: '

x

', text: input.currency }; + }; + return { + renders, + service: { + async send() { return { id: 'mail_2', status: 'sent' as const }; }, + async sendTemplate(input: SendTemplateInput) { render(input); return { id: 'mail_1', status: 'sent' as const }; }, + async renderTemplate(input: unknown) { return render(input); }, + } as unknown as IEmailService, + }; + } + + it('a failed `sys_setting` read reaches the mail as the transient failure', async () => { + const ql = engine({ fail: 'sys_setting' }); + const m = mail(); + const wired = withMailTenantCurrency(m.service, localizationMailCurrencyResolver(() => ({ ql, settings: undefined }))); + const error = await wired.sendTemplate({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn' }).then(() => undefined, (e: Error) => e); + expect(error?.message).toMatch(/default currency could not be read \(the localization settings of organization 'org_cn' could not be read\)/); + expect(error?.message).not.toMatch(/TEMPLATE_/); + }); + + it('a failed `sys_organization` probe (identity mail, no organization named) reaches it the same way', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const ql = engine({ fail: 'sys_organization' }); + const m = mail(); + const wired = withMailTenantCurrency(m.service, localizationMailCurrencyResolver(() => ({ ql, settings: undefined }))); + const error = await wired.sendTemplate({ template: 'auth.verify_email', to: 'a@x.com', identityMail: true }).then(() => undefined, (e: Error) => e); + expect(ql.reads).toEqual(['sys_organization']); + expect(error?.message).toMatch(/default currency could not be read \(the localization settings could not be read\)/); + expect(error?.message).not.toMatch(/TEMPLATE_/); + }); + + it('a read that succeeds and finds no default reaches it as the refusal, unchanged', async () => { + const ql = engine({}); + const m = mail(); + const wired = withMailTenantCurrency(m.service, localizationMailCurrencyResolver(() => ({ ql, settings: undefined }))); + await expect(wired.sendTemplate({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn' })).rejects.toThrow(REFUSAL); + expect(m.renders).toHaveLength(1); + }); + + it('a read that finds the default formats in it', async () => { + const ql = engine({}, [{ namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'org_cn', value: 'CNY' }]); + const m = mail(); + const wired = withMailTenantCurrency(m.service, localizationMailCurrencyResolver(() => ({ ql, settings: undefined }))); + await expect(wired.sendTemplate({ template: 'auth.invitation', to: 'a@x.com', organizationId: 'org_cn' })).resolves.toMatchObject({ status: 'sent' }); + expect(m.renders[0].currency).toBe('CNY'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/mail-tenant-currency.ts b/packages/plugins/plugin-auth/src/mail-tenant-currency.ts index 006bb2224a8..42fee99819b 100644 --- a/packages/plugins/plugin-auth/src/mail-tenant-currency.ts +++ b/packages/plugins/plugin-auth/src/mail-tenant-currency.ts @@ -12,24 +12,114 @@ * service the plugin wires into the AuthManager, rather than at each site: a * call that names no `currency` gets the default of the organization it names * (`organizationId`), or the deployment-wide setting for identity mail, which - * names none. A call that names one keeps it, and a failed or empty read adds - * nothing. + * names none. A call that names one keeps it, and an empty read adds nothing. + * + * [#27710] A FAILED read adds nothing either, but it is not a read that found + * no default: it learned nothing. When the render then refuses for want of a + * default, the mail fails with a transient error, never `TEMPLATE_INVALID` + * and its "set localization.currency" remedy, which the organization may not + * need ({@link refusalAfterCurrencyReadFault}). */ +import { resolveLocalizationContext } from '@objectstack/core'; import type { IEmailService, SendTemplateInput } from '@objectstack/spec/contracts'; -/** Answers the default currency of an organization, or of the deployment when none is named. */ +/** + * Answers the default currency of an organization, or of the deployment when + * none is named: `undefined` when the read succeeded and found none. REJECTS + * when the read itself failed. + */ export type MailTenantCurrencyResolver = (organizationId: string | undefined) => Promise; -async function withCurrency(input: SendTemplateInput, resolve: MailTenantCurrencyResolver): Promise { - if (input.currency !== undefined) return input; +/** + * The resolver the plugin wires: `resolveLocalizationContext`, the read a + * request resolves `ExecutionContext.currency` with, over the services as they + * stand at send time. Rejects when that read says it failed (`readFailed`). + */ +export function localizationMailCurrencyResolver( + services: () => { ql: unknown; settings: unknown }, +): MailTenantCurrencyResolver { + return async (organizationId) => { + const { ql, settings } = services(); + const localization = await resolveLocalizationContext({ ql, settings, organizationId }); + if (localization.readFailed) { + const of = organizationId ? ` of organization '${organizationId}'` : ''; + throw new Error(`the localization settings${of} could not be read`); + } + return localization.currency; + }; +} + +/** + * The ISO 4217 code for "no currency" (`XXX`): the stand-in default + * {@link refusalAfterCurrencyReadFault} renders with to ask whether a default + * would cure a refusal. Never sent: the trial only renders. + */ +const NO_CURRENCY_PROBE = 'XXX'; + +const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +/** + * The error a `sendTemplate` call that followed a failed currency read + * (`readFault`) throws, given the error it threw (`err`). + * + * - Not `TEMPLATE_INVALID`: `err` as it stands. + * - `TEMPLATE_INVALID`: the template is rendered once more WITHOUT sending, + * with {@link NO_CURRENCY_PROBE} as the default. 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`, or the trial fails some other way, the + * missing default is the refusal, and the mail fails with a transient error + * that names the read and carries no `TEMPLATE_*` code: sending it again + * once the read succeeds can work. + */ +async function refusalAfterCurrencyReadFault( + email: IEmailService, + input: SendTemplateInput, + err: unknown, + readFault: string, +): Promise { + if (!/\bTEMPLATE_INVALID\b/.test(messageOf(err))) return err; + if (typeof email.renderTemplate === 'function') { + try { + await email.renderTemplate({ + template: input.template, + data: input.data, + locale: input.locale, + timezone: input.timezone, + currency: NO_CURRENCY_PROBE, + }); + } catch (standing) { + if (/\bTEMPLATE_INVALID\b/.test(messageOf(standing))) return standing; + } + } + return new Error( + `the mail '${input.template}' was not rendered: the organization's default currency could not be read ` + + `(${readFault}); it can be sent once that read succeeds`, + ); +} + +async function sendWithTenantCurrency( + email: IEmailService, + input: SendTemplateInput, + resolve: MailTenantCurrencyResolver, +): ReturnType { + if (input.currency !== undefined) return email.sendTemplate(input); let currency: string | undefined; + let readFault: string | undefined; try { currency = await resolve(input.organizationId); - } catch { - currency = undefined; + } catch (err) { + readFault = messageOf(err); + } + if (readFault === undefined) { + return email.sendTemplate(typeof currency === 'string' && currency !== '' ? { ...input, currency } : input); + } + try { + return await email.sendTemplate(input); + } catch (err) { + throw await refusalAfterCurrencyReadFault(email, input, err, readFault); } - return typeof currency === 'string' && currency !== '' ? { ...input, currency } : input; } /** @@ -41,7 +131,7 @@ export function withMailTenantCurrency(email: IEmailService, resolve: MailTenant return new Proxy(email, { get(target, prop) { if (prop === 'sendTemplate') { - return async (input: SendTemplateInput) => target.sendTemplate(await withCurrency(input, resolve)); + return async (input: SendTemplateInput) => sendWithTenantCurrency(target, input, resolve); } const value = Reflect.get(target, prop, target); return typeof value === 'function' ? value.bind(target) : value; diff --git a/packages/services/service-messaging/src/template-tenant-currency.test.ts b/packages/services/service-messaging/src/template-tenant-currency.test.ts index 0f17027da80..f6329134700 100644 --- a/packages/services/service-messaging/src/template-tenant-currency.test.ts +++ b/packages/services/service-messaging/src/template-tenant-currency.test.ts @@ -8,7 +8,7 @@ * the render refuses such a hole (`TEMPLATE_INVALID`) instead of guessing. */ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { assertEngineDeleteDispatch, assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { createEmailChannel } from './email-channel.js'; import { createInboxChannel } from './inbox-channel.js'; @@ -335,4 +335,58 @@ describe('a failed settings read is retried; a read that finds no default is per }); }); } + + // [#27733] The read can fail one step earlier: a delivery that names no + // organization, where no wall stands, reads the rows of the organization + // the `sys_organization` probe derives. A store fault there read no row, + // so it is graded like a failed settings read: retryable, never TEMPLATE_*. + describe('a failed organization probe (#27733)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + /** The plugin's `data` engine: the `sys_organization` probe throws while `broken`, else holds `org_apex` with `CNY`. */ + function probeEngine(state: { broken: boolean }) { + const base = fakeData(); + const reads: string[] = []; + const find = async (object: string, query: any) => { + if (object === 'sys_organization') { + reads.push(object); + if (state.broken) throw Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + return [{ id: 'org_apex' }].slice(0, query?.limit ?? 1); + } + if (object !== 'sys_setting') return []; + reads.push(object); + const where = query?.where ?? {}; + const rows = [{ namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'org_apex', value: 'CNY' }]; + const matched = rows.filter((r) => r.organization_id === where.organization_id && where.key?.$in?.includes(r.key)); + // Hold the caller's bound, after the filter and by presence. + return typeof query?.limit === 'number' ? matched.slice(0, query.limit) : matched; + }; + return { reads, engine: { ...base, find } }; + } + + it('email channel: a delivery with no organization whose probe fails is graded retryable, never TEMPLATE_*', async () => { + vi.stubEnv('OS_TENANCY_POSTURE', 'single'); + const state = { broken: true }; + const data = probeEngine(state); + const mail = mailService({ refuses: 'missing-currency' }); + const { ctx, messaging } = await bootPlugin(data.engine, mail.service); + const channel = messaging.getChannel('email')!; + const r = await channel.send(ctx, templateDelivery('email')); + expect(data.reads).toEqual(['sys_organization']); + expect(mail.calls[0]).not.toHaveProperty('currency'); + expect(mail.calls[1].currency).toBe('XXX'); + expect(mail.sent()).toBe(0); + expect(r.ok).toBe(false); + expect(r.error).not.toMatch(/TEMPLATE_/); + expect(channel.classifyError?.(r.error)).toBe('retryable'); + + // No memo was written: once the probe answers, the next delivery reads the default. + state.broken = false; + const again = await channel.send(ctx, templateDelivery('email')); + expect(again.ok).toBe(true); + expect(mail.calls[mail.calls.length - 1].currency).toBe('CNY'); + }); + }); });