Repository navigation
fix(plugin-auth): an organization-delete read that fails refuses 503 before better-auth's first write - #25184
Conversation
…before better-auth's first write The organization-delete admission caught every throw in its lookup block and handed the request to better-auth, so a failed sys_member or sys_organization read let better-auth clear the active organization and delete the members and invitations with nothing counted. A failed read now refuses with a typed 503 SERVICE_UNAVAILABLE, and so does a failed Default Organization decision, which answered a bare 500. The vendor-decided cases still fall through. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
… read, with controls Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…g-delete-lookup-refuses Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
…eneath the engine Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 18 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 1e11b53d4d792d0fca357b1539037de60f5b9f6e && git checkout 1e11b53d4d792d0fca357b1539037de60f5b9f6e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7d740376d3953c7b4a7d22c77e0f689c77a228a 5884148db582551b305da826933d2e2ed099da27 && git checkout -B drift-repro f7d740376d3953c7b4a7d22c77e0f689c77a228a && git merge --no-ff 5884148db582551b305da826933d2e2ed099da27
node scripts/docs-audit/affected-docs.mjs --json f7d740376d3953c7b4a7d22c77e0f689c77a228a
|
…session read too resolveActor answered a failed session read with null, the value no session gives, so the admission fell through and better-auth deleted with nothing counted. An opt-in failedReadThrows mode, used only by the admission, lets the explicit token read's failure propagate, which the admission answers with the same typed 503. No session, an unknown or expired credential, one that does not parse and one carrying a control character still answer null; the other nine callers are unchanged. The changeset's Clause-② line now carries no reasoning. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude <noreply@anthropic.com>
席位核对
Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #25059 closes by Generated by Claude Code |
Fixes #25059
Clause-②: no
Metadata-protocol: none
Why
Clause-②: no: the refusal replaces an outcome only a failed store read produces, and the typed 503 replaces a bare 500 fault; no request a caller can make is answered differently.What changed
AuthManager.assertOrganizationDeletable, thePOST /organization/deleteadmission in better-auth's globalhooks.before:resolveActoranswered a failed session read withnull, the value "no session" gives, so the admission fell through. It takes an opt-infailedReadThrowsoption now. With it, when no actor was resolved and the explicit token read itself threw, that throw propagates, and the admission answers it with the same typed503. No credential, a credential the store does not hold, an expired session, a credential that does not parse, and one carrying a control character all still answernull, so they reach better-auth's401. Only the admission passes the option. The other ninethis.resolveActor(ctx)call sites are byte-identical in the diff.sys_memberrow or of thesys_organizationrow used to land incatch { return; }and hand the request to better-auth, which re-reads, then clears the session's active organization, then deletes the members and invitations, and only then meets the engine's refusal at the organization row. It now answers503with the standard ADR-0112 codeSERVICE_UNAVAILABLE, before better-auth's first write.503. It already refused before any write, but with a bareError, which better-auth serves as an untyped 500.organization-delete.ts:ORGANIZATION_DELETE_READ_FAULT_CODEandorganizationDeleteReadFaultBody(object). The body names the table it could not read. The driver's own error ridescauseand is not relayed. Two names were added to the existingorganization-delete.jsimport list inauth-manager.ts; nothing else outside the method moved.The four mechanism assumptions, re-measured on this branch
resolveActorcannot throw. Both of its paths catch, and it answersnull, the same value as "no session". So of the four, three can throw into the catch: thesys_memberread,hasPermissionand thesys_organizationread.expected 500 to be 503.503 SERVICE_UNAVAILABLE: the vocabulary this file already uses for a read fault (the organization slug guard, the invitation role cap and the invitation placement check), aStandardErrorCode, so the error-code ledger needs no row.check:dispatcher-error-vocabularyran green (Verification below).hasPermissionthrow for a decidable vendor answer? Yes, and only for that. better-auth 1.7.12plugins/organization/has-permission.mjsreaches the adapter only behindoptions.dynamicAccessControl?.enabled, and the admission hands it the roles map alone, so the call reads nothing. It throws only on the member row itself: a role that is not a string (role.split), or a role name that resolves to an inherited object member (authorizeis not a function). better-auth'sdeleteOrganizationcalls the identical predicate (the same roles object:organizationOptions.rolesis thecustomOrgRolesthe admission holds asorgRolesMap) on the same row, beforesetActiveOrganization. So that throw keeps falling through, and a pin holds that nothing is attempted when it does.The session read
The card names
resolveActoramong the lookups that throw. Measured: it never threw. Both of its paths catch and answernull, so a failed session read read as "no actor" and fell through. An uncommitted scratch test failed only the admission's own session reads while better-auth's stayed real; it got200, and better-auth's session update and member and organization deletes ran with nothing counted.resolveActor(ctx, { failedReadThrows: true })now tells the two apart. It is used by the admission alone, and the default reading is unchanged for the other nine callers.Only the explicit token read counts as a failed read. Two things are deliberately not counted:
%00. Counting it would turn that request's401into a503.Pins
packages/plugins/plugin-auth/src/organization-delete-read-fault.test.ts. RealObjectQLover@objectstack/driver-sqland better-sqlite3, the manifest's objects, a realAuthManagerwith the organization plugin (thesession-read-failure-answer.test.tsharness).resolveActor's paths fail inside the admission's call, fired 2), the caller'ssys_memberread, thesys_organizationread, and the Default Organization decision's count. The admission's own read fails once, scoped to the admission's frame, so better-auth's reads of the same table stay real. Each case asserts503,SERVICE_UNAVAILABLEand the table named. It also asserts that no write was attempted: the session update and the member, invitation and organization deletes are recorded by engine hooks when attempted, not when committed. Then a CONTROL: with the read back, the same delete answers200and the organization is gone.401, with nothing attempted. A credential carrying a control character gets the same401, with nothing attempted, even while the admission's read of it fails.hasPermissionfall-through: a stored member row whose role is null reaches better-auth, which refuses before its first write. Nothing is attempted, and the answer is not the admission's503.Ablations
A1 to A3 ran at
947ab6c25(before the merge ofmain); A4 and A5 at5884148db, the final head (baseline thereTests 7 passed (7)). The test imports./auth-manager.jsrelatively, so the subject resolves tosrc/and no build sits between a mutation and its run. Each mutation was applied withscripts/ablation-replace.mjs(the anchor hit once, landed on disk, blob changed), and then restored.Tests 4 passed (4)expected 200 to be 503: the request reached better-auth and deleted the organizationexpected 500 to be 503; the other three greenhasPermissionthrow refuses503instead of falling throughexpected '{"code":"SERVICE_UNAVAILABLE",…' not to contain 'SERVICE_UNAVAILABLE'resolveActorswallows the failed session read againexpected 200 to be 503: the request reached better-auth and deleted the organizationexpected 503 to be 401Restore after each mutation: blob
f3015ed6f7b0== HEAD,git diff HEADempty, zero markers on disk,git status --porcelainempty.Verification
5884148db, under the lock:pnpm --filter @objectstack/plugin-auth buildexit 0;pnpm --filter @objectstack/plugin-auth exec vitest rungaveTest Files 185 passed (185),Tests 3485 passed | 10 skipped (3495);pnpm --filter @objectstack/plugin-auth typecheckexit 0 (check:test-typecheck: OK, no debt row added);pnpm check:type-check-debtexit 0 (re-measure: OK — 1 ledger entr(ies) re-measured in 190.9s, 26 raw tsc error(s) total, none above its recorded number). The lock printedVERDICT command-exit 0.5884148db: the 70 commands derived bynode scripts/pm/dispatch-gates.mjs --commands(the same 70 at947ab6c25,46c61dadeand5884148db), pluscheck-changeset-fixedandcheck:authz-resolver, whose rosters sit in a directory this diff touches. Every exit code was captured before any pipe, and all are 0.--ran:70 derived · 70 run · 0 NOT-MEASURED · 0 UNRUN.resolveActor(line is the admission's;git grepcounts ninethis.resolveActor(ctx)call sites, andfailedReadThrows: trueoccurs once (auth-manager.ts:8378, insideassertOrganizationDeletable).check:*sweep,pnpm lint, the dogfood battery (the title carries no!and there is no(narrowing)arm), and thepackages/qadogfood pins. CI runs those; every check-run on5884148dbsucceeded or was skipped.Acceptance notes
packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts:282) asserts a status of 400 or above. It stays green with503. It could be tightened to503plusSERVICE_UNAVAILABLE. That file is in thedomain:clilane and was not edited here.organizationDeleteRefusal/defaultOrganizationDeleteRefusal) still propagates a failure bare, as an untyped 500. That is the same family as the Default Organization decision. It is not changed here because what reaches it is not only a store fault: an engine refusal carrying its own code lands there too, so a blanket503would mislabel it.503 SERVICE_UNAVAILABLEfrom an organization delete only when a store read failed. Nothing was deleted then, and a retry is the remedy.Generated by Claude Code