Repository navigation
fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes - #24350
Conversation
…P hook refusals carry their ADR-0112 codes Eight APIError throws in auth-manager.ts answered with a message and no code. Each now carries the code the spec seat's table names, typed RegisteredErrorCode or StandardErrorCode at the throw: - both phone-OTP send walls (daily SMS quota, per-number limit): one shared RATE_LIMIT_EXCEEDED, so the two walls stay indistinguishable; - the slug guard's read fault: SERVICE_UNAVAILABLE; - the reserved __global__ organization id or slug: VALIDATION_ERROR; - organization creation on a posture with no wall: ORGANIZATION_CREATION_DISABLED; - placement intent with no placement runtime: INVITE_PLACEMENT_UNSUPPORTED; - the placement gate's own refusal: INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE; any other throw out of assertIssuable (a store fault) now answers 503 SERVICE_UNAVAILABLE instead of a 403 scope refusal; - a slug change while the organization has an active environment: ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS, with its message corrected to what the guard reads (no hostname). The comment above SMS_QUOTA_EXCEEDED_CODE no longer calls the service's envelope token an ADR-0112 error code. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…rries a code Parses every non-test source file under src/, finds every APIError construction and requires a top-level code in the body, or a body relayed from judgeHeldSession's ADR-0112 envelope. A comment-free textual count and a fixture battery keep the walk from passing on a shape it cannot see. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…e placement catch's two arms - placement: the gate's PermissionDeniedError answers 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE with its message; a store fault (AuthzStoreUnavailableError) answers 503 SERVICE_UNAVAILABLE with the failure on cause; no placement runtime answers INVITE_PLACEMENT_UNSUPPORTED; - the reserved __global__ id or slug answers 400 VALIDATION_ERROR, also on a single-organization posture; - both phone-OTP walls answer the same RATE_LIMIT_EXCEEDED; - the slug guard's refusal and read fault carry their codes; - organization/create on a posture with no wall answers ORGANIZATION_CREATION_DISABLED through the real better-auth route. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…als answer their codes on the wire - a stock single-posture showcase refuses organization/create with 403 ORGANIZATION_CREATION_DISABLED, and the reserved __global__ slug with 400 VALIDATION_ERROR ahead of it, writing no row; - a delegated_admin with no adminScope requesting placement is refused by the real gate with 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, writing no row. The unit pin of the gate's refusal now uses a structural stand-in for PermissionDeniedError, which the dogfood pin covers with the real class. Adds the plugin-auth patch changeset. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…EDED, flat This branch stamps RATE_LIMIT_EXCEEDED on both phone-OTP send walls, which made two statements on the page false: that the SMS daily quota answers TOO_MANY_REQUESTS (it is better-call's status name, never the code), and that RATE_LIMIT_EXCEEDED is never flat. The walls answer better-auth's flat code-and-message body with no Retry-After header and no details. The changeset drops the SMS invitation from its list: the import route catches that send's failure into a per-row INVITE_SMS_FAILED, so the 429 code never reaches its wire. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 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 0135bbfecaa297ad0deec697beef73beca176857 && git checkout 0135bbfecaa297ad0deec697beef73beca176857
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd72a047f83cf8e6b9826183bd4de304d4f16957 4fc739dfbf28939a54b641025938f075e28d5534 && git checkout -B drift-repro dd72a047f83cf8e6b9826183bd4de304d4f16957 && git merge --no-ff 4fc739dfbf28939a54b641025938f075e28d5534
node scripts/docs-audit/affected-docs.mjs --json dd72a047f83cf8e6b9826183bd4de304d4f16957
|
席位核对ACCEPT ·
Generated by Claude Code |
Fixes #24243
Clause-②: no
Eight
APIErrorthrows from plugin-auth's better-auth hooks answered withmessagealone, so a client could only show or pattern-match prose. Each now carries the ADR-0112 code in the spec seat's table (card comment6248819342, the codes landed onmainwith PR #24294), typed at the throw:RegisteredErrorCodefor the four registered codes andStandardErrorCodefor the four standard ones, as the invitation role cap's constants are typed, so a removed or misspelled ledger row failstsc. Every refusal keeps its HTTP status.ec749fbb4:1343daily SMS quota/auth/phone-number/send-otp,/auth/phone-number/request-password-resetRATE_LIMIT_EXCEEDED(standard):5761per-number OTP limitRATE_LIMIT_EXCEEDED(the same constant):1358slug guard read fault/auth/organization/updateSERVICE_UNAVAILABLE(standard):3464reserved__global__id or slug/auth/organization/createVALIDATION_ERROR(standard):3472no organization wall/auth/organization/createORGANIZATION_CREATION_DISABLED:3540no placement runtime/auth/organization/invite-memberINVITE_PLACEMENT_UNSUPPORTED:3558placement outside the issuer's scope/auth/organization/invite-memberINVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE:3668slug change with an active environment/auth/organization/updateORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTSThe three implementer notes
assertIssuablewith 403 and the thrown message. Now only the gate's own refusal (plugin-security'sPermissionDeniedError, read structurally by itsPERMISSION_DENIEDcode, because plugin-auth has no runtime dependency on plugin-security) answers 403INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPEwith the gate's message. Any other throw, such as the store faultresolveUserAuthzGrantsraises asAuthzStoreUnavailableError, answers 503SERVICE_UNAVAILABLEwith its code. The failure ridescauseand its message is not relayed. This is the one behaviour change: a store fault was a 403 scope refusal and is now a 503. No invitation row is written either way. The structural interfaceInvitationPlacementServiceLikegains a doc line that states this refusal contract.:3668message says only what the guard reads. It no longer says environments "still reference" the organization or tells the caller to rename hostnames. The guard countssys_environmentrows byorganization_idandstatus, and reads no hostname. The new text: "Cannot change the organization slug while the organization has N active environment(s), meaning any environment that is neither archived nor failed. Archive those environments first, or keep the current slug." The read-fault message at:1358had the same "still references" wording and now says "whether this organization has an active environment was never determined".SMS_QUOTA_EXCEEDED_CODEno longer calls'TOO_MANY_REQUESTS'an ADR-0112 error code. It is the service envelope's token (better-call's status name), only matched on the way in. In the same edit, the quota wall's status is spelled'TOO_MANY_REQUESTS'directly rather than read off that constant, so both walls now construct the identical status and code. A sentence in the role cap's read-fault helper that this branch made false ("Unlike the slug guard's body, this one carries the code too") now reads "Like".The console's map row for
ORGANIZATION_CREATION_DISABLEDis objectui's and is not in this PR; the seat files it at landing.One docs correction, outside the claim's file surface
content/docs/protocol/kernel/error-handling.mdxhad two statements this branch makes false: theQUOTA_EXCEEDEDentry said the SMS daily quota answersTOO_MANY_REQUESTS, and theRATE_LIMIT_EXCEEDEDentry said that code's envelope is never flat. Both are corrected: the phone-OTP walls answer 429RATE_LIMIT_EXCEEDEDin better-auth's flatcodeandmessagebody, with noRetry-Afterheader (measured: the constructedAPIErrorcarriesheaders {}) and nodetails. This page is not in the claim's declared surface; the report names it.Tests
packages/plugins/plugin-auth/src/api-error-code.pin.test.ts(a test, not a CI gate). It parses every non-test source file underplugin-auth/src, finds everynew APIError(...), and requires a top-levelcodein the body, or a body destructured fromjudgeHeldSession(...)in the same function (the held-session judge's envelope, whose code is nested undererror). A comment-free textual count must equal the walked count per file, a floor (30 sites in 4 files) stops a walk that reads nothing, and a fixture battery shows the classifier both reports the code-less shapes and accepts the coded ones.auth-manager.tsswapped to itsec749fbb4blob, the pin reports exactlyauth-manager.ts:1343, :1358, :3464, :3472, :3540, :3558, :3668, :5761, the eight positions of the corrected enumeration (6245494426), and does not flag the two relayedjudgeHeldSessionbodies. Restored to the HEAD blob,git diff HEADempty.scripts/ablation-replace.mjs, WRAP mode): deleting the:3668stamp turns the pin red at that one site (auth-manager.ts:3769in HEAD numbering); restored, blob equal to HEAD,git diff HEADempty.expected 403 to be 503); restored the same way.auth-manager.test.tsimports./auth-managerfrom source, so no build sits between the mutation and the reading.singleposture)::3472:organization-create-refusal-codes.dogfood.test.ts, a new organization answers 403ORGANIZATION_CREATION_DISABLED, no row; it first asserts/auth/configpublishesmultiOrgEnabled: false.:3464: the same file, the__global__slug answers 400VALIDATION_ERRORahead of the posture refusal, no row.:3558:delegated-admin-invite.dogfood.test.ts, adelegated_adminwith no adminScope who requests placement is refused by the real gate with 403INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, no row.:1343and:5761:bootStackmounts no phone-number plugin (OS_AUTH_PHONE_NUMBER_ENABLEDis read only byobjectstack serve). The existing "both walls present the SAME outward shape" pin now asserts the one sharedRATE_LIMIT_EXCEEDED.:3668and:1358:sys_environmentis cloud-provided and no composition in this repository registers it, so the guard does not apply on a stock boot.:3540:bootStackalways boots plugin-security, which registers the placement service.:3472is also pinned in-package through the real better-auth route (org-create-posture-gate.test.ts, the single and degraded postures), and:3464inauth-manager.test.ts.The gate-refusal unit pin uses a structural stand-in for
PermissionDeniedError. Importing plugin-security there measured roughly 1 to 2 s more import time onauth-manager.test.ts, and the real class reaches the catch on the wire in the dogfood pin above.Local readings
All on
4fc739dfb(this branch merged withmainat51ad8e4fb), the head this PR opens with:pnpm --filter @objectstack/plugin-auth typecheck: exit 0.pnpm --filter @objectstack/dogfood typecheck: exit 0.vitest run): 161 files passed, 3126 tests passed, 10 skipped.node scripts/pm/dispatch-gates.mjs --commandsderives 99 commands for this diff, and all 99 exited 0 on this head. Two first refused with PREREQUISITE NOT MET (exit 3) because packages outside this diff had nodist/:check:skill-examples(needsclient-react) andcheck:dual-build-cjs-loads(seven unbuilt packages). After building those, both re-ran with exit 0, and so did the four other dist-reading gates, re-run on a quiet tree.--ranreconciliation: 99 derived, 99 run, 0 NOT-MEASURED. Outside the derivation,spec check:error-code-provenanceandspec check:generated(spec moved onmain's side of the merge) both exit 0.cd27c533a, the catch ablation on00cafa8b2. The merge brought four plugin-auth source files frommain, which the enumeration pin also reads; run alone on this head it passes 7 of 7.pnpm lint,test:repo, and the full dogfood suite.Acceptance notes
packages/services/service-sms/src/sms-daily-quota.ts(the comment above itsSMS_QUOTA_EXCEEDED_CODE) says the envelope token is "the same code the per-number guard raises in plugin-auth (APIError('TOO_MANY_REQUESTS'))". After this PR that spelling is the shared status name, and the code both walls carry isRATE_LIMIT_EXCEEDED. Comment only, outside this card's surface, not touched. Carrier: none.organizationHooksinauth-manager.tsstill says "any lookup error is treated as 'no envs to protect'". Since the slug guard fails closed on a read fault, that is not what the code does. Comment only, not touched. Carrier: none.beforeUpdateOrganizationdoes not reserve__global__whilebeforeCreateOrganizationdoes) is unchanged here; the review escalated it to the seat.packages/specedit, none of the four files PR fix(cli,runtime): the 17 → 18 ownership ceremony leaves no row without an organization on a single-posture database, its prescribed boot writes nothing before the guard refuses, and an unverified ceremony is not served #24325 touches, nothing in objectui.Generated by Claude Code