Repository navigation
perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7; /auth/me/* resolves grants once - #24185
Conversation
…the session token readback is answered from the lookup; me/* resolves grants once per request Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
… engine write seam and a TTL; pin the prelude's query count Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…eout heartbeat, the write an in-process session read still makes; changeset Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…ycle sources to the shared resolver Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
…th-prelude-queries
… the half a held keyring hides from the count Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 21 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 ac9fa98c9d9bba4bd4ce8bd0401235514670fdbb && git checkout ac9fa98c9d9bba4bd4ce8bd0401235514670fdbb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 978ce6133f3eb9332b3aae99564553d99803540f && git checkout -B drift-repro 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 && git merge --no-ff 978ce6133f3eb9332b3aae99564553d99803540f
node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882
|
席位核对ACCEPT ·
Generated by Claude Code |
|
CI red on head
Generated by Claude Code |
…th-prelude-queries
…tion-store outage; the #13279 transport ledger classifies them The faces resolve through resolveAuthzContext since this branch, which puts them in that ruling's transport ledger: an unlisted transport reds the core suite's set-equality audit, and a guarded one re-raises the outage to the host's error seam instead of answering a signed-out body. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
… on the real host seam (503 SERVICE_UNAVAILABLE); cite the ruling by its live record Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude <noreply@anthropic.com>
席位核对 — patch round 1ACCEPT extended to head
Generated by Claude Code |
|
Generated by Claude Code |
Part of #24061 — four of the five Done-when items land here. The remaining half is item 3's "one read (or one round)": this PR takes the session and user rows from 3 serial reads to 2, and one round trip is not reachable on this engine without a join primitive. That is a fork, set out in the report on the card.
Clause-②: no
What a request paid before its handler, and what it pays now
Measured on a real
ObjectQLover@objectstack/driver-sql+ better-sqlite3, this plugin's identity objects, the grant tablesSecurityPlugindeclares, the activation ledger and a realAuthManager. The user is signed in by cookie, and their session has an active organization backed by asys_memberrow. Each driver call is recorded below the engine and yields one macrotask, so reads issued together are in flight together. That gives the round-trip count, the same leg definition core's batch-equivalence test kit uses. The engine was warm (core's memo observer was already registered). BASE isb0fe9fad4. The BASE readings match the card's 11 and 17 exactly.Shared prelude (
resolveAuthzContextwith the in-processauth.api.getSession, as the REST door and the runtime dispatcher call it):b0fe9fad4sys_sessionby tokensys_sessionby tokensys_sessiontokenreadback (select id, token … where id in (…))sys_user(the session's user)sys_user(the session's user)sys_user,sys_member×2,sys_user_position,sys_user_permission_setsys_metadata_activationsys_metadata_activationsys_jwkskey rows (jwt/get-sessionafter-hook)sys_jwksprivate_keyreadback/auth/me/*and/me/apps(makeExecutionContextResolver): 17 reads in 9 round trips at BASE (the 11 above, plus a second grant wave and ledger read) → 8 in 4./auth/me/localizationadds its own one localization read on top, unchanged (18 → 9).HTTP
GET /api/v1/auth/get-session: 11 in 7 at BASE → 10 in 6 when it reads the signing key, 8 in 4 while the key is held. It still carriesset-auth-jwt.What changed
plugin-auth/src/jwt-key-algorithm.ts,protectGetSessionJwtHook). better-auth runs a path's after-hooks for a server-sideauth.api.*call exactly as for a routed request (dist/api/dispatch.mjs). So every in-process session read signed aset-auth-jwtheader that landed on its own discarded response. The guard that already wraps that hook's handler now also narrows its matcher, to a dispatch that answers an HTTP request. The test is the dispatch'srequest, which better-call's router always passes andinProcessSessionReadInputcallers never do. It mirrors better-auth's ownisRequestLike. Measured alternatives:disableSettingJwtHeaderis global, so it would drop the HTTP header too. Calling the raw endpoint skips every hook, but it needs anAuthContextthat none of the ~20 in-process callers hold.plugin-auth/src/signing-key-read-cache.ts, new and module-internal, wired through jwt'sadapter.getJwkson every host). Both halves of Design: cross-request caching for the authenticated request path (tranche 2 of #10757) — write-invalidation-first, short-TTL fallback, configurable staleness window #11633's rule are met. (a) A middleware onsys_jwkscounts every write that enters the engine and every write that settles. The held keyring is served only while both counters are unchanged since its read opened and no write is in flight, so a write retires it the moment it starts, and a read that overlapped a write is never held. (b)SIGNING_KEY_CACHE_TTL_MS= 30 s, a constant and not a config key, bounds writes this process cannot see. An engine withoutregisterMiddlewareholds nothing. On a host without Ed25519 the miss still reads throughcreateHostUsableJwksReader. The seat's premise, measured: after item 1, in-process reads readsys_jwkszero times, so the key is read only where a JWT is really signed.tokenis answered from the lookup (plugin-auth/src/internal-field-readback.ts,objectql-adapter.ts). better-auth finds the session bywhere token = …, so the privileged readback oftokenhad nothing to add. A read keyed on that column alone (one equality, not case-insensitive, a non-empty string) attaches the lookup value. Every other shape recovers through the accessor as before. A cookie's token is the HMAC-signed value the server issued, byte for byte. With no join primitive in the engine (query.joinsretired, ADR-0049;expandresolves by a second batch read), session → user remains two serial reads./auth/me/*and/me/apps(plugin-hono-server/src/current-user-endpoints.ts). The second resolution was NOT inpackages/runtime(the PM's hypothesis, falsified: runtime registers no/me/*route). It was inmakeExecutionContextResolver, which calledresolveUserAuthzGrantsoutside any request scope after the session read'scustomSessionhook had already resolved the same grants. It now resolves throughresolveAuthzContext, whose existing request-scoped memo serves the second resolution from the first, and the memo dies with the call. The face keeps its semantics. It is still session-only:headers: undefined, so no API-key admission runs, and the session is read by the face's own closure. No tenancy posture is passed, so the session's organization claim stands as before.resolve-authz-context.tsandsecurity-catalog.tsare not touched.plugin-auth/src/auth-prelude-query-count.pin.test.ts): exact per-object counts and round trips for the shared prelude and the/auth/me/*resolver, the HTTP/get-sessionheader, and the held keyring. Two more pins: an in-process read signs nothing even when it asks for headers, and asys_jwkswrite through the engine is seen on the very next signing (newkid, one key minted).Landing sites:
packages/plugins/plugin-auth(declared) andpackages/plugins/plugin-hono-server(the measured producer of the me/* double resolution, outside the claim's listed surface: the seat declares the cross-lane).packages/rest/src/rest-server.tsandpackages/runtimeare untouched.resolveAuthzContextputsmakeExecutionContextResolverin the transport ledger of the 2026-08-30 ruling (commit6a180e42d; core'sauthz-store-unavailable.test.ts). Its fail-closed catch re-raises the outage (rethrowAuthzStoreUnavailable). So/auth/me/permissions,/auth/me/localizationand/me/appsanswer503 SERVICE_UNAVAILABLEon a failed permission-store read instead of a body identical to a signed-out caller's. An unprovisioned store still answers the signed-in caller. Pinned bycurrent-user-endpoints-authz-outage.test.ts. The ledger row is the onepackages/coreedit (domain:engine).Tests
pnpm --filter @objectstack/plugin-auth exec vitest runat3e1faaeaf: 155 files passed, 3062 tests passed, 10 skipped (the lock's verdict line: command-exit 0). Includessession-of-record.test.ts, green.pnpm --filter @objectstack/plugin-hono-server exec vitest run: 30 files, 386 tests passed.pnpm --filter @objectstack/plugin-auth --filter @objectstack/plugin-hono-server run typecheck: exit 0 (both packages' test layers compile; the plugin-auth debt ledger is unchanged).session-grants-resolved-once.test.tspinned that the first in-process read minted the signing key inside the request, and that the memo declined across that write. That premise is gone, since in-process reads no longer sign. The decline is now pinned across the write a session read still makes inside the request: the ADR-0069 D4 idle-timeout heartbeat.Ablations (each through
scripts/ablation-replace.mjsin WRAP mode, anchor 1 → 0 on disk, restored with blob == HEAD andgit diff HEADempty; subjects resolve to source, so no build was involved):sys_session: 2in all three counted pins; the lookup unit casesys_user_position2,sys_member4, ledger 2)The first me/* ablation was refused by the tool as a no-op: the replacement contained the anchor, so the count stayed 1 → 1. It was re-run with a non-overlapping replacement, and that run is the one reported.
Local gates, derived by
node scripts/pm/dispatch-gates.mjs --commandsat3e1faaeaf(13 paths, +971 / −74): 69 families. 68 exit 0.pnpm check:dual-build-cjs-loadsexits 3 (PREREQUISITE NOT MET: it reads every package'sdist/, and this worktree built only the closure). That is NOT MEASURED locally, and CI'sLint & Repo Gatesmeasures it.--ranreconciliation: 69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN.Clause-②: the built
dist/index.d.tsandindex.d.mtsof both packages, BASE vs this head, are identical once comments are stripped (TypeScript printer,removeComments). The control, a one-type change, reads DIFFERENT. No export, no config key and no schema change.Acceptance notes
When an ADR-0069 gate is active,
rest-server.tsre-reads the session for the gate afterresolveAuthzContext. That is a full second session read outside the memo scope, with its owncustomSessiongrant resolution. It is not in this card's Done-when, and it is not changed here.The grant resolver's own
sys_userread repeats the session's user row in the same request. That read is inresolve-authz-context.ts, [epic] ADR-0131 — total organization ownership: no NULL organization_id (v18 line) #15194's declared territory, so it is not touched.The in-process
/get-sessionre-dispatches throughhandleRequest(SSO provider registration, send-verification-email) still sign a header they discard. They are rare user actions, not per-request.Seat's append, from the dev's report (read-only inference, not filed):
packages/runtime/src/security/resolve-session-principal.tssays the inbound rate limiter runs a full in-process session read on every request. With this PR that read no longer signs a JWT, but it still runscustomSession's grant resolution outside any memo scope. Whether the limiter is on by default was not measured.Seat's append: the verifier's TTL-only JWKS cache (
verifyMcpAccessToken) is filed as plugin-auth: the MCP access-token verifier holds the JWKS on a 5-minute TTL alone, with no write seam, so a removed signing key keeps verifying and a rotated-in key is refused until it lapses #24186.Generated by Claude Code