|
| 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 | +} |
0 commit comments