Skip to content

Commit 925e35e

Browse files
committed
fix(plugin-auth): invite-member's unknown-role refusal carries code ROLE_NOT_FOUND (#24750)
better-auth's invite route throws the refusal with only a message; the after hook re-throws exactly that refusal with the ADR-0112 code, the same body update-member-role already answers. Registers ROLE_NOT_FOUND under plugin-auth in the error-code ledger. Claude-Session: https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD Co-authored-by: Claude <noreply@anthropic.com>
1 parent 26729d2 commit 925e35e

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import {
105105
} from './impersonation-bearer-rotation.js';
106106
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
107107
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
108+
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
108109
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
109110
import {
110111
applyPlatformAdminImpersonation,
@@ -2970,6 +2971,12 @@ export class AuthManager {
29702971
return;
29712972
}
29722973

2974+
// ── #24750: invite-member's unknown-role refusal carries its code ──
2975+
// better-auth throws it with only a message; the spec promises
2976+
// `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws
2977+
// on a match, so it stays after every section above.
2978+
await codeInviteRoleNotFound(ctx);
2979+
29732980
if (ctx?.path !== '/sign-up/email') return;
29742981
// ADR-0069 D1 — stamp password_changed_at for a newly-created local
29752982
// user (expiry clock starts at sign-up). Best-effort.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #24750 — `POST /organization/invite-member` refused a role outside the
5+
* closed membership vocabulary with a message and no `code`.
6+
*
7+
* ## The defect, at the seam
8+
*
9+
* better-auth's invite route checks the requested role against the static
10+
* roles (plus the organization's dynamic ones, when dynamic access control is
11+
* on) and throws
12+
*
13+
* new APIError("BAD_REQUEST", { message: `${ROLE_NOT_FOUND}: ${unknownRoles}` })
14+
*
15+
* (`dist/plugins/organization/routes/crud-invites.mjs`, measured against the
16+
* installed better-auth `1.7.3`). Its sibling `update-member-role`
17+
* (`routes/crud-members.mjs`) throws the SAME refusal with
18+
* `code: ROLE_NOT_FOUND` beside the identical message — so one vendor
19+
* condition reached the wire coded on one door and uncoded on the other. The
20+
* spec promises the code on this door: the invitation schema's `role`
21+
* docstring (`identity/organization.zod.ts`) says a name outside the
22+
* vocabulary is "refused at the door (`ROLE_NOT_FOUND`) before any invitation
23+
* row exists", and an ADR-0112 client branches on `error.code`, never on prose.
24+
*
25+
* ## What this does
26+
*
27+
* Runs in the `after` hook, where better-auth hands the thrown `APIError` over
28+
* on `ctx.context.returned`, and re-throws THIS refusal — and only this one —
29+
* as `APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message })` with the
30+
* vendor's own message, so the body is byte-for-byte what `update-member-role`
31+
* answers for the same condition. better-auth's hook runner replaces the
32+
* returned value with an `APIError` an after hook throws, so the status, the
33+
* message and the moment of the refusal (before any invitation row is written)
34+
* are the vendor's; only the missing `code` is added.
35+
*
36+
* Recognition is deliberately narrow: the invite path, a 400 `APIError`, no
37+
* `code` of its own, and a message that starts with the vendor's
38+
* `ROLE_NOT_FOUND: ` prefix. Every other refusal on the route — including any
39+
* future vendor release that codes this one itself — passes through untouched.
40+
* The vendored library is not patched.
41+
*/
42+
43+
/** The published endpoint whose refusal this codes. */
44+
export const INVITE_MEMBER_PATH = '/organization/invite-member';
45+
46+
/** The ADR-0112 code the spec's invitation `role` docstring promises. */
47+
export const ROLE_NOT_FOUND_CODE = 'ROLE_NOT_FOUND';
48+
49+
/** better-auth's message prefix for this refusal: `ROLE_NOT_FOUND: <roles>`. */
50+
const VENDOR_MESSAGE_PREFIX = `${ROLE_NOT_FOUND_CODE}: `;
51+
52+
/**
53+
* Re-throw invite-member's uncoded unknown-role refusal with
54+
* `code: 'ROLE_NOT_FOUND'`. A no-op on every other path and every other
55+
* answer. Call it LAST in the `after` hook: the throw ends the hook.
56+
*/
57+
export async function codeInviteRoleNotFound(ctx: any): Promise<void> {
58+
if (ctx?.path !== INVITE_MEMBER_PATH) return;
59+
const returned = ctx?.context?.returned;
60+
const { APIError, isAPIError } = await import('better-auth/api');
61+
if (!isAPIError(returned)) return;
62+
if (returned.statusCode !== 400) return;
63+
const body = returned.body as { code?: unknown; message?: unknown } | undefined;
64+
if (body?.code !== undefined) return;
65+
const message = body?.message;
66+
if (typeof message !== 'string' || !message.startsWith(VENDOR_MESSAGE_PREFIX)) return;
67+
throw new APIError('BAD_REQUEST', { code: ROLE_NOT_FOUND_CODE, message });
68+
}

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ export const ERROR_CODE_LEDGER = {
604604
'PASSWORD_REUSE',
605605
'OAUTH_REGISTER_FAILED', // better-auth rejected the client registration
606606
'PHONE_NOT_ENABLED',
607+
'ROLE_NOT_FOUND', // [#24750] 400 on /organization/invite-member and /organization/update-member-role: the requested membership role is outside the closed vocabulary, refused before any invitation or membership row is written; better-auth's code, stamped by plugin-auth on the invite door (`invite-role-not-found-code.ts`), pass-through from better-auth on update-member-role
607608
'SAML_REGISTER_FAILED',
608609
'SELF_REGISTRATION_CLOSED', // [#11739] audience posture invite_only: self-registration is closed (no pending invitation for this address)
609610
'SSO_REGISTER_FAILED',

0 commit comments

Comments
 (0)