Repository navigation
Commit f792290
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)
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 `ObjectQL` over `@objectstack/driver-sql` +
better-sqlite3, this plugin's identity objects, the grant tables
`SecurityPlugin` declares, the activation ledger and a real
`AuthManager`. The user is signed in by cookie, and their session has an
active organization backed by a `sys_member` row. 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 is
`b0fe9fad4`. The BASE readings match the card's 11 and 17 exactly.
**Shared prelude** (`resolveAuthzContext` with the in-process
`auth.api.getSession`, as the REST door and the runtime dispatcher call
it):
| round trip | BASE `b0fe9fad4` | this PR |
|:--|:--|:--|
| 1 | `sys_session` by token | `sys_session` by token |
| 2 | `sys_session` `token` readback (`select id, token … where id in
(…)`) | `sys_user` (the session's user) |
| 3 | `sys_user` (the session's user) | grant wave: `sys_user`,
`sys_member` ×2, `sys_user_position`, `sys_user_permission_set` |
| 4 | grant wave (same 5 reads) | `sys_metadata_activation` |
| 5 | `sys_metadata_activation` | — |
| 6 | `sys_jwks` key rows (jwt `/get-session` after-hook) | — |
| 7 | `sys_jwks` `private_key` readback | — |
| **total** | **11 reads, 7 round trips** | **8 reads, 4 round trips** |
**`/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/localization` adds 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 carries
`set-auth-jwt`.
## What changed
1. **An in-process principal read does not run the JWT after-hook**
(`plugin-auth/src/jwt-key-algorithm.ts`, `protectGetSessionJwtHook`).
better-auth runs a path's after-hooks for a server-side `auth.api.*`
call exactly as for a routed request (`dist/api/dispatch.mjs`). So every
in-process session read signed a `set-auth-jwt` header 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's `request`, which better-call's
router always passes and `inProcessSessionReadInput` callers never do.
It mirrors better-auth's own `isRequestLike`. Measured alternatives:
`disableSettingJwtHeader` is global, so it would drop the HTTP header
too. Calling the raw endpoint skips every hook, but it needs an
`AuthContext` that none of the ~20 in-process callers hold.
2. **The signing key is cached**
(`plugin-auth/src/signing-key-read-cache.ts`, new and module-internal,
wired through jwt's `adapter.getJwks` on every host). Both halves of
#11633's rule are met. (a) A middleware on `sys_jwks` counts 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 without
`registerMiddleware` holds nothing. On a host without Ed25519 the miss
still reads through `createHostUsableJwksReader`. The seat's premise,
measured: after item 1, in-process reads read `sys_jwks` zero times, so
the key is read only where a JWT is really signed.
3. **The session row's `token` is answered from the lookup**
(`plugin-auth/src/internal-field-readback.ts`, `objectql-adapter.ts`).
better-auth finds the session by `where token = …`, so the privileged
readback of `token` had 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.joins`
retired, ADR-0049; `expand` resolves by a second batch read), session →
user remains two serial reads.
4. **Grants are memoized per request on `/auth/me/*` and `/me/apps`**
(`plugin-hono-server/src/current-user-endpoints.ts`). The second
resolution was NOT in `packages/runtime` (the PM's hypothesis,
falsified: runtime registers no `/me/*` route). It was in
`makeExecutionContextResolver`, which called `resolveUserAuthzGrants`
outside any request scope after the session read's `customSession` hook
had already resolved the same grants. It now resolves through
`resolveAuthzContext`, 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.ts` and
`security-catalog.ts` are not touched.
5. **A test counts the queries of one authenticated request's prelude**
(`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-session` header, and the held
keyring. Two more pins: an in-process read signs nothing even when it
asks for headers, and a `sys_jwks` write through the engine is seen on
the very next signing (new `kid`, one key minted).
Landing sites: `packages/plugins/plugin-auth` (declared) and
`packages/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.ts` and
`packages/runtime` are untouched.
6. **The current-user faces answer an authorization-store outage as
one** (seat's append, from patch round 1). Resolving through
`resolveAuthzContext` puts `makeExecutionContextResolver` in the
transport ledger of the 2026-08-30 ruling (commit `6a180e42d`; core's
`authz-store-unavailable.test.ts`). Its fail-closed catch re-raises the
outage (`rethrowAuthzStoreUnavailable`). So `/auth/me/permissions`,
`/auth/me/localization` and `/me/apps` answer `503 SERVICE_UNAVAILABLE`
on 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 by `current-user-endpoints-authz-outage.test.ts`. The
ledger row is the one `packages/core` edit (`domain:engine`).
## Tests
- `pnpm --filter @objectstack/plugin-auth exec vitest run` at
`3e1faaeaf`: 155 files passed, 3062 tests passed, 10 skipped (the lock's
verdict line: command-exit 0). Includes `session-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.ts` pinned 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.mjs` in WRAP mode,
anchor 1 → 0 on disk, restored with blob == HEAD and `git diff HEAD`
empty; subjects resolve to source, so no build was involved):
| removed work put back | red |
|:--|:--|
| jwt hook matcher no longer checks the request | the matcher unit case;
"an in-process session read signs nothing"; the HTTP keyring-read count
|
| lookup-key attach deleted (token readback returns) | `sys_session: 2`
in all three counted pins; the lookup unit case |
| me/* face resolves the grants again outside the memo | the me/*
resolver count (`sys_user_position` 2, `sys_member` 4, ledger 2) |
| held keyring ignores the write counters | unit (a); "seen on the very
next signing" (3 key rows, a key minted per signature); the HTTP
keyring-read count |
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
--commands` at `3e1faaeaf` (13 paths, +971 / −74): 69 families. 68 exit
0. `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it
reads every package's `dist/`, and this worktree built only the
closure). That is NOT MEASURED locally, and CI's `Lint & Repo Gates`
measures it. `--ran` reconciliation: 69 derived, 68 run, 1 NOT-MEASURED,
0 UNRUN.
**Clause-②**: the built `dist/index.d.ts` and `index.d.mts` of 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.ts` re-reads the session
for the gate after `resolveAuthzContext`. That is a full second session
read outside the memo scope, with its own `customSession` grant
resolution. It is not in this card's Done-when, and it is not changed
here.
- The grant resolver's own `sys_user` read repeats the session's user
row in the same request. That read is in `resolve-authz-context.ts`,
#15194's declared territory, so it is not touched.
- The in-process `/get-session` re-dispatches through `handleRequest`
(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.ts`
says 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 runs `customSession`'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 #24186.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent f963d8e commit f792290
15 files changed
Lines changed: 1070 additions & 76 deletions
File tree
- .changeset
- packages
- core/src/security
- plugins
- plugin-auth
- src
- plugin-hono-server/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
| 189 | + | |
189 | 190 | | |
190 | 191 | | |
191 | 192 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
191 | 191 | | |
192 | 192 | | |
193 | 193 | | |
| 194 | + | |
194 | 195 | | |
195 | 196 | | |
196 | 197 | | |
| |||
3012 | 3013 | | |
3013 | 3014 | | |
3014 | 3015 | | |
3015 | | - | |
3016 | | - | |
3017 | | - | |
3018 | | - | |
| 3016 | + | |
| 3017 | + | |
| 3018 | + | |
| 3019 | + | |
| 3020 | + | |
| 3021 | + | |
| 3022 | + | |
| 3023 | + | |
3019 | 3024 | | |
3020 | 3025 | | |
3021 | | - | |
| 3026 | + | |
| 3027 | + | |
| 3028 | + | |
| 3029 | + | |
3022 | 3030 | | |
3023 | 3031 | | |
3024 | 3032 | | |
| |||
3042 | 3050 | | |
3043 | 3051 | | |
3044 | 3052 | | |
3045 | | - | |
3046 | | - | |
3047 | | - | |
3048 | | - | |
3049 | | - | |
3050 | | - | |
| 3053 | + | |
| 3054 | + | |
| 3055 | + | |
| 3056 | + | |
| 3057 | + | |
| 3058 | + | |
| 3059 | + | |
| 3060 | + | |
| 3061 | + | |
| 3062 | + | |
| 3063 | + | |
| 3064 | + | |
| 3065 | + | |
| 3066 | + | |
3051 | 3067 | | |
3052 | 3068 | | |
3053 | 3069 | | |
| |||
3059 | 3075 | | |
3060 | 3076 | | |
3061 | 3077 | | |
3062 | | - | |
| 3078 | + | |
| 3079 | + | |
3063 | 3080 | | |
3064 | 3081 | | |
3065 | 3082 | | |
| |||
0 commit comments