Skip to content

Commit 8a49e37

Browse files
fix(plugin-auth): the last-administrator guard answers a read fault 503 SERVICE_UNAVAILABLE, the code its sibling guards answer with (#25388)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao2WtPchvUshT9LuGkEhb1h3)_ Fixes #25364 Clause-②: no Metadata-protocol: none ## Measured first: the shared shape, not one half The card asked whether to change one half of the last-administrator guard or the shared `failClosed` shape. Measured on `main` at d12e650: - Every half that judges a write (`enforce` for the two `sys_user` halves, `enforceStanding` for the membership, grant, activation-ledger and organization halves, and `judgeOrganizationDelete`) runs inside `failClosed`, which answered a read fault with `refuse()`: 403 `PERMISSION_DENIED`. - The Default Organization slug-capture rule (`guardOrganizationDefaultSlug`, from #25286) does **not** use `failClosed`. It has its own `catch`, which also answered 403 `PERMISSION_DENIED`. - So changing the slug rule alone would have split the guard: a read fault on the slug-capture rule would answer 503 while the same fault on the update half of the same `organization/update` request answered 403. Both now throw one shape. ## What changes - **`last-admin-guard.ts`:** a new module-private `readFault()` beside `refuse()`: `code: 'SERVICE_UNAVAILABLE'` (typed `StandardErrorCode`, an existing ADR-0112 member, no new code), `status: 503`, `object` = the table the caller was writing, `cause` = the read's own failure. `failClosed` and the slug-capture rule's `catch` both throw it, with their messages unchanged. Both now pass an inner read fault through unchanged as well as an inner refusal (`judgeOrganizationDelete` nests one `failClosed` inside another), so the fault is never wrapped twice. - **Refusals on the merits stay 403 `PERMISSION_DENIED`, byte for byte.** That includes the `maxScan` overflow: that read completed and proved too large, so it is a refusal with a remedy, not a read fault. - **`objectql-adapter.ts` (the better-auth door):** `rethrowAsBetterAuthError` gains an arm for an engine error that declares `code: 'SERVICE_UNAVAILABLE'` **and** `status: 503`, answering `APIError('SERVICE_UNAVAILABLE')` with that code and a fixed message (the driver's text is not relayed, as `mapDataError`'s declared-5xx relay withholds it). Without this arm the 503 reached better-call's router as an unhandled fault: measured by reverting only this file, `organization/update` answered **500 with no code**. The arm is keyed on the declared pair, not on the guard, so any engine error declaring that pair gets the answer `/data` already gives it. - **`auth-manager-actor-guards.ts` (`organization/delete`):** the Default Organization's standing judge mapped only `PERMISSION_DENIED`. It now maps the guard's read fault to `APIError('SERVICE_UNAVAILABLE')` with `ORGANIZATION_DELETE_READ_FAULT_CODE`, the code that admission's own read faults (#25059) already answer with, and a message that does not relay the driver's text. The expected landing was `last-admin-guard.ts`. The two other files are in the same package (`@objectstack/plugin-auth`) and are the doors the guard's refusal crosses: without them, the code moves at the engine and the `/data` door but the better-auth doors answer 500. ## Security floor No change turns a refusal into an allow. Every changed site still throws, from the same place, before any write. Only the code and status of a fail-closed refusal move (403 to 503). The pins below assert that nothing was written in each fault case (`bannedFlag`, `userExists`, `memberRole`, `slugOf`, and no attempted write on `organization/delete`). ## Pins (all assert `code` + `status`) - **HTTP door, `organization/update`** (`single-reslug-default-anchor.test.ts`): a failed read of the Default Organization now answers `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` (was 403 `PERMISSION_DENIED`), the body does not contain the injected driver text, and the slug is unchanged. That is the code `auth-manager.ts`'s organization slug guard answers its own read fault with (`auth-manager.org-slug-guard-system-context.test.ts`). - **Engine door, system-context update:** the same fault gives `{ code: 'SERVICE_UNAVAILABLE', status: 503 }`. - **Flipped** in `last-admin-guard.test.ts`: the ban, user-delete and role-downgrade fail-closed cases now assert `{ code: 'SERVICE_UNAVAILABLE', status: 503 }`. Their message assertions (the reason, "could not be verified") are unchanged. - **New:** the `maxScan` overflow keeps `{ code: 'PERMISSION_DENIED', status: 403 }`; `judgeOrganizationDelete` with a failed inner read answers the read fault once (one `SERVICE_UNAVAILABLE:` prefix); and `organization/delete` of the Default Organization through the real guard answers 503 `SERVICE_UNAVAILABLE` with nothing attempted. Its CONTROL, with the read back, is the guard's verdict on the same door: 403 `PERMISSION_DENIED`. - **Repo sweep for pins on the old shape:** `git grep` for the guard's fail-closed prose and for tests driving the guard through a failing read found pins only in `plugin-auth`: the 5 flipped above. No doc or ADR states the read-fault code. ## Verification - Reverse check, run against `main`'s three source files (`git restore --source=d12e650` on those files only, then restored from HEAD with `git diff HEAD` empty): **7 failed / 137 passed** across the four touched test files, every new or flipped pin red. Adapter alone reverted: the HTTP pin reads `expected { status: 500, code: null }`. - `@objectstack/plugin-auth` full suite at 3aee198: **187/187 files, 3528 passed, 10 skipped**. `pnpm --filter @objectstack/plugin-auth typecheck` (src, examples, test-typecheck): exit 0. - Derived gate families (`dispatch-gates.mjs --commands`, reconciled with `--ran`): 69 derived, 67 run with exit 0, 0 unrun. Two came back NOT MEASURED locally and are left to CI: `check-plugin-teardown-shape --self-test` needs history this shallow clone does not have, and `check:dual-build-cjs-loads` needs a full monorepo build. ## Acceptance notes - The adapter arm also gives every other engine error that declares `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` the 503 answer at the better-auth door, where it used to be an empty 500. That is the same answer `mapDataError` gives on `/data`. The other in-repo producers of that pair found by `git grep` (`objectql/src/action-activation.ts`, `service-automation/src/engine.ts`) are action and flow paths, not adapter writes; this was read, not measured. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01CuJfNjvts78GKesupEdgbz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c109bfc commit 8a49e37

8 files changed

Lines changed: 209 additions & 24 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the last-administrator guard answers a read it could not complete with 503 `SERVICE_UNAVAILABLE`, the code the sibling guards on the same doors answer their read faults with
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The last-administrator guard fails closed: when a read its verdict depends on throws, it refuses the write. That refusal answered 403 `PERMISSION_DENIED`, the same code as a refusal on the merits, so a caller could not tell "you may not" from "try again" by the code. The organization slug guard (`auth-manager.ts`) and the organization delete admission already answer their read faults with 503 `SERVICE_UNAVAILABLE`.
11+
12+
- **Every half of the guard, one shape.** The shared fail-closed envelope and the Default Organization slug-capture rule now throw the same read fault: `code: 'SERVICE_UNAVAILABLE'`, `status: 503`, the read's own failure on `cause`. The write is still refused and nothing is written. A refusal on the merits, and an enumeration that completed but returned more rows than the guard can judge, keep 403 `PERMISSION_DENIED` unchanged.
13+
- **The data API** answers the read fault 503 `SERVICE_UNAVAILABLE` with the server-fault message, as it does every declared 5xx.
14+
- **better-auth's endpoints** (`organization/update`, the admin ban and remove-user endpoints, and every other endpoint that writes through the adapter) answer it 503 `SERVICE_UNAVAILABLE`. The adapter now maps any engine error that declares `status: 503` and `code: 'SERVICE_UNAVAILABLE'` to that answer; before, such an error reached better-auth's router as an unhandled 500 with an empty body. The driver's message is not relayed.
15+
- **`organization/delete`** of the Default Organization answers the guard's read fault 503 `SERVICE_UNAVAILABLE`, the code the admission's own read faults answer with. A refusal on the merits there stays 403 `PERMISSION_DENIED`.
16+
- **For a client:** branch on the code. `SERVICE_UNAVAILABLE` means retry once the data store is reachable; `PERMISSION_DENIED` means the write itself is refused.

‎packages/plugins/plugin-auth/src/auth-manager-actor-guards.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,26 @@ export async function assertOrganizationDeletable(this: Pick<ActorGuardHost, 'ge
289289
try {
290290
await this.organizationDeleteStandingJudge(organizationId);
291291
} catch (err) {
292-
if ((err as { code?: unknown } | null)?.code !== 'PERMISSION_DENIED') throw err;
292+
const code = (err as { code?: unknown } | null)?.code;
293293
const { APIError } = await import('better-auth/api');
294-
throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' });
294+
if (code === 'PERMISSION_DENIED') {
295+
throw new APIError('FORBIDDEN', { message: (err as Error).message, code: 'PERMISSION_DENIED' });
296+
}
297+
// The guard's fail-closed answer to a lookup it could not complete (503,
298+
// `last-admin-guard.ts` `readFault`): this admission's own read-fault
299+
// code, and never the driver's message, which names the failed read.
300+
if (code === ORGANIZATION_DELETE_READ_FAULT_CODE && (err as { status?: unknown }).status === 503) {
301+
const fault = new APIError('SERVICE_UNAVAILABLE', {
302+
code: ORGANIZATION_DELETE_READ_FAULT_CODE,
303+
message:
304+
'The organization delete could not verify the remaining administrators, so whether this ' +
305+
'organization may be deleted was never decided, and nothing was deleted. This is a server-side ' +
306+
'outage, not a refusal of the delete: retry once the data store is reachable.',
307+
});
308+
(fault as { cause?: unknown }).cause = err;
309+
throw fault;
310+
}
311+
throw err;
295312
}
296313
}
297314

‎packages/plugins/plugin-auth/src/last-admin-guard.default-organization.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* engine over better-sqlite3 `:memory:`, so the engine dispatches the hooks.
2121
*/
2222

23-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
23+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2424
import { ObjectQL } from '@objectstack/objectql';
2525
import { SqlDriver } from '@objectstack/driver-sql';
2626
import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity';
@@ -266,6 +266,21 @@ describe('[#24948] the same question about deleting the Default Organization, as
266266
expect((await members(engine)).map((m) => m.id)).toEqual(['mem_owner']);
267267
});
268268

269+
it('a read the question cannot complete refuses with the read-fault code, once: an inner envelope\'s answer passes the outer one unchanged', async () => {
270+
const engine = await promotedSingle();
271+
const real = engine.find.bind(engine);
272+
vi.spyOn(engine, 'find').mockImplementation(async (object: string, ...rest: any[]) => {
273+
if (object === 'sys_user_permission_set') throw new Error('sys_user_permission_set is unreadable');
274+
return (real as any)(object, ...rest);
275+
});
276+
277+
const err = (await guard.judgeOrganizationDelete('org_default').catch((e: unknown) => e)) as Error & { code?: unknown; status?: unknown };
278+
vi.restoreAllMocks();
279+
expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
280+
expect(err.message.match(/SERVICE_UNAVAILABLE:/g)).toHaveLength(1);
281+
expect(err.message).toContain('sys_user_permission_set is unreadable');
282+
});
283+
269284
it('CONTROL: with another administrator standing elsewhere, the delete is admitted', async () => {
270285
const engine = await promotedSingle({ secondOrganization: true, grantOwner: 'org_b' });
271286
await engine.insert('sys_organization', { id: 'org_c', name: 'C', slug: 'c' }, SYSTEM);

‎packages/plugins/plugin-auth/src/last-admin-guard.test.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,9 @@ describe('[#5892] the guard fails CLOSED — an unverifiable population refuses
590590
// because the guard could not prove it.
591591
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/could not be verified/i);
592592
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/sys_member is unreadable/);
593-
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
593+
// [ADR-0112] Refused, but no verdict: the read-fault code, not the 403 a
594+
// refusal on the merits answers.
595+
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 });
594596
expect(await bannedFlag(engine, 'usr_admin')).toBeFalsy();
595597
});
596598

@@ -601,6 +603,9 @@ describe('[#5892] the guard fails CLOSED — an unverifiable population refuses
601603
await seedUser(engine, 'usr_admin', { role: 'admin' });
602604

603605
await expect(ban(engine, 'usr_admin')).rejects.toThrow(/more than 1 rows/);
606+
// A read that COMPLETED and proved too large is a refusal with a remedy,
607+
// not a read fault: it keeps the 403.
608+
await expect(ban(engine, 'usr_admin')).rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
604609
});
605610

606611
it('an environment with no administrator at all is not blocked (nothing to protect)', async () => {
@@ -935,7 +940,8 @@ describe('[#5941] the delete guard fails CLOSED too', () => {
935940
await expect(removeUser(engine, 'usr_admin')).rejects.toThrow(/could not be verified/i);
936941
await expect(removeUser(engine, 'usr_admin')).rejects.toThrow(/sys_member is unreadable/);
937942
await expect(removeUser(engine, 'usr_admin')).rejects.toMatchObject({
938-
code: 'PERMISSION_DENIED',
943+
code: 'SERVICE_UNAVAILABLE',
944+
status: 503,
939945
});
940946
expect(await userExists(engine, 'usr_admin')).toBe(true);
941947
});
@@ -1557,7 +1563,7 @@ describe('[#5978] the standing halves fail CLOSED', () => {
15571563

15581564
await expect(
15591565
engine.update('sys_member', { id: 'mem_usr_admin', role: 'member' }, SYSTEM),
1560-
).rejects.toMatchObject({ code: 'PERMISSION_DENIED', object: 'sys_member' });
1566+
).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503, object: 'sys_member' });
15611567
expect(await memberRole(engine, 'mem_usr_admin')).toBe('admin');
15621568
});
15631569

‎packages/plugins/plugin-auth/src/last-admin-guard.ts‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@
314314
* reach the identity tables reach this guard.
315315
*/
316316

317+
import type { StandardErrorCode } from '@objectstack/spec/api';
317318
import type { BaseEngineOptions, EngineQueryOptions } from '@objectstack/spec/data';
318319
import {
319320
ADMIN_FULL_ACCESS,
@@ -636,6 +637,44 @@ function isRefusal(err: unknown): boolean {
636637
return (err as { code?: string } | null)?.code === 'PERMISSION_DENIED';
637638
}
638639

640+
/**
641+
* [ADR-0112] The code every half of this guard answers a read it could not
642+
* complete with: the standard catalog's member for a store that cannot answer
643+
* right now, the code the organization slug guard's read fault
644+
* (`auth-manager.ts`, `slugGuardReadFaultApiError`) and the organization delete
645+
* admission's read fault answer with too. One code for the fault across every
646+
* guard on a door, so a caller tells "you may not" (403 `PERMISSION_DENIED`)
647+
* from "try again" by the code alone.
648+
*/
649+
const LAST_ADMIN_GUARD_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE';
650+
651+
/**
652+
* The fail-closed answer to a read the guard could not complete: 503
653+
* {@link LAST_ADMIN_GUARD_READ_FAULT_CODE}. Still a refusal of the write — the
654+
* hook throws, so nothing is written — but no verdict about it, so it does not
655+
* wear {@link refuse}'s 403. The read's own failure rides `cause`. `object`
656+
* names the table the caller was writing, as {@link refuse}'s does.
657+
*/
658+
function readFault(message: string, object: string, cause: unknown): Error {
659+
const err = new Error(`${LAST_ADMIN_GUARD_READ_FAULT_CODE}: ${message}`) as Error & {
660+
code?: string;
661+
status?: number;
662+
object?: string;
663+
cause?: unknown;
664+
};
665+
err.code = LAST_ADMIN_GUARD_READ_FAULT_CODE;
666+
err.status = 503;
667+
err.object = object;
668+
err.cause = cause;
669+
return err;
670+
}
671+
672+
/** Marker so a nested fail-closed wrapper re-throws an inner read fault unchanged. */
673+
function isReadFault(err: unknown): boolean {
674+
const e = err as { code?: unknown; status?: unknown } | null;
675+
return e?.code === LAST_ADMIN_GUARD_READ_FAULT_CODE && e.status === 503;
676+
}
677+
639678
const BREAK_GLASS_CITATION =
640679
'break-glass invariant, ADR-0135 D5.2 — an environment must always keep at least one ' +
641680
'administrator who can sign in';
@@ -945,7 +984,7 @@ function touchesAny(data: Record<string, unknown>, keys: readonly string[]): boo
945984
* slug is an engine update: better-auth's `organization/update` (its adapter
946985
* writes through the engine, and the refusal reaches the caller as 403
947986
* `PERMISSION_DENIED`), a seed, a plugin, the data API. A read that throws
948-
* propagates to the hook, which fails closed.
987+
* propagates to the hook, which fails closed with 503 `SERVICE_UNAVAILABLE`.
949988
*/
950989
async function refuseDefaultOrganizationSlugCapture(
951990
engine: Pick<LastAdminGuardEngine, 'find'>,
@@ -1573,24 +1612,30 @@ export function registerLastAdminGuard(
15731612
* The fail-CLOSED envelope every half runs inside. A lookup that throws is
15741613
* not "probably fine": the guard could not prove another administrator
15751614
* survives, and the cost of guessing wrong is a permanently locked-out
1576-
* environment. A deliberate refusal from inside passes through unchanged.
1615+
* environment. A deliberate refusal from inside passes through unchanged,
1616+
* and so does a read fault an inner envelope already answered.
1617+
*
1618+
* The write is refused either way; only the code tells the two apart: a
1619+
* verdict is 403 `PERMISSION_DENIED` ({@link refuse}), a lookup that could
1620+
* not complete is 503 `SERVICE_UNAVAILABLE` ({@link readFault}).
15771621
*/
15781622
const failClosed = async (op: GuardedOp, judge: () => Promise<void>): Promise<void> => {
15791623
const words = OP_WORDS[op];
15801624
try {
15811625
await judge();
15821626
} catch (err) {
1583-
if (isRefusal(err)) throw err;
1627+
if (isRefusal(err) || isReadFault(err)) throw err;
15841628
const reason = (err as Error)?.message ?? String(err);
15851629
logger?.warn(
15861630
`[LastAdminGuard] administrator lookup failed — ${words.noun} refused: ${reason}`,
15871631
);
1588-
throw refuse(
1632+
throw readFault(
15891633
`Refusing this ${words.noun}: the remaining administrators could not be verified ` +
15901634
`(${reason}). This guard fails closed — a ${words.noun} is only permitted when at ` +
15911635
`least one other unbanned administrator is provably left (${BREAK_GLASS_CITATION}). ` +
15921636
'Retry once the identity tables are readable again.',
15931637
words.table,
1638+
err,
15941639
);
15951640
}
15961641
};
@@ -1920,7 +1965,8 @@ export function registerLastAdminGuard(
19201965
* ({@link refuseDefaultOrganizationSlugCapture}). A payload naming another
19211966
* slug, or none, costs no reads. Fails closed like every half: the rows a
19221967
* predicate write addresses, or the Default Organization itself, that cannot
1923-
* be read refuse the write.
1968+
* be read refuse the write, with the same 503 read-fault code
1969+
* ({@link readFault}) as {@link failClosed}.
19241970
*/
19251971
const guardOrganizationDefaultSlug = async (rawCtx: unknown): Promise<void> => {
19261972
const ctx = ctxOf(rawCtx);
@@ -1931,14 +1977,15 @@ export function registerLastAdminGuard(
19311977
const ids = await resolveTargetIds('organization-update', SystemObjectName.ORGANIZATION, ctx.input?.id, ctx.input?.options, data);
19321978
await refuseDefaultOrganizationSlugCapture(engine, ids, data.slug);
19331979
} catch (err) {
1934-
if (isRefusal(err)) throw err;
1980+
if (isRefusal(err) || isReadFault(err)) throw err;
19351981
const reason = (err as Error)?.message ?? String(err);
1936-
throw refuse(
1982+
throw readFault(
19371983
`Refusing this organization update: the organizations it addresses, or the Default Organization, ` +
19381984
`could not be read (${reason}), so whether the slug '${DEFAULT_ORGANIZATION_SLUG}' would make ` +
19391985
'another organization the Default Organization is unknown (ADR-0131 D5). Retry once ' +
19401986
`'${SystemObjectName.ORGANIZATION}' is readable again.`,
19411987
SystemObjectName.ORGANIZATION,
1988+
err,
19421989
);
19431990
}
19441991
};

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

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,27 @@ function isEnginePolicyRefusal(err: unknown): err is { code?: string; message?:
546546
return (err as { code?: unknown }).code === 'PERMISSION_DENIED';
547547
}
548548

549+
/**
550+
* A declared READ FAULT — an engine-level guard that could not complete a read
551+
* its verdict depends on, and so refused the write without reaching one
552+
* (`SERVICE_UNAVAILABLE`, 503, ADR-0112). The concrete case is the
553+
* last-administrator guard's fail-closed envelope (`last-admin-guard.ts`,
554+
* `readFault`): without this arm its refusal reached better-auth's router as an
555+
* unhandled fault, a 500 with an empty body.
556+
*
557+
* Read by the declared `code` AND `status`, both, the way `mapDataError`'s
558+
* declared-5xx relay reads them for the REST data routes, so the two
559+
* transports answer the same engine error with the same status and code. The
560+
* engine's prose is not relayed, as that relay withholds a declared fault's:
561+
* the guard's message names the driver's own failure, which is for the server
562+
* log, not the caller.
563+
*/
564+
function isEngineReadFault(err: unknown): boolean {
565+
if (!err || typeof err !== 'object') return false;
566+
const e = err as { code?: unknown; status?: unknown };
567+
return e.code === 'SERVICE_UNAVAILABLE' && e.status === 503;
568+
}
569+
549570
/**
550571
* [#7724] A REFERENTIAL refusal — the engine's `cascadeDeleteRelations` found
551572
* dependent rows it may neither cascade nor null, so it vetoed the delete
@@ -607,9 +628,9 @@ function wireDependents(raw: unknown): Array<{ object: string; count?: number }>
607628

608629
/**
609630
* Re-throw `err` as a better-auth `APIError` when it is an ObjectQL validation
610-
* failure (400), an engine policy refusal (403) or a referential delete
611-
* restriction (409); otherwise re-throw it verbatim. Always throws — the return
612-
* type is `never`.
631+
* failure (400), an engine policy refusal (403), a referential delete
632+
* restriction (409) or a declared engine read fault (503); otherwise re-throw
633+
* it verbatim. Always throws — the return type is `never`.
613634
*/
614635
async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
615636
if (isObjectQLValidationError(err)) {
@@ -634,6 +655,17 @@ async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
634655
code: 'PERMISSION_DENIED',
635656
});
636657
}
658+
if (isEngineReadFault(err)) {
659+
const { APIError } = await import('better-auth/api');
660+
const apiErr = new APIError('SERVICE_UNAVAILABLE', {
661+
message:
662+
'This request could not be completed: the data store could not answer a read it depends on. ' +
663+
'This is a server-side outage, not a refusal of the request: retry once the data store is reachable.',
664+
code: 'SERVICE_UNAVAILABLE',
665+
});
666+
(apiErr as { cause?: unknown }).cause = err;
667+
throw apiErr;
668+
}
637669
if (isReferentialDeleteRestriction(err)) {
638670
const { APIError } = await import('better-auth/api');
639671
throw new APIError('CONFLICT', {
@@ -655,9 +687,9 @@ async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
655687

656688
/**
657689
* Wrap every function-valued method of a better-auth adapter so an ObjectQL
658-
* `ValidationError` (400), an engine policy refusal (403) or a referential
659-
* delete restriction (409, #7724) thrown from the underlying engine surfaces as
660-
* a 4xx `APIError` instead of an opaque 500. Non-function properties pass
690+
* `ValidationError` (400), an engine policy refusal (403), a referential
691+
* delete restriction (409, #7724) or a declared engine read fault (503) thrown
692+
* from the underlying engine surfaces as an `APIError` instead of an opaque 500. Non-function properties pass
661693
* through untouched, and every error that carries none of those signatures is
662694
* re-thrown verbatim.
663695
*/

0 commit comments

Comments
 (0)