Repository navigation
Expand file tree
/
Copy pathsys-session.object.ts
More file actions
341 lines (320 loc) · 14.3 KB
/
Copy pathsys-session.object.ts
File metadata and controls
341 lines (320 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { ObjectSchema, Field } from '@objectstack/spec/data';
/**
* sys_session — System Session Object
*
* Active user session record for the ObjectStack platform.
* Backed by better-auth's `session` model with ObjectStack field conventions.
*
* The `token` field is hidden by default — sessions are managed by the
* auth plugin, not edited manually. Admins see session metadata
* (user, expiry, IP, active context) without exposing the token value.
*
* @namespace sys
*/
export const SysSession = ObjectSchema.create({
name: 'sys_session',
label: 'Session',
pluralLabel: 'Sessions',
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}`, commit 801296050)
// 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.
protection: {
lock: 'full',
reason: 'Identity table managed by better-auth — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
description: 'Active user sessions',
displayNameField: 'user_id',
nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: 'Session — {user_id}',
highlightFields: ['user_id', 'ip_address', 'expires_at'],
// Custom actions — sessions are managed by better-auth (generic CRUD
// suppressed). "Sign out other devices" is the high-value self-service
// affordance every IdP exposes. Maps to better-auth's
// `revoke-other-sessions` endpoint which terminates every session for
// the current user except the one making the request.
actions: [
{
name: 'revoke_my_other_sessions',
label: 'Sign out other devices',
icon: 'log-out',
variant: 'danger',
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/revoke-other-sessions',
confirmText: 'Sign out of every other device where you\'re currently logged in? Your current session will remain active.',
successMessage: 'All other sessions revoked',
refreshAfter: true,
},
{
name: 'revoke_session',
label: 'Revoke Session',
icon: 'log-out',
variant: 'danger',
mode: 'delete',
locations: ['list_item'],
type: 'api',
// Keyed on the row's `id` — every list row carries it — and resolved to
// the session SERVER-side by plugin-auth's `sys-session/revoke` door, so
// the `internal` token below is never sent to or from the console. That
// door admits the session's owner (better-auth's own `/revoke-session`)
// and, for another user's session, a platform administrator (ADR-0068
// D2). ⛔ Never key this action on `token`: no read path serves it, so a
// row can never supply it.
target: '/api/v1/auth/sys-session/revoke',
recordIdParam: 'sessionId',
confirmText: 'Revoke this session? The user will be signed out from that device.',
successMessage: 'Session revoked',
refreshAfter: true,
},
],
// [#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: {
// [#21972] Declared FIRST on purpose: a route that names no view opens the
// first declared list view, so a caller-scoped view (`mine`) is never
// first. Setup's `nav_sessions` names this view; the Account app's entry
// names `mine`.
all_sessions: {
type: 'grid',
name: 'all_sessions',
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 },
},
mine: {
type: 'grid',
name: 'mine',
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}' },
{ 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 },
},
},
// [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy
// plugin-security's shipped permission sets spread for it today (the `_self`
// carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert
// on this table (no organization column), so Layer 1 silence means this scope, not no
// filter. The security plugin's row-policy collection applies it when no set the
// caller holds carries a row policy for the object and operation; the shipped sets
// still carry it, so their holders read as before. An `extend` contribution cannot set
// or loosen it (refused at registration).
defaultRowScope: {
name: 'sys_session_self',
operation: 'all',
using: 'user_id == current_user.id',
},
fields: {
// ── Session owner & expiry ──────────────────────────────────
user_id: Field.lookup('sys_user', {
label: 'User',
required: true,
searchable: true,
group: 'Session',
}),
expires_at: Field.datetime({
label: 'Expires At',
required: true,
group: 'Session',
}),
// ── ADR-0069 D4 — session controls (idle / absolute / revoke) ──
last_activity_at: Field.datetime({
label: 'Last Activity At',
required: false,
readonly: true,
group: 'Session',
description: 'Timestamp of the last request on this session; drives idle-timeout. System-managed.',
}),
revoked_at: Field.datetime({
label: 'Revoked At',
required: false,
readonly: true,
group: 'Session',
description: 'When set, this session was revoked (idle / absolute-max / concurrent-cap / admin / organization membership ended). System-managed.',
}),
revoke_reason: Field.text({
label: 'Revoke Reason',
required: false,
maxLength: 64,
readonly: true,
group: 'Session',
// [#15784] The accept set widens here, and this description IS the
// published vocabulary — there is no Zod enum behind this column (it is
// free `text`), so this line is the only contract a consumer can read.
// Every value before `organization_membership_ended` is either a TIMER
// (idle_timeout, absolute_max, concurrent_cap) or an interactive revoke
// (user_revoked, admin — `plugin-auth/session-tombstone.ts`); that one is
// the first AUTHORIZATION-EVENT cause, written when the membership
// backing a session's active organization ends and the user holds no
// other. ⛔ It is a COURTESY, never the enforcement: the wall is the
// per-request membership check in `resolve-authz-context.ts`.
// `admin_password_reset` is the second authorization-event cause: an
// administrator's forced password reset revoked the session
// (`plugin-auth/forced-reset-revocation.ts`, `FORCED_RESET_REVOKE_REASON`).
description:
'Why the session was revoked (idle_timeout, absolute_max, concurrent_cap, user_revoked, '
+ 'admin, organization_membership_ended, admin_password_reset, …).',
}),
// ── Active context (multi-org/team) ──────────────────────────
active_organization_id: Field.lookup('sys_organization', {
label: 'Active Organization',
required: false,
group: 'Context',
}),
active_team_id: Field.lookup('sys_team', {
label: 'Active Team',
required: false,
group: 'Context',
}),
// ── Client fingerprint ───────────────────────────────────────
ip_address: Field.text({
label: 'IP Address',
required: false,
maxLength: 45, // Support IPv6
group: 'Client',
}),
user_agent: Field.textarea({
label: 'User Agent',
required: false,
group: 'Client',
}),
// ── Admin (managed by better-auth `admin` plugin) ────────────
impersonated_by: Field.lookup('sys_user', {
label: 'Impersonated By',
required: false,
group: 'Admin',
description: 'User id of the admin that started this impersonation session, if any.',
}),
// ── Secret (hidden by default) ──────────────────────────────
//
// [#7823] `internal: true` is what makes the description below TRUE. It was
// false on every build before this flag existed: `hidden` is a UI contract
// ("Hidden from default UI"), never a serialization one, and the engine's
// credential read mask collects by field TYPE — so this `text` column was
// collected by nothing and the token came back on the generic data path.
//
// Measured on a real engine: an ADMIN caller got this column on list, on
// get-by-id for ANOTHER user's session, and on an explicit `?select=id,token`.
// The disclosure is admin-cross-user — a member's own reads are self-scoped
// and the `sys_session_self` RLS policy already answers 404 across users.
//
// This column is a LIVE BEARER CREDENTIAL, which is where it differs from
// its `sys_api_key.key` sibling (#7728): that one is a SHA-256 hash, while
// the value here was replay-proven — a member's token, read off the data API
// by an admin, authenticates as that member when sent as `Authorization:
// Bearer <token>`. Disclosure is impersonation, not merely exposure.
//
// Still `text`, deliberately. `Field.secret` would encrypt at rest and
// replace the column with a `sys_secret` ref, destroying the by-token
// session lookup better-auth performs on every authenticated request — i.e.
// it would break authentication to fix a disclosure. `password` is inert
// here: the read mask skips `password` on `managedBy: 'better-auth'`
// objects, and collects by TYPE anyway, so a `text` column is never
// collected regardless. `internal` is read-side only: storage, the unique
// index on `token` and the verifier's filter are all untouched.
token: Field.text({
label: 'Session Token',
required: true,
// [commit 3954fb7df] Bound from better-auth 1.7.1's own MySQL schema: a unique
// string column is emitted as varchar(255) (get-migration.mjs), and the
// producer writes generateId(32) — 32 chars. 255 admits everything the
// upstream schema admits, and lets the unique index exist on MySQL.
maxLength: 255,
hidden: true,
readonly: true,
internal: true,
description: 'Opaque session token — never exposed in UI',
group: 'Secret',
}),
// ── System ───────────────────────────────────────────────────
id: Field.text({
label: 'Session ID',
required: true,
readonly: true,
group: 'System',
}),
created_at: Field.datetime({
label: 'Created At',
defaultValue: 'NOW()',
readonly: true,
group: 'System',
}),
updated_at: Field.datetime({
label: 'Updated At',
defaultValue: 'NOW()',
readonly: true,
group: 'System',
}),
},
indexes: [
{ fields: ['token'], unique: 'global' },
{ fields: ['user_id'], unique: false },
{ fields: ['expires_at'], unique: false },
],
enable: {
trackHistory: false,
searchable: false,
apiEnabled: true,
// #1591 — reads only: writes are refused by the identity write guard
// (ADR-0092 D2) and owned by better-auth. HTTP answers 405 before the 403.
apiMethods: ['get', 'list'],
clone: false,
},
});