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/24750-invite-role-not-found-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/plugin-auth': minor
'@objectstack/spec': minor
---

fix(plugin-auth): `POST /api/v1/auth/organization/invite-member` refuses a role outside the membership vocabulary with `code: 'ROLE_NOT_FOUND'`

Clause-②: yes (widening)

- **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'`.
- **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.
- **The ledger.** `ROLE_NOT_FOUND` is registered under `@objectstack/plugin-auth` in the error-code ledger, so `ErrorCode` (and `ApiErrorSchema.code`) admits it.
- **Unchanged.** A valid role invites as before, and every other refusal on invite-member keeps its answer.
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Readable error message |
| **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`. |
Expand Down Expand Up @@ -311,6 +311,7 @@ const result = ApiErrorSchema.parse(data);
* `RESUME_FAILED`
* `RESUME_IN_PROGRESS`
* `RESUME_TARGET_LOST`
* `ROLE_NOT_FOUND`
* `ROLLED_BACK`
* `ROUTE_NOT_FOUND`
* `RULE_DEFINE_FAILED`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ const result = ErrorCode.parse(data);
* `RESUME_FAILED`
* `RESUME_IN_PROGRESS`
* `RESUME_TARGET_LOST`
* `ROLE_NOT_FOUND`
* `ROLLED_BACK`
* `ROUTE_NOT_FOUND`
* `RULE_DEFINE_FAILED`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,70 @@ describe('#7725 — the delegated-admin issuance scope is UNCHANGED (ADR-0090 D1
expect(invitationsFor(engine, 'scoped.denied@example.com')).toHaveLength(0);
});
});

describe('#24750 — invite-member refuses an unknown role with the ADR-0112 code ROLE_NOT_FOUND', () => {
// The spec's invitation `role` docstring (`identity/organization.zod.ts`)
// promises "refused at the door (`ROLE_NOT_FOUND`) before any invitation row
// exists". better-auth's invite route throws that refusal with a message and
// no `code`; `update-member-role` throws the same refusal WITH one. These pin
// the door's answer, the untouched valid-role path, and the control door.
beforeEach(() => {
vi.spyOn(console, 'info').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('an unknown role is refused 400 with code ROLE_NOT_FOUND, the message names the role, and no invitation row exists', async () => {
const { engine, manager, owner } = await bootWithOwner();

const res = await post(
manager,
'/organization/invite-member',
{ email: 'qa-invitee@example.com', role: 'no_such_role', organizationId: DEFAULT_ORG },
owner.cookie,
);
expect(res.status).toBe(400);
const body: any = await res.json();
expect(body.code).toBe('ROLE_NOT_FOUND');
expect(body.message).toContain('no_such_role');
expect(invitationsFor(engine, 'qa-invitee@example.com')).toHaveLength(0);
});

it('CONTROL: a valid role is unchanged — 200 and one pending row carrying that role', async () => {
const { engine, manager, owner } = await bootWithOwner();

const res = await post(
manager,
'/organization/invite-member',
{ email: 'qa-valid@example.com', role: 'member', organizationId: DEFAULT_ORG },
owner.cookie,
);
expect(res.status, await res.clone().text()).toBe(200);
const rows = invitationsFor(engine, 'qa-valid@example.com');
expect(rows).toHaveLength(1);
expect(rows[0]!.status).toBe('pending');
expect(rows[0]!.role).toBe('member');
});

it('CONTROL: update-member-role keeps the code it already answered with — 400 ROLE_NOT_FOUND, role unchanged', async () => {
const { engine, manager, owner } = await bootWithOwner();
const colleague = await signUp(manager, engine, 'colleague@example.com');
const [membership] = membersOf(engine, DEFAULT_ORG, colleague.userId);
expect(membership, 'the reconciler did not bind the colleague').toBeDefined();

const res = await post(
manager,
'/organization/update-member-role',
{ memberId: String(membership!.id), role: 'no_such_role', organizationId: DEFAULT_ORG },
owner.cookie,
);
expect(res.status).toBe(400);
const body: any = await res.json();
expect(body.code).toBe('ROLE_NOT_FOUND');
expect(body.message).toContain('no_such_role');
expect(membersOf(engine, DEFAULT_ORG, colleague.userId)[0]!.role).toBe('member');
});
});
7 changes: 7 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import {
} from './impersonation-bearer-rotation.js';
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
import {
applyPlatformAdminImpersonation,
Expand Down Expand Up @@ -2970,6 +2971,12 @@ export class AuthManager {
return;
}

// ── #24750: invite-member's unknown-role refusal carries its code ──
// better-auth throws it with only a message; the spec promises
// `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws
// on a match, so it stays after every section above.
await codeInviteRoleNotFound(ctx);

if (ctx?.path !== '/sign-up/email') return;
// ADR-0069 D1 — stamp password_changed_at for a newly-created local
// user (expiry clock starts at sign-up). Best-effort.
Expand Down
68 changes: 68 additions & 0 deletions packages/plugins/plugin-auth/src/invite-role-not-found-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #24750 — `POST /organization/invite-member` refused a role outside the
* closed membership vocabulary with a message and no `code`.
*
* ## The defect, at the seam
*
* better-auth's invite route checks the requested role against the static
* roles (plus the organization's dynamic ones, when dynamic access control is
* on) and throws
*
* new APIError("BAD_REQUEST", { message: `${ROLE_NOT_FOUND}: ${unknownRoles}` })
*
* (`dist/plugins/organization/routes/crud-invites.mjs`, measured against the
* installed better-auth `1.7.3`). Its sibling `update-member-role`
* (`routes/crud-members.mjs`) throws the SAME refusal with
* `code: ROLE_NOT_FOUND` beside the identical message — so one vendor
* condition reached the wire coded on one door and uncoded on the other. The
* spec promises the code on this door: the invitation schema's `role`
* docstring (`identity/organization.zod.ts`) says a name outside the
* vocabulary is "refused at the door (`ROLE_NOT_FOUND`) before any invitation
* row exists", and an ADR-0112 client branches on `error.code`, never on prose.
*
* ## What this does
*
* Runs in the `after` hook, where better-auth hands the thrown `APIError` over
* on `ctx.context.returned`, and re-throws THIS refusal — and only this one —
* as `APIError('BAD_REQUEST', { code: 'ROLE_NOT_FOUND', message })` with the
* vendor's own message, so the body is byte-for-byte what `update-member-role`
* answers for the same condition. better-auth's hook runner replaces the
* returned value with an `APIError` an after hook throws, so the status, the
* message and the moment of the refusal (before any invitation row is written)
* are the vendor's; only the missing `code` is added.
*
* Recognition is deliberately narrow: the invite path, a 400 `APIError`, no
* `code` of its own, and a message that starts with the vendor's
* `ROLE_NOT_FOUND: ` prefix. Every other refusal on the route — including any
* future vendor release that codes this one itself — passes through untouched.
* The vendored library is not patched.
*/

/** The published endpoint whose refusal this codes. */
export const INVITE_MEMBER_PATH = '/organization/invite-member';

/** The ADR-0112 code the spec's invitation `role` docstring promises. */
export const ROLE_NOT_FOUND_CODE = 'ROLE_NOT_FOUND';

/** better-auth's message prefix for this refusal: `ROLE_NOT_FOUND: <roles>`. */
const VENDOR_MESSAGE_PREFIX = `${ROLE_NOT_FOUND_CODE}: `;

/**
* Re-throw invite-member's uncoded unknown-role refusal with
* `code: 'ROLE_NOT_FOUND'`. A no-op on every other path and every other
* answer. Call it LAST in the `after` hook: the throw ends the hook.
*/
export async function codeInviteRoleNotFound(ctx: any): Promise<void> {
if (ctx?.path !== INVITE_MEMBER_PATH) return;
const returned = ctx?.context?.returned;
const { APIError, isAPIError } = await import('better-auth/api');
if (!isAPIError(returned)) return;
if (returned.statusCode !== 400) return;
const body = returned.body as { code?: unknown; message?: unknown } | undefined;
if (body?.code !== undefined) return;
const message = body?.message;
if (typeof message !== 'string' || !message.startsWith(VENDOR_MESSAGE_PREFIX)) return;
throw new APIError('BAD_REQUEST', { code: ROLE_NOT_FOUND_CODE, message });
}
1 change: 1 addition & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ export const ERROR_CODE_LEDGER = {
'PASSWORD_REUSE',
'OAUTH_REGISTER_FAILED', // better-auth rejected the client registration
'PHONE_NOT_ENABLED',
'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
'SAML_REGISTER_FAILED',
'SELF_REGISTRATION_CLOSED', // [#11739] audience posture invite_only: self-registration is closed (no pending invitation for this address)
'SSO_REGISTER_FAILED',
Expand Down
Loading