Skip to content

Commit 431327b

Browse files
fix(plugin-auth): write the session last_activity_at heartbeat on every deployment (#25398)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoRcazFf3zbHR5zgBPMo64bA)_ Fixes #25343 Clause-②: no Metadata-protocol: none The second and last half of #25343. The column half (`user_agent` and `last_activity_at` on the Sessions list views) landed as #25368. This half makes `last_activity_at` actually get written on a default deployment, so the Last Activity column is filled. Ruling: 6358844136, letter A (maintainer 「同意」, 2026-10-23), per analysis 6357589247. ## What changes `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts`, `enforceSessionControls` (run per authenticated request from `customSession`): - **The heartbeat runs on every deployment.** The early return when the idle and absolute limits are both off (the default) is gone, and the `last_activity_at` write is no longer inside `if (idleMin > 0)`. It is still at most one write per session per minute (`SESSION_HEARTBEAT_INTERVAL_MS`, 60 s, the same threshold as before). - **With both limits off, the read is throttled too.** A per-process, per-session memo (`SessionHeartbeatMemo`) remembers when each session's `sys_session` row was last read and skips the `findOne` (and so the write) for 60 s after it. The memo is bounded at `SESSION_HEARTBEAT_MEMO_MAX` (10,000) sessions, least recently read evicted first; an evicted session is simply read once more. It is held in a `WeakMap` keyed by the data engine, so in a running process it is the one engine's memo, and a second engine (another manager, a test double) never answers from it. - **With a limit on, nothing about enforcement changes.** The memo is not consulted, so every request still reads the row, exactly as revocation needs. The heartbeat now also runs when only the absolute limit is configured (before, that configuration wrote no heartbeat either). - A failed read is not memoised, so the next request reads again. When both limits are off, the read-failure warning now names what was lost (the heartbeat) instead of saying the idle and absolute limits were not evaluated, which on that deployment would name controls nobody turned on. With a limit on, the message is unchanged. - The write-failure path (`logDurabilityDegradation` on the heartbeat update) is unchanged: same text, same meta, only re-indented one level. `auth-manager.ts`: the `enforceSessionControls` docblock no longer says "No-op when both are off" (comment only). ## Measured cost Under the default config, per live session per minute: - **One request** reads `sys_session` once and, when `last_activity_at` is older than a minute, writes it once. Every other request in that minute reads nothing extra (the memo). - **That same request resolves the caller's grants twice.** Core's request-grants memo (`packages/core/src/security/request-grants-memo.ts`) declines when a write lands inside the request, and the heartbeat is such a write. Measured in `session-grants-resolved-once.test.ts` for an organization member: the declined request reads 6 more grant rows than a memo-served one (`twice.reads.length - once.reads.length`, the existing pin). So the price is about 6 extra grant reads per session per minute, on top of the one read and one write. - ADR-0127 D8 is confirmed on `main` (`docs/adr/0127-*.md` around :125): `sys_session` is deliberately outside the grants cache's watched set, so the heartbeat does not retire the authorization cache. The cost above is the per-request memo, not the cache. ## Verification - New `session-heartbeat-default-config.test.ts`, 11 cases: heartbeat written under the default config (first request, and again after more than 60 s); the memo skips the read and the write within 60 s and reads again at 60 s + 1 ms; a row touched by another process 20 s ago is read but not written; per session; per engine; a failed read is not memoised and its warning names the heartbeat; a revoked session is read once a minute and never touched; the memo cap evicts the least recently read; with an idle timeout or an absolute max on, two requests a second apart both read. - `auth-manager.test.ts`: the pin "is a no-op when idle + absolute are both off" asserted that `findOne` is never called, which is the behaviour the ruling reverses. It is rewritten in place to "revokes nothing but still writes the heartbeat". The idle-timeout and absolute-max pins beside it are unchanged and green. - `session-grants-resolved-once.test.ts`: its warm-up asserted that the warm request writes nothing. A session's first request now writes the heartbeat, so the warm-up makes one request per session and expects exactly `update sys_session` from each; the measured requests after it write nothing, as before, and every count in the file is unchanged. The pin "a write inside the request: the memo declines" gains a default-config row beside its idle-timeout row. - Ablation: `idleMin > 0 &&` put back in front of the heartbeat condition (`scripts/ablation-replace.mjs`, anchor 1 to 0, blob `b6da2ab9` to `8519f0aa`; the tests import `src/`, no `dist/` on the path). 15 cases went red, including both "heartbeat written under the default config" pins, the rewritten no-op pin and the default-config grants-memo row. Restored: blob back to `b6da2ab9`, `git diff HEAD` empty. - `pnpm --filter @objectstack/plugin-auth test`: 188 files, 3538 passed, 10 skipped (at `302d5e29`; the two commits after it change only `session-heartbeat-default-config.test.ts` and the ledger below, and the three touched suites re-ran at `af4a5fe5`: 326/326). `pnpm --filter @objectstack/plugin-auth typecheck` (tsc, examples, `check:test-typecheck`) exit 0 at `af4a5fe5`; the test-typecheck debt ledger is unchanged. - The new suite's engine double opens `findOne` / `update` with `assertEngineFindOnePredicate` / `assertEngineUpdateDispatch`, and `scripts/engine-double-contract.pinned.json` gains its two rows (`check-engine-double-contract.mjs --write`, 0 lost). - Gates at `af4a5fe5`: `dispatch-gates --commands` derived 79; `--ran` with recorded exit codes: 79 accounted, 78 run, 1 NOT MEASURED (`node scripts/check-plugin-teardown-shape.mjs --self-test` exits 3, it needs an unshallowed clone). `pnpm check:dts-closure` and `pnpm check:dual-build-cjs-loads` first exited 1 on a local `packages/plugins/organizations/dist` with no `index.d.ts` (a package this diff does not touch); after `pnpm --filter @objectstack/organizations build --force` both exit 0. `pnpm check:durability-log-level` and `pnpm check:engine-double-contract` exit 0. ## Acceptance notes - The heartbeat's write-failure message still explains its consequence in idle-timeout terms ("this session will be revoked as idle EARLIER"). On a deployment with no idle timeout the real consequence is only a stale Last Activity. The dispatch asked for that path to stay exactly as it is, so it is unchanged here. - The memo is per process, as ruled. On a multi-node deployment a session served by several nodes can be read once a minute per node; the write is still guarded by the row's own `last_activity_at`. - The raw `user_agent` shown as browser and OS is the objectui polish item noted on the card, not part of this PR. --- _Generated by [Claude Code](https://claude.ai/code/session_01URyDu4ZGxRVsi6YiRz5Z8g)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77a77e4 commit 431327b

7 files changed

Lines changed: 386 additions & 39 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
The session activity heartbeat now runs on every deployment. `sys_session.last_activity_at` is "the last request on this session", and Setup → Sessions shows it so an administrator can tell two of one user's sessions apart. Until now it was written only while `sessionIdleTimeoutMinutes` was above 0, so on a default deployment (no idle or absolute limit) the Last Activity column stayed empty.
9+
10+
It is still written at most once a minute per session. When neither the idle nor the absolute limit is configured, the `sys_session` read that feeds it is throttled too: a per-process memo, per session and bounded in size, skips the read for 60 seconds after one. With a limit configured, every request still reads the session row, as enforcement needs.

‎packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts‎

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -450,22 +450,90 @@ export async function unlockUser(this: Pick<PasswordSessionPolicyHost, 'getDataE
450450
return true;
451451
}
452452

453+
/**
454+
* [#25343] The `last_activity_at` heartbeat's throttle: at most one write per
455+
* session per minute, and — when no session limit is configured — at most one
456+
* `sys_session` read per session per minute as well.
457+
*/
458+
const SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
459+
/**
460+
* [#25343] The most sessions one {@link SessionHeartbeatMemo} remembers. An
461+
* entry is worth keeping for one {@link SESSION_HEARTBEAT_INTERVAL_MS} only,
462+
* so the cap is a bound on memory, not on correctness: an evicted session is
463+
* read once more, exactly as if the memo had expired.
464+
*/
465+
export const SESSION_HEARTBEAT_MEMO_MAX = 10_000;
466+
467+
/**
468+
* [#25343] Per-process, per-session memo of "this session's row was read less
469+
* than a minute ago", consulted only when neither the idle nor the absolute
470+
* limit is configured (ADR-0069 D4). There the read exists only to feed the
471+
* heartbeat, which writes at most once a minute, so a read inside that minute
472+
* cannot lead to a write and is skipped. With a limit on, every request still
473+
* reads: enforcement needs the row's current state, and the memo is not asked.
474+
*
475+
* Bounded least-recently-noted: a `Map` keeps insertion order, `note` moves
476+
* the session to the end, and the oldest entry — the stalest — is dropped
477+
* past {@link SESSION_HEARTBEAT_MEMO_MAX}.
478+
*/
479+
class SessionHeartbeatMemo {
480+
private readonly readAt = new Map<string, number>();
481+
482+
isFresh(sessionId: string, now: number): boolean {
483+
const at = this.readAt.get(sessionId);
484+
return at !== undefined && now - at <= SESSION_HEARTBEAT_INTERVAL_MS;
485+
}
486+
487+
note(sessionId: string, now: number): void {
488+
this.readAt.delete(sessionId);
489+
this.readAt.set(sessionId, now);
490+
if (this.readAt.size > SESSION_HEARTBEAT_MEMO_MAX) {
491+
const oldest = this.readAt.keys().next().value;
492+
if (oldest !== undefined) this.readAt.delete(oldest);
493+
}
494+
}
495+
}
496+
497+
/**
498+
* [#25343] One memo per data engine, held weakly: in a running process that
499+
* is the one engine the manager is wired to (so the memo is per-process), and
500+
* a second engine — another manager, a test double — never answers from the
501+
* first one's memo.
502+
*/
503+
const sessionHeartbeatMemos = new WeakMap<object, SessionHeartbeatMemo>();
504+
505+
function sessionHeartbeatMemoFor(engine: object): SessionHeartbeatMemo {
506+
let memo = sessionHeartbeatMemos.get(engine);
507+
if (!memo) {
508+
memo = new SessionHeartbeatMemo();
509+
sessionHeartbeatMemos.set(engine, memo);
510+
}
511+
return memo;
512+
}
513+
453514
/** The body of `AuthManager#enforceSessionControls`; the method's docblock there is its contract. */
454515
export async function enforceSessionControls(this: Pick<PasswordSessionPolicyHost, 'config' | 'getDataEngine' | 'logDurabilityDegradation'>, sessionId: string | undefined, createdAtHint: unknown): Promise<void> {
455516
const idleMin = Math.floor(Number(this.config.sessionIdleTimeoutMinutes) || 0);
456517
const absHrs = Math.floor(Number(this.config.sessionAbsoluteMaxHours) || 0);
457-
if (idleMin <= 0 && absHrs <= 0) return;
518+
// [#25343] Both limits off no longer returns here: `last_activity_at` is
519+
// "the last request on this session" on every deployment, not only where an
520+
// idle timeout reads it (ruling 6358844136, letter A). What both-off buys
521+
// instead is the memo below, so the added read is throttled like the write.
522+
const limitsOff = idleMin <= 0 && absHrs <= 0;
458523
const engine = this.getDataEngine();
459524
if (!engine || !sessionId) return;
525+
const memo = limitsOff ? sessionHeartbeatMemoFor(engine) : undefined;
526+
if (memo?.isFresh(sessionId, Date.now())) return;
460527
try {
461528
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
462529
const srow = await engine.findOne('sys_session', {
463530
where: { id: sessionId },
464531
fields: ['id', 'created_at', 'last_activity_at', 'revoked_at'],
465532
context: SYSTEM_CTX,
466533
});
467-
if (!srow?.id || srow.revoked_at) return;
468534
const now = Date.now();
535+
memo?.note(sessionId, now);
536+
if (!srow?.id || srow.revoked_at) return;
469537
let reason: string | undefined;
470538
if (absHrs > 0) {
471539
const created = srow.created_at ?? createdAtHint;
@@ -498,33 +566,42 @@ export async function enforceSessionControls(this: Pick<PasswordSessionPolicyHos
498566
));
499567
return;
500568
}
501-
if (idleMin > 0) {
502-
const la = srow.last_activity_at ? new Date(srow.last_activity_at as any).getTime() : 0;
503-
if (now - la > 60_000) {
504-
await engine.update('sys_session', { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX } as any)
505-
.catch((e: unknown) => this.logDurabilityDegradation(
506-
'AuthManager: the session activity heartbeat was NOT written — the request '
507-
+ 'succeeds and the session stays live, so nothing looks wrong. '
508-
+ '`last_activity_at` keeps its older value, so the idle-timeout clock (ADR-0069 '
509-
+ 'D4) keeps measuring from that older instant and this session will be revoked '
510-
+ 'as idle EARLIER than the configured window — an active user signed out '
511-
+ 'mid-session for no visible reason. Remedy: make the sys_session update land; '
512-
+ 'the heartbeat retries on the next request past the one-minute throttle.',
513-
e,
514-
{ object: 'sys_session', sessionId, field: 'last_activity_at' },
515-
));
516-
}
569+
// [#25343] The heartbeat runs on every deployment, idle timeout or not.
570+
const la = srow.last_activity_at ? new Date(srow.last_activity_at as any).getTime() : 0;
571+
if (now - la > SESSION_HEARTBEAT_INTERVAL_MS) {
572+
await engine.update('sys_session', { id: sessionId, last_activity_at: new Date(now) }, { context: SYSTEM_CTX } as any)
573+
.catch((e: unknown) => this.logDurabilityDegradation(
574+
'AuthManager: the session activity heartbeat was NOT written — the request '
575+
+ 'succeeds and the session stays live, so nothing looks wrong. '
576+
+ '`last_activity_at` keeps its older value, so the idle-timeout clock (ADR-0069 '
577+
+ 'D4) keeps measuring from that older instant and this session will be revoked '
578+
+ 'as idle EARLIER than the configured window — an active user signed out '
579+
+ 'mid-session for no visible reason. Remedy: make the sys_session update land; '
580+
+ 'the heartbeat retries on the next request past the one-minute throttle.',
581+
e,
582+
{ object: 'sys_session', sessionId, field: 'last_activity_at' },
583+
));
517584
}
518585
} catch (e) {
519586
// Never break a request. [#12981] But a control that silently stops
520587
// running is a control the operator still believes is on.
521588
this.logDurabilityDegradation(
522-
'AuthManager: the per-request session controls did not run to completion — the request '
523-
+ 'succeeds, so nothing looks wrong. Idle and absolute session limits (ADR-0069 D4) '
524-
+ 'were not evaluated for this session, so a session past its limit is neither revoked '
525-
+ 'nor reported by the branch above. Remedy: this is the LOOKUP half failing (the '
526-
+ 'sys_session read), so check driver connectivity and read permission on sys_session; '
527-
+ 'the controls re-run on the next request.',
589+
limitsOff
590+
// [#25343] With no limit configured the read feeds only the heartbeat,
591+
// so that is what was lost — saying "limits not evaluated" would name
592+
// a control this deployment never turned on.
593+
? 'AuthManager: the session activity heartbeat did not run — the request succeeds, so '
594+
+ 'nothing looks wrong. `last_activity_at` was not advanced for this session, so the '
595+
+ 'Sessions list shows an older Last Activity than the session really has. Remedy: '
596+
+ 'this is the LOOKUP half failing (the sys_session read), so check driver '
597+
+ 'connectivity and read permission on sys_session; the heartbeat re-runs on the '
598+
+ 'next request.'
599+
: 'AuthManager: the per-request session controls did not run to completion — the request '
600+
+ 'succeeds, so nothing looks wrong. Idle and absolute session limits (ADR-0069 D4) '
601+
+ 'were not evaluated for this session, so a session past its limit is neither revoked '
602+
+ 'nor reported by the branch above. Remedy: this is the LOOKUP half failing (the '
603+
+ 'sys_session read), so check driver connectivity and read permission on sys_session; '
604+
+ 'the controls re-run on the next request.',
528605
e,
529606
{ object: 'sys_session', sessionId, idleMinutes: idleMin, absoluteHours: absHrs },
530607
);

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4806,11 +4806,18 @@ describe('AuthManager', () => {
48064806
find: vi.fn(async () => []),
48074807
});
48084808

4809-
it('is a no-op when idle + absolute are both off', async () => {
4809+
// [#25343] Both off no longer means no-op: the `last_activity_at`
4810+
// heartbeat runs on every deployment (ruling 6358844136, letter A); its
4811+
// memo and throttle are pinned in session-heartbeat-default-config.test.ts.
4812+
it('with idle + absolute both off it revokes nothing but still writes the heartbeat', async () => {
48104813
const engine = oneEngine({ id: 's1', created_at: new Date(0).toISOString() });
48114814
const m = mgr(engine, {});
48124815
await (m as any).enforceSessionControls('s1', undefined);
4813-
expect(engine.findOne).not.toHaveBeenCalled();
4816+
expect(engine.findOne).toHaveBeenCalledTimes(1);
4817+
expect(engine.update).toHaveBeenCalledTimes(1);
4818+
const [, patch] = engine.update.mock.calls[0] as unknown as [string, Record<string, unknown>];
4819+
expect(patch.last_activity_at instanceof Date).toBe(true);
4820+
expect(patch.revoke_reason).toBeUndefined();
48144821
});
48154822

48164823
it('revokes (absolute_max) when the session is older than the absolute cap', async () => {

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8882,10 +8882,12 @@ export class AuthManager {
88828882

88838883
/**
88848884
* ADR-0069 D4 — idle / absolute session enforcement, run per request from
8885-
* `customSession`. No-op when both are off. Revokes (expires in place +
8886-
* stamps revoked_at/revoke_reason) when a limit is exceeded so better-auth
8887-
* returns no session on the NEXT request; otherwise touches `last_activity_at`
8888-
* (throttled to once a minute). Best-effort — never throws.
8885+
* `customSession`. Revokes (expires in place + stamps
8886+
* revoked_at/revoke_reason) when a limit is exceeded so better-auth returns
8887+
* no session on the NEXT request; otherwise touches `last_activity_at`
8888+
* (throttled to once a minute). [#25343] The touch runs on every deployment,
8889+
* limits or not; with both off, a per-process 60-second memo per session
8890+
* skips the `sys_session` read too. Best-effort — never throws.
88898891
*/
88908892
private enforceSessionControls(sessionId: string | undefined, createdAtHint: unknown): Promise<void> {
88918893
// [#25224 cut 3] The body moved to `auth-manager-password-session-policy.ts` (see `PasswordSessionPolicyHost`).

‎packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -217,16 +217,23 @@ const arrange = async (config: Record<string, unknown> = {}) => {
217217
* reads twice). Every later request is the case the count is about.
218218
*
219219
* [#24061] An in-process session read signs no JWT any more, so it no longer
220-
* mints the signing key either: the warm-up request writes nothing. A write
221-
* inside the request is still declined across — pinned below with the
222-
* idle-timeout heartbeat, the write a session read still makes.
220+
* mints the signing key either. [#25343] The one write a session read still
221+
* makes is the `last_activity_at` heartbeat, which now runs on every
222+
* deployment — on a session's first request and then at most once a minute.
223+
* So the warm-up is one request per session: each writes exactly that
224+
* heartbeat, and every request after it inside the minute writes nothing,
225+
* which is the steady state of a live session. A write inside the request is
226+
* still declined across — pinned below with that same heartbeat.
223227
*/
224228
const arrangeWarm = async () => {
225229
const a = await arrange();
226230
const observersBefore = a.probe.middlewareCount();
227231
const warm = await resolveRequest(a, a.bearers.owner);
228-
expect(warm.writes).toEqual([]);
232+
expect(warm.writes).toEqual(['update sys_session']);
229233
expect(a.probe.middlewareCount()).toBe(observersBefore + 1);
234+
for (const bearer of [a.bearers.member, a.bearers.leaver, a.bearers.operator]) {
235+
expect((await resolveRequest(a, bearer)).writes).toEqual(['update sys_session']);
236+
}
230237
return a;
231238
};
232239

@@ -375,11 +382,16 @@ describe.concurrent('the real customSession hook and resolveAuthzContext resolve
375382
expect(countOf(reads, 'sys_user_position')).toBe(1);
376383
});
377384

378-
it('a write inside the request (the idle-timeout heartbeat): the memo declines, the envelope is unchanged', async () => {
379-
// [ADR-0069 D4] With an idle timeout configured, the hook stamps the
380-
// session's `last_activity_at` AFTER it resolved the grants — at most once
381-
// a minute, and on a session's first request since sign-in.
382-
const a = await arrange({ sessionIdleTimeoutMinutes: 30 });
385+
it.each([
386+
['the default config (no session limit)', {}],
387+
['an idle timeout', { sessionIdleTimeoutMinutes: 30 }],
388+
])('a write inside the request (the heartbeat, under %s): the memo declines, the envelope is unchanged', async (_label, config) => {
389+
// [ADR-0069 D4] The hook stamps the session's `last_activity_at` AFTER it
390+
// resolved the grants — at most once a minute, and on a session's first
391+
// request since sign-in. [#25343] On every deployment, not only where an
392+
// idle timeout is configured: that once-a-minute request is the one that
393+
// pays the second resolution.
394+
const a = await arrange(config);
383395
// Register core's write observer with an anonymous request first (no
384396
// session, nothing written), so the ONLY reason left for the member's
385397
// first request to read twice is the write inside it.

0 commit comments

Comments
 (0)