diff --git a/.changeset/24750-invite-role-not-found-code.md b/.changeset/24750-invite-role-not-found-code.md new file mode 100644 index 00000000000..8b8d81b4285 --- /dev/null +++ b/.changeset/24750-invite-role-not-found-code.md @@ -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: `) 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. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index b9d95113cd7..d542c87a612 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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`. | @@ -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` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index af0915a155b..7194045bbf2 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -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` diff --git a/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts b/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts index 51bad787eb2..553e3661ead 100644 --- a/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts +++ b/packages/plugins/plugin-auth/src/accept-invitation-adopt-membership.test.ts @@ -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'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 886ff6a119e..658b4666de2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -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, @@ -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. diff --git a/packages/plugins/plugin-auth/src/invite-role-not-found-code.ts b/packages/plugins/plugin-auth/src/invite-role-not-found-code.ts new file mode 100644 index 00000000000..cb8a65dc5ed --- /dev/null +++ b/packages/plugins/plugin-auth/src/invite-role-not-found-code.ts @@ -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: `. */ +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 { + 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 }); +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index c1463e2555f..bce4082daca 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -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',