Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/sys-session-ttl-spare-tombstones.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
---
"@objectstack/platform-objects": minor
---

Declare an ADR-0057 lifecycle policy on `sys_session` (#7826): the object is
now `class: 'transient'` with
`ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }`.

**Ordinary expired sessions are now reaped** by the LifecycleService Reaper one
day after `expires_at` passes — the same window `sys_device_code` uses. Until
now nothing swept this table: better-auth's only expiry-driven collector fires
inside `GET /get-session`, so it can never reach a row whose cookie is never
presented again, and an abandoned session was effectively immortal.

**Revoked tombstones are deliberately spared.** The `onlyWhen` filter (#10165)
is load-bearing, not defensive: the #7732 revocation write backdates
`expires_at` to `now - 1000` and clears nothing, so an ADR-0069 D4 audit
tombstone looks *maximally* expired — a TTL on `expires_at` without the filter
would reap the audit trail first and hardest.

Deliberate, known consequence: because tombstones are spared entirely,
`sys_session` still grows without bound on the revoked arm. How long a
revoked-session tombstone should be retained is compliance / audit-trail
policy and is not settled here.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#7826] `sys_session`'s ADR-0057 lifecycle declaration, at the SPEC tier.
//
// This is the third control on the card: the declaration parses, and neither
// of #10165's two `ttl.onlyWhen` conflict refines fires for it.
//
// ⚠️ "Neither refine fires" is worth nothing as a bare absence — `sys_session`
// declares no `archive` and no rotation `storage`, so of course they do not
// fire, and the same green would be printed by a build in which both refines
// had been deleted. So each is measured against its own counterfactual: the
// exact declaration plus the conflicting block must be REFUSED, with #10165's
// own message. That turns "no refine fired" into a statement about live rules.
//
// The sweep behaviour these keys buy — the tombstone-sparing and positive
// controls — is measured where a real Reaper and a real SQL backend are
// reachable: `@objectstack/plugin-auth`'s `sys-session-ttl-sweep.test.ts`.

import { describe, it, expect } from 'vitest';
import { LifecycleSchema, ObjectSchema } from '@objectstack/spec/data';
import { SysSession } from './sys-session.object.js';
import { SysDeviceCode } from './sys-device-code.object.js';

const lifecycle = (SysSession as any).lifecycle;

describe('[#7826] sys_session lifecycle declaration', () => {
it('is exactly the ruled declaration (maintainer 2026-08-20, option A)', () => {
expect(lifecycle).toEqual({
class: 'transient',
ttl: {
field: 'expires_at',
expireAfter: '1d',
onlyWhen: { revoked_at: { $null: true } },
},
});
});

it('parses — both as a lifecycle block and as part of the whole object', () => {
expect(LifecycleSchema.safeParse(lifecycle).success).toBe(true);
const parsed = ObjectSchema.safeParse(SysSession);
expect(parsed.success).toBe(true);
});

it('filters on a field the object actually declares, of a nullable type', () => {
// A filter naming a column that does not exist would compile to a
// predicate matching nothing — the sweep would silently stop reaping.
const field: any = (SysSession.fields as any)[Object.keys(lifecycle.ttl.onlyWhen)[0]];
expect(field).toBeTruthy();
expect(field.required).not.toBe(true);
expect((SysSession.fields as any)[lifecycle.ttl.field]).toBeTruthy();
});

it('matches the window of sys_device_code, the only other better-auth transient object', () => {
expect((SysDeviceCode as any).lifecycle.class).toBe('transient');
expect((SysDeviceCode as any).lifecycle.ttl.expireAfter).toBe(lifecycle.ttl.expireAfter);
});

// ── #10165's two refines: not fired here, and proved to be live ──────────

it('declares neither conflicting block, so neither #10165 refine fires', () => {
expect(lifecycle.archive).toBeUndefined();
expect(lifecycle.storage).toBeUndefined();
});

it('COUNTERFACTUAL — adding `archive` to this exact declaration is refused', () => {
const r = LifecycleSchema.safeParse({ ...lifecycle, archive: { after: '7y', to: 'cold_store' } });
expect(r.success).toBe(false);
expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | '))
.toContain('lifecycle.ttl.onlyWhen cannot be combined with archive');
});

it('COUNTERFACTUAL — adding rotation storage to this exact declaration is refused', () => {
const r = LifecycleSchema.safeParse({
...lifecycle,
storage: { strategy: 'rotation', shards: 7, unit: 'day' },
});
expect(r.success).toBe(false);
expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | '))
.toContain('lifecycle.ttl.onlyWhen cannot be combined with rotation storage');
});
});
31 changes: 31 additions & 0 deletions packages/platform-objects/src/identity/sys-session.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,37 @@ export const SysSession = ObjectSchema.create({
icon: 'key',
isSystem: true,
managedBy: 'better-auth',

// [#7826] ADR-0057 lifecycle — ordinary expired sessions are swept by the
// Reaper; revoked TOMBSTONES are spared ENTIRELY.
//
// `onlyWhen` here is load-bearing, not defensive. The #7732 tombstone write
// (`plugin-auth`'s `reconcileSessionDelete`) BACKDATES `expires_at` to
// `now - 1000` and clears nothing, so a tombstone is a strict SUPERSET of an
// ordinary row that looks MAXIMALLY expired. A `ttl` keyed on `expires_at`
// without this filter would therefore reap the ADR-0069 D4 audit records
// FIRST AND HARDEST — the very rows it exists to preserve — and no existing
// test would go red. The canonical null predicate (`{$null: true}`, #10165)
// is what lets that exclusion be declared HERE, in the object file, instead
// of hiding in a plugin registration one package away.
//
// ⚠️ Deliberate consequence, stated rather than left implicit: tombstones are
// never swept, so `sys_session` still grows without bound on that arm. How
// long a revoked-session tombstone is retained is compliance / audit-trail
// policy and is the maintainer's to settle (#7826's hard fence) — this
// declaration picks no window for it.
//
// `1d` (a grace day AFTER `expires_at` passes) matches `sys_device_code`,
// the only other `managedBy: 'better-auth'` transient object.
lifecycle: {
class: 'transient',
ttl: {
field: 'expires_at',
expireAfter: '1d',
onlyWhen: { revoked_at: { $null: true } },
},
},

// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
// but may add overlay row-level config. Use `no-overlay` if you need to
// forbid sys_metadata overlays entirely.
Expand Down
242 changes: 242 additions & 0 deletions packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#7826] `sys_session`'s ADR-0057 TTL sweep, driven end to end: the REAL
// declaration (`@objectstack/platform-objects`) through the REAL Reaper
// (`@objectstack/objectql` `LifecycleService`) against a REAL SQL backend
// (`@objectstack/driver-sql`, live better-sqlite3), over a table this driver
// created from that same declaration.
//
// ## Why this suite exists at all
//
// The declaration it exercises is
//
// ttl: { field: 'expires_at', expireAfter: '1d',
// onlyWhen: { revoked_at: { $null: true } } }
//
// and the `onlyWhen` clause is the whole point. `reconcileSessionDelete` in
// `session-tombstone.ts` (#7732 / ADR-0069 D4) BACKDATES `expires_at` to
// `now - 1000` when it tombstones a revoked session, and clears nothing — so a
// tombstone is a strict SUPERSET of an ordinary row that looks MAXIMALLY
// expired. A TTL keyed on `expires_at` without the filter therefore reaps the
// audit records FIRST AND HARDEST. That backdating is pinned independently in
// `session-tombstone.test.ts`; here it is produced by that same function and
// then fed to the sweep, so the row under test is the one production writes
// rather than one this file imagined.
//
// ## The two controls, and why neither is sufficient alone
//
// * SPARING — the tombstone survives the sweep.
// * POSITIVE — an ordinary expired row is deleted BY THE SAME SWEEP.
//
// Without the positive control the filter could be disabling the sweep
// outright and the sparing control would still pass; without the sparing
// control the sweep is just a sweep. They are made maximally discriminating by
// giving both rows the IDENTICAL `expires_at`: the only property that differs
// is `revoked_at`, so nothing but the filter can separate their fates.
//
// ⚠️ The honest before-state for the sparing control is NOT the pre-fix tree:
// on `origin/main` `sys_session` declared no `lifecycle` at all, so there was
// no sweep and a tombstone survived trivially. The control was proved to
// discriminate by ABLATING the declaration itself — dropping `onlyWhen` while
// keeping the `ttl`, rebuilding `@objectstack/platform-objects` (this package
// resolves it through `exports`, i.e. `dist/`) and watching the tombstone get
// reaped. See the PR body for that run.

import { describe, it, expect, afterEach, vi } from 'vitest';
import { SqlDriver } from '@objectstack/driver-sql';
import { LifecycleService, assertEngineDeleteDispatch } from '@objectstack/objectql';
import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { runWithEndpointContext } from '@better-auth/core/context';
import { SysSession } from '@objectstack/platform-objects/identity';
import { reconcileSessionDelete } from './session-tombstone';

/** The instant the revocation happens; the sweep runs two days later. */
const REVOKED_AT_MS = Date.parse('2026-08-01T00:00:00.000Z');
const SWEEP_AT_MS = REVOKED_AT_MS + 2 * 86_400_000;

const openDrivers: SqlDriver[] = [];
afterEach(async () => {
while (openDrivers.length) {
const d = openDrivers.pop();
try { await d?.disconnect(); } catch { /* noop */ }
}
});

const silentLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} };

/**
* The tombstone patch as the REAL writer composes it — `reconcileSessionDelete`
* under a real better-auth endpoint context for an interactive revoke. Only the
* `update` surface is needed: the function answers the delete by writing this
* patch instead of deleting.
*/
async function realTombstonePatch(atMs = REVOKED_AT_MS): Promise<Record<string, any>> {
const patches: Array<Record<string, any>> = [];
const engine = { update: async (_o: string, p: any) => { patches.push(p); } };
// The writer stamps from `Date.now()`. Pinning the clock to the simulated
// revocation instant is what puts the row on the same timeline as the sweep,
// WITHOUT rebasing (and so possibly flattening) the backdating this suite is
// about — the offset is still the one the real function chose.
vi.useFakeTimers();
vi.setSystemTime(new Date(atMs));
try {
const proceed = await runWithEndpointContext(
{ path: '/revoke-session', context: {} } as any,
() => reconcileSessionDelete(engine as any, 'sys_session', { id: 'sess_tombstone', revoked_at: null }),
);
expect(proceed).toBe(false); // answered by a tombstone, not a delete
} finally {
vi.useRealTimers();
}
expect(patches).toHaveLength(1);
return patches[0];
}

/**
* `LifecycleEngineLike` over a live `SqlDriver`. `delete` opens with ObjectQL's
* own dispatch predicate so this double refuses exactly what the real engine
* refuses (#4550) rather than re-deriving the rule.
*/
function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): LifecycleEngineLike {
return {
registry: { getAllObjects: () => objects },
getDriverForObject: () => driver,
async find(object: string, options: any) {
// Typed rather than erased to `any`: the driver silently DROPS an
// unrecognised query key, so `tsc` is the only channel that can reject a
// misspelt one here (#4918).
const query: DriverQuery = { where: options?.where, limit: options?.limit };
return driver.find(object, query);
},
async delete(object: string, options: any) {
const dispatch = assertEngineDeleteDispatch(options);
if (dispatch.kind === 'by-id') {
// `EngineDeleteDispatch.id` admits `bigint`; the driver's by-id delete
// takes `string | number`. Narrowed by stringifying — the same reason
// `LifecycleService`'s own `idKey` stringifies — rather than cast away,
// which is what hid the mismatch here in the first place.
const id = typeof dispatch.id === 'bigint' ? dispatch.id.toString() : dispatch.id;
return (await driver.delete(object, id)) ? 1 : 0;
}
const query: DriverQuery = { where: options?.where };
return driver.deleteMany(object, query);
},
};
}

/**
* Live `sys_session` table, created by the driver from the REAL object
* declaration, seeded with the three rows the policy has to tell apart.
*
* `lifecycle` is the declaration under test unless `override` replaces it —
* that parameter is what lets the ablation be expressed as a case in this file
* as well as being run for real against a rebuilt `dist/` (see the header).
*/
async function seeded(override?: any) {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
openDrivers.push(driver);
await driver.initObjects([SysSession as any]);

const patch = await realTombstonePatch();
const tombstoneExpiry = new Date(patch.expires_at).toISOString();

await driver.create('sys_session', {
id: 'sess_tombstone',
user_id: 'usr_1',
token: 'tok_tombstone',
// Exactly what the real tombstone writer produced.
expires_at: tombstoneExpiry,
revoked_at: new Date(patch.revoked_at).toISOString(),
revoke_reason: patch.revoke_reason,
});
await driver.create('sys_session', {
id: 'sess_expired',
user_id: 'usr_1',
token: 'tok_expired',
// IDENTICAL expiry to the tombstone — `revoked_at` is the only difference.
expires_at: tombstoneExpiry,
revoked_at: null,
});
await driver.create('sys_session', {
id: 'sess_live',
user_id: 'usr_1',
token: 'tok_live',
expires_at: new Date(SWEEP_AT_MS + 7 * 86_400_000).toISOString(),
revoked_at: null,
});

const object: LifecycleObjectLike = {
name: SysSession.name,
lifecycle: (override === undefined ? (SysSession as any).lifecycle : override),
fields: SysSession.fields as any,
};
const service = new LifecycleService({
getEngine: () => sweepEngine(driver, [object]),
logger: silentLogger,
now: () => SWEEP_AT_MS,
initialDelayMs: 1,
sweepIntervalMs: 10,
} as any);

return { driver, service, patch, tombstoneExpiry };
}

const ALL_ROWS: DriverQuery = {};
const survivors = async (driver: SqlDriver) =>
(await driver.find('sys_session', ALL_ROWS)).map((r: any) => r.id).sort();

describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live SQL', () => {
it('the hazard is real: the tombstone writer backdates expires_at below the revocation instant', async () => {
const patch = await realTombstonePatch();
expect(patch.revoked_at).toBeInstanceOf(Date);
expect(patch.revoke_reason).toBeTruthy();
// The defining property: the tombstone looks MORE expired than a session
// that merely lapsed, which is why a naive TTL reaps tombstones first.
expect(new Date(patch.expires_at).getTime()).toBeLessThan(new Date(patch.revoked_at).getTime());
});

it('SPARING CONTROL — the revoked tombstone survives the sweep', async () => {
const { driver, service } = await seeded();

const report = await service.sweep();

expect(await survivors(driver)).toContain('sess_tombstone');
const tombstoneById: DriverQuery = { where: { id: 'sess_tombstone' } };
const row: any = await driver.findOne('sys_session', tombstoneById);
expect(row).toBeTruthy();
expect(row.revoke_reason).toBeTruthy(); // the audit content is intact
expect(report.errors).toEqual([]);
});

it('POSITIVE CONTROL — an ordinary expired session IS deleted by that same sweep', async () => {
const { driver, service } = await seeded();

const report = await service.sweep();

// One sweep, three rows, two verdicts: the expired row is gone, the
// tombstone and the live session remain.
expect(await survivors(driver)).toEqual(['sess_live', 'sess_tombstone']);
const ttl = report.swept.find((s: any) => s.object === 'sys_session' && s.policy === 'ttl');
expect(ttl).toBeTruthy();
expect(ttl!.deleted).toBe(1);
});

it('ABLATION — without `onlyWhen` the same sweep reaps the tombstone too', async () => {
// The declaration minus its filter: the naive policy #10165 existed to
// make avoidable. This is the case the sparing control has to discriminate
// against, so the control is not vacuous.
const { driver, service } = await seeded({
class: 'transient',
ttl: { field: 'expires_at', expireAfter: '1d' },
});

await service.sweep();

expect(await survivors(driver)).toEqual(['sess_live']);
});
});
5 changes: 5 additions & 0 deletions scripts/engine-double-contract.pinned.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -1311,6 +1311,11 @@
"verb": "update",
"pinned": 1
},
{
"file": "packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts",
"verb": "delete",
"pinned": 1
},
{
"file": "packages/plugins/plugin-email/src/attachment-reclaim.test.ts",
"verb": "delete",
Expand Down
Loading