Repository navigation
fix(plugin-auth)!: sixteen auth routes refuse an unparseable declared-JSON request body with 400 INVALID_REQUEST instead of reading it as {} - #24049
Conversation
…ith 400 INVALID_REQUEST instead of reading it as {}
Nine body readers in plugin-auth read a body that does not parse as `{}`:
the three inline mounts in auth-plugin.ts (admin/oauth2/toggle-disabled,
admin/unlock-user, sys-oauth-application/register), the identity import,
the four /admin/sso/* bridges and the self-service verification resend.
A truncated body was answered as if the client had sent none, and the
resend mailed the caller's own address for a body that named another.
A package-local reader (request-json-body.ts, not re-exported) now answers
a declared application/json body that is not blank and does not parse
with the transports' 400 INVALID_REQUEST refusal and message. An empty or
whitespace-only body, a body that parses (whatever its Content-Type) and a
body that does not declare JSON are read as before.
Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
…hipped as minor Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP Co-authored-by: Claude <noreply@anthropic.com>
…th-malformed-body-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 921b63d84c59cee2d73d93b78bc9e115a477b7c7 && git checkout 921b63d84c59cee2d73d93b78bc9e115a477b7c7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 905f57bc7480ea9838b99b65b99f94aa54431e1b 7160ac93d7216d3ab9be492350ae7b9fdaf1d2e5 && git checkout -B drift-repro 905f57bc7480ea9838b99b65b99f94aa54431e1b && git merge --no-ff 7160ac93d7216d3ab9be492350ae7b9fdaf1d2e5
node scripts/docs-audit/affected-docs.mjs --json 905f57bc7480ea9838b99b65b99f94aa54431e1b
|
…th-malformed-body-refusal
…unparseable declared-JSON body too
ban-user, unban-user, set-user-manager, create-user, set-user-password,
organization/add-member and set-initial-password read a body that does not
parse as {} through a module-private parseJson (or parsed = {}), and
answered it with a required-field refusal for a field the body carried.
Each reader now goes through the package-local readJsonRequestBody and
refuses a declared application/json body that is not blank and does not
parse with 400 INVALID_REQUEST. A well-formed non-object body still reads
as no fields, and an empty body keeps its answer. The changeset lists the
sixteen routes and the eleven exported bridges they mount.
Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
…al, not just 400
The row asserted only status 400 and INVALID_REQUEST, which the old
parsed = {} swallow also answered ("newPassword is required"), so it could
not tell a refused body from one read as empty. It now asserts the parse
refusal and that setPassword is never reached, and a second row pins the
undeclared text/plain spelling, which is still no body.
Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
Patch round 1 addendum: os-dev, #24032, claim amendment
|
Contract reviewServed-tier: Net diff against the merge base ① Derived judgments1. The reader, 2. The sixteen routes: one input narrowed, at the read, before anything runs. Right. Per reader, with the read line at the head:
Nothing after the read runs on a refusal: no 3. The answer. Right. Status 4. What is unchanged. Right, and pinned. An empty body (no body and no Content-Type; a zero-byte body sent as JSON with 5. 6. 7. The other exported bridges. Right. 8. The swallow census after this PR, 9. Legitimate callers. None refused. Every declared producer in 10. Pins. Right. 11. Routes whose answer changes beyond the narrowing. None found. The only other delta on any route is ② Semver levelRight: ③ Boundary flags
Check-runs on Implemented-by: VERDICT: PASS |
席位核对 · ACCEPT ·
|
Fixes #24032
Clause-②: no (narrowing)
What
Sixteen of
@objectstack/plugin-auth's own routes read a request body that does not parse as{}. There were nine at round 0, and patch round 1 added seven more: claim amendment6225083981, addendum6225763180. A truncated body was answered as if the client had sent none. Each of them now refuses a body that is declaredapplication/json, is not blank, and does not parse. The refusal is400 INVALID_REQUESTin the ADR-0112 envelope, with the same code and message the HTTP transports (plugin-hono-server,hono) andcloud-connectiongive the same body since PR #24005.INVALID_REQUESTis an existingERROR_CODE_LEDGERcode, and nothing is added to the ledger. The message never echoes the body.The reader is one package-local module,
src/request-json-body.ts. It is not re-exported fromindex.ts, and nothing is imported fromplugin-hono-serverorcloud-connection(ruling B on #23914: no new export). As incloud-connection, it answers a message, and each route builds its own400body beside its other refusals.Premise, measured on
origin/main2b274600b1All nine sites swallow a parse failure into
{}. Triage counted 9 and the card listed 7. The card's grep missedregister-sso-provider.ts:101andsend-verification-email.ts:96, because both spell the swallow over several lines (body = {};on its own line). They have the same shape, so they are the same family.:96is the one site where{}is an action rather than a refusal./api/v1/auth){}auth-plugin.ts:2735POST /admin/oauth2/toggle-disabledINVALID_REQUEST"client_id is required", before the platform-admin gateauth-plugin.ts:2870POST /admin/unlock-userauth-plugin.ts:3423POST /sys-oauth-application/registeradmin-import-users.ts:493POST /admin/import-usersregister-sso-provider.ts:101POST /admin/sso/registerregister-sso-provider.ts:250POST /admin/sso/register-samlregister-sso-provider.ts:444POST /admin/sso/request-domain-verificationregister-sso-provider.ts:501POST /admin/sso/verify-domainsend-verification-email.ts:96POST /send-verification-emailSo, for eight sites, a truncated body got a required-field refusal for a field the body carried. At the resend, a truncated body that named another address (
{"email": "other@example.test") was answered as the one-click resend and mailed the caller.What a reading answers
Content-Type. These routes never read the media type to parse:request.json()parses a valid JSON body sent astext/plain, and so doesJSON.parseovertext(). They still do not, so the transports'text/plainrefusal (PR fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005 step 4) is not matched. Matching it would narrow a second input that these routes never distinguished.application/json, not blank, does not parse: refused, "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all."Content-Type, a zero-byte body withContent-Type: application/jsonandContent-Length: 0, or a whitespace-only body. Each family's pin drives all three, and each still gets today's answer (the table above). For the resend, that answer is still the one-click resend to the caller's own address, which thesys_useraction fires with no body.{}, the same rule ascloud-connectionandcreateHonoApp(Acceptance notes).Order. Each route refuses where it read its body.
toggle-disabled,unlock-user,sys-oauth-application/registerand the resend read before anything else runs, as they always did. An anonymous caller's malformed body there is answered 400, and it was answered 400 "... is required" before. The import and the four SSO bridges read after their platform-admin gate. Nothing is looked up, written, sent or forwarded to better-auth.Exported bridges.
runAdminImportUsers,runRegisterSsoProviderFromForm,runRegisterSamlProviderFromForm,runRequestDomainVerification,runVerifyDomainandrunResendVerificationEmailanswer the same refusal. A host that mounts them itself, such as the cloud auth proxy, gets the refusal with its next dependency bump. Signatures are unchanged.Changeset:
minor, not the dispatchedpatchThe dispatch asked for a
patchchangeset. Under this PR'sClause-②: no (narrowing)line, the requiredCheck Changesetlevel axis refusespatch. Both were measured at the same tree withnode scripts/check-changeset-no-major.mjs --base 2b274600b1 --head ... --eventand apull_requestpayload carrying this body's first three lines:plugin-auth: patch(a scratch commit object, never pushed) exits 1: "clause-② declaresno (narrowing)while no moved package is gradedminoror above".plugin-auth: minor(dccf68bae1) exits 0: "LEVEL AXIS: this PR declares clause-②no (narrowing), and no package whose published source ... it moves is gradedpatch".So
.changeset/24032-plugin-auth-body-refusal.mdisminorunder the launch-window convention for breaking changes, the same grade as PR #24005's three changesets. Its first line after the frontmatter isClause-②: no (narrowing). It carries the**BREAKING**banner, an ADR-0087 marker of kindnot-required (no-migration-prescription)with the other categories closed on facts, the routes, the FROM / TO for an integrator, the one-line remedy, and a statement that an empty body is unchanged.Tests
New:
src/request-json-body-refusal.test.ts. It had 43 cases at round 0, and 71 after patch round 1 added blocks E to I for the seven more readers. The ablation turns 21 red, and no CONTROL goes red (addendum6225763180). It drives the plugin's realregisterAuthRouteson a real Hono app (theadmin-sso-bridge-gate.test.tsharness), with one block per family:{ not json, both refused, plus three empty CONTROLs. Further rows onunlock-user: an unreadable stream, refused; CONTROL, a well-formed body acted on; CONTROL, valid JSON sent astext/plainacted on; CONTROL, an undeclared truncated body is still{}.Each refusal asserts four things:
envelopeViolations(body)empty, andcode;unlockUser, no engine call, nocreateOAuthClient, no better-auth call, nothing sent.admin-import-users.jsis loaded at module top. The route's ownimport()measured 8.7 s inside a 10 s clocked test on the first run.Fixture triage: in
send-verification-email.test.ts, the row "tolerates a non-JSON body and falls back to the session email" sentnot-json{asapplication/json. It pinned exactly the branch this removes. That row now pins the undeclared spelling (text/plain), which is unchanged, and a new row pins the declared refusal with no session lookup and nothing sent.Runs (all under
scripts/pm/os-verify-lock.sh, exit codes captured before any pipe):2b274600b1's sources): 13 failed, 30 passed. The 13 are every refusal row, each failing on today's required-field answer or the 200 resend. The 30 are every CONTROL.8d97b20b34): the new file plus the 7 neighbouring files (send-verification-email,register-sso-provider,admin-sso-bridge-gate,admin-import-users,principal-less-producers-system-context, bothsso-domain-verificationpins): 8 files, 175 passed.1f1a7fd026(mergesorigin/mainb74434c581; install, closure build and full build refreshed after the merge):pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 151 files, 2986 passed, 10 skipped (pre-existing; none in these files).pnpm --filter @objectstack/plugin-auth typecheck: exit 0,check:test-typecheckOK, its debt ledger unchanged.pnpm exec eslint --no-inline-configover the 7 changed.tsfiles: exit 0.Ablation (WRAP mode through
scripts/ablation-replace.mjs, on8d97b20b34)The suites import their subject by relative path, so they run on source and need no rebuild.
request-json-body.tsreturn { ok: false, message: MALFORMED_JSON_BODY_MESSAGE };becomesreturn { ok: true, body: {} };71516e4a3cd6toa2d9e371a38csend-verification-email.test.ts. 38 passed: every CONTROL, and the unreadable row, which is the other branch{ ok: false, message: UNREADABLE_BODY_MESSAGE }becomes{ ok: true, body: {} }71516e4a3cd6to97a4b1b53117Each leg's anchor hit once, and the write was proven on disk. The restore was proven by blob
71516e4a3cd6matchingHEAD, withgit diff HEADempty. After both legs, the marker count is 0 andgit status --porcelainis 0 lines.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon1f1a7fd026(merge baseb74434c58, 8 paths) derived 69 commands. All 69 ran with the documented capture idiom. 68 exited 0 on the first pass.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET, nodist/for 41 packages). Afterpnpm turbo run build --filter='!@objectstack/docs' --concurrency=2(75 of 75 tasks), the same command exited 0: 110 entry points across 68 packages load.--ranover the final record: "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN".These include
check:route-envelope,check:dispatcher-error-vocabulary,check:error-status-conformance,check:auth-mount-ledger,check:test-source-alias,check:cross-package-test-inputs,check:type-check-coverage,check:type-check-debt,check:nul-bytes,check:published-files,check:dts-closure,check-adr-0087-registration,check-changeset-no-majorandcheck-empty-changeset. Not run locally:test:repoand the repo-widepnpm lint. CI owns both.origin/mainhas moved three commits pastb74434c581. One of them,invitation-role-cap.tsand its test, touches this package but none of these files.git merge-tree --write-tree HEAD origin/mainexits 0.Acceptance notes
A body that does not declare JSON and does not parse is still
{}. This is the same rule ascloud-connectionandcreateHonoApp. At the resend, it still means the one-click resend to the caller's own address, reachable only by a client that sends a non-JSON media type. Refusing it too would narrow a second input. Carrier: none.A fourth copy of the two messages.
plugin-hono-server,hono,cloud-connectionand this package each keep a module-private copy, per ruling B. A cross-package parity test would importplugin-hono-server's builtdist/into this package's tests, whichcheck:test-source-aliasreports as a new unaliased import. So the first sentence is pinned here instead.Seven more routes in this package carry the same swallow under another spelling, outside this claim's file surface. Each reads through a module-private
parseJsonthat returns{}, or throughparsed = {}:admin-ban-endpoints.ts:106:ban-user,unban-user;admin-set-user-manager.ts:217:set-user-manager;admin-user-endpoints.ts:217:create-user,set-user-password;organization-add-member.ts:100:organization/add-member;set-initial-password.ts:57:set-initial-password, also an exported bridge.Each answered
{}with a required-field refusal. Neither the card's grep nor triage'sbody = {}count reached them. These are now in this PR: the seat answered the dev's open question with A (claim amendment6225083981), and patch round 1 converted all seven readers toreadJsonRequestBody, one pin block each.set-initial-password's signature and result are unchanged; its callers see only the refusal's message.Two other body reads in the package are not this family.
auth-manager.ts:7201is a best-effort probe over a clone for trusted origins: a parse failure there is[], and better-auth then judges the body itself./admin/has-permission(auth-plugin.ts:3206) hands an unreadable body to better-auth's own 400.Contract review
Clause-②: no (narrowing): this owes oneCONTRACT_REVIEW_TIERreview before the queue, as PR #24005 did. No governed surface is touched. The record is6226126497, PASS, at7160ac93d7.Seat edit ·
domain:servicesseat 2 ·session_018VqJPGA1NDNSCZsnecsChP· 2026-10-16T22:55Z: brought to patch round 1. The route count is sixteen, the seven routes are now in this PR, the test count is updated, the contract record is linked, and the title follows.Generated by Claude Code