@@ -25,7 +25,10 @@ import { PuterStore } from '../types';
2525// the cached row doesn't gate anything, and eating a Redis write per
2626// request isn't worth it.
2727
28- const CACHE_KEY_PREFIX = 'sessions' ;
28+ // Prefix is versioned so a schema change (new columns added to the
29+ // row shape) doesn't leak stale cached rows through Redis on a
30+ // rolling deploy. Bump the suffix on the next schema change.
31+ const CACHE_KEY_PREFIX = 'sessions:v2' ;
2932const CACHE_TTL_SECONDS = 15 * 60 ;
3033// Min interval between successive activity flushes per session/user.
3134// In-memory throttle keeps DB writes bounded; multi-node duplicates
@@ -44,15 +47,21 @@ export class SessionStore extends PuterStore {
4447 #lastSessionTouchMs = new Map ( ) ;
4548 #lastUserTouchMs = new Map ( ) ;
4649
47- /** Look up a session by its uuid. Returns `null` if not found. */
50+ /**
51+ * Look up an active session by its uuid. Returns `null` if not
52+ * found or soft-revoked.
53+ */
4854 async getByUuid ( uuid ) {
4955 if ( ! uuid ) return null ;
5056
5157 const cached = await this . #readCache( uuid ) ;
52- if ( cached ) return cached ;
58+ if ( cached ) {
59+ if ( cached . revoked_at != null ) return null ;
60+ return cached ;
61+ }
5362
5463 const rows = await this . clients . db . read (
55- 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1' ,
64+ 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1' ,
5665 [ uuid ] ,
5766 ) ;
5867 const normalized = this . #normalizeRow( rows [ 0 ] ) ;
@@ -64,32 +73,65 @@ export class SessionStore extends PuterStore {
6473 return normalized ;
6574 }
6675
67- /** Get all sessions for a user. */
68- async getByUserId ( userId ) {
69- const rows = await this . clients . db . read (
70- 'SELECT * FROM `sessions` WHERE `user_id` = ?' ,
71- [ userId ] ,
72- ) ;
76+ /**
77+ * Get sessions for a user. By default returns only active rows;
78+ * pass `{ includeRevoked: true }` to include soft-revoked rows.
79+ */
80+ async getByUserId ( userId , { includeRevoked = false } = { } ) {
81+ const sql = includeRevoked
82+ ? 'SELECT * FROM `sessions` WHERE `user_id` = ?'
83+ : 'SELECT * FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL' ;
84+ const rows = await this . clients . db . read ( sql , [ userId ] ) ;
7385 return rows . map ( ( r ) => this . #normalizeRow( r ) ) . filter ( Boolean ) ;
7486 }
7587
7688 /**
77- * Create a new session.
89+ * Create a new session row .
7890 *
7991 * @param userId - User ID (numeric)
80- * @param meta - Metadata object (IP, user-agent, etc.)
81- * @returns The created session row
92+ * @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON.
93+ * @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'.
94+ * @param opts.label - User-editable label for manage-sessions UI.
95+ * @param opts.parent_session_id - uuid of root session, for derived kinds.
96+ * @param opts.last_ip - Request IP at creation.
97+ * @param opts.last_user_agent - Request User-Agent at creation.
98+ * @param opts.expires_at - Row-level expiry (unix seconds). NULL means
99+ * JWT `exp` is the sole truth. AUTH-4 slides this forward on activity.
100+ * @returns The created session row.
82101 */
83- async create ( userId , meta = { } ) {
102+ async create (
103+ userId ,
104+ {
105+ meta = { } ,
106+ kind = 'web' ,
107+ label = null ,
108+ parent_session_id = null ,
109+ last_ip = null ,
110+ last_user_agent = null ,
111+ expires_at = null ,
112+ } = { } ,
113+ ) {
84114 const uuid = uuidv4 ( ) ;
85115 const now = Math . floor ( Date . now ( ) / 1000 ) ;
86116
87117 meta . created = new Date ( ) . toISOString ( ) ;
88118 meta . created_unix = now ;
89119
90120 await this . clients . db . write (
91- 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) VALUES (?, ?, ?, ?, ?)' ,
92- [ uuid , userId , JSON . stringify ( meta ) , now , now ] ,
121+ 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ,
122+ [
123+ uuid ,
124+ userId ,
125+ JSON . stringify ( meta ) ,
126+ now ,
127+ now ,
128+ kind ,
129+ label ,
130+ parent_session_id ,
131+ last_ip ,
132+ last_user_agent ,
133+ expires_at ,
134+ ] ,
93135 ) ;
94136
95137 return {
@@ -98,20 +140,62 @@ export class SessionStore extends PuterStore {
98140 meta,
99141 created_at : now ,
100142 last_activity : now ,
143+ kind,
144+ label,
145+ parent_session_id,
146+ last_ip,
147+ last_user_agent,
148+ revoked_at : null ,
149+ expires_at,
101150 } ;
102151 }
103152
104- /** Delete a session by uuid. Invalidates cache on this node + peers. */
153+ /**
154+ * Soft-revoke a session by uuid. The row remains in the table
155+ * with `revoked_at` set; subsequent `getByUuid` calls treat it
156+ * as not found. Invalidates cache on this node + peers.
157+ */
105158 async removeByUuid ( uuid ) {
106- await this . clients . db . write ( 'DELETE FROM `sessions` WHERE `uuid` = ?' , [
107- uuid ,
108- ] ) ;
159+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
160+ await this . clients . db . write (
161+ 'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL' ,
162+ [ now , uuid ] ,
163+ ) ;
109164 await this . publishCacheKeys ( {
110165 keys : [ this . #cacheKey( uuid ) ] ,
111166 broadcast : true ,
112167 } ) ;
113168 }
114169
170+ /**
171+ * Soft-revoke a root session and every derived session that
172+ * points back to it via `parent_session_id`. Broadcasts cache
173+ * invalidation for each affected row.
174+ */
175+ async revokeCascade ( rootUuid ) {
176+ if ( ! rootUuid ) return ;
177+
178+ // Collect affected uuids first so we can broadcast cache
179+ // invalidation for each row. A single UPDATE ... RETURNING
180+ // would be cleaner but isn't portable between sqlite/mysql.
181+ const rows = await this . clients . db . read (
182+ 'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL' ,
183+ [ rootUuid , rootUuid ] ,
184+ ) ;
185+ if ( rows . length === 0 ) return ;
186+
187+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
188+ await this . clients . db . write (
189+ 'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL' ,
190+ [ now , rootUuid , rootUuid ] ,
191+ ) ;
192+
193+ await this . publishCacheKeys ( {
194+ keys : rows . map ( ( r ) => this . #cacheKey( r . uuid ) ) ,
195+ broadcast : true ,
196+ } ) ;
197+ }
198+
115199 /** Update session activity timestamp. */
116200 async updateActivity ( uuid , lastActivity ) {
117201 await this . clients . db . write (
0 commit comments