fix(hono): the /auth/* mount yields only a 404 that disclaims ownership - #16027
fix(hono): the /auth/* mount yields only a 404 that disclaims ownership#16027os-warren wants to merge 1 commit into
/auth/* mount yields only a 404 that disclaims ownership#16027Conversation
`createHonoApp`'s `${prefix}/auth/*` mount handed the request on whenever the
auth service answered 404, with only the status to go on, so a 404 a routed
endpoint produced — a real answer — was replaceable by whatever matched next.
Measured on a real boot through this adapter (real kernel + AuthPlugin,
`prefix: '/api/v1'`):
GET /api/v1/auth/delete-user/callback?token=abc&callbackURL=/x
better-auth direct : 404 {"message":"Not found","code":"NOT_FOUND"}
through the mount : 200 {}
No composition had to install the overwriting layer: the `${prefix}/*`
dispatcher catch-all registered by the same function is terminal and answers
`200 {}` for paths under `/auth/`. `auth-route-ledger.ts` carries that route
under its `disabled` disposition because it is published and answers 404, so
the ledger's answer was true of the auth service and false on the wire.
The mount now asks the auth service whether its own router serves the path,
through an optional `ownsRoute(request)` — the seam `AuthManager` grew for the
plugin-side half of the same defect — and yields only when it does not. The
adapter does not import the ownership walk: it does not depend on
`@objectstack/plugin-auth` and gains no dependency here. Every answer that is
not a literal `true` means yield, so a service predating the method is
unaffected and a failure to decide can never cost #4088's ordering-independent
surface.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
|
Read-back note on the PR body: one token did not survive the platform's body sanitizer. Under "The change" it now reads The angle-bracketed type parameter was eaten. The signature in the diff is Generated by Claude Code |
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 1 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 3a184c671bfbaf5958600cb9cc2db3a2ea07f8d8 && git checkout 3a184c671bfbaf5958600cb9cc2db3a2ea07f8d8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2da2901efa2708d8895e436867838cbeeda191cb 5ddff5274c5846a18b05eb181d794161311e8e29 && git checkout -B drift-repro 2da2901efa2708d8895e436867838cbeeda191cb && git merge --no-ff 5ddff5274c5846a18b05eb181d794161311e8e29
node scripts/docs-audit/affected-docs.mjs --json 2da2901efa2708d8895e436867838cbeeda191cb
|
PM note — contract review deferred, and ⛔ not silently downgradedThe Clause-② review dispatched for this PR terminated before measuring anything: HTTP 429, "You've reached your Fable limit", on
⇒ This PR stays in draft, unarmed, and nothing lands. That is the correct state, not a stall to work around. What is already established and does not need re-doing when the review resumes (from the dev's report, ⛔ none of it independently verified yet):
The first thing the review must attack when it runs — recorded now so it does not get lost: the dev's 89-pair population sweep reported zero owned-404s, and stated its own limit — the real owned-404 needs a Generated by Claude Code |
Fixes #15928
All readings below are at
5ddff5274, the head of this branch, on a real boot through the adapter: a realObjectKernelwithAuthPlugin(so a realAuthManagerand a real better-auth with 100auth.apientries) booted via@objectstack/verify'sbootStack, thencreateHonoApp({ kernel, prefix: '/api/v1' }), with requests injected through the returned app.Step 1 — the measurement, before any code
The card recorded the location and the "identical unconditioned yield" reading from a reviewer of #15918 and said plainly that this seat had not measured it. Measured now: the reading is confirmed, and the blast radius at this layer is wider than the plugin's.
plugin-auth'sauth-route-ledger.tscarriesPOST /api/v1/auth/delete-userandGET /api/v1/auth/delete-user/callbackunder thedisableddisposition precisely because they are published and answer 404 (user.deleteUserdeliberately unconfigured, maintainer ruling 2026-08-12). So the ledger's recorded answer was true of the auth service and false on this adapter's wire.Wider because #15918's defect needed a composition to mount a downstream wildcard, and this one does not. The overwriting layer is registered by
createHonoAppitself: theFIELD_PREFIX + '/*'dispatcher catch-all is terminal and answers200 {}for paths under/auth/. Measured on the same boot, before the fix,POST /api/v1/auth/definitely-not-a-route-1989andGET /api/v1/auth/me/permissionsboth came back200 {}from it.One correction to the card's framing: the card's own example,
POST /api/v1/auth/delete-user, does not produce a 404 on this composition — an ObjectStack guard pre-empts it with409 "Cannot remove the last local password login", which was returned unchanged both before and after this change. The 404 case is itsGETcallback half.The two portability questions, answered by measurement
Does the adapter reach the same
auth.apiinstance? Yes, literally the same object.plugin-authdoesctx.registerService('auth', this.authManager), andkernel.getServiceAsync('auth')from the adapter's side returns_AuthManagerwithtypeof handleRequest === 'function'andtypeof ownsRoute === 'function'.getAuthInstance()returns a memoized instance stable across calls (===), 100auth.apientries, andownsRoute('POST', '/api/v1/auth/delete-user')answerstruefrom there.Is the same
owns()walk available at that layer? Yes — through the service instance, not by import.@objectstack/honodepends only onplugin-hono-server,runtimeandtypes, sobuildBetterAuthRouteOwnershipis not reachable and should not be made reachable. What is reachable isAuthManager.ownsRoute(request), the seam the plugin-side fix added. So the fix is a port of the shape, not of the code: the adapter's structuralAuthServiceinterface grows an optionalownsRoute?(request), called when present, exactly as the mount already duck-typesgetPublicConfig?.()andisSsoUsable.A bounded limitation the port inherits, measured, not assumed.
ownsRoutederives better-auth's endpoint path from the auth service's configuredbasePath, not from the adapter'sprefix. Measured:ownsRoute('POST', '/api/v1/auth/delete-user')istruewhileownsRoute('POST', '/api/auth/delete-user')isfalse. A deployment whose two disagree therefore getsfalsefor everything and keeps the pre-change yield — the safe direction, and the reason every undecidable answer here isfalse.The trailing-slash divergence the card named, measured at this layer.
GET /api/v1/auth/get-session/and/api/v1/auth//get-session:ownsRouteclaims both (true) while better-auth refuses both as unrouted (404). Non-blocking here for the same reason as in the plugin, plus one this layer adds: what those spellings lose is the dispatcher's200 {}, so the change is from a silent success to the framework's honest 404. Not fixed here — the one-line alignment is the plugin-side follow-up, and no measurement at this layer argues for pulling it in.The change
packages/adapters/hono/src/index.tsonly:AuthServicegrows an optionalownsRoute?(request: Request): Promise;authOwnsRoute(authService, request)helper — every answer that is not a literaltrue(no such method, a throw, anything else) means yieldif (response.status === 404 && !(await authOwnsRoute(authService, c.req.raw)))⛔ The mount is untouched and still claims
FIELD_PREFIX + '/auth/*'. 401/403 were never yielded and still are not. What narrowed is only which 404 may be handed on.#4088's contract survives — pinned, and measured on the real boot after the fix
The catch-all is still non-terminal and still ordering-independent; objectui's permission layer reads
/auth/me/permissions. After the fix, on the same boot:and the defect case flips:
GET /api/v1/auth/delete-user/callback?token=abc&callbackURL=/xnow answers404 {"message":"Not found","code":"NOT_FOUND"}instead of200 {}.Pin population — what the new cases cover, and what they do NOT
packages/adapters/hono/src/hono-auth-owned-404.test.ts, 8 cases. Its header carries this in full.Covered: the adapter's decision logic — which 404s are yielded and which returned, behaviour with a service that has no
ownsRouteat all, with one that throws, with one returning a non-truevalue, that the predicate is not consulted on a non-404, that it is handed the raw request (full wire URL and method, not the stripped subpath), and that a non-defaultprefixis honoured.⛔ NOT covered by any case in this file — these are measured above and pinned nowhere here:
ownsRouteis a path SET, notbuildBetterAuthRouteOwnershipover a realauth.api; that matcher isplugin-auth's and is pinned there.authservice really carriesownsRoute. Measured on the real boot, not pinned.basePath/prefixalignment described above.Mutation proof
Fix committed first, then reverted on disk to the pre-change
if (response.status === 404) return yieldUnowned(...), undertrap ... EXIT INT TERMwith absolute paths. The mutation anchor was asserted unique in the form written (1 occurrence) and its landing on disk proved by counting both the removed and the injected text (removed&& !(await authOwnsRoute: 0; injected: 1) plus a blob-hash changeba7ea919... -> f037abf0....The pin went RED — 4 failed, 4 passed — with the headline assertion text:
which is exactly the defect shape. Restore proved by an empty
git diff HEAD, an emptygit status --porcelain, and blob equality with both sides non-empty: HEADba7ea91924b4310113204b842cd4959c5718a765= on diskba7ea91924b4310113204b842cd4959c5718a765.No rebuild step is claimed for this ablation and none is needed: the pin imports
./indexby relative path, sodist/is not in its resolution path. The separate real-boot probes DO resolve throughexportstodist/, and for those the adapter was rebuilt after the fix and the marker confirmed present indist/index.mjs.Verification
pnpm --filter @objectstack/hono test— exit 0,Test Files 3 passed (3),Tests 82 passed (82)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(51 families matched, derived at5ddff5274) — 41 exit 0. Verdict lines includecheck-test-source-alias OK — 72 packages with tests scanned,OK: 27 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob(check:cross-package-test-inputs), andRoute-envelope conformance — 10 route module(s) audited: 7 conformant, 2 ratcheted, 1 exempt.pnpm check:dual-build-cjs-loadsexit 3 — NOT MEASURED, not a pass. Its own verdict:PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.It needs a whole-repopnpm build(32 packages listed as missingdist); CI'sBuild Coreruns it.typecheckscript, sopnpm --filter @objectstack/hono typecheckis not a reading — it exits 1 withERR_PNPM_RECURSIVE_RUN_NO_SCRIPT.check:check-type-check-coveragepasses and names@objectstack/honoexplicitly as one of five packages with no such script. A realtsc --noEmit -p tsconfig.jsonover the package reports 3 errors, all insrc/hono.test.ts(lines 36, 69, 70), a file this diff does not touch. Proved pre-existing rather than assumed: withindex.tsrestored to the merge base and the new test file moved aside, the diagnostics are byte-identical — same file, same lines, same codes. This diff adds zero type errors.pnpm lintwas narrowed, and the narrowing is declared:eslint --no-inline-config --format jsonover the two changed source files — 2 files, 0 errors, 0 warnings, exit 0. The narrowing is safe by configuration, not by hope: this repo runs oneeslint.config.mjswhich never enables type-aware linting for any file (its own comment at line 328: "noparserOptions.project, no typed@typescript-eslintrules"), so a source-only diff cannot move the verdict on a file it does not touch. CI runs the full sweep regardless.Out-of-scope findings, filed unassigned
Both were measured on the same boots and are not fixed here:
createHonoApp's defaultprefix: '/api'does not compose with AuthPlugin's defaultbasePath: '/api/v1/auth'— the documented embed answers200 {}to sign-in #16025 —createHonoApp's defaultprefix: '/api'does not compose with AuthPlugin's defaultbasePath: '/api/v1/auth'; the documented embed answers200 {}toPOST /api/auth/sign-in/email. Not addressed in this PR.HttpDispatcher.dispatchclaims every path whose first segment merely STARTS WITHauth—/authx,/authentication/fooall answer200 {}instead ofROUTE_NOT_FOUND#16026 —dispatch()claims every path whose first segment merely starts withauth(/authx,/authentication/fooanswer200 {};/aut/fooanswersROUTE_NOT_FOUND), and the claimed answer is an empty200 {}. Not addressed in this PR.Lint & Repo Gatesis expected red onmainitself at the Merge-driver wiring gate (#15992, another seat's). Read this job's own failing step number before attributing a failure here — no fix for it is carried in this diff.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code