Skip to content

fix(plugin-auth): every in-process auth bridge hands handleRequest the request's transport - #24931

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24877-bridge-transport
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24877-bridge-transport

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #24877

Clause-②: no

What changes

AuthManager.handleRequest(request, transport?) establishes a request's client address by the platform's client-address trust rule (ADR-0069 D5/D7, resolveClientAddress, server.trustProxy): forwarded headers only when the deployment declares a trusted proxy, the transport's peer address otherwise. Every in-process bridge in auth-plugin.ts that re-dispatches through handleRequest now judges the re-dispatched request on the same facts as the request it serves, on both branches of that rule:

  • The transport. Each bridge hands handleRequest the transport of the request it serves, as the catch-all auth mount does (transportOf(c)).
  • The forwarding headers. The six bridges whose helper rebuilds the request before re-dispatching it now carry the incoming request's forwarding headers onto it. The list is the rule's own, FORWARDED_CLIENT_ADDRESS_HEADERS. resolveClientAddress now iterates that same list, and the helpers copy it through one shared function, so no helper names a header itself. Without a declared trusted proxy, the rule ignores those headers and the re-dispatched request is judged on the connection peer. With one, it is judged on the forwarded client address.
Bridge How it reaches handleRequest Transport Forwarding headers
POST /admin/remove-user delegates the request itself handleRequest(c.req.raw, transportOf(c)) already on the request
POST /admin/has-permission (delegate lane) delegates the request itself handleRequest(c.req.raw, transportOf(c)) already on the request
POST /admin/sso/register runRegisterSsoProviderFromForm(handle, …) handle is handleWithTransportOf(c) forwardAuthHeaders (now also used here), and the session read
POST /admin/sso/register-saml runRegisterSamlProviderFromForm(handle, …) handle is handleWithTransportOf(c) forwardAuthHeaders (now also used here), and the session read
POST /admin/sso/request-domain-verification runRequestDomainVerification(handle, …) handle is handleWithTransportOf(c) forwardAuthHeaders
POST /admin/sso/verify-domain runVerifyDomain(handle, …) handle is handleWithTransportOf(c) forwardAuthHeaders
POST /send-verification-email runResendVerificationEmail(handle, …) handle is handleWithTransportOf(c) the session read and the send
POST /sys-session/revoke sysSessionRevokeDepsFor(manager, …), whose handle calls manager.handleRequest the manager slice's handleRequest is handleWithTransportOf(c) the owner lane's re-dispatch

handleWithTransportOf(c) is one helper beside gateAdmin: (request) => this.authManager!.handleRequest(request, transportOf(c)). The list and the one copy function, carryForwardedClientAddress(from, to), live in a new internal module, client-address-forwarding.ts. That module is import-free and is not on the package's exports map. Neither the package root nor the published ./client-address entry re-exports it, so this PR adds no published export: the built ./client-address entry still exports resolveClientAddress alone. client-address.ts imports the list for resolveClientAddress. The three helper modules call the shared function: twice in register-sso-provider.ts (forwardAuthHeaders and the session read), twice in send-verification-email.ts (the session read and the send), and once in sys-session-revoke.ts (the owner lane). The OIDC and SAML register builders were byte-identical to forwardAuthHeaders and now call it. transportOf and handleRequest are unchanged. resolveClientAddress decides exactly as before: it reads X-Forwarded-For, then X-Real-IP, now from the list rather than from two literals.

The eighth bridge

The card lists seven sites, from git grep -n "handleRequest(" -- packages/plugins/plugin-auth/src/auth-plugin.ts. POST /sys-session/revoke is an eighth. Its owner lane re-dispatches through handleRequest one hop away, inside sysSessionRevokeDepsFor (sys-session-revoke.ts: handle: (request) => manager.handleRequest(request)), so that grep does not list it. It is the same rule and the same mechanical fix, so it is fixed here. The mount hands the factory a manager slice whose handleRequest carries the transport. The seat accepted it and amended the claim's surface.

Pins

packages/plugins/plugin-auth/src/bridge-redispatch-transport.pin.test.ts uses the D5/D7 real-mount fixture from client-address-vendor-trust-rule.test.ts: AuthPlugin.registerAuthRoutes on a real Hono app in front of a real AuthManager on the installed better-auth, with the peer on c.env.incoming. It runs two deployments: one declaring no trusted proxy, one declaring trustProxy. Each pin reads the client address carried by every request the bridge hands the vendored library's handler. That address is the platform-owned header handleRequest writes from the address it established. The bridge's own inner route must be among those requests, so a bridge that never reached the library cannot pass vacuously. Every request also carries the platform-owned header naming another address, and on neither branch may it decide anything.

No trusted proxy declared (the caller's forwarding headers name another address):

  • control: a request through the catch-all is judged on its connection peer
  • a request through ROUTE is judged on its connection peer when no trusted proxy is declared, once for each of the eight bridges

A trusted proxy declared (the connection peer is the proxy hop; each pin sends X-Forwarded-For and, separately, X-Real-IP):

  • control: a request through the catch-all is judged on the forwarded client address
  • a request through ROUTE is judged on the forwarded client address when a trusted proxy is declared, once for each of the eight bridges

Ablation

Each leg ran through scripts/ablation-replace.mjs with the anchor hit count declared and the mutation confirmed on disk. Legs A to C ran at 486e46d1b; auth-plugin.ts and the pin file are byte-identical at this PR's head. Legs D to G ran at this PR's head, fbd92ad31. Each restore was proven: blob equals HEAD's, git diff HEAD is empty, and the tree is clean. The test imports its subjects from src/, so no dist/ sits on the path.

Leg Forbidden behaviour put back Red (Tests N failed | M passed (28))
A handleWithTransportOf drops the transport 6: the no-trust pins of sso/register, sso/register-saml, request-domain-verification, verify-domain, send-verification-email and sys-session/revoke
B the two delegate sites drop transportOf(c) (2 hits) 2: the no-trust pins of remove-user and has-permission
C the revoke mount goes back to sysSessionRevokeDepsFor(this.authManager!, basePath) 1: the no-trust pin of sys-session/revoke
D register-sso-provider.ts stops calling the shared copy (2 calls) 4: the trusted-proxy pins of sso/register, sso/register-saml, request-domain-verification and verify-domain
E send-verification-email.ts stops calling it (2 calls) 1: the trusted-proxy pin of send-verification-email
F sys-session-revoke.ts stops calling it (1 call) 1: the trusted-proxy pin of sys-session/revoke
G the shared carryForwardedClientAddress copies nothing 6: the trusted-proxy pins of the six helper bridges

Every other pin, and both controls, stayed green in every leg.

Verification

All readings at fbd92ad31: this branch with origin/main 74fcec1ed merged. No later main commit touches plugin-auth or the lockfile.

  • pnpm --filter '@objectstack/plugin-auth...' build, then pnpm --filter @objectstack/plugin-auth run typecheck: exit 0. That covers tsc --noEmit, the examples config and check:test-typecheck: OK, whose debt ledger is unchanged at 10 files / 88 errors / 23 signatures. The pin file is inside that program and has no entry in the ledger.
  • The built entries were read back. dist/client-address.js and dist/client-address.mjs export resolveClientAddress alone and require nothing. The package root exports neither the list nor the copy. Spot checks of resolveClientAddress gave the same answers as before: the chain's first entry, then X-Real-IP, the peer when trust is undeclared, and undefined when neither is present.
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: Test Files 172 passed (172), Tests 3309 passed | 10 skipped (3319).
  • node scripts/pm/dispatch-gates.mjs --commands with no paths printed 72 commands. Each ran with its exit code captured before any pipe. 71 exited 0 on the first pass. check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: no workspace dist/ yet in this fresh worktree). After a later gate in the same pass had built the workspace, it re-ran and exited 0. --ran reconciled them: 72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED.
  • check:published-readme-exports exited 0, with 0 entries in its baseline. The roster gates under touched directories also exited 0: check-changeset-fixed, check:authz-resolver, check:error-code-casing and check:filter-alias-parity. eslint (--no-inline-config) on the seven changed source files exited 0.
  • CI on fbd92ad31: 41 check-runs, 34 success and 7 skipped, among them Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core and Governed Surface Queue Guard.

Acceptance notes

  • sysSessionRevokeDepsFor could take the transport itself. That edit lands in the factory's signature, outside this card's surface. The slice keeps the transport change in auth-plugin.ts.
  • IAuthService.handleRequest(request) in the spec contract has no transport parameter, and two callers outside plugin-auth hand the request alone. Their reach is unmeasured, and both sit in landings this card excludes. Carrier: none.
  • No new gate. The pins sit beside the code, and a bridge added later is covered only when its PR pins it.

Generated by Claude Code

…uest's transport

The client-address trust rule (ADR-0069 D5/D7) establishes a re-dispatched
request's address from the transport's peer when no trusted proxy is
declared. Eight raw mounts re-dispatched through AuthManager.handleRequest
with the request alone; each now hands over transportOf(c), as the
catch-all does. Pinned per bridge.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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 feb09db14a6b84dd91ca756160ef101222bdd1dc → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 927190cdc53f13d9485584db27a0020a9fd75d4b — the merge of head fbd92ad310df75fe938242f6d4af858f05b90f6b into base feb09db14a6b84dd91ca756160ef101222bdd1dc, 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 927190cdc53f13d9485584db27a0020a9fd75d4b && git checkout 927190cdc53f13d9485584db27a0020a9fd75d4b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin feb09db14a6b84dd91ca756160ef101222bdd1dc fbd92ad310df75fe938242f6d4af858f05b90f6b && git checkout -B drift-repro feb09db14a6b84dd91ca756160ef101222bdd1dc && git merge --no-ff fbd92ad310df75fe938242f6d4af858f05b90f6b

node scripts/docs-audit/affected-docs.mjs --json feb09db14a6b84dd91ca756160ef101222bdd1dc

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

REWORK (patch round 1) · #24877 · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T16:05Z · head 7d167c2e51, claim 6292784934 (file surface widened in this act). Security family: the disclosure ruling recorded on #9387 (5323310017) applies to this record too.

The diff matches the card. Every bridge now reaches handleRequest with the transport of the request it serves: five take their handler from the one helper handleWithTransportOf(c), and two pass transportOf(c) inline. transportOf, resolveClientAddress and handleRequest are reused unchanged. The eighth bridge (POST /sys-session/revoke), which reaches handleRequest one hop away through sysSessionRevokeDepsFor, is accepted as a deviation: same file, same defect class, its own pin and ablation leg. The seat amends the claim's surface for it. One item is owed before ACCEPT:

  1. The rule holds on the trusted-proxy branch too, at the same doors.
    • Six bridges re-dispatch a fresh request that their helper builds (register-sso-provider.ts forwardAuthHeaders and its callers, send-verification-email.ts, sys-session-revoke.ts's owner lane). That request carries the credential and origin headers but not the forwarding facts the trust rule reads. Under a declared server.trustProxy, the re-dispatched request is therefore judged on different facts than the request the bridge serves.
    • Fix: the re-dispatched request carries the same forwarding facts. The list comes from the one rule's module (client-address.ts, e.g. an exported constant of the headers resolveClientAddress reads); ⛔ no hand-copied header names in the helpers.
    • Under an undeclared trust the copied headers are ignored by the rule, so that branch is unchanged.
    • Pins: extend bridge-redispatch-transport.pin.test.ts with the declared-trust branch per bridge family. Names state the rule ("…is judged on the forwarded client address when a trusted proxy is declared"). Ablation-verify each.
    • The claim's surface is widened in this act to those three helpers' re-dispatch header builders, and client-address.ts for the exported list, only.

The dev's second note stays an Acceptance note, carrier none: IAuthService.handleRequest(request) in the spec contract has no transport parameter, and two callers outside plugin-auth (the Hono adapter's auth mount, the runtime dispatcher's auth domain) hand the request alone. Reach is unmeasured, and both sit in landings this card excludes.


Generated by Claude Code

… the trust rule reads

The six bridges whose helper rebuilds the request before re-dispatching it
now carry the incoming request's forwarding headers onto it, read from the
one list the client-address trust rule reads (FORWARDED_CLIENT_ADDRESS_HEADERS,
exported from client-address.ts, which resolveClientAddress now iterates).
Under a declared server.trustProxy the re-dispatched request is judged on
the forwarded client address; without one the rule ignores these headers.
Pins extended with the declared-trust branch per bridge.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Oct 20, 2026
The declared-trust pins passed an inferred union of two object literals
(each with the other key optional-undefined) where through() takes a
Record of strings: TS2345 twice under tsconfig.test.json.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

REWORK (patch round 2) · #24877 · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T17:27Z · head 486e46d1b3, claim 6292784934. Security family: the disclosure ruling recorded on #9387 (5323310017) applies.

Patch round 1 is accepted on its content:

  • All eight bridges now judge the re-dispatched request on the same facts as the request they serve, on both branches of the trust rule.
  • Ablation legs A–F went red exactly where predicted.
  • The two CI type errors are fixed in the test, with the debt ledger untouched.
  • CI on 486e46d1b: 41 check-runs, 34 success, 7 skipped, 0 failed.
  • Accepted deviations:
    • resolveClientAddress iterates the list, with identical decisions;
    • the OIDC and SAML builders now call forwardAuthHeaders;
    • the session reads also carry the headers.

One item is owed before ACCEPT:

  1. No new published export.

The seat writes the PR body from the dev's replacement (pr-body-r1.md, updated for this round) at ACCEPT. The dev's out-of-scope note on IAuthService.handleRequest(request) stays an Acceptance note, carrier none.


Generated by Claude Code

…ve in an internal module

FORWARDED_CLIENT_ADDRESS_HEADERS moves off the published ./client-address
entry into client-address-forwarding.ts, which is on no exports map and
re-exported from neither the package root nor ./client-address.
resolveClientAddress imports the list from it and decides as before.
The three module-local copies of carryForwardedClientAddress are replaced
by the one shared function beside the list. The changeset no longer names
a subpath export.

Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m and removed size/l labels Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #24877 · domain:services seat 2 · os-support-ai · session_01UmqzD97e14iws6hmVhgZ5y · 2026-10-20T18:19Z · head fbd92ad310, after patch rounds 1 (REWORK 6295342749) and 2 (REWORK 6296974750), the review of record for claim 6292784934. Security family: the disclosure ruling recorded on #9387 (5323310017) applies to this record too.

  1. Form:
    • The first line is Fixes #24877; the seat's grep of the body finds no other closing keyword beside an issue number.
    • Clause-②: no at line start in the body, in the changeset and in the claim. The seat confirmed it on this head:
      • client-address.ts, the published ./client-address entry, exports ClientAddressInput and resolveClientAddress and nothing new;
      • the new client-address-forwarding.ts is on neither the exports map nor index.ts.
    • One patch changeset for @objectstack/plugin-auth.
    • 8 files, +465/−28.
    • Path face read with the files endpoint on this head: check-governed-merges --pr 24931 reports 0 of 8 paths governed. Ordinary queue landing.
    • The PR body is the dev's round-2 replacement, written by the seat (the agent contract reserves body edits to the seat).
  2. Premise and fix:
    • Reach measured once at a public door (PR fix(plugin-auth): the vendored auth library's client-address source follows server.trustProxy #24871's review); detail withheld.
    • The transport. Every in-process bridge hands handleRequest the transport of the request it serves: five through one helper, handleWithTransportOf(c), and two inline.
    • The eighth bridge. POST /sys-session/revoke reaches handleRequest one hop away. It gets a manager slice bound to the same helper.
    • The trusted-proxy branch. The six helper-built re-dispatches carry the incoming request's forwarding headers through one shared function, over the one list the rule reads. Under an undeclared trust the rule ignores them.
    • transportOf, resolveClientAddress (decisions identical; it now iterates the list) and handleRequest are reused, not copied.
    • Pins and ablation. 18 real-mount cases: the no-trust control plus eight bridges, and the trusted-proxy control plus eight bridges. Names state the rule. Legs A–G went red exactly where predicted, each restored to the HEAD blob.
  3. Gates:
    • CI on fbd92ad310: 41 check-runs, 34 success, 7 skipped, 0 failed. Every skip is on the roster in scripts/pm/check-expected-skips.mjs (read from the file): Temporal Conformance, Dogfood Verify CLI, the Dogfood Regression Gate matrix, Build Docs, Console Pin Gate, the 18.x upgrade harness, and Packed-tarball smoke.
    • Local: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN; check:published-readme-exports exit 0.
    • Package suite: plugin-auth 172 files, 3309 passed and 10 skipped; typecheck exit 0, with check:test-typecheck unchanged at 10 / 88 / 23.
  4. Accepted deviations:
    • The eighth bridge, beyond the card's grep.
    • The trusted-proxy branch folded in at round 1.
    • The list and the shared function live in a new internal module beside the rule. in-process-redispatch.ts is untouched; its header says it never touches request headers.
    • client-address.ts gains one import from that module; the built entry still requires nothing.
    • The OIDC and SAML builders now call forwardAuthHeaders.
    • The CI type errors at round 1 were fixed in the test, with the debt ledger untouched.
  5. Out of scope: IAuthService.handleRequest(request) in the spec contract has no transport parameter, and two callers outside plugin-auth hand the request alone. Acceptance notes, carrier none: reach is unmeasured, and both sit in landings this card excludes.
  6. Landing: ordinary queue landing. Every check on this head is green or an expected skip, so the seat flips it (pr_ready + automerge_enable) on this record. Landing unblocks OAuth app registration rejects http://localhost and http://127.0.0.1 redirect URIs as "non-loopback", with HTTP 500 #24773's second half, which edits the same file.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 18:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 18:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 2941a19 Oct 20, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24877-bridge-transport branch October 20, 2026 18:47
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/m tests tooling

Projects

None yet

2 participants