fix(plugin-auth): tombstone interactive session revocations instead of deleting the row - #7825
Conversation
…f deleting better-auth's revoke-session / revoke-sessions / revoke-other-sessions and the admin plugin's revoke-user-session(s) all end a session by DELETING the sys_session row, so the `admin` cause ADR-0069 D4 declares `revoked_at`/`revoke_reason` capture was unrecordable by construction (#7732). Reconcile the physical write at the better-auth -> ObjectQL adapter, the same seam #7725 used: under an interactive-revoke endpoint the delete becomes an in-place stamp in the shape auth-manager.ts already writes (expires_at into the past + both columns). Hook lifecycle is untouched, so OIDC back-channel logout still fires. A tombstone is also hidden from better-auth's session reads, which is what makes the stamp worth keeping: the only expiry-driven collector in the library is inside GET /get-session, and it only runs on a row findSession returned. A hidden row therefore de-authenticates harder AND survives. User-erasure routes see tombstones again so they are physically removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
Every case asserts both halves of the same claim: the row survives with its cause on it, AND the cookie stops authenticating. Real better-auth pipeline through AuthManager.handleRequest, following session-of-record.test.ts. Covers the three self-service revoke routes end to end, the measured GC (a revoked client polling /get-session no longer collects its own record), the non-revoke paths that must stay byte-for-byte (sign-out, natural expiry), the path ledger for all five revoke routes plus the erasure exemption, and a conformance check that every ledgered path is one better-auth still mounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…stones a view (#7732) Retaining revoked rows means the two sys_session grids would otherwise list a revoked session as if it were live. Both now filter `revoked_at is_null`, so revoke_session still makes the row leave the grid exactly as it did when the row was deleted, and a new Revoked view exposes revoked_at / revoke_reason — the columns the issue notes appear in no listView at all. Field declarations are untouched. Changeset + regenerated i18n bundles for the new view label (translated in all four locales, so the coverage ratchet does not move). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
…spatch (#7732) check:engine-double-contract flagged the new double: its update() did not route through assertEngineUpdateDispatch, and the whole fix IS an update, so a fake looser than ObjectQLEngine.update could green a write the engine refuses. The sibling session-of-record.test.ts copy still carries the #5480 DEBT entry; a new double does not get to inherit it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…ept-invitation-adopt-membership Resolved packages/plugins/plugin-auth/src/objectql-adapter.ts: the only conflict was the import block, where #7825's session-tombstone helpers and this branch's adoptExistingMembership landed on the same line. Both imports are kept — the two features hook disjoint adapter methods (adoption on `create`, tombstoning on `findOne`/`findMany`/`delete`/`deleteMany`), so neither side may be taken wholesale.
Fixes #7732
The defect
sys_session.revoked_at/revoke_reasonare declaredreadonlyand documented"System-managed", and
revoked_at's description names all four causes they capture:idle / absolute-max / concurrent-cap / admin (ADR-0069 D4).
Three of them honour it.
auth-manager.tsexpires the row in place —expires_atintothe past plus both columns — from
enforceSessionControls(idle / absolute-max) andenforceConcurrentCap.The fourth could not. An admin or user-initiated revoke reaches better-auth's
internalAdapter.deleteSession/deleteUserSessions, which delete the row, and adeleted row carries no
revoke_reason. Theadmincause was unrecordable byconstruction, so D4's audit trail was inert exactly where an audit most wants it.
Direction of record from triage (2026-08-11): restore-invariant, not a new decision —
the interactive revoke stamps the columns in place, matching the automatic path.
sys-session.object.ts's field declarations are unchanged.What this does
Two rules, both at the better-auth → ObjectQL adapter, both in
packages/plugins/plugin-auth/src/session-tombstone.ts(whose module header carries thefull argument and the measurements below).
1. A delete under an interactive-revoke endpoint is a revocation, not a deletion.
Five routes, ledgered by better-auth's own endpoint path:
revoke_reasonPOST /revoke-sessionuser_revokedPOST /revoke-sessionsuser_revokedPOST /revoke-other-sessionsuser_revokedPOST /admin/revoke-user-sessionadminPOST /admin/revoke-user-sessionsadminWhy two values.
revoke_reasonis free text (maxLength: 64) whose description givesan open-ended list —
idle_timeout, absolute_max, concurrent_cap, …— so there is noclosed enum to violate, and the field is the only thing in the row that says who ended
the session. Recording
adminfor a user signing out their own other device would not bea vague audit record, it would be a wrong one: it names an actor class that took no
action.
admintherefore means exactly the two/admin/*routes.2. A revoked session is not a session — a tombstoned row is invisible to better-auth's
own session reads. That is what makes rule 1 worth anything; see "GC, measured" below.
Everything else is byte-for-byte as before: sign-out still deletes, natural expiry is still
collected, ban / password-reset / two-factor session drops are untouched, and any write
with no endpoint context in scope degrades to a plain delete.
Why the seam is the adapter
Verified against better-auth
1.7.0-rc.2, the version this package pins.internalAdapter.endPreservedSessionsreplaces the physical delete withupdateMany({ expiresAt: now })while keeping the delete hooks running(
deleteManyWithHooks(..., { fn, executeMainFn: false })) — but it is gated onsecondaryStorage, which ObjectStack deliberately does not wire because it moves thesession of record into the cache and makes every D4 control inert (bug(plugin-auth):
[auth] no cache service registered在 CacheServicePlugin 注册前 21ms 就喊了 —— 误报,且把人引向「你需要 Redis」 #4772 / decision(plugin-auth): 会话的「记录之处」到底在哪 —— better-auth secondaryStorage 一旦接上 cache,ADR-0069 D4 的会话管控就静默失效 #4785,pinned in
session-of-record.test.ts). The shape upstream chose is right andunreachable; this is that shape at the layer we own.
databaseHooks.session.delete.beforereturningfalsewas refused. It aborts thedelete — but
getWithHooksthen skips everydelete.afterhook, and@better-auth/oauth-provider(on by default here) registerssession.delete.before/afterto prepare and dispatch OIDC back-channel logout.Suppressing back-channel logout on an admin revoke trades an audit row for a security
hole. Substituting the physical write at the adapter keeps the whole hook lifecycle
intact.
adopt-membership.ts(invitation-scope-gates:accept-invitation→ bodyless HTTP 500, invitation stayspendingforever (UNIQUE constraint on sys_member) #7725) records:re-implementing these routes duplicates better-auth's ownership and freshness checks,
and duplicated security checks are where bypasses live.
The cause is read from
getCurrentAuthContext().path— better-auth dispatches everyendpoint inside
runWithEndpointContext(internalContext, …)whereinternalContext.pathis the endpoint's own declared path, so it is request-scoped, authoritative, and equally
present for a programmatic
auth.api.*call.GC, measured
better-auth 1.7.0-rc.2 has no scheduled sweeper of session rows. Its one expiry-driven
collection in the entire library is inside
GET /get-session: on finding a row whoseexpiresAthas passed it callsinternalAdapter.deleteSession(token)"to clean up thesession". That single line is what makes the automatic path's stamps best-effort today,
and it would have eaten an interactive tombstone the moment the revoked client polled
once — seconds, for a browser session. Stamping alone would have satisfied the letter of
D4 and left the trail as inert as it was.
The collector only fires on a row
findSessionreturned. Hiding the tombstone thereforeends the session harder than expiring it —
findSessionanswersnull, the request isunauthenticated,
deleteSessionis never called, and the record survives. It also removestwo problems a "refuse the delete" rule would have created: the delete hooks would
otherwise re-fire on every stale-cookie poll (re-dispatching back-channel logout), and a
later
revoke-sessionssweep would re-date an older tombstone.Cost, stated plainly: revoked rows are retained indefinitely. There is no retention
window, TTL or sweeper for
sys_session— here or in better-auth. That is not a newclass of growth (a session abandoned without signing out is already immortal for the
same reason), but a retention policy is genuinely unowned. Called out on the issue rather
than invented here.
Erasure is not collection. User-deletion routes (
/delete-user,/delete-user/callback,/admin/remove-user) see tombstones again and physically removethem: keeping an audit row about a user the deployment has erased is the wrong trade.
sys_sessionviewsRetaining rows means the Sessions grids would otherwise list a revoked session as if it
were live.
My SessionsandAllnow filterrevoked_at is_null—revoke_sessionstill makes the row leave the grid, exactly as it did when the row was deleted — and a new
Revoked view exposes
revoked_at/revoke_reason, which the issue notes appear in nolistView at all. i18n bundles regenerated, the new label translated in all four locales so
the coverage ratchet does not move.
Tests
session-tombstone.test.ts— 18 cases, real better-auth pipeline throughAuthManager.handleRequest, followingsession-of-record.test.ts. Every case pins bothdirections, because either alone is a defect dressed as a fix: the row survives with its
cause on it, and the cookie stops authenticating.
Ablation, direction predicted in advance, both matched exactly:
reconcileSessionDeletealways proceeds) → 6 red, all in the stampinghalf: the three revoke routes, the re-date guard, the poll-survival case, the ledger
mapping.
session-of-record.test.tsstayed fully green — the automatic paths areuntouched.
sign-out-after-tombstone case, the hide/erasure unit case, and
/revoke-session'strailing re-read.
/revoke-other-sessions,/revoke-sessionsand the re-date guardstayed green — correctly, since those are protected by the reconcile guard rather than by
hiding.
Green locally:
plugin-auth1015/1015,platform-objects311/311, the full downstreamconsumer sweep (
--filter '...@objectstack/plugin-auth' --filter '...@objectstack/platform-objects', prefix = consumers) includingqa/dogfood582 andqa/http-conformance72, pluscheck:i18n,check:i18n-coverage,check:engine-double-contract,check:nul-bytes, ESLint and typecheck.Not built here
No
sys_audit_logwriter. #7675 owns whether non-CRUD actions get audit-log writers(
logoutis one of its writer-less enum values); this change stays insidesys_sessionand plugin-auth's revoke paths so the two cards cannot build the same event twice.
MANAGED_EXTENSION_FIELDSis untouched — the tombstone write is the identity authority'sown
isSystemwrite through the same engine the automatic path uses, so it needs noADR-0092 D2 whitelist entry.
Generated by Claude Code