Skip to content

Commit 03ac4ec

Browse files
fix(plugin-auth): the invitation role cap's refusals carry their error codes, and a membership read fault answers 503 (#24238)
Fixes #23945 Clause-②: no ## What changes The invitation role cap in `beforeCreateInvitation` refused with `403` and a message, but no `code`. better-auth's own `owner` refusal on the same endpoint (`POST /api/v1/auth/organization/invite-member`) carries one. Each of the cap's three refusals now carries the code #24082 registered for it under `@objectstack/plugin-auth`. A membership read that throws now answers as a store outage, not as a refusal. | the cap's branch (`invitation-role-cap.ts`) | status | body `code` | |:--|:--|:--| | the requested role grades above the issuer's own | 403 | `INVITE_ROLE_ABOVE_ISSUER` | | an issuer below admin grade asked for a role other than `member` | 403 | `INVITE_ISSUER_MEMBER_ONLY` | | the issuer's role was not established (an answered read with no usable row, or no data engine) | 403 | `INVITE_ISSUER_UNVERIFIED` | | the issuer's `sys_member` read **threw** (was 403 with no code, through the branch above) | **503** | `SERVICE_UNAVAILABLE` | - `invitationRoleCapFailure()` returns `{ code, message }` from each branch instead of a bare string. The three codes are exported constants typed `RegisteredErrorCode`, so a removed or misspelled ledger row fails `tsc` (reverse-verified below). The verdicts do not change: the same invitations are admitted and refused. - The hook throws `new APIError('FORBIDDEN', { message, code })`, the body shape `remove-member` and the vendor's `owner` refusal already use. better-call passes `body` through verbatim, so `code` reaches the wire (measured below). - `resolveMembershipRole` used to end in `catch { return null; }`, which turned a thrown read into the unverified verdict. It now throws `invitationRoleCapReadFaultApiError(err)`: `503 SERVICE_UNAVAILABLE`, the driver's error on `cause`. This is the slug guard's shape (`slugGuardReadFaultApiError`), with one difference: this body carries `code: 'SERVICE_UNAVAILABLE'` too, so every answer from this endpoint has a code. Still fail-closed: the throw ends the endpoint, so no invitation row is written. - The ledger comment on `INVITE_ISSUER_UNVERIFIED` is reworded to what the emitter does now. The phrase "or the lookup did not answer" is gone. The comment now covers the no-engine exit and names the 503 for a read that throws. The three-line block comment above it said "today the cap throws a bare `FORBIDDEN`". That is false after this PR, so it is reworded too (see Deviations). Both are comment-only, and no code is added, removed or renamed. ## The seat's rulings on #24082's four review points (premise measured first) 1. **Split the outage from the refusal.** Premise holds. The cap's read path could not tell a thrown read from an empty answer, and the place that merged them is the cap's own read: `resolveMembershipRole`'s `catch { return null; }` (`auth-manager.ts`, the last line of the method on `6e43b7bc3f`). Its one caller is `beforeCreateInvitation` (`git grep resolveMembershipRole` finds the definition and one call). The split is made at that read. 2. **The no-engine exit fails closed.** It already returned `null`, which grades below a plain member, so it answers `INVITE_ISSUER_UNVERIFIED` (403). This PR keeps it that way and pins it. Reachability: no shipped composition reaches the hook without an engine. `AuthPlugin` hard-depends on ObjectQL (`dependencies`, `requiresServices: ['data', …]`), and `ctx.getService('data')` throws when the service is absent. The one production construction, `auth-plugin.ts` `new AuthManager(authConfig)`, always passes `dataEngine`. A bare `AuthManager` built without `dataEngine` (an exported class, documented as usable outside the plugin) runs better-auth on its in-memory adapter and does reach the hook with nothing to read. The ledger comment covers that exit. 3. **Is the "no row" input (`:175`) reachable through the public door?** Measured on the wire, once, with a throwaway dogfood probe that was deleted afterwards (showcase boot at `313560aef`): - a user with a live session and no membership row, inviting as `admin`: **400** `{"message":"Member not found","code":"MEMBER_NOT_FOUND"}`. better-auth's own refusal, so the hook never ran. (The first try answered 401: ending a membership ends its sessions, so the probe signs in again first.) - a plain `member`, inviting as `admin`: **403** `{"message":"You are not allowed to invite users to this organization","code":"YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"}`. Again before the hook. - The installed better-auth 1.7.3 source agrees (`plugins/organization/routes/crud-invites.mjs`). `findMemberByOrgId`, then `MEMBER_NOT_FOUND`, then `hasPermission(invitation:create)`, all run before `beforeCreateInvitation`. The cap then re-reads the same `sys_member` rows in system context. - So `INVITE_ISSUER_UNVERIFIED` has no input that reaches it through this door. Getting there needs the row to vanish between the two reads. It is pinned at the unit level (`auth-manager.test.ts`, "an unresolvable issuer membership fails CLOSED", which says why in a comment). The two reachable codes are pinned on the wire in the dogfood file. 4. **All three codes get their emitter here.** Each refusal carries its `code` through better-auth's `APIError` body. `check:error-code-provenance` sees the three constant definitions as stamp sites listed under `@objectstack/plugin-auth` (exit 0, "every registered-code stamp site is listed under its own owner key"). ## PM mechanism hypotheses - `auth-manager.ts` threw `new APIError('FORBIDDEN', { message: capFailure })`, and `invitationRoleCapFailure()` returned a bare string from three branches. **Holds** on `6e43b7bc3f`. - better-auth serialises `APIError`'s `code` into the body. **Holds, and a custom code travels the same way.** better-call 1.4.0 `InternalAPIError` stores `body` verbatim. `APIError.from` (the vendor's `owner` refusal) only builds `{ message, code }` from its error record. Measured on the wire: `INVITE_ROLE_ABOVE_ISSUER` and `INVITE_ISSUER_MEMBER_ONLY` arrive as `body.code` (dogfood, below). - `delegated-admin-invite.dogfood.test.ts` already drives these refusals. **Holds** for `admin`. It had no `delegated_admin`-by-delegate case, so one is added. ## Clause-② measured `no`. On the built entry declarations: `packages/plugins/plugin-auth/dist/index.d.ts` and `index.d.mts` carry 0 occurrences of `invitationRoleCapFailure`, `InvitationRoleCapRefusal` and the three `*_CODE` constants. The control `class AuthManager` appears 1 time, and `rate-limit-storage.d.ts` has 0. `invitation-role-cap.ts` is not re-exported from `src/index.ts`. The only declaration text that moved is the doc comment on the private `resolveMembershipRole`. For `@objectstack/spec`, `check:api-surface` answered "public API surface + factory signatures unchanged". The ledger edit is a comment, so `ErrorCode` accepts the same strings. Nothing widens an accept-set: no invitation is newly admitted, and no code is added. ## Tests (at `313560aef`) - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` (the whole package): `Test Files 158 passed (158)`, `Tests 3096 passed | 10 skipped (3106)`. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. It includes `check:test-typecheck`: "test layer compiles … 10 file(s) / 88 error(s) / 23 pinned signature(s) held". - `pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`: `Tests 24 passed (24)`. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `tsc --listFiles` lists the edited dogfood file once. - Dogfood on the wire, after building `@objectstack/dogfood^...` (66 tasks): `test/delegated-admin-invite.dogfood.test.ts`, `Tests 5 passed`, including "the same delegate issuing `admin` is REFUSED" (`body.code` `INVITE_ROLE_ABOVE_ISSUER`) and the new "the same delegate minting another delegate is REFUSED as member-only" (`body.code` `INVITE_ISSUER_MEMBER_ONLY`, no `sys_invitation` row). - ESLint `--no-inline-config` over the seven changed `.ts` files: exit 0. Refusal tests assert the envelope (`statusCode`, `status`, `body.code`), not a bare throw. The cap's unit tests now assert codes rather than prose. The parity oracle in `invitation-role-grant-offer.test.ts` spells the codes as literals, so its grid of over 100 refusals also pins which branch answers which code. **Ablation** (one-time, through `scripts/ablation-replace.mjs` with restore proven by blob hash and an empty `git diff HEAD`). The unit tests import `./auth-manager` from source, so no `dist/` rebuild applies: - A: drop `code: refusal.code` from the throw. The run went red with `Tests 5 failed | 3 passed`: the `admin`, member-only, unresolvable, no-engine and before-placement cases. The two admit cases and the 503 case stayed green. Restored, blob `698973c2e6c6` equal to HEAD. - B: replace the read fault's `throw` with `return null` (the old swallow). The run went red with `Tests 1 failed | 7 passed`: the 503 case failed with "expected 403 to be 503". Restored, blob equal to HEAD. **Reverse verification** of the `RegisteredErrorCode` annotation: misspelling the first constant as `INVITE_ROLE_ABOVE_ISSUER_TYPO` turns `tsc --noEmit` red with `TS2820 … Did you mean '"INVITE_ROLE_ABOVE_ISSUER"'?`. Restored. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff derives the same 91 commands as the seat's list. All 91 were run locally at `313560aef`, each exit captured before any pipe. 90 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it needs a whole-workspace build), so it is NOT MEASURED locally and left to CI's `Build Core`. `--ran`: "✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)." The PR's check-runs had not reported when this was written. ## Acceptance notes - **The organization slug guard's 503 body carries no `code`** (`slugGuardReadFaultApiError`). That is the same gap this PR addresses for the cap's read fault, on `update-organization`. It is reachable only through a store fault, and no public door produces one on demand, so it is noted here and not filed. Carrier: none. - **`packages/spec/src/api/error-code-ledger.test.ts`** still describes the cap's refusals as "a bare `FORBIDDEN` today" and names the case "registered ahead of their plugin-auth producer". That is history once this lands. It is not on this PR's surface, so it is left alone. Carrier: none. - **`docs/qa/platform-checklist/areas/identity-auth.json`** `identity-auth.invitation-scope-gates` scores the admin-role refusal as "non-2xx". It could now name `INVITE_ROLE_ABOVE_ISSUER`. Carrier: none. ## Deviations - The ledger's three-line block comment above the rows is reworded as well as the `INVITE_ISSUER_UNVERIFIED` comment. It said "today the cap throws a bare `FORBIDDEN`, and #23945 stamps these", which this PR makes false. Comment-only, same file, same rows. - `origin/main` moved 6 commits after the branch point (`6e43b7bc3` to `7715d049e`). None touches a file in this diff. It is not merged in: this PR's CI runs on the merge ref, and the queue re-verifies on its rebuilt generation. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent af284b0 commit 03ac4ec

8 files changed

Lines changed: 288 additions & 85 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the invitation role cap's refusals on `POST /organization/invite-member` carry an error code, and a membership read that fails answers `503` instead of a refusal
6+
7+
Clause-②: no
8+
9+
The role cap in `beforeCreateInvitation` refused with `403` and a message but no `code`, while better-auth's own `owner` refusal on the same endpoint carries one. A client, the console's Invite User dialog among them, could not tell the refusals apart without reading English prose. Each refusal now carries the code the error-code ledger registers for it under `@objectstack/plugin-auth`, in the same `{ message, code }` body:
10+
11+
- `INVITE_ROLE_ABOVE_ISSUER`: the requested role grades above the issuer's own, for example a `delegated_admin` inviting as `admin`.
12+
- `INVITE_ISSUER_MEMBER_ONLY`: an issuer below admin grade asked for a role other than a plain `member`, for example a `delegated_admin` inviting as `delegated_admin`.
13+
- `INVITE_ISSUER_UNVERIFIED`: fail-closed. The issuer's membership role was not established: the read found no usable `sys_member` row, or the `AuthManager` has no data engine to read from.
14+
15+
A membership read that throws is a store outage, not a verdict about the issuer. It used to answer the same `403` as an unverified issuer. It now answers `503` with the code `SERVICE_UNAVAILABLE`, as the organization slug guard does for its own read faults. No invitation is created either way.
16+
17+
Unchanged: which invitations are admitted, the status of every refusal (`403`), better-auth's own `owner` refusal, and the exports.

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

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,20 +1198,49 @@ describe('AuthManager', () => {
11981198
expect(roles.sales_rep).toBeUndefined();
11991199
});
12001200

1201+
/**
1202+
* [#23945] A cap refusal asserted on its envelope (ADR-0112): the status
1203+
* and the `code` better-auth serialises into the body — never the bare
1204+
* throw, which a codeless `FORBIDDEN` passes just as well.
1205+
*/
1206+
const capAnswer = async (attempt: Promise<unknown>) => {
1207+
const err = (await attempt.then(
1208+
() => undefined,
1209+
(e: unknown) => e,
1210+
)) as
1211+
| { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown }; cause?: unknown }
1212+
| undefined;
1213+
expect(err, 'the invitation was admitted').toBeTruthy();
1214+
return err;
1215+
};
1216+
const inviteAs = (orgPlugin: any, role: string, inviterId = 'u_delegate') =>
1217+
orgPlugin._opts.organizationHooks.beforeCreateInvitation({
1218+
invitation: { organizationId: 'org-42', email: 'e@x.test', role },
1219+
inviter: { id: inviterId },
1220+
organization: { id: 'org-42' },
1221+
});
1222+
12011223
it('role cap: a delegate inviting an `admin` is REFUSED (the escalation chain)', async () => {
12021224
// delegate invites admin → sys_member(role='admin') → auto-org-admin-grant
12031225
// → organization_admin → wildcard modifyAllRecords → isTenantAdmin().
12041226
const orgPlugin = await bootOrgPlugin({
12051227
dataEngine: engineWithMemberRole('delegated_admin') as never,
12061228
});
12071229

1208-
await expect(
1209-
orgPlugin._opts.organizationHooks.beforeCreateInvitation({
1210-
invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'admin' },
1211-
inviter: { id: 'u_delegate' },
1212-
organization: { id: 'org-42' },
1213-
}),
1214-
).rejects.toThrow(/never confer a role above the issuer/);
1230+
const err = await capAnswer(inviteAs(orgPlugin, 'admin'));
1231+
expect(err?.statusCode).toBe(403);
1232+
expect(err?.status).toBe('FORBIDDEN');
1233+
expect(err?.body?.code).toBe('INVITE_ROLE_ABOVE_ISSUER');
1234+
});
1235+
1236+
it('role cap: a delegate minting another delegate is REFUSED as member-only, not as outranked', async () => {
1237+
const orgPlugin = await bootOrgPlugin({
1238+
dataEngine: engineWithMemberRole('delegated_admin') as never,
1239+
});
1240+
1241+
const err = await capAnswer(inviteAs(orgPlugin, 'delegated_admin'));
1242+
expect(err?.statusCode).toBe(403);
1243+
expect(err?.body?.code).toBe('INVITE_ISSUER_MEMBER_ONLY');
12151244
});
12161245

12171246
it('role cap: a delegate inviting a plain `member` passes — and costs no lookup', async () => {
@@ -1244,17 +1273,50 @@ describe('AuthManager', () => {
12441273
});
12451274

12461275
it('role cap: an unresolvable issuer membership fails CLOSED', async () => {
1276+
// Pinned HERE, not on the wire: through `POST /organization/invite-member`
1277+
// this input is not reachable. better-auth reads the inviter's membership
1278+
// before the hook runs and refuses a non-member (`MEMBER_NOT_FOUND`, 400)
1279+
// or a role without `invitation:create` (403) first, and the cap re-reads
1280+
// the same `sys_member` rows in system context — so a read that answers
1281+
// "no usable row" needs the row to vanish between the two reads.
12471282
const orgPlugin = await bootOrgPlugin({
12481283
dataEngine: engineWithMemberRole(null) as never,
12491284
});
12501285

1251-
await expect(
1252-
orgPlugin._opts.organizationHooks.beforeCreateInvitation({
1253-
invitation: { organizationId: 'org-42', email: 'e@x.test', role: 'admin' },
1254-
inviter: { id: 'u_ghost' },
1255-
organization: { id: 'org-42' },
1256-
}),
1257-
).rejects.toThrow(/could not be verified/);
1286+
const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_ghost'));
1287+
expect(err?.statusCode).toBe(403);
1288+
expect(err?.body?.code).toBe('INVITE_ISSUER_UNVERIFIED');
1289+
});
1290+
1291+
it('role cap: NO data engine to read fails CLOSED too — "cannot check" is never "does not apply"', async () => {
1292+
// Not reachable through `AuthPlugin`, which hard-depends on ObjectQL and
1293+
// always passes its `data` service; only a bare `AuthManager` built
1294+
// without `dataEngine` (better-auth then runs on its in-memory adapter)
1295+
// reaches the hook with nothing to read.
1296+
const orgPlugin = await bootOrgPlugin();
1297+
1298+
const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_any'));
1299+
expect(err?.statusCode).toBe(403);
1300+
expect(err?.body?.code).toBe('INVITE_ISSUER_UNVERIFIED');
1301+
});
1302+
1303+
it('role cap: a membership read that THROWS is an outage (503 SERVICE_UNAVAILABLE), not a refusal', async () => {
1304+
// An owner's invitation, so the only thing between it and admission is
1305+
// the read: the verdict the store would have given is "admitted".
1306+
const engine = engineWithMemberRole('owner');
1307+
engine.find.mockImplementation(async () => {
1308+
throw new Error('store unavailable');
1309+
});
1310+
const orgPlugin = await bootOrgPlugin({ dataEngine: engine as never });
1311+
1312+
const err = await capAnswer(inviteAs(orgPlugin, 'admin', 'u_owner'));
1313+
expect(err?.statusCode).toBe(503);
1314+
expect(err?.status).toBe('SERVICE_UNAVAILABLE');
1315+
expect(err?.body?.code).toBe('SERVICE_UNAVAILABLE');
1316+
expect(err?.body?.code).not.toBe('INVITE_ISSUER_UNVERIFIED');
1317+
// The driver's own failure is kept for the operator.
1318+
expect((err?.cause as Error | undefined)?.message).toBe('store unavailable');
1319+
expect(engine.find).toHaveBeenCalledTimes(1);
12581320
});
12591321

12601322
it('role cap: it runs BEFORE the placement gate and applies without placement intent', async () => {
@@ -1278,7 +1340,7 @@ describe('AuthManager', () => {
12781340
inviter: { id: 'u_delegate' },
12791341
organization: { id: 'org-42' },
12801342
}),
1281-
).rejects.toThrow(/never confer a role above the issuer/);
1343+
).rejects.toMatchObject({ statusCode: 403, body: { code: 'INVITE_ROLE_ABOVE_ISSUER' } });
12821344
// Refused before the placement gate was ever consulted.
12831345
expect(assertIssuable).not.toHaveBeenCalled();
12841346
});

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

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
} from '@objectstack/spec';
7474
import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security';
7575
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
76+
import type { StandardErrorCode } from '@objectstack/spec/api';
7677
import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js';
7778
import { permissionSetInEffect } from './catalog-set-in-effect.js';
7879
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
@@ -1364,6 +1365,34 @@ async function slugGuardReadFaultApiError(object: string, cause: unknown): Promi
13641365
return err;
13651366
}
13661367

1368+
/** [#23945] The standard ADR-0112 code a store outage answers with — the status's own name. */
1369+
const INVITATION_ROLE_CAP_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE';
1370+
1371+
/**
1372+
* [#23945] The invitation role cap's answer when its one read — the issuer's
1373+
* `sys_member` rows — cannot answer: `503 SERVICE_UNAVAILABLE`, as the slug
1374+
* guard's read fault answers ({@link slugGuardReadFaultApiError}).
1375+
*
1376+
* Not the cap's `403 INVITE_ISSUER_UNVERIFIED`: that code is a verdict — the
1377+
* store answered and established no role — while a read that threw reached no
1378+
* verdict, so it must not wear the refusal's code. Still fail-closed: the
1379+
* throw ends the endpoint, so no invitation row is written. Unlike the slug
1380+
* guard's body, this one carries the code too, so every answer from the
1381+
* invitation endpoint has one. The driver's own failure rides `cause`.
1382+
*/
1383+
async function invitationRoleCapReadFaultApiError(cause: unknown): Promise<Error> {
1384+
const { APIError } = await import('better-auth/api');
1385+
const err = new APIError('SERVICE_UNAVAILABLE', {
1386+
message:
1387+
'The invitation role cap could not read `sys_member`, so the role the issuer holds was never ' +
1388+
'determined, and no invitation was created. This is a server-side outage, not a refusal of the ' +
1389+
'invitation: retry once the data store is reachable.',
1390+
code: INVITATION_ROLE_CAP_READ_FAULT_CODE,
1391+
});
1392+
(err as { cause?: unknown }).cause = cause;
1393+
return err;
1394+
}
1395+
13671396
/**
13681397
* [#24166, ruling 6232069920 A] The access-token claim that carries the
13691398
* organization an OAuth consent bound. Minted by `customAccessTokenClaims` on
@@ -3487,13 +3516,18 @@ export class AuthManager {
34873516
// placement intent at all. Skipped for a plain `member` invitation,
34883517
// which can never trip the cap — so the ordinary path pays nothing.
34893518
if (!isPlainMemberInvitation(invitation?.role)) {
3490-
const capFailure = invitationRoleCapFailure(
3519+
// [#23945] Each refusal carries its ADR-0112 code beside its
3520+
// message, the way better-auth's own `owner` refusal on this
3521+
// endpoint does. A membership read that THROWS never reaches
3522+
// this verdict: `resolveMembershipRole` answers it as an outage
3523+
// (503 `SERVICE_UNAVAILABLE`), not as a refusal.
3524+
const refusal = invitationRoleCapFailure(
34913525
invitation?.role,
34923526
await this.resolveMembershipRole(inviterUserId, organizationId),
34933527
);
3494-
if (capFailure) {
3528+
if (refusal) {
34953529
const { APIError } = await import('better-auth/api');
3496-
throw new APIError('FORBIDDEN', { message: capFailure });
3530+
throw new APIError('FORBIDDEN', { message: refusal.message, code: refusal.code });
34973531
}
34983532
}
34993533

@@ -7813,10 +7847,18 @@ export class AuthManager {
78137847
* org-scoped RLS read performed as a non-privileged delegate could return
78147848
* nothing and silently grade them as unresolved.
78157849
*
7816-
* Returns `null` when the role cannot be established — missing keys, no
7817-
* engine, no membership row, or a lookup error. That grades BELOW a plain
7818-
* member, so the cap refuses anything above a `member` invitation: this
7819-
* backs a security gate, and an unverifiable issuer must confer nothing.
7850+
* Returns `null` when the role is not established — missing keys, no
7851+
* engine, or a read that answered with no usable membership row. That
7852+
* grades BELOW a plain member, so the cap refuses anything above a `member`
7853+
* invitation with `INVITE_ISSUER_UNVERIFIED`: this backs a security gate,
7854+
* and an issuer it cannot check confers nothing — "no engine to read" is
7855+
* "cannot check", never "the cap does not apply".
7856+
*
7857+
* [#23945] A read that THROWS is not one of those answers: it throws
7858+
* `503 SERVICE_UNAVAILABLE` ({@link invitationRoleCapReadFaultApiError}), so
7859+
* an outage is never reported as a verdict about the issuer. Still no
7860+
* invitation is created.
7861+
*
78207862
* Multiple membership rows (legacy duplicates) are joined so the highest
78217863
* grade among them wins — the cap must judge the authority they actually
78227864
* hold, not whichever row came back first.
@@ -7830,18 +7872,19 @@ export class AuthManager {
78307872
if (!uid || !oid) return null;
78317873
const engine = this.getDataEngine();
78327874
if (!engine) return null;
7875+
let members: unknown;
78337876
try {
7834-
const members = await withSystemReadContext(engine).find('sys_member', {
7877+
members = await withSystemReadContext(engine).find('sys_member', {
78357878
where: { user_id: uid, organization_id: oid },
78367879
limit: 10,
78377880
});
7838-
const roles = (Array.isArray(members) ? members : [])
7839-
.map((m: any) => (typeof m?.role === 'string' ? m.role : ''))
7840-
.filter((r: string) => r.length > 0);
7841-
return roles.length > 0 ? roles.join(',') : null;
7842-
} catch {
7843-
return null;
7881+
} catch (err) {
7882+
throw await invitationRoleCapReadFaultApiError(err);
78447883
}
7884+
const roles = (Array.isArray(members) ? members : [])
7885+
.map((m: any) => (typeof m?.role === 'string' ? m.role : ''))
7886+
.filter((r: string) => r.length > 0);
7887+
return roles.length > 0 ? roles.join(',') : null;
78457888
}
78467889

78477890
/**

‎packages/plugins/plugin-auth/src/invitation-role-cap.test.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
import { describe, expect, it } from 'vitest';
1616
import {
17+
INVITE_ISSUER_MEMBER_ONLY_CODE,
18+
INVITE_ISSUER_UNVERIFIED_CODE,
19+
INVITE_ROLE_ABOVE_ISSUER_CODE,
1720
invitationRoleCapFailure,
1821
isPlainMemberInvitation,
1922
orgRoleGrade,
@@ -22,6 +25,9 @@ import {
2225

2326
const DELEGATE = 'delegated_admin';
2427

28+
/** The refusal's ADR-0112 code — what a client branches on (#23945). */
29+
const codeOf = (requested: unknown, issuer: unknown) => invitationRoleCapFailure(requested, issuer)?.code;
30+
2531
describe('parseOrgRoles', () => {
2632
it('accepts better-auth’s three role shapes', () => {
2733
expect(parseOrgRoles('admin')).toEqual(['admin']);
@@ -79,16 +85,15 @@ describe('isPlainMemberInvitation', () => {
7985

8086
describe('invitationRoleCapFailure — the escalation chain', () => {
8187
it('REFUSES the chain: a delegate inviting an admin', () => {
82-
const failure = invitationRoleCapFailure('admin', DELEGATE);
83-
expect(failure).toMatch(/never confer a role above the issuer/);
88+
expect(codeOf('admin', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE);
8489
});
8590

8691
it('REFUSES a delegate inviting an owner', () => {
87-
expect(invitationRoleCapFailure('owner', DELEGATE)).toMatch(/above the issuer/);
92+
expect(codeOf('owner', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE);
8893
});
8994

9095
it('REFUSES admin smuggled inside a comma list', () => {
91-
expect(invitationRoleCapFailure('member,admin', DELEGATE)).toMatch(/above the issuer/);
96+
expect(codeOf('member,admin', DELEGATE)).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE);
9297
});
9398

9499
it('REFUSES a delegate inviting an app role — membership roles are a capability channel too', () => {
@@ -97,13 +102,11 @@ describe('invitationRoleCapFailure — the escalation chain', () => {
97102
// them with nothing gating the hand-out. A delegate's channel for
98103
// capability is the invitation's PLACEMENT intent, which the D12 gate
99104
// allowlists position-by-position.
100-
expect(invitationRoleCapFailure('sales_manager', DELEGATE)).toMatch(
101-
/may invite only as 'member'/,
102-
);
105+
expect(codeOf('sales_manager', DELEGATE)).toBe(INVITE_ISSUER_MEMBER_ONLY_CODE);
103106
});
104107

105108
it('REFUSES a delegate minting another delegate', () => {
106-
expect(invitationRoleCapFailure(DELEGATE, DELEGATE)).toMatch(/may invite only as 'member'/);
109+
expect(codeOf(DELEGATE, DELEGATE)).toBe(INVITE_ISSUER_MEMBER_ONLY_CODE);
107110
});
108111

109112
it('ALLOWS a delegate inviting a plain member — the capability the fix exists to grant', () => {
@@ -123,22 +126,38 @@ describe('invitationRoleCapFailure — org owners and admins are unaffected', ()
123126
expect(invitationRoleCapFailure('admin', 'admin')).toBeNull();
124127
expect(invitationRoleCapFailure(DELEGATE, 'admin')).toBeNull();
125128
expect(invitationRoleCapFailure('sales_manager', 'admin')).toBeNull();
126-
expect(invitationRoleCapFailure('owner', 'admin')).toMatch(/above the issuer/);
129+
expect(codeOf('owner', 'admin')).toBe(INVITE_ROLE_ABOVE_ISSUER_CODE);
127130
});
128131

129132
it('an issuer holding both roles is judged on the higher one', () => {
130133
expect(invitationRoleCapFailure('owner', 'member,owner')).toBeNull();
131134
});
132135
});
133136

137+
describe('invitationRoleCapFailure — each refusal answers its own code (#23945)', () => {
138+
it('the three codes, by value — the strings the error-code ledger registers under plugin-auth', () => {
139+
expect([INVITE_ROLE_ABOVE_ISSUER_CODE, INVITE_ISSUER_MEMBER_ONLY_CODE, INVITE_ISSUER_UNVERIFIED_CODE]).toEqual([
140+
'INVITE_ROLE_ABOVE_ISSUER',
141+
'INVITE_ISSUER_MEMBER_ONLY',
142+
'INVITE_ISSUER_UNVERIFIED',
143+
]);
144+
});
145+
146+
it('a refusal carries its reason beside the code', () => {
147+
for (const [requested, issuer] of [['admin', DELEGATE], [DELEGATE, DELEGATE], ['admin', null]] as const) {
148+
const refusal = invitationRoleCapFailure(requested, issuer);
149+
expect(refusal?.message, `${requested} by ${issuer}`).toEqual(expect.any(String));
150+
expect(refusal?.message.length).toBeGreaterThan(0);
151+
}
152+
});
153+
});
154+
134155
describe('invitationRoleCapFailure — fail-closed', () => {
135156
it('an unresolvable issuer role confers nothing above a plain member', () => {
136157
for (const unresolved of [null, undefined, '']) {
137158
// Reported as what it is — unverified, not outranked.
138-
expect(invitationRoleCapFailure('admin', unresolved)).toMatch(/could not be verified/);
139-
expect(invitationRoleCapFailure('sales_manager', unresolved)).toMatch(
140-
/could not be verified/,
141-
);
159+
expect(codeOf('admin', unresolved)).toBe(INVITE_ISSUER_UNVERIFIED_CODE);
160+
expect(codeOf('sales_manager', unresolved)).toBe(INVITE_ISSUER_UNVERIFIED_CODE);
142161
// …but the ordinary invitation still works: an unreadable membership row
143162
// must not take the whole invite surface down with it.
144163
expect(invitationRoleCapFailure('member', unresolved)).toBeNull();

0 commit comments

Comments
 (0)