Skip to content

fix(plugin-auth)!: sixteen auth routes refuse an unparseable declared-JSON request body with 400 INVALID_REQUEST instead of reading it as {} - #24049

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24032-auth-malformed-body-refusal
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24032-auth-malformed-body-refusal

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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 amendment 6225083981, addendum 6225763180. A truncated body was answered as if the client had sent none. Each of them now refuses a body that is declared application/json, is not blank, and does not parse. The refusal is 400 INVALID_REQUEST in the ADR-0112 envelope, with the same code and message the HTTP transports (plugin-hono-server, hono) and cloud-connection give the same body since PR #24005. INVALID_REQUEST is an existing ERROR_CODE_LEDGER code, 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 from index.ts, and nothing is imported from plugin-hono-server or cloud-connection (ruling B on #23914: no new export). As in cloud-connection, it answers a message, and each route builds its own 400 body beside its other refusals.

Premise, measured on origin/main 2b274600b1

All nine sites swallow a parse failure into {}. Triage counted 9 and the card listed 7. The card's grep missed register-sso-provider.ts:101 and send-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. :96 is the one site where {} is an action rather than a refusal.

site route (/api/v1/auth) what the handler answers for {} family
auth-plugin.ts:2735 POST /admin/oauth2/toggle-disabled 400 INVALID_REQUEST "client_id is required", before the platform-admin gate A, inline mount
auth-plugin.ts:2870 POST /admin/unlock-user 400 "userId is required", before the gate A
auth-plugin.ts:3423 POST /sys-oauth-application/register 400 "name is required" A
admin-import-users.ts:493 POST /admin/import-users after the gate: 400 "Provide format:..." (503 when no data engine) B, identity import
register-sso-provider.ts:101 POST /admin/sso/register after the gate: 400 "Missing required field(s): providerId, issuer, domain, clientId, clientSecret" C, SSO bridges
register-sso-provider.ts:250 POST /admin/sso/register-saml 400 "Missing required field(s): providerId, issuer, domain, entryPoint, cert" C
register-sso-provider.ts:444 POST /admin/sso/request-domain-verification 400 "Missing required field: providerId" C
register-sso-provider.ts:501 POST /admin/sso/verify-domain 400 "Missing required field: providerId" C
send-verification-email.ts:96 POST /send-verification-email an action: with a session, it resends the verification link to the caller's own address (200). Without a session, 400 "email is required (sign in to resend to your own address)" D, self-service resend

So, 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

  • A body that parses: that value, as before, whatever its Content-Type. These routes never read the media type to parse: request.json() parses a valid JSON body sent as text/plain, and so does JSON.parse over text(). They still do not, so the transports' text/plain refusal (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.
  • Declared 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."
  • Declared JSON whose stream cannot be read: refused with the same code, "The request body could not be read. It was refused rather than read as an empty body."
  • Empty: unchanged. That is no body and no Content-Type, a zero-byte body with Content-Type: application/json and Content-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 the sys_user action fires with no body.
  • Undeclared and does not parse: still {}, the same rule as cloud-connection and createHonoApp (Acceptance notes).

Order. Each route refuses where it read its body. toggle-disabled, unlock-user, sys-oauth-application/register and 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, runVerifyDomain and runResendVerificationEmail answer 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 dispatched patch

The dispatch asked for a patch changeset. Under this PR's Clause-②: no (narrowing) line, the required Check Changeset level axis refuses patch. Both were measured at the same tree with node scripts/check-changeset-no-major.mjs --base 2b274600b1 --head ... --event and a pull_request payload carrying this body's first three lines:

  • plugin-auth: patch (a scratch commit object, never pushed) exits 1: "clause-② declares no (narrowing) while no moved package is graded minor or above".
  • plugin-auth: minor (dccf68bae1) exits 0: "LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose published source ... it moves is graded patch".

So .changeset/24032-plugin-auth-body-refusal.md is minor under the launch-window convention for breaking changes, the same grade as PR #24005's three changesets. Its first line after the frontmatter is Clause-②: no (narrowing). It carries the **BREAKING** banner, an ADR-0087 marker of kind not-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 (addendum 6225763180). It drives the plugin's real registerAuthRoutes on a real Hono app (the admin-sso-bridge-gate.test.ts harness), with one block per family:

  • A: the three inline routes. Each gets a truncated body and { not json, both refused, plus three empty CONTROLs. Further rows on unlock-user: an unreadable stream, refused; CONTROL, a well-formed body acted on; CONTROL, valid JSON sent as text/plain acted on; CONTROL, an undeclared truncated body is still {}.
  • B: the import. A truncated body is refused, plus three empty CONTROLs.
  • C: the four SSO bridges. Each gets a truncated body, refused, plus three empty CONTROLs.
  • D: the resend. A truncated body naming another address is refused, plus three empty CONTROLs that still resend to the session address.

Each refusal asserts four things:

  • the status, envelopeViolations(body) empty, and code;
  • the message's first sentence, since that message family is the card's contract;
  • that the message does not echo the body;
  • that nothing after the read ran: no unlockUser, no engine call, no createOAuthClient, no better-auth call, nothing sent.

admin-import-users.js is loaded at module top. The route's own import() 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" sent not-json{ as application/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):

  • Today's code (the new file against 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.
  • Fix (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, both sso-domain-verification pins): 8 files, 175 passed.
  • Head 1f1a7fd026 (merges origin/main b74434c581; 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-typecheck OK, its debt ledger unchanged.
    • pnpm exec eslint --no-inline-config over the 7 changed .ts files: exit 0.

Ablation (WRAP mode through scripts/ablation-replace.mjs, on 8d97b20b34)

The suites import their subject by relative path, so they run on source and need no rebuild.

leg mutation in request-json-body.ts blob result
malformed return { ok: false, message: MALFORMED_JSON_BODY_MESSAGE }; becomes return { ok: true, body: {} }; 71516e4a3cd6 to a2d9e371a38c 13 failed: the 12 malformed refusal rows across families A to D, and the declared-refusal row in send-verification-email.test.ts. 38 passed: every CONTROL, and the unreadable row, which is the other branch
unreadable { ok: false, message: UNREADABLE_BODY_MESSAGE } becomes { ok: true, body: {} } 71516e4a3cd6 to 97a4b1b53117 1 failed (the unreadable row), 42 passed

Each leg's anchor hit once, and the write was proven on disk. The restore was proven by blob 71516e4a3cd6 matching HEAD, with git diff HEAD empty. After both legs, the marker count is 0 and git status --porcelain is 0 lines.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1f1a7fd026 (merge base b74434c58, 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-loads exited 3 (PREREQUISITE NOT MET, no dist/ for 41 packages). After pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2 (75 of 75 tasks), the same command exited 0: 110 entry points across 68 packages load. --ran over 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-major and check-empty-changeset. Not run locally: test:repo and the repo-wide pnpm lint. CI owns both.

origin/main has moved three commits past b74434c581. One of them, invitation-role-cap.ts and its test, touches this package but none of these files. git merge-tree --write-tree HEAD origin/main exits 0.

Acceptance notes

  • A body that does not declare JSON and does not parse is still {}. This is the same rule as cloud-connection and createHonoApp. 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-connection and this package each keep a module-private copy, per ruling B. A cross-package parity test would import plugin-hono-server's built dist/ into this package's tests, which check:test-source-alias reports 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 parseJson that returns {}, or through parsed = {}:

    • 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's body = {} count reached them. These are now in this PR: the seat answered the dev's open question with A (claim amendment 6225083981), and patch round 1 converted all seven readers to readJsonRequestBody, 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:7201 is 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 one CONTRACT_REVIEW_TIER review before the queue, as PR #24005 did. No governed surface is touched. The record is 6226126497, PASS, at 7160ac93d7.

Seat edit · domain:services seat 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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-auth, touching 27 documentable anchor(s).

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 905f57bc7480ea9838b99b65b99f94aa54431e1b.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 905f57bc7480ea9838b99b65b99f94aa54431e1b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 921b63d84c59cee2d73d93b78bc9e115a477b7c7 — the merge of head 7160ac93d7216d3ab9be492350ae7b9fdaf1d2e5 into base 905f57bc7480ea9838b99b65b99f94aa54431e1b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 905f57bc7480ea9838b99b65b99f94aa54431e1b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Patch round 1 addendum: os-dev, #24032, claim amendment 6225083981 (answer A)

New head: 7160ac93d7. That is round base 1f1a7fd026, plus one merge of origin/main 5c2ac83a23 (15bac5268d), plus e71777cdfd (the seven readers, pins and changeset), plus 7160ac93d7 (one test row triaged). There was no rebase and no force-push. The PR body is untouched, and the PR stays draft.

  1. Worktree: re-created on the PR branch at 1f1a7fd026. origin/main was merged once, cleanly, with no regeneration pending. Install, the full build (75 of 75 tasks) and the plugin-auth build were refreshed after the merge.
  2. Readers: all seven now go through readJsonRequestBody.
    • Each module-private parseJson (admin-ban-endpoints.ts, admin-set-user-manager.ts, admin-user-endpoints.ts, organization-add-member.ts) now returns the reading. Its handler refuses with the module's own 400 INVALID_REQUEST builder. set-user-manager refuses under its existing invalid_body reason. A well-formed non-object body still reads as no fields.
    • set-initial-password.ts refuses before setPassword.
    • The reader is still not re-exported, and no export was added anywhere.
    • set-initial-password callers (the cloud auth proxy included) see no change beyond the refusal. The signature and SetInitialPasswordResult are unchanged. A body that parses is read as before. An empty body still answers 400 "newPassword is required". setPassword is reached on exactly the bodies it was reached on before. For a declared-JSON body that does not parse, the status 400 and code INVALID_REQUEST are what it already got; only the message changes.
  3. Pins: blocks E to I in request-json-body-refusal.test.ts, one per reader, each driven through the real registerAuthRoutes. Every route gets a truncated declared-JSON body, answered 400 INVALID_REQUEST with no echo and nothing run after the read: no auth-api call, no auth-context read, no engine call. Every route also gets three empty CONTROLs (no body, a zero-byte body with Content-Length: 0, whitespace only), each at today's answer. The file grows from 43 to 71 cases. Triage: set-initial-password.test.ts's "rejects a non-JSON body with 400" asserted only 400 and INVALID_REQUEST, which the swallow also answered. It now asserts the parse refusal with setPassword not called, and a new row pins the undeclared text/plain spelling, which is unchanged.
  4. Changeset: it now lists sixteen routes and the eleven exported bridges they mount. It stays minor, and its first line is still Clause-②: no (narrowing).
  5. Gates and tests at 7160ac93d7:
    • dispatch-gates --commands --repo objectstack-ai/objectstack derives 69 commands, the same set as round 0. All 69 exit 0, with each exit captured before any pipe. --ran reads "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN".
    • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 on e71777cdfd: 152 files, 3028 passed, 10 skipped (pre-existing). The 7160ac93d7 delta is that one test file: the three touched files give 87 of 87 passed.
    • typecheck exits 0, with the test-layer ledger unchanged. eslint --no-inline-config over the 8 changed .ts files exits 0.
  6. Addendum: this comment, posted once. The PR body is not edited, and the PR is left draft.
  7. Worktree: removed after this comment posts, node_modules first, without --force.

Ablation: scripts/ablation-replace.mjs in WRAP mode, run at the new head. return { ok: false, message: MALFORMED_JSON_BODY_MESSAGE }; became return { ok: true, body: {} }; (blob 47763441e87b to 4235daa1e692).

  • 21 failed, 66 passed. The red rows are the 19 malformed refusal rows (12 from round 0, plus ban-user, unban-user, set-user-manager, create-user, set-user-password, organization/add-member and set-initial-password), the resend's declared-refusal row, and the triaged set-initial-password row. No CONTROL row and no undeclared row went red.
  • The same leg at e71777cdfd, before the triage, read 20 failed and 59 passed.
  • After each leg, the restore was proven: the blob equals HEAD 47763441e87b, git diff HEAD is empty, the marker count is 0, and git status --porcelain shows 0 lines.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7160ac93d7216d3ab9be492350ae7b9fdaf1d2e5
Local-runs: none
Scope: packages/plugins/plugin-auth/src/request-json-body.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/plugins/plugin-auth/src/register-sso-provider.ts, packages/plugins/plugin-auth/src/send-verification-email.ts, packages/plugins/plugin-auth/src/admin-ban-endpoints.ts, packages/plugins/plugin-auth/src/admin-set-user-manager.ts, packages/plugins/plugin-auth/src/admin-user-endpoints.ts, packages/plugins/plugin-auth/src/organization-add-member.ts, packages/plugins/plugin-auth/src/set-initial-password.ts, packages/plugins/plugin-auth/src/request-json-body-refusal.test.ts, packages/plugins/plugin-auth/src/send-verification-email.test.ts, packages/plugins/plugin-auth/src/set-initial-password.test.ts + packages/plugins/plugin-hono-server/src/request-body.ts, packages/cloud-connection/src/request-json-body.ts (PR #24005's refusal family, the parity this PR claims), packages/plugins/plugin-auth/src/index.ts and package.json (the entry and the exports map), packages/plugins/plugin-auth/src/auth-manager.ts (the ssoDiscoveryTrustedOrigins read at :7194-7212), packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/contract.zod.ts (envelopeViolations), docs/adr/0112-error-code-vocabulary-and-ledger.md, packages/platform-objects/src/identity/sys-user.object.ts, sys-member.object.ts, sys-sso-provider.object.ts and sys-oauth-application.object.ts (the declared producers), .changeset/pre/22398-plugin-auth-session-redispatch.md (the cloud auth proxy's mount list), and in objectui at the pinned sha f8267df93aba: packages/core/src/actions/ActionRunner.ts, packages/app-shell/src/views/identityImport.ts, packages/auth/src/createAuthClient.ts. The changeset .changeset/24032-plugin-auth-body-refusal.md is the prose face, judged in ②.

Net diff against the merge base 5c2ac83a23: 14 files, 754 insertions, 68 deletions; 3 added, 11 modified. No packages/spec path, no governed path, no package entry touched. Everything was read with git show and git grep at the head after git fetch origin pull/24049/head; nothing was built, run or re-run. The cloud repository is not in this container, so the cloud auth proxy's own mount of the shared bridges was not read; that caller is judged on the bridges' signatures and result types, which the diff leaves untouched.

① Derived judgments

1. The reader, request-json-body.ts (:64-81). Right, and in PR #24005's family. "Declared JSON" is (content-type ?? '').toLowerCase().includes('application/json'): the same predicate as plugin-hono-server/src/request-body.ts:134-136 and cloud-connection/src/request-json-body.ts:54, so case, ; charset= parameters and the +json suffix types (not matched, in all three) are decided identically. Blank is text.trim() === '' and answers {} (:78; transports :145 and :64). The body is read as text and parsed with JSON.parse, which is what request.json() and Hono's c.req.json() did, so every body that parsed before parses now, whatever its media type. An unreadable stream is refused only when JSON was declared (:73), otherwise {}: cloud-connection's rule (:55), the right sibling for routes that answer a message per module rather than a transport throw. MALFORMED_JSON_BODY_MESSAGE and UNREADABLE_BODY_MESSAGE are byte-equal to request-body.ts:103-107 and request-json-body.ts:44-48; neither interpolates anything, so the body is never echoed. JsonBodyReading is the cloud-connection shape. Module-private: imported by the nine route modules only, absent from index.ts (read at the head: its export * list carries the six bridge modules and not this one), and the exports map is . and ./rate-limit-storage only. No new export, type or code (ruling B on #23914). INVALID_REQUEST is a ledger member (error-code-ledger.zod.ts:252); the envelope each route builds is ADR-0112's { success: false, error: { code, message } }, which the pins run through envelopeViolations.

2. The sixteen routes: one input narrowed, at the read, before anything runs. Right. Per reader, with the read line at the head:

  • Inline mounts, auth-plugin.ts: /admin/oauth2/toggle-disabled :2738, /admin/unlock-user :2878, /sys-oauth-application/register :3435. The read is the handler's first statement; the refusal precedes the platform-admin gate exactly where the "client_id / userId / name is required" refusal preceded it before, so an anonymous caller's malformed body is answered 400 as it was. Nothing precedes the read but the /* IP gate (:2611-2629), which reads headers only.
  • set-initial-password.ts:59 (runSetInitialPassword), mounted at :2696-2699: the mount resolves getApi() (a handle, no call) and the bridge reads first; setPassword (:81) is reached on exactly the bodies it was reached on.
  • send-verification-email.ts:97 (runResendVerificationEmail), mounted at :3527: read first, before resolveSessionEmail and the re-dispatch.
  • admin-import-users.ts:499, the four SSO bridges register-sso-provider.ts:113, :262, :457, :515, ban/unban admin-ban-endpoints.ts:138 and :203, admin-set-user-manager.ts:372, create-user admin-user-endpoints.ts:562, set-user-password :693, organization-add-member.ts:165: each read is the bridge's first act after the capability probe it already had (typeof authApi.addMember !== 'function', adminPluginMissing, typeof authApi.createUser: typeof tests, not calls), and each mount runs gateAdmin (:2816-2822, one getSession read) before the bridge. That one auth-API call ahead of the read is the route's own authorization, pre-existing and ordered first on purpose (ADR-0068 D4, identity before capability before body); moving the read ahead of it would be the wrong change. runAttributedToUser opens an attribution context and writes nothing.

Nothing after the read runs on a refusal: no getAuthContext, no engine operation, no unlockUser, createOAuthClient, setPassword, createUser, banUser or addMember, no vendor re-dispatch, nothing sent. The route-driven pin's nothingActed() records every one of those seams (request-json-body-refusal.test.ts:62-71, :196-203) and asserts them empty on every refusal row, and getAuthContext in that harness throws, so a route that reached it would answer 500 and fail the row.

3. The answer. Right. Status 400, success: false, error.code INVALID_REQUEST, error.message the transports' first sentence, on all sixteen; pinned per route with the envelope check and not.toContain on a body fragment. set-user-manager answers through its own envelope(deny(400, 'INVALID_REQUEST', 'invalid_body', message)) (admin-set-user-manager.ts:199-216), which adds details: { reason: 'invalid_body' }. Consistent with the other fifteen: same status, code and message; details.reason is the discriminator every refusal this module builds already carries (SetUserManagerRefusalReason, :135-144), not a new dialect, and invalid_body is its existing member for a body that does not say what it must. The pin asserts the reason and then the family envelope (:433-437).

4. What is unchanged. Right, and pinned. An empty body (no body and no Content-Type; a zero-byte body sent as JSON with Content-Length: 0; whitespace only) answers as before on all sixteen routes: forty-eight CONTROL rows, one per route per spelling, each asserting today's exact message (userId is required, Provide format:, Missing required field(s)…, newPassword is required, Either email or phoneNumber is required, and the resend to the session address). A body that parses is read as before whatever its media type (/admin/unlock-user CONTROL, valid JSON as text/plain, acted on, :270-274). A body that does not declare JSON and does not parse is still {} (:276-280, and the unit rows below). The parseJson wrappers in E to H keep their typeof parsed === 'object' ? parsed : {} coercion, so a well-formed non-object body still reads as no fields; the inline mounts and set-initial-password keep their body?.x reads over a null parse. In admin-import-users.ts the read moved below the fail closure definition, which binds nothing.

5. send-verification-email. Right. Before: with a session, {} was the one-click resend to the caller's own address, so a truncated body naming another address mailed the caller. Now a declared body that does not parse is refused before resolveSessionEmail (the unit row asserts handle never called and nothing sent; the route row asserts vendor and sent empty). What still reaches the resend: an empty body in any spelling (the sys_user resend_verification_email action, sys-user.object.ts:531-544, which objectui's ActionRunner posts as application/json with JSON.stringify of the record data, always well-formed), a parsed body with no email, and the remainder the dev names: a body that does not declare JSON and does not parse. That remainder is acceptable, carrier none: it needs a client that sends a non-JSON media type AND garbage; what it then gets is the route's declared answer for "no address supplied", mailing the authenticated caller's own address; and closing it would need the route to read the media type, which would also refuse valid JSON sent as text/plain, a second input these routes have always accepted, outside the card, and pinned here as unchanged. It is the cloud-connection rule, the sibling that also answers a message per route.

6. set-initial-password, the exported bridge the cloud auth proxy mounts. Right. runSetInitialPassword(authApi: SetPasswordCapableApi, request: Request), its result type (a promise of SetInitialPasswordResult) and SetInitialPasswordResult itself (:31-40, :50-53) are untouched by the diff. A caller sees: a parsed body read as before; an empty body still 400 INVALID_REQUEST "newPassword is required"; a declared body that does not parse still 400 INVALID_REQUEST, with the message changed from "newPassword is required" to the parse refusal. Nothing else. The one in-repo client, objectui's createAuthClient.setInitialPassword at the pinned sha, posts JSON.stringify({ newPassword }) as application/json.

7. The other exported bridges. Right. index.ts re-exports set-initial-password, admin-user-endpoints, admin-ban-endpoints, admin-import-users, register-sso-provider and send-verification-email: exactly the eleven bridges the changeset names (runSetInitialPassword, runAdminCreateUser, runAdminSetUserPassword, runAdminBanUser, runAdminUnbanUser, runAdminImportUsers, runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain, runResendVerificationEmail). runSetUserManager and runOrganizationAddMember are lazy-imported by the plugin and not on the entry, so "eleven exported" is exact. Every bridge signature and result type is unchanged; the four parseJson helpers whose return shape changed are module-private async functions with no export. readFormBody in register-sso-provider.ts:47-55 is module-private too.

8. The swallow census after this PR, git grep over plugin-auth/src (non-test) at the head. Right, none left in this family. No catch { body = {} }, catch { return {} } or .json().catch(…) answering {} on a request remains. The four parsed = {} spellings that remain (register-sso-provider.ts:235, :329, :477, :534) and the two .json().catch(…) answering null (register-sso-provider.ts:82, send-verification-email.ts:69) read better-auth RESPONSES (resp), not request bodies. The two the dev names: auth-manager.ts:7201 reads a clone of an /sso/register or /sso/update-provider request to collect trusted-origin candidates, null on failure and [] returned, and better-auth then parses the real body itself and refuses it on its own; auth-plugin.ts:3218 (/admin/has-permission) parses a clone, sets body = undefined on failure, and falls through to handleRequest, where better-auth refuses the body. Neither invents a {} a route acts on. Not this family.

9. Legitimate callers. None refused. Every declared producer in packages/platform-objects (the sys_user, sys_member, sys_sso_provider and sys_oauth_application actions) runs through objectui's ActionRunner (packages/core/src/actions/ActionRunner.ts:2299-2338 at the pinned sha), which always sets Content-Type: application/json and JSON.stringifys an object. The two direct posters, identityImport.ts:199-204 and createAuthClient.ts:661-665, do the same. A curl -d with no header is form-encoded, undeclared, and reads as before.

10. Pins. Right. request-json-body-refusal.test.ts drives the plugin's real registerAuthRoutes on a real Hono app: one block per reader, A to I, nine blocks for the fourteen readers and sixteen routes; per route a truncated declared body refused (status, envelope, code, first sentence, no echo, nothing acted) and the three empty CONTROLs at today's answer; the inline block adds { not json, an unreadable declared stream (the other branch), a well-formed body acted on, valid JSON as text/plain acted on, and an undeclared truncated body still {}. That is 19 refusal rows, 1 unreadable row, 48 empty CONTROLs and 3 further CONTROLs: 71 cases, the dev's count. send-verification-email.test.ts converts the one row that pinned the removed branch (not-json{ as application/json, session fallback) into the undeclared text/plain spelling, still the fallback, and adds the declared refusal with handle not called and nothing sent. set-initial-password.test.ts tightens "rejects a non-JSON body with 400": both files' makeRequest default to application/json, so the old row was a declared body, and 400 INVALID_REQUEST was also what the swallow answered through "newPassword is required"; the row now asserts the parse-refusal prefix and setPassword not called, and a new row pins the undeclared spelling at "newPassword is required". A tightening, not a loosening. The dev's ablation legs (addendum): malformed branch reverted, 21 red (the 19 route rows plus the two unit rows) and 66 green with every CONTROL green; unreadable branch reverted in round 0, 1 red. Those counts match the row census above; they are the dev's readings and were not re-run here.

11. Routes whose answer changes beyond the narrowing. None found. The only other delta on any route is set-initial-password's message for the declared-unparseable case (item 6), which is the narrowing itself.

② Semver level

Right: '@objectstack/plugin-auth': minor with Clause-②: no (narrowing). The diff publishes one released package, @objectstack/plugin-auth (src/** of a package with a . export), and nothing in packages/spec. The accept set of sixteen routes and eleven exported bridges narrows for one input, so the arm is (narrowing), which AGENTS.md classes as BREAKING; scripts/check-changeset-no-major.mjs (the DIRECTION ARM block) ships a declared narrowing as minor in the launch window and refuses patch under it, which the dev measured offline both ways and Check Changeset confirms green on this head. The dispatch's patch was corrected by claim amendment 6225083981. The changeset carries the **BREAKING** banner, the ADR-0087 marker not-required (no-migration-prescription) with the other categories closed on facts (true against the diff: no metadata, stored shape or export moves; the remedy is in client requests), the FROM / TO for an integrator, the one-line remedy, the sixteen routes (verified against the sixteen mounts in auth-plugin.ts), the eleven exported bridges (verified against index.ts, item 7), the statement that signatures and result types are unchanged (true), and the Unchanged paragraph (true, item 4). The PR body's third line carries the same Clause-②: no (narrowing). The quoted message is the constant word for word.

③ Boundary flags

  1. open_questions[0], the seven sibling routes under the second spelling: answered A by the seat (6225083981), delivered in e71777cdfd and 7160ac93d7. Verified: all five files converted, all seven routes read through readJsonRequestBody, blocks E to I pin them, the changeset lists them. Closed.
  2. Deviation, minor not patch: accepted by the amendment; the gate enforces it (②). Closed.
  3. Acceptance note, an undeclared body that does not parse is still {} (carrier none): accepted, with the reasoning in ① item 5. Closed.
  4. Acceptance note, a fourth module-private copy of the two messages: accepted under ruling B; the first sentence is pinned in-package, and a cross-package parity test would import a built dist/ that check:test-source-alias reports. Closed.
  5. Acceptance note, auth-manager.ts:7201 and auth-plugin.ts:3206 are not this family: verified (① item 8). Closed.
  6. test:repo and the repo-wide pnpm lint not run locally: Lint & Repo Gates, Lint · ESLint and the six Test Core shards are green on this head. Closed.
  7. Out-of-scope finding, class a (the seven siblings): folded into this PR by A; no sibling card owed. Closed.
  8. origin/main has moved four commits past the merge base (5c2ac83a23); under plugin-auth they touch auth-plugin.ts (+10, the API key restore: the owner can restore a key that an administrator forced password reset revoked (the compromise default of #23287 ruling C′), though that ruling's changeset names the administrator as the one who restores #23993 kernel:ready registration the claim excluded), forced-reset-revocation.ts, api-key-restore-guard.ts, managed-extension-fields.ts and admin-user-endpoints.test.ts, none of this PR's hunks; the PR reports mergeable. The queue rebuilds the merge; no scoped re-check is owed. Closed.
  9. Docs Drift (advisory, 6225021566): a grep of content/docs at the head for a page restating "a body that does not parse is read as empty" on these routes finds none (content/docs/ui/forms.mdx:226 is the forms slug door, another family). No docs rider owed. Closed.
  10. Escalated to the seat, non-blocking: the PR title and body still count nine routes (written at round 0 and, by the dev's rule, not patched; the addendum 6225763180 carries sixteen). The squash subject is derived from the title, so a sixteen-route change would land in history as "nine auth routes". Refresh the title before arming; a seat act on its own PR, not a dev round.
  11. Escalated, non-blocking: the cloud auth proxy's mount of the six shared bridges was not readable from this container (no cloud checkout); the contract it sees is unchanged by construction (① items 6 and 7), and its dependency bump brings the refusal, as the changeset says.

Check-runs on 7160ac93d7, read once at 2026-10-16T22:40Z, after everything above: 40 runs, all completed, none still running. The five required contexts are success: Lint & Repo Gates, TypeScript Type Check, Test Core (and its six shards), Build Core, Governed Surface Queue Guard. Check Changeset is success. Skipped, as PR runs skip the full battery: Build Docs, Console Pin Gate, Dogfood Regression Gate shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Packed-tarball smoke. No failure. The PR is draft with auto-merge off; this record is what the narrowing owed before the queue. Rendered 2026-10-16T22:46Z.

Implemented-by: claude/issue-24032-auth-malformed-body-refusal
Reviewed-by: session_018VqJPGA1NDNSCZsnecsChP

VERDICT: PASS

@objectstack-fleet objectstack-fleet Bot changed the title fix(plugin-auth)!: nine auth routes refuse an unparseable declared-JSON request body with 400 INVALID_REQUEST instead of reading it as {} fix(plugin-auth)!: sixteen auth routes refuse an unparseable declared-JSON request body with 400 INVALID_REQUEST instead of reading it as {} Oct 16, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-16T22:57Z

Reviewed against GitHub at head 7160ac93d7. The contract record of record is 6226126497, PASS (CONTRACT_REVIEW_TIER, narrowing face), owed by Clause-②: no (narrowing). It has no FAIL reasons.

Form

What lands

The seat's acts on this PR. The dev's open question was answered A in claim amendment 6225083981. The title and body were brought to sixteen routes, with the record linked, in this act's preceding writes.

Gates and CI

  • The dev's readings at 7160ac93d7: 69 derived, 69 exit 0, reconciled with --ran. The plugin-auth suite passes 152 files and 3,028 tests; the pin file passes 71/71, and the ablation turns 21 red with no CONTROL red.
  • CI at 7160ac93d7: latest run per check name, 40 names, 32 success, 0 failure, 8 skipped.
    • Six skips are the roster's six, Build Docs included.
    • Two are the label-event re-runs of Auto Label and Check PR Size. Both passed earlier on this head.
    • Three claim-guard runs were cancelled when the title and body edits re-triggered them, and their later runs passed.
  • All required contexts are green.

ACCEPT. This act marks the PR ready and enables auto-merge through the fleet relay. It lands through the merge queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 22:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 22:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit ab3ba2c Oct 16, 2026
52 of 55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24032-auth-malformed-body-refusal branch October 16, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants