Skip to content

Commit 97aa34e

Browse files
fix(platform-objects): Setup → Sessions shows each session's user agent and last activity (#25368)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoBt3iJSAGzrBZHSPU8iwNqw)_ Part of #25343 Clause-②: no Metadata-protocol: none ## What Setup → Sessions (`sys_session.listViews.all_sessions`) and the Account app's `mine` view now show `user_agent` and `last_activity_at` beside the IP address. Both fields already exist on `sys_session`. The `revoked` audit view is unchanged. Before, two sessions of one user read the same user, organization and creation minute, so an administrator who meant to end one session revoked both. The user agent tells the devices apart. Ruling carried: objectstack-ai/hotcrm#2194, record `6354968262`: 「25251 B hotcrm B」 (adopts R2-T2-U05). ## Why `Part of`, not a closing keyword Two halves of the card's *Done when* are outside this PR's surface, and the card stays open for them: 1. **Last Activity is empty by default.** `last_activity_at` is written only by plugin-auth's `enforceSessionControls`, and only while `session_idle_timeout_minutes` is above 0. With both idle and absolute limits at their default of 0, the function returns before it reads the row (`packages/plugins/plugin-auth/src/auth-manager.ts`, the `idleMin <= 0 && absHrs <= 0` early return, and the heartbeat sits under `if (idleMin > 0)`). So on a default deployment the new column is blank. Writing a heartbeat with the controls off adds a `sys_session` read on every authenticated request and a write about once a minute per session, on every deployment. That is a cost decision for the seat, not a mechanical change in this file. 2. **Browser and OS.** `user_agent` is the raw string better-auth records, shown as stored. The device part sits near the start of the string ("Macintosh; Intel Mac OS X" against "iPhone; CPU iPhone OS"), so two devices differ even in a truncated cell. A readable "Chrome on macOS" needs a console renderer, which is objectui's surface. No server-side derived field is added here. ## Tests - New pin `packages/platform-objects/src/identity/sys-session-list-columns.test.ts`: both live-session views carry `user_agent` and `last_activity_at`, and every column in every view names a declared, non-`internal` field. - `pnpm --filter @objectstack/platform-objects test`: Test Files 89 passed (89), Tests 1324 passed (1324). `typecheck` (tsc, scripts config, `check:test-typecheck`) exit 0. - Reverse check (one-shot, not kept): with `ablation-replace.mjs`, the `mine` view's columns lost `user_agent` and `last_activity_at` (anchor 1 → 0, blob `49bf758d0dfd` → `92353a98cd87`). The pin went red (`× mine shows the device (user_agent) and the last request (last_activity_at)`). After the restore, `git diff HEAD` was empty. ## Gates (local, at `ccce09d66`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands. Each one ran with its exit code captured before any pipe. `--ran` result: 66 derived, 64 run, 2 NOT MEASURED, 0 unrun. - NOT MEASURED (exit 3, prerequisite): `pnpm check:dual-build-cjs-loads` needs every package's `dist/`. `node scripts/check-plugin-teardown-shape.mjs --self-test` is checker-health only and needs an unshallowed clone. CI runs both. - `pnpm check:i18n` and `pnpm check:lean-entry-closure` first exited 3 (unbuilt prerequisites). After the prerequisite build they exited 0. `pnpm check:type-check-debt` hit a 280 s local timeout, then ran to the end with exit 0. ## Acceptance notes - Changeset `.changeset/25343-session-list-columns.md` (`patch`, `@objectstack/platform-objects`) states that Last Activity is empty while no idle timeout is configured. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014Hse9cj2i4o5UAVrd21NKr --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d60b42 commit 97aa34e

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
fix: Setup → Sessions shows each session's user agent and last activity, so two sessions of one user can be told apart
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Two sessions of one user read the same in the `sys_session` list: same user, organization and creation minute, with no device or activity column. An administrator who wanted to end one of them could not tell which, and revoked both.
11+
12+
The two views that list live sessions, `all_sessions` (Setup's Sessions page) and `mine` (the Account app), now show `user_agent` and `last_activity_at` next to the IP address. Both fields already existed on `sys_session`. The `revoked` audit view is unchanged.
13+
14+
`user_agent` is the raw string better-auth records. `last_activity_at` is written only while an idle timeout is configured (`session_idle_timeout_minutes` above 0). With the default of 0 the column is empty.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `sys_session`'s session-listing views must let an administrator tell two
4+
// sessions of one user apart. Without the device and the last request, two
5+
// rows read the same user, organization and creation minute, and the admin
6+
// who meant to end one session revokes both.
7+
//
8+
// Pinned here, at the declaration: both views that list live sessions carry
9+
// `user_agent` and `last_activity_at`, every column names a declared field,
10+
// and no column names the `internal` bearer token.
11+
12+
import { describe, it, expect } from 'vitest';
13+
import { SysSession } from './sys-session.object.js';
14+
15+
type ViewLike = { columns?: unknown };
16+
const views = (SysSession as any).listViews as Record<string, ViewLike>;
17+
const fields = (SysSession as any).fields as Record<string, { internal?: boolean }>;
18+
19+
const columnsOf = (view: string): string[] => {
20+
const cols = views[view]?.columns;
21+
expect(Array.isArray(cols), `sys_session.listViews.${view}.columns`).toBe(true);
22+
return cols as string[];
23+
};
24+
25+
const LIVE_SESSION_VIEWS = ['all_sessions', 'mine'] as const;
26+
27+
describe('sys_session list views tell one user\'s sessions apart', () => {
28+
it.each(LIVE_SESSION_VIEWS)('%s shows the device (user_agent) and the last request (last_activity_at)', (view) => {
29+
const cols = columnsOf(view);
30+
expect(cols).toContain('user_agent');
31+
expect(cols).toContain('last_activity_at');
32+
});
33+
34+
it('both columns are declared fields of the object', () => {
35+
expect(fields.user_agent).toBeDefined();
36+
expect(fields.last_activity_at).toBeDefined();
37+
});
38+
39+
it.each(Object.keys(views))('%s names only declared, non-internal fields', (view) => {
40+
for (const col of columnsOf(view)) {
41+
expect(fields[col], `sys_session.listViews.${view} column ${col}`).toBeDefined();
42+
expect(fields[col]?.internal, `sys_session.listViews.${view} column ${col}`).not.toBe(true);
43+
}
44+
});
45+
});

‎packages/platform-objects/src/identity/sys-session.object.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ export const SysSession = ObjectSchema.create({
115115
// the audit trail those columns exist for gets a view of its own — otherwise
116116
// the fields would be written and still readable nowhere, which is the same
117117
// declared-≠-enforced gap one layer up.
118+
//
119+
// Both session-listing views carry `user_agent` and `last_activity_at`: two
120+
// sessions of one user otherwise read identically (same user, organization
121+
// and creation minute), and an administrator who cannot tell which device a
122+
// row is revokes both. The device and the last request are what tell them
123+
// apart. The revoked view is an audit trail ordered by `revoked_at` and is
124+
// left as it was.
118125
listViews: {
119126
// [#21972] Declared FIRST on purpose: a route that names no view opens the
120127
// first declared list view, so a caller-scoped view (`mine`) is never
@@ -125,7 +132,7 @@ export const SysSession = ObjectSchema.create({
125132
name: 'all_sessions',
126133
label: 'All',
127134
data: { provider: 'object', object: 'sys_session' },
128-
columns: ['user_id', 'ip_address', 'active_organization_id', 'created_at', 'expires_at'],
135+
columns: ['user_id', 'user_agent', 'ip_address', 'last_activity_at', 'active_organization_id', 'created_at', 'expires_at'],
129136
filter: [{ field: 'revoked_at', operator: 'is_null' }],
130137
sort: [{ field: 'created_at', order: 'desc' }],
131138
pagination: { pageSize: 50 },
@@ -135,7 +142,7 @@ export const SysSession = ObjectSchema.create({
135142
name: 'mine',
136143
label: 'My Sessions',
137144
data: { provider: 'object', object: 'sys_session' },
138-
columns: ['ip_address', 'active_organization_id', 'created_at', 'expires_at'],
145+
columns: ['user_agent', 'ip_address', 'last_activity_at', 'active_organization_id', 'created_at', 'expires_at'],
139146
filter: [
140147
{ field: 'user_id', operator: 'equals', value: '{current_user_id}' },
141148
{ field: 'revoked_at', operator: 'is_null' },

0 commit comments

Comments
 (0)