Skip to content

Commit de9cd1f

Browse files
fix(plugin-auth): invite-member refuses an unknown role with code ROLE_NOT_FOUND (#24846)
Fixes #24750 Clause-②: yes ## What changes `POST /api/v1/auth/organization/invite-member` with a role outside the closed membership vocabulary now answers `400` with `code: 'ROLE_NOT_FOUND'` and the vendor's own message (`ROLE_NOT_FOUND: no_such_role`). That is the body `update-member-role` already gives for the same refusal, and the answer the spec's invitation `role` docstring (`packages/spec/src/identity/organization.zod.ts`) and `content/docs/permissions/authentication.mdx` promise. The refusal still lands before any invitation row is written. - `packages/plugins/plugin-auth/src/invite-role-not-found-code.ts` (new): `codeInviteRoleNotFound(ctx)` recognises exactly this refusal: the invite path, a 400 `APIError`, no `code` of its own, and a message starting with the vendor prefix `ROLE_NOT_FOUND: `. It re-throws it as `APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message })`. Any other answer passes through unchanged, including a future vendor release that codes the refusal itself. - `auth-manager.ts`: the `after` hook calls it last, right before the sign-up tail. The throw ends the hook, so it has to come after every path-specific section. - `packages/spec/src/api/error-code-ledger.zod.ts`: one `ROLE_NOT_FOUND` row in `@objectstack/plugin-auth`'s section. This is the spec lane's pre-approved ledger-append class: no field, no mechanism change, no other entry. `check:generated` named `content/docs/references/**` as fed by it, and `check:generated --fix` regenerated it (two reference pages, one row each, plus the `+339 more` → `+340 more` count). - `.changeset/24750-invite-role-not-found-code.md`: `@objectstack/plugin-auth` minor, `@objectstack/spec` minor, `Clause-②: yes (widening)`. The rule is AGENTS.md Post-Task Checklist step 3: "`yes` takes at least `minor`". The ledger row widens spec's published `ErrorCode` union, so spec is minor as well. ## Mechanism, as measured - better-auth 1.7.3 `dist/plugins/organization/routes/crud-invites.mjs` throws `new APIError("BAD_REQUEST", { message: ROLE_NOT_FOUND + ": " + roles })` with no `code`. `routes/crud-members.mjs` (update-member-role) throws the same condition with `code: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND.code`. **The control door's code comes from the vendor, not from a plugin-auth mapping.** So the dispatch's hypothesis that both codes come from one mapping step in `auth-manager.ts` is false: there was no plugin-auth mapping seam to reuse. - The seam used is better-auth's own hook runner (`dist/api/dispatch.mjs`, `runAfterHooks`). The thrown `APIError` reaches the user `after` hook on `ctx.context.returned`, and an `APIError` an after hook throws replaces the returned value. The user hook runs before the plugin after-hooks. The vendored library is not touched. ## Pins (through the real door: `AuthManager.handleRequest`, real better-auth pipeline) Added to `accept-invitation-adopt-membership.test.ts`, the suite that already mounts the invitation door. Putting them there reuses its pinned engine double, so `engine-double-contract`'s ledger does not move. 1. An unknown role is refused 400 with `code: 'ROLE_NOT_FOUND'`, the message names the role, and no `sys_invitation` row exists. 2. CONTROL: a valid role (`member`) still gets 200 and one pending row with that role. 3. CONTROL: `update-member-role` with an unknown role still answers 400 `ROLE_NOT_FOUND`, and the membership role is unchanged. Repro before the fix: pin 1 red (`expected undefined to be 'ROLE_NOT_FOUND'`), pins 2–3 green. Ablation at `925e35e1`, through `scripts/ablation-replace.mjs`. The anchor `await codeInviteRoleNotFound(ctx);` went x1 → x0 and the blob went `658b4666de2f` → `44a4a7103dff`. Pin 1 went red with the same assertion, and pins 2–3 stayed green, as controls should. Restore with `git checkout HEAD -- PATH`: the blob was back to the HEAD blob `658b4666de2f` and `git diff HEAD` was empty. The test imports `./auth-manager` from source, so there is no `dist` leg. ## Verification (final head `65cdcf23c`, after merging `origin/main` `08d201a80`) - `pnpm --filter '@objectstack/plugin-auth^...' build` and `pnpm --filter @objectstack/plugin-auth build`: exit 0. - `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated`: exit 0, "All 14 generated artifacts are up to date". - `pnpm --filter @objectstack/plugin-auth typecheck && pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/plugin-auth test`: 168 files, 3227 passed, 10 skipped. - `pnpm --filter @objectstack/spec test`: 703 files, 21360 passed, 1 todo. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 118 commands from the 7 changed paths. Every one was run with its exit captured before any pipe, and every one exited 0 (`check:error-code-casing`, `check:error-code-provenance`, `check:dispatcher-error-vocabulary`, `check:engine-double-contract` and `check:auth-mount-ledger` among them). `--ran` answered "118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN". ## Decisions taken (self-driven dispatch, recorded for review) - **Seam:** an `after`-hook re-throw of the vendor's refusal (chosen), not a `before`-hook re-validation of the role. Re-validating would give plugin-auth a second copy of the vendor's vocabulary check (static roles plus dynamic `organizationRole` rows), and two owners of one decision drift apart. The after-hook adds only the missing code, on the vendor's own verdict. - **Message:** the vendor's message is kept verbatim. It already names the role, and it is byte-identical to the control door's body. - **spec changeset level:** minor, because a new member in the published `ErrorCode` union is a widening of spec's face too. ## Acceptance notes - The ledger row registers a code that `update-member-role` already put on the wire as a better-auth pass-through, unregistered. This PR makes that existing pass-through registered too. It does not change that door's answer, and pin 3 holds that. - QA source: #24538 (identity-auth run) and the re-measure from #24779. The nearest earlier cards are #23945 and #24243, which covered plugin-auth's own throws. They are named here for context only; this PR closes nothing but the first line's card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7cacad5 commit de9cd1f

7 files changed

Lines changed: 159 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`
7+
8+
Clause-②: yes (widening)
9+
10+
- **Before.** An invitation naming a role outside the closed membership vocabulary was refused `400` with only a message (`ROLE_NOT_FOUND: <role>`) and no `code`, so a client that branches on `error.code` (ADR-0112) could not recognise it. `update-member-role` already answered the same refusal with `code: 'ROLE_NOT_FOUND'`.
11+
- **Now.** invite-member answers `400` with `code: 'ROLE_NOT_FOUND'` and the same message, naming the role, the body `update-member-role` gives for the same condition. The refusal still happens before any invitation row is written. This is the answer the invitation schema's `role` docstring and the authentication docs already promise.
12+
- **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
13+
- **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +339 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +340 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -311,6 +311,7 @@ const result = ApiErrorSchema.parse(data);
311311
* `RESUME_FAILED`
312312
* `RESUME_IN_PROGRESS`
313313
* `RESUME_TARGET_LOST`
314+
* `ROLE_NOT_FOUND`
314315
* `ROLLED_BACK`
315316
* `ROUTE_NOT_FOUND`
316317
* `RULE_DEFINE_FAILED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ const result = ErrorCode.parse(data);
478478
* `RESUME_FAILED`
479479
* `RESUME_IN_PROGRESS`
480480
* `RESUME_TARGET_LOST`
481+
* `ROLE_NOT_FOUND`
481482
* `ROLLED_BACK`
482483
* `ROUTE_NOT_FOUND`
483484
* `RULE_DEFINE_FAILED`

‎packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,3 +448,70 @@ describe('#7725 — the delegated-admin issuance scope is UNCHANGED (ADR-0090 D1
448448
expect(invitationsFor(engine, 'scoped.denied@example.com')).toHaveLength(0);
449449
});
450450
});
451+
452+
describe('#24750 — invite-member refuses an unknown role with the ADR-0112 code ROLE_NOT_FOUND', () => {
453+
// The spec's invitation `role` docstring (`identity/organization.zod.ts`)
454+
// promises "refused at the door (`ROLE_NOT_FOUND`) before any invitation row
455+
// exists". better-auth's invite route throws that refusal with a message and
456+
// no `code`; `update-member-role` throws the same refusal WITH one. These pin
457+
// the door's answer, the untouched valid-role path, and the control door.
458+
beforeEach(() => {
459+
vi.spyOn(console, 'info').mockImplementation(() => {});
460+
vi.spyOn(console, 'warn').mockImplementation(() => {});
461+
});
462+
463+
afterEach(() => {
464+
vi.restoreAllMocks();
465+
});
466+
467+
it('an unknown role is refused 400 with code ROLE_NOT_FOUND, the message names the role, and no invitation row exists', async () => {
468+
const { engine, manager, owner } = await bootWithOwner();
469+
470+
const res = await post(
471+
manager,
472+
'/organization/invite-member',
473+
{ email: 'qa-invitee@example.com', role: 'no_such_role', organizationId: DEFAULT_ORG },
474+
owner.cookie,
475+
);
476+
expect(res.status).toBe(400);
477+
const body: any = await res.json();
478+
expect(body.code).toBe('ROLE_NOT_FOUND');
479+
expect(body.message).toContain('no_such_role');
480+
expect(invitationsFor(engine, 'qa-invitee@example.com')).toHaveLength(0);
481+
});
482+
483+
it('CONTROL: a valid role is unchanged — 200 and one pending row carrying that role', async () => {
484+
const { engine, manager, owner } = await bootWithOwner();
485+
486+
const res = await post(
487+
manager,
488+
'/organization/invite-member',
489+
{ email: 'qa-valid@example.com', role: 'member', organizationId: DEFAULT_ORG },
490+
owner.cookie,
491+
);
492+
expect(res.status, await res.clone().text()).toBe(200);
493+
const rows = invitationsFor(engine, 'qa-valid@example.com');
494+
expect(rows).toHaveLength(1);
495+
expect(rows[0]!.status).toBe('pending');
496+
expect(rows[0]!.role).toBe('member');
497+
});
498+
499+
it('CONTROL: update-member-role keeps the code it already answered with — 400 ROLE_NOT_FOUND, role unchanged', async () => {
500+
const { engine, manager, owner } = await bootWithOwner();
501+
const colleague = await signUp(manager, engine, 'colleague@example.com');
502+
const [membership] = membersOf(engine, DEFAULT_ORG, colleague.userId);
503+
expect(membership, 'the reconciler did not bind the colleague').toBeDefined();
504+
505+
const res = await post(
506+
manager,
507+
'/organization/update-member-role',
508+
{ memberId: String(membership!.id), role: 'no_such_role', organizationId: DEFAULT_ORG },
509+
owner.cookie,
510+
);
511+
expect(res.status).toBe(400);
512+
const body: any = await res.json();
513+
expect(body.code).toBe('ROLE_NOT_FOUND');
514+
expect(body.message).toContain('no_such_role');
515+
expect(membersOf(engine, DEFAULT_ORG, colleague.userId)[0]!.role).toBe('member');
516+
});
517+
});

‎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)