Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/27733-27710-read-failed-probe-and-auth-mail.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 46 additions & 23 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2734,32 +2747,41 @@ 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.
*
* ⛔ Kept by design, not drift: the requested posture where the service reads
* 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<OrganizationReach> {
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).
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -2866,5 +2888,6 @@ async function resolveLocalizationContextUncached(
currency: coerceCurrency(valueOf('currency')),
},
backendFailed,
probeFailed,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,6 +52,7 @@ function makeBrokenQl() {

afterEach(() => {
vi.useRealTimers();
vi.unstubAllEnvs();
});

describe('resolveLocalizationContext — a failed read says so (#27257)', () => {
Expand Down Expand Up @@ -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<Record<string, unknown>>) {
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']);
});
});
11 changes: 6 additions & 5 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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<IDataEngine>('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
Expand Down
Loading
Loading