Repository navigation
fix(plugin-auth): invite-member refuses an unknown role with code ROLE_NOT_FOUND - #24846
Conversation
repro, red) Claude-Session: https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD Co-authored-by: Claude <noreply@anthropic.com>
…OLE_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>
Claude-Session: https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD Co-authored-by: Claude <noreply@anthropic.com>
) Claude-Session: https://claude.ai/code/session_01SKHckexWmsVZ6qZ1sioSkD Co-authored-by: Claude <noreply@anthropic.com>
…vite-role-not-found-code
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 147 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4cc910d167fdff278b3292190f05b31f44976dd1 && git checkout 4cc910d167fdff278b3292190f05b31f44976dd1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb 65cdcf23cdc0de772a07cc12b60a98c84ca29bb6 && git checkout -B drift-repro 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb && git merge --no-ff 65cdcf23cdc0de772a07cc12b60a98c84ca29bb6
node scripts/docs-audit/affected-docs.mjs --json 5cb6e6e0aa54c0d08a340a56c729f4be1beafbcb
|
Contract review — 2026-10-20T10:48ZServed-tier: Face: schema ( Inputs: card #24750 body and all five comments (6284251818, the claim 6286575652, the dispatch 6286608990, 6288124493, the os-dev-report 6288480387); PR #24846 body, its file list (7 files, +159/−1) and the net diff against ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT ·
|
Fixes #24750
Clause-②: yes
What changes
POST /api/v1/auth/organization/invite-memberwith a role outside the closed membership vocabulary now answers400withcode: 'ROLE_NOT_FOUND'and the vendor's own message (ROLE_NOT_FOUND: no_such_role). That is the bodyupdate-member-rolealready gives for the same refusal, and the answer the spec's invitationroledocstring (packages/spec/src/identity/organization.zod.ts) andcontent/docs/permissions/authentication.mdxpromise. 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 400APIError, nocodeof its own, and a message starting with the vendor prefixROLE_NOT_FOUND:. It re-throws it asAPIError('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: theafterhook 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: oneROLE_NOT_FOUNDrow 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:generatednamedcontent/docs/references/**as fed by it, andcheck:generated --fixregenerated it (two reference pages, one row each, plus the+339 more→+340 morecount)..changeset/24750-invite-role-not-found-code.md:@objectstack/plugin-authminor,@objectstack/specminor,Clause-②: yes (widening). The rule is AGENTS.md Post-Task Checklist step 3: "yestakes at leastminor". The ledger row widens spec's publishedErrorCodeunion, so spec is minor as well.Mechanism, as measured
dist/plugins/organization/routes/crud-invites.mjsthrowsnew APIError("BAD_REQUEST", { message: ROLE_NOT_FOUND + ": " + roles })with nocode.routes/crud-members.mjs(update-member-role) throws the same condition withcode: 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 inauth-manager.tsis false: there was no plugin-auth mapping seam to reuse.dist/api/dispatch.mjs,runAfterHooks). The thrownAPIErrorreaches the userafterhook onctx.context.returned, and anAPIErroran 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, soengine-double-contract's ledger does not move.code: 'ROLE_NOT_FOUND', the message names the role, and nosys_invitationrow exists.member) still gets 200 and one pending row with that role.update-member-rolewith an unknown role still answers 400ROLE_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, throughscripts/ablation-replace.mjs. The anchorawait codeInviteRoleNotFound(ctx);went x1 → x0 and the blob went658b4666de2f→44a4a7103dff. Pin 1 went red with the same assertion, and pins 2–3 stayed green, as controls should. Restore withgit checkout HEAD -- PATH: the blob was back to the HEAD blob658b4666de2fandgit diff HEADwas empty. The test imports./auth-managerfrom source, so there is nodistleg.Verification (final head
65cdcf23c, after mergingorigin/main08d201a80)pnpm --filter '@objectstack/plugin-auth^...' buildandpnpm --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-contractandcheck:auth-mount-ledgeramong them).--rananswered "118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN".Decisions taken (self-driven dispatch, recorded for review)
after-hook re-throw of the vendor's refusal (chosen), not abefore-hook re-validation of the role. Re-validating would give plugin-auth a second copy of the vendor's vocabulary check (static roles plus dynamicorganizationRolerows), and two owners of one decision drift apart. The after-hook adds only the missing code, on the vendor's own verdict.ErrorCodeunion is a widening of spec's face too.Acceptance notes
update-member-rolealready 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.code— the sibling vendor refusal on the same endpoint carries one #23945 and plugin-auth: seven refusals and one outage thrown from better-auth hooks carry no ADR-0112 code, the same gap #23945 closes for the invitation role cap #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
Generated by Claude Code