Repository navigation
fix(plugin-auth): the invitation role cap's refusals carry their error codes, and a membership read fault answers 503 - #24238
Conversation
…r codes; a membership read fault answers 503
invitationRoleCapFailure() now returns { code, message } from each of its
three refusal branches (INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY,
INVITE_ISSUER_UNVERIFIED), and beforeCreateInvitation throws the code beside
the message on better-auth's APIError, the shape the vendor's own owner
refusal on the same endpoint uses.
resolveMembershipRole no longer swallows a thrown sys_member read into the
unverified verdict: a read fault throws 503 SERVICE_UNAVAILABLE (code in the
body, driver failure on cause). No engine and an answered-empty read stay
fail-closed as INVITE_ISSUER_UNVERIFIED. The ledger comment for that code is
reworded to what the emitter now does.
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 2eddb435a4c9107475437c62f768287d1fc1cfe5 && git checkout 2eddb435a4c9107475437c62f768287d1fc1cfe5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7715d049e4438f3b656c8d77a873871d38cf130e 313560aefa4483bddbd243e0092b937d16d4e5fc && git checkout -B drift-repro 7715d049e4438f3b656c8d77a873871d38cf130e && git merge --no-ff 313560aefa4483bddbd243e0092b937d16d4e5fc
node scripts/docs-audit/affected-docs.mjs --json 7715d049e4438f3b656c8d77a873871d38cf130e
|
Contract reviewServed-tier: Inputs: card #23945 (body; comments ① Derived judgmentsGate verdicts on this head (41 check-runs, all completed). 35 are success, 6 are skipped by their path filters or opt-in ( The ledger hunk changes no accept-set, and it is the whole schema face of this PR. The hunk is −4/+3, every line a comment, inside the Each comment against its emitter, read in the cited code. The producer is
The missing-keys exit (:7872) is still not named by the comment, exactly as the #24082 review accepted: better-auth hands the hook a session user and an organization, so an invitation that reaches the hook cannot practically produce it. The reworded block comment — "each thrown 403 from The row-to-emitter link is now compiler-checked. The three constants are typed The 503 split (option B of #24082's open question 1) is right, and the card decided it on the record. Four grounds: the #21941 rule in the same file — a read that could not answer is not a verdict, so it must not wear the refusal's words — applied at the one place the two were merged ( Public surface of Bounds declared. (a) The pass-through of ② Semver level
③ Boundary flagsDev deviations (
#24082's four points to this card (
Escalated to the owning seat, non-blocking (this review has no GitHub write beyond this comment):
Landing. No governed surface is touched ( Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #23945
Clause-②: no
What changes
The invitation role cap in
beforeCreateInvitationrefused with403and a message, but nocode. better-auth's ownownerrefusal on the same endpoint (POST /api/v1/auth/organization/invite-member) carries one. Each of the cap's three refusals now carries the code #24082 registered for it under@objectstack/plugin-auth. A membership read that throws now answers as a store outage, not as a refusal.invitation-role-cap.ts)codeINVITE_ROLE_ABOVE_ISSUERmemberINVITE_ISSUER_MEMBER_ONLYINVITE_ISSUER_UNVERIFIEDsys_memberread threw (was 403 with no code, through the branch above)SERVICE_UNAVAILABLEinvitationRoleCapFailure()returns{ code, message }from each branch instead of a bare string. The three codes are exported constants typedRegisteredErrorCode, so a removed or misspelled ledger row failstsc(reverse-verified below). The verdicts do not change: the same invitations are admitted and refused.new APIError('FORBIDDEN', { message, code }), the body shaperemove-memberand the vendor'sownerrefusal already use. better-call passesbodythrough verbatim, socodereaches the wire (measured below).resolveMembershipRoleused to end incatch { return null; }, which turned a thrown read into the unverified verdict. It now throwsinvitationRoleCapReadFaultApiError(err):503 SERVICE_UNAVAILABLE, the driver's error oncause. This is the slug guard's shape (slugGuardReadFaultApiError), with one difference: this body carriescode: 'SERVICE_UNAVAILABLE'too, so every answer from this endpoint has a code. Still fail-closed: the throw ends the endpoint, so no invitation row is written.INVITE_ISSUER_UNVERIFIEDis reworded to what the emitter does now. The phrase "or the lookup did not answer" is gone. The comment now covers the no-engine exit and names the 503 for a read that throws. The three-line block comment above it said "today the cap throws a bareFORBIDDEN". That is false after this PR, so it is reworded too (see Deviations). Both are comment-only, and no code is added, removed or renamed.The seat's rulings on #24082's four review points (premise measured first)
resolveMembershipRole'scatch { return null; }(auth-manager.ts, the last line of the method on6e43b7bc3f). Its one caller isbeforeCreateInvitation(git grep resolveMembershipRolefinds the definition and one call). The split is made at that read.null, which grades below a plain member, so it answersINVITE_ISSUER_UNVERIFIED(403). This PR keeps it that way and pins it. Reachability: no shipped composition reaches the hook without an engine.AuthPluginhard-depends on ObjectQL (dependencies,requiresServices: ['data', …]), andctx.getService('data')throws when the service is absent. The one production construction,auth-plugin.tsnew AuthManager(authConfig), always passesdataEngine. A bareAuthManagerbuilt withoutdataEngine(an exported class, documented as usable outside the plugin) runs better-auth on its in-memory adapter and does reach the hook with nothing to read. The ledger comment covers that exit.:175) reachable through the public door? Measured on the wire, once, with a throwaway dogfood probe that was deleted afterwards (showcase boot at313560aef):admin: 400{"message":"Member not found","code":"MEMBER_NOT_FOUND"}. better-auth's own refusal, so the hook never ran. (The first try answered 401: ending a membership ends its sessions, so the probe signs in again first.)member, inviting asadmin: 403{"message":"You are not allowed to invite users to this organization","code":"YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"}. Again before the hook.plugins/organization/routes/crud-invites.mjs).findMemberByOrgId, thenMEMBER_NOT_FOUND, thenhasPermission(invitation:create), all run beforebeforeCreateInvitation. The cap then re-reads the samesys_memberrows in system context.INVITE_ISSUER_UNVERIFIEDhas no input that reaches it through this door. Getting there needs the row to vanish between the two reads. It is pinned at the unit level (auth-manager.test.ts, "an unresolvable issuer membership fails CLOSED", which says why in a comment). The two reachable codes are pinned on the wire in the dogfood file.codethrough better-auth'sAPIErrorbody.check:error-code-provenancesees the three constant definitions as stamp sites listed under@objectstack/plugin-auth(exit 0, "every registered-code stamp site is listed under its own owner key").PM mechanism hypotheses
auth-manager.tsthrewnew APIError('FORBIDDEN', { message: capFailure }), andinvitationRoleCapFailure()returned a bare string from three branches. Holds on6e43b7bc3f.APIError'scodeinto the body. Holds, and a custom code travels the same way. better-call 1.4.0InternalAPIErrorstoresbodyverbatim.APIError.from(the vendor'sownerrefusal) only builds{ message, code }from its error record. Measured on the wire:INVITE_ROLE_ABOVE_ISSUERandINVITE_ISSUER_MEMBER_ONLYarrive asbody.code(dogfood, below).delegated-admin-invite.dogfood.test.tsalready drives these refusals. Holds foradmin. It had nodelegated_admin-by-delegate case, so one is added.Clause-② measured
no. On the built entry declarations:packages/plugins/plugin-auth/dist/index.d.tsandindex.d.mtscarry 0 occurrences ofinvitationRoleCapFailure,InvitationRoleCapRefusaland the three*_CODEconstants. The controlclass AuthManagerappears 1 time, andrate-limit-storage.d.tshas 0.invitation-role-cap.tsis not re-exported fromsrc/index.ts. The only declaration text that moved is the doc comment on the privateresolveMembershipRole. For@objectstack/spec,check:api-surfaceanswered "public API surface + factory signatures unchanged". The ledger edit is a comment, soErrorCodeaccepts the same strings. Nothing widens an accept-set: no invitation is newly admitted, and no code is added.Tests (at
313560aef)pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2(the whole package):Test Files 158 passed (158),Tests 3096 passed | 10 skipped (3106).pnpm --filter @objectstack/plugin-auth typecheck: exit 0. It includescheck:test-typecheck: "test layer compiles … 10 file(s) / 88 error(s) / 23 pinned signature(s) held".pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts:Tests 24 passed (24).pnpm --filter @objectstack/spec typecheck: exit 0.pnpm --filter @objectstack/dogfood typecheck: exit 0.tsc --listFileslists the edited dogfood file once.@objectstack/dogfood^...(66 tasks):test/delegated-admin-invite.dogfood.test.ts,Tests 5 passed, including "the same delegate issuingadminis REFUSED" (body.codeINVITE_ROLE_ABOVE_ISSUER) and the new "the same delegate minting another delegate is REFUSED as member-only" (body.codeINVITE_ISSUER_MEMBER_ONLY, nosys_invitationrow).--no-inline-configover the seven changed.tsfiles: exit 0.Refusal tests assert the envelope (
statusCode,status,body.code), not a bare throw. The cap's unit tests now assert codes rather than prose. The parity oracle ininvitation-role-grant-offer.test.tsspells the codes as literals, so its grid of over 100 refusals also pins which branch answers which code.Ablation (one-time, through
scripts/ablation-replace.mjswith restore proven by blob hash and an emptygit diff HEAD). The unit tests import./auth-managerfrom source, so nodist/rebuild applies:code: refusal.codefrom the throw. The run went red withTests 5 failed | 3 passed: theadmin, member-only, unresolvable, no-engine and before-placement cases. The two admit cases and the 503 case stayed green. Restored, blob698973c2e6c6equal to HEAD.throwwithreturn null(the old swallow). The run went red withTests 1 failed | 7 passed: the 503 case failed with "expected 403 to be 503". Restored, blob equal to HEAD.Reverse verification of the
RegisteredErrorCodeannotation: misspelling the first constant asINVITE_ROLE_ABOVE_ISSUER_TYPOturnstsc --noEmitred withTS2820 … Did you mean '"INVITE_ROLE_ABOVE_ISSUER"'?. Restored.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon this diff derives the same 91 commands as the seat's list. All 91 were run locally at313560aef, each exit captured before any pipe. 90 exited 0.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: it needs a whole-workspace build), so it is NOT MEASURED locally and left to CI'sBuild Core.--ran: "✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)." The PR's check-runs had not reported when this was written.Acceptance notes
code(slugGuardReadFaultApiError). That is the same gap this PR addresses for the cap's read fault, onupdate-organization. It is reachable only through a store fault, and no public door produces one on demand, so it is noted here and not filed. Carrier: none.packages/spec/src/api/error-code-ledger.test.tsstill describes the cap's refusals as "a bareFORBIDDENtoday" and names the case "registered ahead of their plugin-auth producer". That is history once this lands. It is not on this PR's surface, so it is left alone. Carrier: none.docs/qa/platform-checklist/areas/identity-auth.jsonidentity-auth.invitation-scope-gatesscores the admin-role refusal as "non-2xx". It could now nameINVITE_ROLE_ABOVE_ISSUER. Carrier: none.Deviations
INVITE_ISSUER_UNVERIFIEDcomment. It said "today the cap throws a bareFORBIDDEN, and plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and nocode— the sibling vendor refusal on the same endpoint carries one #23945 stamps these", which this PR makes false. Comment-only, same file, same rows.origin/mainmoved 6 commits after the branch point (6e43b7bc3to7715d049e). None touches a file in this diff. It is not merged in: this PR's CI runs on the merge ref, and the queue re-verifies on its rebuilt generation.Generated by Claude Code