From 04b64b86dfcd9691aac165c07c4ee24941bc096b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 15:57:12 +0000 Subject: [PATCH 1/4] fix(plugin-auth): tombstone interactive session revocations instead of 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 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd --- packages/plugins/plugin-auth/src/index.ts | 5 + .../plugin-auth/src/objectql-adapter.ts | 33 +- .../plugin-auth/src/session-tombstone.ts | 287 ++++++++++++++++++ 3 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/session-tombstone.ts diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index eca49e068c..b61fbe33a4 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -40,6 +40,11 @@ export * from './secondary-storage.js'; export * from './register-sso-provider.js'; export * from './send-verification-email.js'; export * from './objectql-adapter.js'; +// [#7732] ADR-0069 D4's revoke-audit trail. Exported alongside the adapter it +// plugs into, so a host reading `sys_session` knows the one rule that governs +// those rows: a `revoked_at` row is a TOMBSTONE — an ended session kept as the +// audit record of its ending — never a live session. +export * from './session-tombstone.js'; // [#4586] The better-auth actor seam. Exported because a host that writes an // identity table on better-auth's behalf (a control-plane provisioning hook, // an SSO JIT path) must construct the SAME two-part context — diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 0f89b4f278..583358bf47 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -5,6 +5,11 @@ import { createAdapterFactory } from 'better-auth/adapters'; import type { CleanedWhere, WhereOperator } from 'better-auth/adapters'; import { SystemObjectName } from '@objectstack/spec/system'; import { resolveAttributedUserId } from './auth-actor-attribution.js'; +import { + filterRevokedSessionRows, + hideRevokedSessionRow, + reconcileSessionDelete, +} from './session-tombstone.js'; /** * Mapping from better-auth model names to ObjectStack protocol object names. @@ -671,10 +676,22 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { const objectName = resolveProtocolName(model); const bridged = objectName !== model; const filter = convertWhere(model, bridged ? remapWhere(where) : where); - const fields = bridged && select ? select.map(camelToSnake) : select; + let fields = bridged && select ? select.map(camelToSnake) : select; + // [#7732] A projection that omits `revoked_at` would make every row look + // live to the tombstone rule. Ask for it, then drop it again below if + // the caller did not. + const revokedAtIsBorrowed = + objectName === SystemObjectName.SESSION && + Array.isArray(fields) && + fields.length > 0 && + !fields.includes('revoked_at'); + if (revokedAtIsBorrowed) fields = [...(fields as string[]), 'revoked_at']; const result = await dataEngine.findOne(objectName, { where: filter, fields }); if (!result) return null; + // [#7732] A revoked session is not a session — see `session-tombstone.ts`. + if (await hideRevokedSessionRow(objectName, result)) return null; + if (revokedAtIsBorrowed) delete (result as Record).revoked_at; const norm = normaliseLegacyDates(model, result); return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T; }, @@ -693,12 +710,14 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { ? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction as 'asc' | 'desc' }] : undefined; - const results = await dataEngine.find(objectName, { + const found = await dataEngine.find(objectName, { where: filter, limit: limit || 100, offset, orderBy, }); + // [#7732] A revoked session is not a session — see `session-tombstone.ts`. + const results = await filterRevokedSessionRows(objectName, found); return results.map((r) => { const norm = normaliseLegacyDates(model, r as Record); @@ -761,6 +780,10 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { const record = await dataEngine.findOne(objectName, { where: filter }); if (!record) return; + // [#7732] An interactive revoke ends the session by stamping it, not by + // deleting it — see `session-tombstone.ts` for the ledger and the seam. + if (!(await reconcileSessionDelete(dataEngine, objectName, record))) return; + await dataEngine.delete(objectName, { where: { id: record.id } }); }, @@ -771,8 +794,12 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { const bridged = objectName !== model; const filter = convertWhere(model, bridged ? remapWhere(where) : where); - const records = await dataEngine.find(objectName, { where: filter }); + const found = await dataEngine.find(objectName, { where: filter }); + // [#7732] Same rule, per row: a matched session the platform has + // already tombstoned is left exactly as it is. + const records = await filterRevokedSessionRows(objectName, found); for (const record of records) { + if (!(await reconcileSessionDelete(dataEngine, objectName, record))) continue; await dataEngine.delete(objectName, { where: { id: record.id } }); } return records.length; diff --git a/packages/plugins/plugin-auth/src/session-tombstone.ts b/packages/plugins/plugin-auth/src/session-tombstone.ts new file mode 100644 index 0000000000..dcad128b57 --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-tombstone.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7732] Session tombstones — make ADR-0069 D4's revoke-audit trail real for + * the one cause an audit most wants: an interactive revoke. + * + * ## The defect this closes + * + * `sys_session.revoked_at` / `revoke_reason` are declared `readonly` and + * documented "System-managed", and `revoked_at`'s description names all four + * causes it is meant to capture: *idle / absolute-max / concurrent-cap / + * admin*. Three of them honour that. `auth-manager.ts` expires the row in place + * — `expires_at` into the past plus both columns — from + * `enforceSessionControls` (idle / absolute-max) and `enforceConcurrentCap`. + * + * The fourth never did. An admin or user-initiated revoke reaches better-auth's + * `internalAdapter.deleteSession` / `deleteUserSessions`, which **delete the + * row**. A deleted row carries no `revoke_reason`, so the `admin` cause was + * unrecordable by construction and D4's trail was inert exactly where it + * matters. + * + * Two rules close it, and both live at the better-auth → ObjectQL adapter: + * + * 1. **A delete under an interactive-revoke endpoint is a revocation, not a + * deletion** — the row is stamped in place instead + * ({@link INTERACTIVE_REVOKE_REASON}), in the same shape the automatic path + * already writes. + * 2. **A revoked session is not a session** — a tombstoned row is invisible to + * better-auth's own session reads ({@link hideRevokedSessionRow}), which is + * what makes rule 1 worth anything. See "Retention" below. + * + * ## Why the seam is HERE, at the adapter + * + * Verified against better-auth `1.7.0-rc.2`, the version this package pins: + * + * - **better-auth already implements this exact substitution, one layer up, + * and we cannot reach it.** `internalAdapter.endPreservedSessions` replaces + * the physical delete with `updateMany({ expiresAt: now })` while keeping + * the delete hooks running (`deleteManyWithHooks(..., { fn, executeMainFn: + * false })`). It is gated on `secondaryStorage` being configured + * (`deleteSession`: `if (secondaryStorage) { … if (preserveSessionInDatabase) + * … }`), and ObjectStack deliberately does not wire one — handing better-auth + * a `secondaryStorage` moves the session OF RECORD into the cache and makes + * every D4 control inert (#4772 / #4785, pinned in + * `session-of-record.test.ts`). So the shape upstream chose is right and + * unreachable; this module is that shape at the only layer we own. + * - **`databaseHooks.session.delete.before` returning `false` was refused.** + * It aborts the delete, which is what we want — but `getWithHooks` then skips + * every `delete.after` hook, and `@better-auth/oauth-provider` (enabled by + * default here) registers `session.delete.before`/`after` to prepare and + * dispatch **OIDC back-channel logout**. Suppressing back-channel logout on + * an admin revoke — the single revocation that most needs downstream relying + * parties told — trades an audit row for a security hole. Upstream's own + * preserve path is careful about exactly this ("the session-delete hooks + * still run, so OAuth token revocation and back-channel logout fire on + * session end"). Substituting the physical write at the adapter keeps the + * whole hook lifecycle intact. + * - **The route boundary was refused** for the reason `adopt-membership.ts` + * records: re-implementing `revoke-session` / `revoke-user-sessions` means + * duplicating better-auth's ownership and freshness checks, and duplicated + * security checks are where bypasses live. + * + * ## How the cause is known + * + * better-auth dispatches every endpoint inside + * `runWithEndpointContext(internalContext, …)` (`api/dispatch.mjs`), where + * `internalContext.path` is the endpoint's own declared path. So + * `getCurrentAuthContext().path` names the route the current adapter write is + * serving — request-scoped, from the endpoint object rather than from a hook + * that may not have run, and equally available to a programmatic `auth.api.*` + * call because those route through the same dispatcher. + * + * No context (WebContainer's `AsyncLocalStorage`, which does not propagate + * across `await` — the caveat `auth-actor-attribution.ts` documents; or a raw + * engine write that never touches this adapter) resolves to `undefined`, and + * every rule below then degrades to **today's behaviour: a plain delete**. The + * audit row is lost, nothing is retained, and no session outlives its + * revocation. Losing the record is the safe direction; keeping a session alive + * would not be. + * + * ## Retention — why hiding, and not just stamping + * + * Measured, not assumed. better-auth 1.7.0-rc.2 has **no scheduled sweeper** of + * session rows: the only expiry-driven collection in the whole library is + * inside `GET /get-session`, which on finding a row whose `expiresAt` has passed + * calls `internalAdapter.deleteSession(token)` to "clean up the session" + * (`api/routes/session.mjs`). That single line is what makes the automatic + * path's stamps best-effort today — and it would eat an interactive tombstone + * the moment the revoked client polled once, which for a browser session is + * seconds. Stamping alone therefore satisfies the letter of D4 and leaves the + * trail as inert as it was. + * + * The collector only fires on a row it can see: + * + * ```js + * const session = await ctx.context.internalAdapter.findSession(token); + * if (!session || session.session.expiresAt < new Date()) { + * deleteSessionCookie(ctx); + * if (session) { … await ctx.context.internalAdapter.deleteSession(…); } + * return ctx.json(null); + * } + * ``` + * + * So hiding the tombstone from the adapter's session reads ends the session + * *harder* than expiring it — `findSession` answers `null`, the request is + * unauthenticated, and `deleteSession` is never called, so the record survives. + * It also removes, for free, two problems a "refuse the delete" rule would have + * created: the `delete.before`/`after` hooks would otherwise **re-fire on every + * stale-cookie poll** (re-dispatching back-channel logout — the same repeat + * upstream guards against by restricting `endPreservedSessions` to live rows), + * and a later `revoke-sessions` sweep would re-stamp an older tombstone and + * overwrite the revocation time it was recording. + * + * ⚠️ **The cost, stated plainly: revoked rows are now retained indefinitely.** + * There is no retention window, no TTL and no sweeper for `sys_session` — in + * this repo or in better-auth. That is not a new *class* of growth (a session + * abandoned without sign-out is already immortal for the same reason: nothing + * ever presents its cookie again, so the one collector never runs on it), but a + * retention policy for `sys_session` is genuinely unowned and is called out on + * #7732 rather than invented here. + * + * ## Erasure is not collection + * + * One consequence of hiding has to be handled rather than accepted: user + * deletion sweeps sessions with `deleteUserSessions(userId)`, and rows it cannot + * see are rows it cannot erase. {@link SESSION_ERASURE_PATHS} lists the three + * endpoints whose job is to remove the subject itself; under those, tombstones + * are visible again and are physically deleted. Keeping an audit row about a + * user the deployment has erased is the wrong trade. + * + * ## Deliberately NOT in the revoke ledger + * + * `POST /admin/ban-user` and `POST /admin/update-user` (with `banned: true`) + * both drop the user's sessions, and both are admin-initiated. They stay plain + * deletes: a ban already records itself, with far more detail, on + * `sys_user.banned` / `ban_reason` / `ban_expires`, and #7732 scoped this fix + * to the three interactive-revoke families. `POST /sign-out` stays a plain + * delete too — signing yourself out is not a revocation, it has no cause in + * `revoke_reason`'s vocabulary, and whether `logout` earns an audit record at + * all is #7675's question, not this one. `/admin/stop-impersonating` ends an + * impersonation session, not a real one. + */ + +import type { IDataEngine } from '@objectstack/core'; +import { SystemObjectName } from '@objectstack/spec/system'; + +/** + * Endpoint path → the `revoke_reason` a session ended there should record. + * + * Paths are better-auth's own (`endpoint.path`), i.e. without the + * `/api/v1/auth` base — that is what `getCurrentAuthContext().path` carries. + * + * ## The two values, and why not one + * + * `revoked_at`'s declared description names the interactive cause `admin`; + * `revoke_reason` is free text (`Field.text({ maxLength: 64 })`) whose + * description gives an open-ended list — `idle_timeout, absolute_max, + * concurrent_cap, …`. There is no closed enum to violate, and + * `sys-session.object.ts` is untouched by this change. + * + * Within that, admin-initiated and self-initiated revokes get **distinct** + * values, because the field is the only thing in the row that says who ended + * the session. Recording `admin` for a user clicking "sign out my other + * devices" would not be a vague audit record, it would be a **wrong** one — it + * names an actor class that took no action, which is worse for an auditor than + * no record at all. `admin` therefore means exactly the two `/admin/*` routes, + * and `user_revoked` means the session's own owner did it. + */ +export const INTERACTIVE_REVOKE_REASON: Readonly> = { + // Self-service — the session owner acting on their own sessions. + '/revoke-session': 'user_revoked', + '/revoke-sessions': 'user_revoked', + '/revoke-other-sessions': 'user_revoked', + // Admin acting on someone else's sessions (better-auth `admin` plugin). + '/admin/revoke-user-session': 'admin', + '/admin/revoke-user-sessions': 'admin', +}; + +/** + * Endpoints that ERASE the subject rather than ending a session. + * + * Under these, a tombstone stops being a record worth keeping and becomes data + * about a user the deployment is removing: it is visible to better-auth's + * session reads again, and its physical delete goes through untouched. + */ +export const SESSION_ERASURE_PATHS: ReadonlySet = new Set([ + '/delete-user', + '/delete-user/callback', + '/admin/remove-user', +]); + +/** Columns a tombstone write touches, in the shape `auth-manager.ts` writes. */ +export interface SessionTombstonePatch { + id: unknown; + expires_at: Date; + revoked_at: Date; + revoke_reason: string; +} + +/** + * The better-auth endpoint path currently being served, or `undefined`. + * + * Never throws: `getCurrentAuthContext()` rejects when no endpoint context is + * in scope, and the import itself is dynamic so this module stays loadable in + * environments where `@better-auth/core`'s async-hooks shim is unavailable. + */ +export async function currentAuthEndpointPath(): Promise { + try { + const { getCurrentAuthContext } = await import('@better-auth/core/context'); + const ctx: any = await getCurrentAuthContext(); + const path = ctx?.path; + return typeof path === 'string' && path.length > 0 ? path : undefined; + } catch { + return undefined; + } +} + +/** Is this row one the platform has already tombstoned? */ +export function isRevokedSessionRow(row: unknown): boolean { + return (row as { revoked_at?: unknown } | null | undefined)?.revoked_at != null; +} + +/** + * Should a `sys_session` row this read matched be hidden from better-auth? + * + * True for a tombstoned row on every path except {@link SESSION_ERASURE_PATHS}. + * `objectName` is the resolved protocol object, so nothing but `sys_session` is + * ever considered. + */ +export async function hideRevokedSessionRow(objectName: string, row: unknown): Promise { + if (objectName !== SystemObjectName.SESSION) return false; + if (!isRevokedSessionRow(row)) return false; + const path = await currentAuthEndpointPath(); + return !(path && SESSION_ERASURE_PATHS.has(path)); +} + +/** + * {@link hideRevokedSessionRow} over a result set, resolving the endpoint path + * once for the whole batch rather than once per row. + */ +export async function filterRevokedSessionRows(objectName: string, rows: T[]): Promise { + if (objectName !== SystemObjectName.SESSION) return rows; + if (!rows.some((row) => isRevokedSessionRow(row))) return rows; + const path = await currentAuthEndpointPath(); + if (path && SESSION_ERASURE_PATHS.has(path)) return rows; + return rows.filter((row) => !isRevokedSessionRow(row)); +} + +/** + * Reconcile one physical `sys_session` delete with ADR-0069 D4. + * + * Returns `true` when the caller should go ahead and delete the row, `false` + * when this call has been answered some other way (a tombstone was written, or + * an existing tombstone was left alone). + * + * `row` must be the row already fetched by the caller — the adapter reads it to + * resolve the id anyway, so this costs no extra query. + */ +export async function reconcileSessionDelete( + engine: IDataEngine, + objectName: string, + row: { id?: unknown; revoked_at?: unknown } | null | undefined, +): Promise { + if (objectName !== SystemObjectName.SESSION || !row?.id) return true; + + const path = await currentAuthEndpointPath(); + const reason = path ? INTERACTIVE_REVOKE_REASON[path] : undefined; + if (!reason) return true; + + // Already tombstoned: keep the revocation this row is recording. Upstream + // restricts `endPreservedSessions` to live rows for the same reason — a + // later sweep must not re-date an earlier ending. + if (isRevokedSessionRow(row)) return false; + + const now = Date.now(); + const patch: SessionTombstonePatch = { + id: row.id, + // A second in the past, matching `enforceSessionControls` / + // `enforceConcurrentCap`, so every `expiresAt < now` liveness check in the + // library is strictly true even at millisecond resolution. + expires_at: new Date(now - 1000), + revoked_at: new Date(now), + revoke_reason: reason, + }; + await (engine as any).update(objectName, patch); + return false; +} From 7bd3da84a9069c80d20c83682a1ac8962cf5c1c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:15:17 +0000 Subject: [PATCH 2/4] test(plugin-auth): pin session tombstones in both directions (#7732) 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 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd --- .../plugin-auth/src/session-tombstone.test.ts | 494 ++++++++++++++++++ 1 file changed, 494 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/session-tombstone.test.ts diff --git a/packages/plugins/plugin-auth/src/session-tombstone.test.ts b/packages/plugins/plugin-auth/src/session-tombstone.test.ts new file mode 100644 index 0000000000..aa890cd78e --- /dev/null +++ b/packages/plugins/plugin-auth/src/session-tombstone.test.ts @@ -0,0 +1,494 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7732 — ADR-0069 D4 declares `sys_session.revoked_at`/`revoke_reason` capture +// EVERY revocation cause: idle / absolute-max / concurrent-cap / admin. Three of +// them were honoured by `auth-manager.ts`. The fourth — an interactive revoke — +// deleted the row instead, so the `admin` cause was unrecordable and the audit +// trail was inert precisely where an audit wants it. +// +// Every test here pins BOTH directions of the same claim, because either one +// alone is a defect dressed as a fix: +// +// * the row SURVIVES with its cause on it (otherwise there is no trail), and +// * the cookie STOPS AUTHENTICATING (a tombstone that still authenticates is +// a security regression far worse than a missing audit row). +// +// Real better-auth pipeline throughout, following `session-of-record.test.ts`: +// requests go in as `Request` objects through `AuthManager.handleRequest`, the +// cookie is the one better-auth minted, and the revoke path is better-auth's +// own. A stub of our adapter would prove nothing — the whole question is what +// the library does around our write. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/objectql'; +import { runWithEndpointContext } from '@better-auth/core/context'; +import { AuthManager } from './auth-manager'; +import { BETTER_AUTH_MOUNTED_SURFACE } from './auth-route-ledger'; +import { + INTERACTIVE_REVOKE_REASON, + SESSION_ERASURE_PATHS, + currentAuthEndpointPath, + filterRevokedSessionRows, + hideRevokedSessionRow, + isRevokedSessionRow, + reconcileSessionDelete, +} from './session-tombstone'; + +/** + * In-memory IDataEngine — the `session-of-record.test.ts` harness, unchanged + * apart from this note, because these tests assert against the same table + * through the same library and a second, more forgiving fake would be able to + * disagree with it. + * + * `delete` is pinned to ObjectQL's own dispatch predicate + * ({@link assertEngineDeleteDispatch}) rather than being a free-form filter: + * several tests below turn on a delete NOT happening, and a fake that accepted + * a malformed delete would report that as success. + */ +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? '')); + } + return eq(actual, v); + }); + /** `fields` projection — `id` always survives, as it does in ObjectQL. */ + const project = (row: any, fields?: string[]) => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out: any = {}; + for (const f of ['id', ...fields]) if (f in row) out[f] = row[f]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + const row = rows(name).find((r) => matches(r, q.where)); + return row ? project(row, q.fields) : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + const order = q.orderBy?.[0]; + if (order) { + out = [...out].sort( + (a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1), + ); + } + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => project(r, q.fields)); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any) { + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const PASSWORD = 'S3cure!Passw0rd-7732'; + +const makeManager = (engine: any, config: Record = {}) => + new AuthManager({ + secret: SECRET, + baseUrl: 'http://localhost:3000', + dataEngine: engine, + ...config, + } as any); + +const post = (manager: AuthManager, path: string, cookie?: string, body?: unknown) => + manager.handleRequest( + new Request(`http://localhost:3000/api/v1/auth/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body ?? {}), + }), + ); + +const signUp = (manager: AuthManager, email: string) => + post(manager, 'sign-up/email', undefined, { email, password: PASSWORD, name: 'Tombstone' }); + +const signIn = (manager: AuthManager, email: string) => + post(manager, 'sign-in/email', undefined, { email, password: PASSWORD }); + +const getSession = (manager: AuthManager, cookie: string) => + manager.handleRequest( + new Request('http://localhost:3000/api/v1/auth/get-session', { headers: { cookie } }), + ); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +/** + * Is this cookie still authenticated? + * + * better-auth answers `/get-session` with HTTP 200 and a JSON `null` body when + * the session is gone — NOT a 401 — so a status-only assertion would pass + * against a fully revoked session. Read the body. + */ +const isAuthenticated = async (manager: AuthManager, cookie: string): Promise => { + const res = await getSession(manager, cookie); + if (res.status !== 200) return false; + const body = await res.json().catch(() => null); + return Boolean((body as any)?.user?.id); +}; + +const sessionRows = (engine: any) => (engine.tables.get('sys_session') ?? []) as any[]; +const rowById = (engine: any, id: string) => sessionRows(engine).find((r) => r.id === id); + +/** Sign up, then sign in again: two live sessions for one user. */ +const twoSessions = async (manager: AuthManager, engine: any, email: string) => { + const first = cookieFrom(await signUp(manager, email)); + const firstRow = sessionRows(engine)[0]!; + const second = cookieFrom(await signIn(manager, email)); + const secondRow = sessionRows(engine).find((r) => r.id !== firstRow.id)!; + return { first, firstRow, second, secondRow }; +}; + +const MINUTE = 60_000; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7732 — an interactive revoke tombstones the row instead of deleting it', () => { + it('POST /revoke-session leaves the row behind, stamped, and the cookie dead', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const { first, second, secondRow } = await twoSessions(manager, engine, 'revoke-one@example.com'); + + expect(await isAuthenticated(manager, second)).toBe(true); + + const res = await post(manager, 'revoke-session', first, { token: secondRow.token }); + expect(res.status).toBe(200); + + // Direction 1 — the audit record EXISTS. Before this change the row was + // gone, which is why the `admin` cause could never be recorded at all. + const row = rowById(engine, secondRow.id); + expect(row).toBeDefined(); + expect(row.revoked_at).toBeInstanceOf(Date); + expect(row.revoke_reason).toBe('user_revoked'); + expect(new Date(row.expires_at).getTime()).toBeLessThan(Date.now()); + + // Direction 2 — and it is DEAD. A retained row that still authenticates + // would be a far worse bug than the missing audit row. + expect(await isAuthenticated(manager, second)).toBe(false); + // The revoking session is untouched. + expect(await isAuthenticated(manager, first)).toBe(true); + expect(rowById(engine, secondRow.id).revoke_reason).toBe('user_revoked'); + }); + + it('POST /revoke-other-sessions tombstones the others and spares the caller', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const { first, firstRow, second, secondRow } = await twoSessions( + manager, + engine, + 'revoke-others@example.com', + ); + + expect(await post(manager, 'revoke-other-sessions', first)).toHaveProperty('status', 200); + + expect(rowById(engine, secondRow.id).revoke_reason).toBe('user_revoked'); + expect(rowById(engine, firstRow.id).revoke_reason).toBeUndefined(); + expect(await isAuthenticated(manager, second)).toBe(false); + expect(await isAuthenticated(manager, first)).toBe(true); + }); + + it('POST /revoke-sessions tombstones every session the user has, caller included', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const { first, firstRow, second, secondRow } = await twoSessions( + manager, + engine, + 'revoke-all@example.com', + ); + + expect(await post(manager, 'revoke-sessions', first)).toHaveProperty('status', 200); + + for (const id of [firstRow.id, secondRow.id]) { + expect(rowById(engine, id).revoke_reason).toBe('user_revoked'); + expect(rowById(engine, id).revoked_at).toBeInstanceOf(Date); + } + expect(await isAuthenticated(manager, first)).toBe(false); + expect(await isAuthenticated(manager, second)).toBe(false); + }); + + it('a second sweep does not re-date a tombstone it matches again', async () => { + // `revoke-sessions` matches by user, so it re-encounters every earlier + // tombstone. Upstream restricts its own preserve path to LIVE rows for the + // same reason: a later ending must not overwrite the time an earlier one + // was recording. + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const { first, second, secondRow } = await twoSessions(manager, engine, 'twice@example.com'); + + await post(manager, 'revoke-session', first, { token: secondRow.token }); + const firstStamp = rowById(engine, secondRow.id).revoked_at as Date; + expect(firstStamp).toBeInstanceOf(Date); + + await new Promise((r) => setTimeout(r, 5)); + await post(manager, 'revoke-sessions', first); + + expect((rowById(engine, secondRow.id).revoked_at as Date).getTime()).toBe(firstStamp.getTime()); + expect(await isAuthenticated(manager, second)).toBe(false); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7732 — the tombstone survives the one thing that used to collect it', () => { + it('the revoked client polling /get-session does not garbage-collect its own record', async () => { + // The measured GC: better-auth 1.7.0-rc.2 has no sweeper at all, and its + // ONE expiry-driven collection is inside `/get-session` — a row whose + // `expiresAt` has passed is deleted "to clean up the session". That is why + // even the automatic path's stamps were best-effort. It only fires on a row + // `findSession` returned, so a hidden tombstone is never presented to it. + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const { first, second, secondRow } = await twoSessions(manager, engine, 'poll@example.com'); + + await post(manager, 'revoke-session', first, { token: secondRow.token }); + + // Five polls with the dead cookie — the shape a browser session actually + // has. Every one of them is unauthenticated and none of them collects. + for (let i = 0; i < 5; i++) expect(await isAuthenticated(manager, second)).toBe(false); + + const row = rowById(engine, secondRow.id); + expect(row).toBeDefined(); + expect(row.revoke_reason).toBe('user_revoked'); + }); + + it('an idle-timeout tombstone survives the same poll — the automatic path is retained too', async () => { + // `auth-manager.ts` is untouched by #7732: it stamps exactly as before. + // What changes is that its stamp is no longer eaten on the next request. + const engine = createMemoryEngine(); + const manager = makeManager(engine, { sessionIdleTimeoutMinutes: 30 }); + + const cookie = cookieFrom(await signUp(manager, 'idle-retained@example.com')); + const id = sessionRows(engine)[0]!.id; + rowById(engine, id).last_activity_at = new Date(Date.now() - 90 * MINUTE); + + // The detecting request is still authenticated (`enforceSessionControls` + // runs inside `customSession`, after validation) — the documented lag. + expect(await isAuthenticated(manager, cookie)).toBe(true); + expect(rowById(engine, id).revoke_reason).toBe('idle_timeout'); + + for (let i = 0; i < 3; i++) expect(await isAuthenticated(manager, cookie)).toBe(false); + expect(rowById(engine, id).revoke_reason).toBe('idle_timeout'); + }); + + it('a session that merely EXPIRED is still collected — retention is for records, not litter', async () => { + // The converse. Without this, "nothing is ever deleted" would pass every + // assertion above while quietly turning `sys_session` into an append-only + // log of everything. + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const cookie = cookieFrom(await signUp(manager, 'expired@example.com')); + const id = sessionRows(engine)[0]!.id; + rowById(engine, id).expires_at = new Date(Date.now() - MINUTE); + + expect(await isAuthenticated(manager, cookie)).toBe(false); + expect(rowById(engine, id)).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7732 — everything that is not an interactive revoke is unchanged', () => { + it('a normal sign-out still deletes the row and writes no revoke_reason', async () => { + // Signing yourself out is not a revocation: it has no cause in + // `revoke_reason`'s vocabulary, and whether `logout` earns an audit record + // at all is #7675's question. Pinned here so this change cannot drift into + // answering it. + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const cookie = cookieFrom(await signUp(manager, 'signout@example.com')); + expect(sessionRows(engine)).toHaveLength(1); + + expect(await post(manager, 'sign-out', cookie)).toHaveProperty('status', 200); + + expect(sessionRows(engine)).toHaveLength(0); + expect(await isAuthenticated(manager, cookie)).toBe(false); + }); + + it('a sign-out on a session the idle timeout already tombstoned keeps the record', async () => { + // The tombstone outranks the plain delete: the row is already a record of + // WHY the session ended, and sign-out arriving afterwards does not make + // that untrue. + const engine = createMemoryEngine(); + const manager = makeManager(engine, { sessionIdleTimeoutMinutes: 30 }); + + const cookie = cookieFrom(await signUp(manager, 'signout-after@example.com')); + const id = sessionRows(engine)[0]!.id; + rowById(engine, id).last_activity_at = new Date(Date.now() - 90 * MINUTE); + await isAuthenticated(manager, cookie); // detecting request stamps it + + await post(manager, 'sign-out', cookie); + + expect(rowById(engine, id).revoke_reason).toBe('idle_timeout'); + }); + + it('a live session is never hidden — signing in and reading back still works', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + const cookie = cookieFrom(await signUp(manager, 'live@example.com')); + expect(await isAuthenticated(manager, cookie)).toBe(true); + expect(await isAuthenticated(manager, cookie)).toBe(true); + expect(sessionRows(engine)[0]!.revoked_at).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('#7732 — the path ledger', () => { + const endpointScope = (path: string | undefined, fn: () => Promise): Promise => + path === undefined + ? fn() + : (runWithEndpointContext({ path, context: {} } as any, fn) as Promise); + + const tombstoneEngine = () => { + const updates: any[] = []; + return { + updates, + update: async (name: string, patch: any) => void updates.push({ name, patch }), + } as any; + }; + + it('maps every interactive-revoke route to the reason it should record', async () => { + for (const [path, expected] of Object.entries(INTERACTIVE_REVOKE_REASON)) { + const engine = tombstoneEngine(); + const proceed = await endpointScope(path, () => + reconcileSessionDelete(engine, 'sys_session', { id: 's1' }), + ); + expect(proceed).toBe(false); + expect(engine.updates).toHaveLength(1); + expect(engine.updates[0].name).toBe('sys_session'); + expect(engine.updates[0].patch.revoke_reason).toBe(expected); + expect(engine.updates[0].patch.revoked_at).toBeInstanceOf(Date); + expect(engine.updates[0].patch.expires_at.getTime()).toBeLessThan(Date.now()); + } + }); + + it('splits admin-initiated from self-initiated — the field is the only record of who', async () => { + expect(INTERACTIVE_REVOKE_REASON['/admin/revoke-user-session']).toBe('admin'); + expect(INTERACTIVE_REVOKE_REASON['/admin/revoke-user-sessions']).toBe('admin'); + expect(INTERACTIVE_REVOKE_REASON['/revoke-session']).toBe('user_revoked'); + expect(INTERACTIVE_REVOKE_REASON['/revoke-other-sessions']).toBe('user_revoked'); + expect(INTERACTIVE_REVOKE_REASON['/revoke-sessions']).toBe('user_revoked'); + }); + + it('leaves every other path — and no path at all — as a plain delete', async () => { + for (const path of ['/sign-out', '/get-session', '/admin/ban-user', '/delete-user', undefined]) { + const engine = tombstoneEngine(); + const proceed = await endpointScope(path, () => + reconcileSessionDelete(engine, 'sys_session', { id: 's1' }), + ); + expect(proceed).toBe(true); + expect(engine.updates).toHaveLength(0); + } + }); + + it('never touches an object that is not sys_session', async () => { + const engine = tombstoneEngine(); + const proceed = await endpointScope('/revoke-session', () => + reconcileSessionDelete(engine, 'sys_verification', { id: 'v1' }), + ); + expect(proceed).toBe(true); + expect(engine.updates).toHaveLength(0); + }); + + it('hides a tombstone everywhere except the erasure routes', async () => { + const tombstone = { id: 's1', revoked_at: new Date(), revoke_reason: 'admin' }; + const live = { id: 's2' }; + + for (const path of ['/get-session', '/sign-out', '/revoke-sessions', undefined]) { + expect(await endpointScope(path, () => hideRevokedSessionRow('sys_session', tombstone))).toBe(true); + expect(await endpointScope(path, () => hideRevokedSessionRow('sys_session', live))).toBe(false); + expect( + await endpointScope(path, () => filterRevokedSessionRows('sys_session', [live, tombstone])), + ).toEqual([live]); + } + + // Erasure is not collection: a user being removed takes their audit rows + // with them, so those routes must be able to see and delete them. + for (const path of SESSION_ERASURE_PATHS) { + expect(await endpointScope(path, () => hideRevokedSessionRow('sys_session', tombstone))).toBe(false); + expect( + await endpointScope(path, () => filterRevokedSessionRows('sys_session', [live, tombstone])), + ).toEqual([live, tombstone]); + const engine = tombstoneEngine(); + expect(await endpointScope(path, () => reconcileSessionDelete(engine, 'sys_session', tombstone))).toBe( + true, + ); + } + }); + + it('reads `revoked_at` presence, not truthiness — null and undefined are both live', () => { + expect(isRevokedSessionRow({ revoked_at: new Date() })).toBe(true); + expect(isRevokedSessionRow({ revoked_at: '2026-08-11T00:00:00.000Z' })).toBe(true); + expect(isRevokedSessionRow({ revoked_at: null })).toBe(false); + expect(isRevokedSessionRow({ revoked_at: undefined })).toBe(false); + expect(isRevokedSessionRow({})).toBe(false); + expect(isRevokedSessionRow(null)).toBe(false); + }); + + it('resolves to no path outside an endpoint context, so every rule degrades to today', async () => { + await expect(currentAuthEndpointPath()).resolves.toBeUndefined(); + }); + + it('every ledgered path is a route better-auth actually mounts', async () => { + // The ledger is keyed on better-auth's own endpoint paths. An upstream + // rename would leave every entry matching nothing — the fix would go + // silently inert with no test failing anywhere else, which is the failure + // `auth-route-ledger.ts` exists to make impossible for the SDK surface. + const mounted = new Set( + BETTER_AUTH_MOUNTED_SURFACE.map((route) => route.replace(/^[A-Z]+ \/api\/v1\/auth/, '')), + ); + for (const path of [...Object.keys(INTERACTIVE_REVOKE_REASON), ...SESSION_ERASURE_PATHS]) { + expect({ path, mounted: mounted.has(path) }).toEqual({ path, mounted: true }); + } + }); +}); From 880971a251bb8a0d5419f7a26e0dcf2f820d2656 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 16:58:35 +0000 Subject: [PATCH 3/4] fix(platform-objects): keep the Sessions list live-only and give tombstones a view (#7732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd --- .../session-interactive-revoke-tombstone.md | 51 +++++++++++++++++++ .../apps/translations/en.objects.generated.ts | 3 ++ .../translations/es-ES.objects.generated.ts | 3 ++ .../translations/ja-JP.objects.generated.ts | 3 ++ .../translations/zh-CN.objects.generated.ts | 3 ++ .../src/identity/sys-session.object.ts | 24 ++++++++- 6 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .changeset/session-interactive-revoke-tombstone.md diff --git a/.changeset/session-interactive-revoke-tombstone.md b/.changeset/session-interactive-revoke-tombstone.md new file mode 100644 index 0000000000..b947f0e1c8 --- /dev/null +++ b/.changeset/session-interactive-revoke-tombstone.md @@ -0,0 +1,51 @@ +--- +"@objectstack/plugin-auth": patch +"@objectstack/platform-objects": patch +--- + +fix(plugin-auth,platform-objects): record the `admin` cause an interactive session revoke never could (#7732) + +`sys_session.revoked_at` / `revoke_reason` are declared `readonly` and +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 worked — `enforceSessionControls` and `enforceConcurrentCap` +expire the row in place and stamp both columns. The fourth could not: an +admin or user-initiated revoke reaches better-auth's `deleteSession` / +`deleteUserSessions`, which **delete the row**, and a deleted row carries no +`revoke_reason`. The audit trail was inert for the single cause an audit most +wants. + +**What changes.** An interactive revoke now ends the session by stamping it +rather than deleting it — the same shape the automatic path already writes +(`expires_at` into the past plus both columns). Five endpoints are covered: +`POST /revoke-session`, `/revoke-sessions`, `/revoke-other-sessions`, +`/admin/revoke-user-session` and `/admin/revoke-user-sessions`. Self-service +revocations record `revoke_reason: 'user_revoked'` and the two admin routes +record `'admin'`, because the column is the only thing in the row that says who +ended the session and recording `admin` for a user signing out their own other +device would be a *wrong* audit record rather than a vague one. + +The substitution happens at the better-auth → ObjectQL adapter, so better-auth's +whole session-delete hook lifecycle still runs — **OIDC back-channel logout +still fires on a revoke**. `sys_session`'s field declarations are unchanged. + +**Revoked rows are also retained.** better-auth's one expiry-driven collector +(inside `GET /get-session`) would otherwise delete the new tombstone the moment +the revoked client next polled, leaving the trail exactly as inert as before — +which is why the automatic path's stamps were already best-effort. A revoked +row is now invisible to better-auth's own session reads, so that collector never +sees it. The revoked session therefore stops authenticating *harder* than before +(`findSession` answers nothing at all, rather than answering an expired row), +and its record survives. User-deletion routes still see and physically remove +these rows: erasing a user erases their sessions. + +**Behaviour worth knowing about:** a revoked session no longer disappears from +the database. The `My Sessions` and `All` views on `sys_session` filter revoked +rows out, so the Sessions list looks exactly as it did; a new **Revoked** view +exposes `revoked_at` / `revoke_reason` for auditing. There is no retention +window or sweeper for `sys_session` — revoked rows are kept indefinitely, the +same way a session abandoned without signing out already was. + +A normal sign-out is untouched: it still deletes the row and writes no +`revoke_reason`. Signing yourself out is not a revocation, and whether it earns +an audit record is a separate open question (#7675). diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7b1f0be65b..463b50f245 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -349,6 +349,9 @@ export const enObjects: NonNullable = { }, all_sessions: { label: "All" + }, + revoked: { + label: "Revoked" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 03c9c35ceb..e7c0b01401 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -349,6 +349,9 @@ export const esESObjects: NonNullable = { }, all_sessions: { label: "Todas" + }, + revoked: { + label: "Revocadas" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 730d750c7f..ebfaabd3a7 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -349,6 +349,9 @@ export const jaJPObjects: NonNullable = { }, all_sessions: { label: "すべて" + }, + revoked: { + label: "取り消し済み" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 6b3cd9a451..5d946b717f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -349,6 +349,9 @@ export const zhCNObjects: NonNullable = { }, all_sessions: { label: "全部" + }, + revoked: { + label: "已撤销" } }, _actions: { diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 51846b03ed..67a92acc5a 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -71,6 +71,14 @@ export const SysSession = ObjectSchema.create({ }, ], + // [#7732] A `revoked_at` row is a TOMBSTONE — an ended session kept as the + // ADR-0069 D4 audit record of its ending, not a session. Since the + // interactive revoke stamps the row instead of deleting it, the two + // session-listing views filter tombstones out (`revoke_session` still makes + // the row leave the grid, exactly as it did when the row was deleted), and + // the audit trail those columns exist for gets a view of its own — otherwise + // the fields would be written and still readable nowhere, which is the same + // declared-≠-enforced gap one layer up. listViews: { mine: { type: 'grid', @@ -78,7 +86,10 @@ export const SysSession = ObjectSchema.create({ label: 'My Sessions', data: { provider: 'object', object: 'sys_session' }, columns: ['ip_address', 'active_organization_id', 'created_at', 'expires_at'], - filter: [{ field: 'user_id', operator: 'equals', value: '{current_user_id}' }], + filter: [ + { field: 'user_id', operator: 'equals', value: '{current_user_id}' }, + { field: 'revoked_at', operator: 'is_null' }, + ], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, @@ -88,9 +99,20 @@ export const SysSession = ObjectSchema.create({ label: 'All', data: { provider: 'object', object: 'sys_session' }, columns: ['user_id', 'ip_address', 'active_organization_id', 'created_at', 'expires_at'], + filter: [{ field: 'revoked_at', operator: 'is_null' }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, + revoked: { + type: 'grid', + name: 'revoked', + label: 'Revoked', + data: { provider: 'object', object: 'sys_session' }, + columns: ['user_id', 'ip_address', 'revoked_at', 'revoke_reason', 'created_at'], + filter: [{ field: 'revoked_at', operator: 'is_not_null' }], + sort: [{ field: 'revoked_at', order: 'desc' }], + pagination: { pageSize: 50 }, + }, }, fields: { From 610c69555c7da2bd557ccf35b072d01a69c3a143 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:03:08 +0000 Subject: [PATCH 4/4] test(plugin-auth): pin the tombstone fake's update() to ObjectQL's dispatch (#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 Claude-Session: https://claude.ai/code/session_01BVc1ekPpi6yaWywAUhfzfd --- .../plugin-auth/src/session-tombstone.test.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-auth/src/session-tombstone.test.ts b/packages/plugins/plugin-auth/src/session-tombstone.test.ts index aa890cd78e..94251cc842 100644 --- a/packages/plugins/plugin-auth/src/session-tombstone.test.ts +++ b/packages/plugins/plugin-auth/src/session-tombstone.test.ts @@ -20,7 +20,7 @@ // the library does around our write. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { assertEngineDeleteDispatch } from '@objectstack/objectql'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql'; import { runWithEndpointContext } from '@better-auth/core/context'; import { AuthManager } from './auth-manager'; import { BETTER_AUTH_MOUNTED_SURFACE } from './auth-route-ledger'; @@ -40,10 +40,15 @@ import { * through the same library and a second, more forgiving fake would be able to * disagree with it. * - * `delete` is pinned to ObjectQL's own dispatch predicate - * ({@link assertEngineDeleteDispatch}) rather than being a free-form filter: - * several tests below turn on a delete NOT happening, and a fake that accepted - * a malformed delete would report that as success. + * `delete` AND `update` are both pinned to ObjectQL's own dispatch predicates + * ({@link assertEngineDeleteDispatch} / {@link assertEngineUpdateDispatch}) + * rather than being free-form filters. Both fire for real here: several tests + * turn on a delete NOT happening, and the whole fix is an `update` the real + * engine has to accept — a fake looser than `ObjectQLEngine.update` could green + * a tombstone write the engine would refuse. (`session-of-record.test.ts`'s + * copy of this fake still carries the #5480 `update` DEBT entry in + * `scripts/engine-double-contract.baseline.json`; a new double does not get to + * inherit it.) */ const createMemoryEngine = () => { const tables = new Map(); @@ -103,8 +108,14 @@ const createMemoryEngine = () => { async count(name: string, q: any = {}) { return rows(name).filter((r) => matches(r, q.where)).length; }, - async update(name: string, patch: any) { - const row = rows(name).find((r) => r.id === patch.id); + async update(name: string, patch: any, options?: any) { + const dispatch = assertEngineUpdateDispatch(patch, options); + if (dispatch.kind === 'multi') { + const hit = rows(name).filter((r) => matches(r, options?.where)); + for (const row of hit) Object.assign(row, patch); + return hit.length; + } + const row = rows(name).find((r) => r.id === dispatch.id); if (!row) return null; Object.assign(row, patch); return { ...row };