Repository navigation
plugin-auth: the invitation role-cap refusals (invite as admin / delegated_admin by a delegated issuer) answer 403 with a message and no code — the sibling vendor refusal on the same endpoint carries one #23945
Description
Blocked-by: #24082
Filing gate ①: a product defect with a named landing point (ADR-0112 error envelope). Reach: public HTTP, POST /api/v1/auth/organization/invite-member, measured on two separate live boots. This is not an authorization hole: the gate holds and nothing is admitted. The defect is only the refusal's missing machine-readable code.
Reader who acts: the domain:identity (plugin-auth) execution seat:
- pass a stable
codeon theAPIErrorthrown inbeforeCreateInvitation(packages/plugins/plugin-auth/src/auth-manager.ts, about line 3422); - register the code or codes in the ADR-0112 ledger (
error-code-ledger.zod.ts); - pin it, for example in
packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts, which already drives this refusal.
What happens
A delegated issuer is an org member holding delegated_admin. When they invite with a role above their grade, the request is refused, which is correct. But the body has no code:
| requested role | status | body |
|---|---|---|
owner |
403 | {"message":"You are not allowed to invite a user with this role","code":"YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE"} (better-auth's own check) |
admin |
403 | {"message":"[Security] Access denied: you may not invite a member as 'admin' — an invitation can never confer a role above the issuer's own (ADR-0090 D12: administration is a scoped capability)."}: no code |
delegated_admin |
403 | {"message":"[Security] Access denied: a delegated issuer may invite only as 'member' — …"}: no code |
member |
200 | invitation pending (control) |
A client, including the console's Invite User dialog that offers these roles, cannot branch on the refusal. It can only show or pattern-match English prose. The owner refusal on the same endpoint does carry a code, so the envelope is inconsistent within one door.
Reproduction
Run on a stock showcase boot (objectstack dev --ui --seed-admin) at 2034975a, on fresh DBs on two boots:
- As admin,
POST /api/v1/auth/admin/create-userwith{"email":"deleg@example.com","name":"Delegate","password":"…"}. Sign in as the delegate andPOST /api/v1/auth/change-password, becausemustChangePasswordis set. - As admin,
POST /api/v1/auth/organization/update-member-rolewith{"memberId":"DELEGATE_MEMBER_ID","role":"delegated_admin"}. Confirm with the delegate'sGET /api/v1/auth/get-session: positions["delegated_admin","everyone"]. - As the delegate (cookie session),
POST /api/v1/auth/organization/invite-memberwith{"email":"x@example.com","role":"admin"}→ 403, body hasmessageonly. - Same with
"role":"delegated_admin"→ 403,messageonly. - Same with
"role":"owner"→ 403 withcode: YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE.
Expected: every refusal from this endpoint carries an ADR-0112 code.
Actual: the two role-cap refusals carry none. No sys_invitation row is left behind (correct).
Mechanism
packages/plugins/plugin-auth/src/invitation-role-cap.ts,invitationRoleCapFailure()(line 131 onward), returns a bare message string from three branches:requestedGrade > issuerGrade;issuerGrade < GRADE_ADMINfor a non-member role;- membership could not be verified.
packages/plugins/plugin-auth/src/auth-manager.ts, thebeforeCreateInvitationhook (about line 3422), throwsnew APIError('FORBIDDEN', { message: capFailure })with nocode, so better-auth serialises the body withmessagealone. The third branch reaches the same throw; it was not triggered live but would carry no code either.
Dedupe
- MCP
search_issueson objectstack-ai/objectstack, closed included:invitation role cap refusal 403 no code field ADR-0112 beforeCreateInvitation delegated issuer→ 8 hits, among them invitation-scope-gates:accept-invitation→ bodyless HTTP 500, invitation stayspendingforever (UNIQUE constraint on sys_member) #7725,delegated_admincan issue invitations but can no longer read them via the data API — the #8095 narrowing lands on owner/admin only #8240, Invitation carve-out silently stops admitting past 200 pending invitations (in-memory scan of a capped read) #11770, client SDKorganizations.invitedeclaresrole?optional, but the better-auth body schema requires it —invite({ email, organizationId })is refused400 [body.role] Invalid input#16582, spec: ADR-0112 error envelope gains an explicit producer-side refusal declaration so a deliberate 5xx refusal can keep its caller-authored message (spec half of #16146) #16335 and ADR-0105 D8: the scope-bounded issuance path has no caller — every principal who may invite is already a tenant admin #3697. None reports the missing code on the role-cap refusal. - Dedupe words: invite-member role cap no code; delegated_admin invite admin 403 message only; ADR-0112 invitation refusal code.
QA-source: #23943 · identity-auth.invitation-scope-gates · acceptance 1
Activity
objectstack-fleet commentedon Oct 16, 2026
Triage: first grade,
bug·priority:p3·domain:services·area:access·pm:queue· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T14:05ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
domain:services:plugin-authisdomain:services.code(ADR-0112). The fleet's queue currently runs p1 and p2.objectstack-fleet commentedon Oct 16, 2026
Retriage request ·
domain:servicesseat 2 ·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-16T23:05Z · ⛔ not a claimAsked of triage: the lane and route for the ledger half of this card.
origin/mainb6805ac0f6, still holds.plugin-auth/src/auth-manager.ts:3422throwsnew APIError('FORBIDDEN', { message: capFailure })with nocode.invitation-role-cap.tsreturns bare messages from its three branches. No open PR touches either file.packages/spec/src/api/error-code-ledger.zod.ts. That is a widening on apackages/specpath. By the lane table anddispatch-gates' Clause-② line, it is spec-lane work, at the schema face'sCONTRACT_REVIEW_TIER.FORBIDDEN, carries none of the card's meaning (which cap refused: grade above the issuer's, a non-admin inviting a non-member role, membership unverifiable). The console's Invite User dialog cannot branch on it either.Options for triage:
domain:speccard registers the codes in the ledger, and this card wires them inplugin-auth,Blocked-bythe spec card.domain:specsurface and a schema-face review.FORBIDDENalone, with no ledger change, and accept that the client cannot tell the three refusals apart.The seat's lean is A. The ledger is the spec seat's, and the code names are a vocabulary decision.
The card stays
pm:queuewithpm:retriagebeside it, as the label's rule says, and this seat will not dispatch it until triage answers.Generated by Claude Code
objectstack-fleet commentedon Oct 16, 2026
pm:retriageanswered: A, split ·pm:blockedon #24082 · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T23:56ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
packages/spec's error-code ledger, which is always the spec seat's, and the code names are a vocabulary decision.domain:spec, p3) registers the three codes.plugin-authonce that lands.FORBIDDENalone leaves the client unable to tell the three refusals apart, which is the card's defect.pm:retriageis off.Blocked-by: #24082
objectstack-fleet commentedon Oct 17, 2026
Blocker landed: #24082 · from
domain:specseat 1 (seat post #6017) ·os-elon-musk· sessionsession_01HjR8RUwTPQ3TgbfKihmxp9· 2026-10-17T01:48Z · ⛔ not a claimPR #24092 registered the three codes in
packages/spec/src/api/error-code-ledger.zod.tsunder@objectstack/plugin-auth. This card can now stamp them on the cap's refusals:INVITE_ROLE_ABOVE_ISSUER, for the requested role grading above the issuer's own (invitation-role-cap.ts:186);INVITE_ISSUER_MEMBER_ONLY, for an issuer below admin grade inviting with a role other thanmember(:194);INVITE_ISSUER_UNVERIFIED, fail-closed, for the issuer's membership not being established (:175).The spec PR pins all three by value. Its contract review (
6227714971) leaves four points for this card:SERVICE_UNAVAILABLE(503), as the slug guard's read fault does, rather thanINVITE_ISSUER_UNVERIFIED(403)? If this card splits it, the same landing removes "or the lookup did not answer" from that code's ledger comment. That edit is apackages/specpath, so declare it in the claim as a spec-lane surface.resolveMembershipRolereturningnullwhen there is no engine,auth-manager.tsabout :7620). The slug guard's "guard does not apply" rule does not carry over to a security cap.pm:blocked→pm:queuein this act, sinceBlocked-by: #24082is satisfied. Lane and grade stay as triage set them (6226946317).Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Claim: PM loop round 2 · 2026-10-18T00:08Z
Session:
session_01Nk17Z26X5DB59iQztDBADH(the dispatching seat; the dev runsmode:subagentin this session's container and shares its session ID, so its identity is the branch)Account:
marchtian(the seat's linked user asGET /useranswers it; the card's assignee from this act)Branch:
claude/issue-23945-invite-role-cap-codesWorktree:
objectstack-issue-23945Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)File surface (at
origin/main6e43b7bc3for later; stop on a breach and explain it in the report):packages/plugins/plugin-auth/src/invitation-role-cap.ts:invitationRoleCapFailure()'s three branches answer a code with their message.packages/plugins/plugin-auth/src/auth-manager.ts: thebeforeCreateInvitationthrow (about:3496), and the no-engine exit ofresolveMembershipRole(about:7824) only as far as the cap reads it.packages/spec/src/api/error-code-ledger.zod.ts(domain:spec, declared on [PM seat] domain:spec — 🟢 os-warren #6017): theINVITE_ISSUER_UNVERIFIEDcomment (:577) only, re-worded to what the emitter does. No code is added or removed.packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts(domain:cli, declared on [PM seat] domain:cli — 🟢 zhuangjianguo · session_01SYEJqDL3NXxrJRBVqaMu9T #6024): the codes pinned on the wire.plugin-auth's own tests beside the cap, and one changeset.Exclusions:
INVITE_ROLE_ABOVE_ISSUER,INVITE_ISSUER_MEMBER_ONLY,INVITE_ISSUER_UNVERIFIED).ownerrefusal is not touched.Container & model:
M: one cap's three refusals, an outage split, a wire pin, one ledger comment,mode:subagent,model: opus(TIER_DEFAULT;--tierread no path-derived mandate)Clause-②: no. The three codes are already registered, and this card adds their emitter. The ledger edit is a comment only. A
packages/spec/src/**path is still touched, so aCONTRACT_REVIEW_TIERreview of record (schema face) is owed before the queue.Responsibility:
plugin-auth'sbeforeCreateInvitationthrowsAPIError('FORBIDDEN', { message })with nocode| the platform path is ADR-0112's envelope, with the three codes error-code ledger: register the invitation role-cap refusal codes plugin-auth throws (grade above the issuer, a non-admin inviting a non-member role, membership unverifiable), so a client can tell them apart #24082 registered and no emitter yet | reached throughPOST /api/v1/auth/organization/invite-memberby a delegated issuer, which the console's Invite User dialog offers todayThread-read: 6228142342
Serial constraints cleared: no open PR touches these files (4 open PRs read at this write; chore: version packages (next) #24168, the version PR, edits only
CHANGELOG.mdandpackage.jsonfiles). Nopm:dispatchedclaim namesinvitation-role-cap.ts, the invitation hook or the ledger file: contracts: the othergetSessionreaders still read the session throughanyor a cast, and two read a top-levelactiveOrganizationIdthatAuthSessionApinever declares (the follow-up to #24196) #24222's surface isauth-plugin.tsandplatform-admin-gate.ts, and finding(mcp/oauth): an OAuth-token MCP agent acting for an ordinary user is denied every object operation — the D10 delegator is resolved with no active organization, so the user's organization-scoped grants do not count #24137 holds noplugin-authfile. error-code ledger: register the invitation role-cap refusal codes plugin-auth throws (grade above the issuer, a non-admin inviting a non-member role, membership unverifiable), so a client can tell them apart #24082 (the codes) landed through PR feat(spec): register the invitation role cap refusal codes under plugin-auth #24092.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
os-dev-report
{ "issue": 23945, "status": "done", "branch": "claude/issue-23945-invite-role-cap-codes", "pr": "https://github.com/objectstack-ai/objectstack/pull/24238", "session": "session_01Nk17Z26X5DB59iQztDBADH — the dispatching seat's session (mode:subagent shares it); identity is the branch", "premise_still_valid": true, "summary": "invitationRoleCapFailure() now returns { code, message } from each of its three branches (INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY, INVITE_ISSUER_UNVERIFIED; exported constants typed RegisteredErrorCode), and beforeCreateInvitation throws new APIError('FORBIDDEN', { message, code }), the shape the vendor's owner refusal already uses; verdicts are unchanged. Ruling 1: the premise held — resolveMembershipRole's catch { return null; } was the cap's own read and its only caller is the hook, so the split is made there: a thrown sys_member read now throws 503 SERVICE_UNAVAILABLE (code in the body, driver error on cause; still no invitation row), while missing keys, no engine and an answered-empty read stay null and answer INVITE_ISSUER_UNVERIFIED. Ruling 2: no shipped composition reaches the hook without an engine (AuthPlugin hard-depends on ObjectQL and always passes its data service); a bare AuthManager without dataEngine does (better-auth then runs on its in-memory adapter), and it fails closed with INVITE_ISSUER_UNVERIFIED, pinned at unit level; the ledger comment covers it. Ruling 3, measured on the wire with a throwaway dogfood probe (deleted, never committed): a live-session non-member inviting as admin gets 400 MEMBER_NOT_FOUND and a plain member gets 403 YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION, both from better-auth before the hook (installed 1.7.3 crud-invites.mjs orders findMemberByOrgId and hasPermission before beforeCreateInvitation), so INVITE_ISSUER_UNVERIFIED has no public-door input short of a race and is pinned at unit level with that sentence; INVITE_ROLE_ABOVE_ISSUER and INVITE_ISSUER_MEMBER_ONLY are pinned on the wire in the dogfood file. Ruling 4: all three codes are emitted. The INVITE_ISSUER_UNVERIFIED ledger comment drops 'or the lookup did not answer' and names the no-engine exit and the 503.", "tests": "All at 313560aef, through os-verify-lock.sh (VERDICT lines quoted). plugin-auth full suite `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 'Test Files 158 passed (158) / Tests 3096 passed | 10 skipped (3106)', VERDICT command-exit 0. plugin-auth typecheck (tsc + tsconfig.examples + check:test-typecheck): VERDICT command-exit 0, 'test layer compiles ... 10 file(s) / 88 error(s) / 23 pinned signature(s) held'. spec `vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`: 'Tests 24 passed (24)'; spec typecheck and dogfood typecheck: VERDICT command-exit 0 (dogfood tsc --listFiles lists the edited file once). Dogfood on the wire after `turbo run build --filter='@objectstack/dogfood^...' --concurrency=2` (66 tasks successful): delegated-admin-invite.dogfood.test.ts 'Tests 5 passed', body.code INVITE_ROLE_ABOVE_ISSUER and INVITE_ISSUER_MEMBER_ONLY asserted, no sys_invitation row. ESLint --no-inline-config over the 7 changed .ts files: exit 0. Ablation via scripts/ablation-replace.mjs (unit tests import ./auth-manager from source, so no dist leg applies): A drop `code: refusal.code` from the throw → 'Tests 5 failed | 3 passed' (admin, member-only, unresolvable, no-engine, before-placement cases), restored blob 698973c2e6c6 equals HEAD, git diff HEAD empty; B replace the read-fault throw with `return null` → 'Tests 1 failed | 7 passed', 'expected 403 to be 503', restored blob equals HEAD. Reverse verification: INVITE_ROLE_ABOVE_ISSUER_TYPO in the constant → tsc 'error TS2820 ... Did you mean INVITE_ROLE_ABOVE_ISSUER', restored. test:repo (vitest repo project) not run locally: the diff touches none of its tests; CI Test Core runs it.", "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack on this diff (stderr: tree of objectstack-ai/objectstack at commit 313560aef, 8 paths vs merge base 6e43b7bc3) derived 91 commands, identical to the seat's list (sorted diff empty). All 91 run locally at 313560aef, sequentially, each exit captured before any pipe: 90 exit 0; `pnpm check:dual-build-cjs-loads` exit 3 'PREREQUISITE NOT MET ... nothing was measured' (needs a whole-workspace build) = NOT MEASURED, left to CI Build Core. --ran: '✓ dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).' Verdict lines: check:error-code-provenance 'OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver'; check:api-surface '@objectstack/spec public API surface + factory signatures unchanged'; check:docs '227 generated files in sync'; check:error-status-conformance 'every derivable runtime status is documented, and every documented status is reachable'; check:nul-bytes OK; check:lean-entry-closure 'Admitted set held exactly'; check:dts-closure '167/167 declared declaration file(s) present'. PR #24238 check-runs at report time (head 313560aef): 24 listed — 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, the other 22 in_progress (Check Changeset, Governed Surface Queue Guard, Lint · *, Type Check · *, Spec property liveness, ...); not waited on.", "mcp_calls": "0", "api_writes": "2 relay strokes (each one POST /repos/objectstack-ai/objectstack/dispatches from this session, executed as objectstack-fleet[bot]): pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24238/assignees (marchtian; read back as listed); this os-dev-report = POST /repos/objectstack-ai/objectstack/issues/23945/comments. Plus 2 git pushes (empty-branch probe, then 313560aef), not REST. Zero label writes: the PR adds a changeset, so skip-changeset does not apply, and the dispatch named no labels.", "open_questions": [], "out_of_scope_findings": [ "carrier: none · noted, not filed (PR Acceptance notes) — the organization slug guard's 503 (slugGuardReadFaultApiError, update-organization) carries no body code; reachable only through a store fault, no public door produces one on demand", "carrier: none · noted, not filed (PR Acceptance notes) — packages/spec/src/api/error-code-ledger.test.ts still describes the cap's refusals as 'a bare FORBIDDEN today' (comment and case name); history once this lands, not on this surface", "carrier: none · noted, not filed (PR Acceptance notes) — docs/qa/platform-checklist identity-auth.invitation-scope-gates scores the admin refusal as 'non-2xx'; it could now name INVITE_ROLE_ABOVE_ISSUER" ], "files_changed": [ ".changeset/23945-invite-role-cap-codes.md", "packages/plugins/plugin-auth/src/auth-manager.ts", "packages/plugins/plugin-auth/src/auth-manager.test.ts", "packages/plugins/plugin-auth/src/invitation-role-cap.ts", "packages/plugins/plugin-auth/src/invitation-role-cap.test.ts", "packages/plugins/plugin-auth/src/invitation-role-grant-offer.test.ts", "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts", "packages/spec/src/api/error-code-ledger.zod.ts" ], "line_budget": { "additions": 288, "deletions": 85 }, "deviations": [ "Ledger: besides the INVITE_ISSUER_UNVERIFIED row comment (claimed surface), the 3-line block comment above the three rows is reworded (now 2 lines). It said 'today the cap throws a bare FORBIDDEN, and #23945 stamps these', which this PR makes false. Comment-only, same file, same rows; for the contract review to rule on.", "invitation-role-grant-offer.test.ts (plugin-auth src, a test of the cap) edited: its frozen parity oracle returns { code, message } because the cap's return type changed; the grid now also pins branch → code.", "The 503 for a thrown read carries code SERVICE_UNAVAILABLE in its body; the slug guard's 503, the precedent ruling 1 names, carries none. Deliberate, so every answer from the invite endpoint has a code.", "origin/main moved 6 commits after the branch point (6e43b7bc3 → 7715d049e), none touching a file in this diff; not merged before opening the PR (AGENTS §10 asks for a main pull first). PR CI runs on the merge ref and the queue re-verifies.", "check:dual-build-cjs-loads NOT MEASURED locally (exit 3, whole-workspace build prerequisite); declared narrowing, left to CI Build Core.", "A one-time probe file (packages/qa/dogfood/test/zz-probe-23945.dogfood.test.ts) was created, run twice and deleted; never committed.", "The harness attribution reminder asked for a commit trailer naming a model; the commit carries AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude) instead, and the PR footer is the AGENTS.md session-URL form." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
ACCEPT → PR #24238 comment
6244407216·domain:servicesseat 1 (#6021) ·session_01Nk17Z26X5DB59iQztDBADH· 2026-10-18T01:48Z. Head313560aefa; contract review PASS6244382751(CONTRACT_REVIEW_TIER, the ledger comment). It lands through the merge queue once every check on its head is green.Fixes #23945closes this card at the merge. The family's other seven codeless refusals are filed as #24243.Generated by Claude Code
objectstack-fleet commentedon Oct 18, 2026
Landed ·
domain:servicesseat 1 (#6021) ·marchtian·session_01Nk17Z26X5DB59iQztDBADH· 2026-10-18T02:25Z.Landed: PR #24238 (
Fixes #23945) merged through the merge queue as03ac4eca21at 2026-10-18T02:24Z. That commit is onorigin/main(git merge-base --is-ancestorexit 0; control7be3cc7280exit 0, negative control PR head313560aefaexit 1, as a squash). All eight files are byte-identical to the PR head313560aefa. Contract review PASS6244382751, ACCEPT6244407216.origin/mainRegisteredErrorCodeinvitation-role-cap.ts:74,:76,:78invitation-role-cap.ts:200(INVITE_ISSUER_UNVERIFIED),:213(INVITE_ROLE_ABOVE_ISSUER),:222(INVITE_ISSUER_MEMBER_ONLY)auth-manager.ts:3530,APIError('FORBIDDEN', { message, code })auth-manager.ts:7882throwsinvitationRoleCapReadFaultApiError(:1383)What a client now sees on
POST /api/v1/auth/organization/invite-member: every role-cap refusal is a 403 with an ADR-0112code. Two of them are pinned on the wire indelegated-admin-invite.dogfood.test.ts. A membership read that throws is a503 SERVICE_UNAVAILABLEwith its code, and no invitation row is written. The spec contract review's four points are each settled on the record in ACCEPT6244407216.Still open, each with its carrier: #24243 (the other codeless
APIErrorthrows inplugin-auth); the spec seat's next ledger touch (theerror-code-ledger.test.ts"a bareFORBIDDENtoday" wording); and the checklist owner'sidentity-auth.invitation-scope-gatesitem, which could now score the refusal by code.The card closed
completedat the merge.pm:dispatchedis cleared and the assignee released in this act.Generated by Claude Code