webspresso 0.0.98 → 0.0.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Webspresso Session Revocation & Device Manager
3
+ * Orchestrates session tracking, activity throttling, store cleanup, and event dispatching
4
+ * @module core/auth/session-manager
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { getClientIp, parseUserAgent } = require('./user-agent');
10
+ const { createMemorySessionTracker, createKnexSessionTracker } = require('./sessions');
11
+
12
+ class SessionManager {
13
+ /**
14
+ * @param {Object} options
15
+ * @param {Object} [options.tracker] - Session tracker adapter (knex or memory)
16
+ * @param {Object} [options.sessionStore] - Optional express-session store instance
17
+ * @param {number} [options.touchThrottleMs=60000] - Throttle last_active_at updates (default 1 min)
18
+ * @param {import('events').EventEmitter} [options.emitter] - AuthManager instance for event dispatching
19
+ */
20
+ constructor(options = {}) {
21
+ this.tracker = options.tracker || createMemorySessionTracker();
22
+ this.sessionStore = options.sessionStore || null;
23
+ this.touchThrottleMs = options.touchThrottleMs ?? 60000;
24
+ this.emitter = options.emitter || null;
25
+ /** @type {Map<string, number>} */
26
+ this._lastTouchMap = new Map();
27
+ }
28
+
29
+ /**
30
+ * Record or update session for a user request
31
+ * @param {string} sessionId
32
+ * @param {string|number} userId
33
+ * @param {Object} req - Express request
34
+ * @returns {Promise<Object>}
35
+ */
36
+ async track(sessionId, userId, req) {
37
+ if (!sessionId || !userId) return null;
38
+
39
+ const userAgent = req?.headers?.['user-agent'] || '';
40
+ const ip = getClientIp(req);
41
+ const parsed = parseUserAgent(userAgent);
42
+
43
+ const record = await this.tracker.track(sessionId, userId, {
44
+ ip,
45
+ userAgent,
46
+ browser: parsed.browser,
47
+ os: parsed.os,
48
+ device: parsed.device,
49
+ });
50
+
51
+ this._lastTouchMap.set(sessionId, Date.now());
52
+
53
+ if (this.emitter) {
54
+ this.emitter.emit('session:created', {
55
+ sessionId,
56
+ userId,
57
+ ip,
58
+ browser: parsed.browser,
59
+ os: parsed.os,
60
+ device: parsed.device,
61
+ });
62
+ }
63
+
64
+ return record;
65
+ }
66
+
67
+ /**
68
+ * Touch session to update last_active_at with throttling
69
+ * @param {string} sessionId
70
+ * @returns {Promise<boolean>}
71
+ */
72
+ async touch(sessionId) {
73
+ if (!sessionId) return false;
74
+
75
+ const now = Date.now();
76
+ const lastTouch = this._lastTouchMap.get(sessionId) || 0;
77
+
78
+ if (now - lastTouch < this.touchThrottleMs) {
79
+ return false; // Throttled
80
+ }
81
+
82
+ this._lastTouchMap.set(sessionId, now);
83
+ return await this.tracker.touch(sessionId);
84
+ }
85
+
86
+ /**
87
+ * Check if session is valid and active
88
+ * @param {string} sessionId
89
+ * @param {string|number} [userId]
90
+ * @returns {Promise<boolean>}
91
+ */
92
+ async isSessionValid(sessionId, userId = null) {
93
+ if (!sessionId) return false;
94
+ return await this.tracker.isSessionValid(sessionId, userId);
95
+ }
96
+
97
+ /**
98
+ * List all active sessions for a user
99
+ * @param {string|number} userId
100
+ * @param {string} [currentSessionId]
101
+ * @returns {Promise<Array<Object>>}
102
+ */
103
+ async listSessions(userId, currentSessionId = null) {
104
+ if (!userId) return [];
105
+ return await this.tracker.list(userId, currentSessionId);
106
+ }
107
+
108
+ /**
109
+ * Revoke a specific session
110
+ * @param {string} sessionId
111
+ * @param {Object} [options]
112
+ * @param {string|number} [options.userId]
113
+ * @param {string} [options.revokedBy]
114
+ * @returns {Promise<boolean>}
115
+ */
116
+ async revokeSession(sessionId, options = {}) {
117
+ if (!sessionId) return false;
118
+
119
+ this._lastTouchMap.delete(sessionId);
120
+ const success = await this.tracker.revoke(sessionId);
121
+
122
+ // If express-session store is available, destroy physical session
123
+ if (this.sessionStore && typeof this.sessionStore.destroy === 'function') {
124
+ try {
125
+ await new Promise((resolve) => this.sessionStore.destroy(sessionId, () => resolve()));
126
+ } catch (err) {
127
+ // Continue even if session store destroy errors
128
+ }
129
+ }
130
+
131
+ if (this.emitter) {
132
+ this.emitter.emit('session:revoked', {
133
+ sessionId,
134
+ userId: options.userId,
135
+ revokedBy: options.revokedBy,
136
+ });
137
+ }
138
+
139
+ return success;
140
+ }
141
+
142
+ /**
143
+ * Revoke all other sessions for a user except the current one
144
+ * @param {string|number} userId
145
+ * @param {string} currentSessionId
146
+ * @returns {Promise<number>} Count of revoked sessions
147
+ */
148
+ async revokeOtherSessions(userId, currentSessionId) {
149
+ if (!userId) return 0;
150
+
151
+ const count = await this.tracker.revokeOthers(userId, currentSessionId);
152
+
153
+ if (this.emitter) {
154
+ this.emitter.emit('sessions:revoked_others', {
155
+ userId,
156
+ currentSessionId,
157
+ count,
158
+ });
159
+ }
160
+
161
+ return count;
162
+ }
163
+
164
+ /**
165
+ * Revoke all sessions for a user (e.g. password reset or admin security action)
166
+ * @param {string|number} userId
167
+ * @returns {Promise<number>} Count of revoked sessions
168
+ */
169
+ async revokeAllSessions(userId) {
170
+ if (!userId) return 0;
171
+
172
+ const count = await this.tracker.revokeAll(userId);
173
+
174
+ if (this.emitter) {
175
+ this.emitter.emit('sessions:revoked_all', {
176
+ userId,
177
+ count,
178
+ });
179
+ }
180
+
181
+ return count;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Route handler to list active sessions for current user (GET /api/auth/sessions)
187
+ * @param {import('./manager').AuthManager} authManager
188
+ * @returns {Function} Express route handler
189
+ */
190
+ function createListSessionsHandler(authManager) {
191
+ return async function listSessionsHandler(req, res) {
192
+ if (!req.user || !req.user.id) {
193
+ return res.status(401).json({ error: 'Authentication required' });
194
+ }
195
+
196
+ try {
197
+ const currentSessionId = req.sessionID || null;
198
+ const sessions = await authManager.listSessions(req.user.id, currentSessionId);
199
+ return res.json({ ok: true, sessions });
200
+ } catch (err) {
201
+ return res.status(500).json({ error: err.message || 'Failed to list sessions' });
202
+ }
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Route handler to revoke a session (POST /api/auth/sessions/revoke)
208
+ * @param {import('./manager').AuthManager} authManager
209
+ * @returns {Function} Express route handler
210
+ */
211
+ function createRevokeSessionHandler(authManager) {
212
+ return async function revokeSessionHandler(req, res) {
213
+ if (!req.user || !req.user.id) {
214
+ return res.status(401).json({ error: 'Authentication required' });
215
+ }
216
+
217
+ const { sessionId, allOthers } = req.body || {};
218
+
219
+ try {
220
+ if (allOthers) {
221
+ const count = await authManager.revokeOtherSessions(req.user.id, req.sessionID);
222
+ return res.json({ ok: true, revokedCount: count, message: 'All other sessions revoked' });
223
+ }
224
+
225
+ if (!sessionId) {
226
+ return res.status(400).json({ error: 'sessionId is required' });
227
+ }
228
+
229
+ // Check ownership: ensure session belongs to current user unless user is admin
230
+ const sessions = await authManager.listSessions(req.user.id);
231
+ const ownsSession = sessions.some((s) => s.session_id === sessionId);
232
+
233
+ if (!ownsSession && req.user.role !== 'admin') {
234
+ return res.status(403).json({ error: 'Cannot revoke session belonging to another user' });
235
+ }
236
+
237
+ await authManager.revokeSession(sessionId, { userId: req.user.id });
238
+
239
+ // If user revoked their own current session, log them out
240
+ if (sessionId === req.sessionID && req.auth && typeof req.auth.logout === 'function') {
241
+ await req.auth.logout();
242
+ }
243
+
244
+ return res.json({ ok: true, message: 'Session revoked successfully' });
245
+ } catch (err) {
246
+ return res.status(500).json({ error: err.message || 'Failed to revoke session' });
247
+ }
248
+ };
249
+ }
250
+
251
+ module.exports = {
252
+ SessionManager,
253
+ createListSessionsHandler,
254
+ createRevokeSessionHandler,
255
+ };
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Webspresso Active Sessions & Device Management Store
3
+ * Tracks active devices, last active timestamps, and handles remote session revocation
4
+ * @module core/auth/sessions
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { parseUserAgent, getClientIp } = require('./user-agent');
10
+
11
+ /**
12
+ * Migration helper to create user_sessions table
13
+ * @param {import('knex').Knex} knex
14
+ * @returns {Promise<void>}
15
+ */
16
+ async function createUserSessionsTable(knex) {
17
+ const exists = await knex.schema.hasTable('user_sessions');
18
+ if (exists) return;
19
+
20
+ await knex.schema.createTable('user_sessions', (table) => {
21
+ table.bigIncrements('id').primary();
22
+ table.string('session_id', 128).notNullable().unique();
23
+ table.bigInteger('user_id').unsigned().notNullable();
24
+ table.string('ip_address', 64).nullable();
25
+ table.text('user_agent').nullable();
26
+ table.string('browser', 64).nullable();
27
+ table.string('os', 64).nullable();
28
+ table.string('device', 32).nullable();
29
+ table.timestamp('last_active_at').notNullable().defaultTo(knex.fn.now());
30
+ table.timestamp('created_at').defaultTo(knex.fn.now());
31
+
32
+ table.index(['user_id', 'last_active_at']);
33
+ table.index('session_id');
34
+ table.index('last_active_at');
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Migration helper to drop user_sessions table
40
+ * @param {import('knex').Knex} knex
41
+ * @returns {Promise<void>}
42
+ */
43
+ async function dropUserSessionsTable(knex) {
44
+ await knex.schema.dropTableIfExists('user_sessions');
45
+ }
46
+
47
+ /**
48
+ * Create Knex session tracker adapter
49
+ * @param {Object} db - Database with knex or knex instance
50
+ * @param {Object} [options]
51
+ * @param {string} [options.tableName='user_sessions']
52
+ * @returns {Object} Session tracker adapter
53
+ */
54
+ function createKnexSessionTracker(db, options = {}) {
55
+ const knex = db.knex || db;
56
+ const tableName = options.tableName || 'user_sessions';
57
+
58
+ return {
59
+ /**
60
+ * Record or update an active session for a user
61
+ * @param {string} sessionId - Express session ID
62
+ * @param {string|number} userId - User ID
63
+ * @param {Object} [metadata]
64
+ * @param {string} [metadata.ip]
65
+ * @param {string} [metadata.userAgent]
66
+ * @param {string} [metadata.browser]
67
+ * @param {string} [metadata.os]
68
+ * @param {string} [metadata.device]
69
+ * @returns {Promise<Object>}
70
+ */
71
+ track: async (sessionId, userId, metadata = {}) => {
72
+ if (!sessionId || !userId) return null;
73
+
74
+ const ua = metadata.userAgent || '';
75
+ const parsed = parseUserAgent(ua);
76
+ const ip = metadata.ip || '127.0.0.1';
77
+ const browser = metadata.browser || parsed.browser;
78
+ const os = metadata.os || parsed.os;
79
+ const device = metadata.device || parsed.device;
80
+
81
+ const existing = await knex(tableName).where({ session_id: sessionId }).first();
82
+ const now = new Date();
83
+
84
+ if (existing) {
85
+ await knex(tableName).where({ session_id: sessionId }).update({
86
+ user_id: userId,
87
+ ip_address: ip,
88
+ user_agent: ua ? ua.slice(0, 1000) : null,
89
+ browser,
90
+ os,
91
+ device,
92
+ last_active_at: now,
93
+ });
94
+ return {
95
+ ...existing,
96
+ user_id: userId,
97
+ ip_address: ip,
98
+ browser,
99
+ os,
100
+ device,
101
+ last_active_at: now,
102
+ };
103
+ }
104
+
105
+ const [id] = await knex(tableName).insert({
106
+ session_id: sessionId,
107
+ user_id: userId,
108
+ ip_address: ip,
109
+ user_agent: ua ? ua.slice(0, 1000) : null,
110
+ browser,
111
+ os,
112
+ device,
113
+ last_active_at: now,
114
+ created_at: now,
115
+ });
116
+
117
+ return {
118
+ id,
119
+ session_id: sessionId,
120
+ user_id: userId,
121
+ ip_address: ip,
122
+ browser,
123
+ os,
124
+ device,
125
+ last_active_at: now,
126
+ created_at: now,
127
+ };
128
+ },
129
+
130
+ /**
131
+ * Touch session to update last_active_at
132
+ * @param {string} sessionId
133
+ * @returns {Promise<boolean>}
134
+ */
135
+ touch: async (sessionId) => {
136
+ if (!sessionId) return false;
137
+ const count = await knex(tableName).where({ session_id: sessionId }).update({
138
+ last_active_at: new Date(),
139
+ });
140
+ return count > 0;
141
+ },
142
+
143
+ /**
144
+ * Get a session record by session_id
145
+ * @param {string} sessionId
146
+ * @returns {Promise<Object|null>}
147
+ */
148
+ get: async (sessionId) => {
149
+ if (!sessionId) return null;
150
+ return await knex(tableName).where({ session_id: sessionId }).first() || null;
151
+ },
152
+
153
+ /**
154
+ * List all active sessions for a user, marking the current session
155
+ * @param {string|number} userId
156
+ * @param {string} [currentSessionId]
157
+ * @returns {Promise<Array<Object>>}
158
+ */
159
+ list: async (userId, currentSessionId = null) => {
160
+ if (!userId) return [];
161
+ const rows = await knex(tableName)
162
+ .where({ user_id: userId })
163
+ .orderBy('last_active_at', 'desc');
164
+
165
+ return rows.map((row) => ({
166
+ ...row,
167
+ is_current: Boolean(currentSessionId && row.session_id === currentSessionId),
168
+ }));
169
+ },
170
+
171
+ /**
172
+ * Check if a session exists and belongs to the given user
173
+ * @param {string} sessionId
174
+ * @param {string|number} [userId]
175
+ * @returns {Promise<boolean>}
176
+ */
177
+ isSessionValid: async (sessionId, userId = null) => {
178
+ if (!sessionId) return false;
179
+ const query = knex(tableName).where({ session_id: sessionId });
180
+ if (userId) {
181
+ query.andWhere({ user_id: userId });
182
+ }
183
+ const record = await query.first();
184
+ return Boolean(record);
185
+ },
186
+
187
+ /**
188
+ * Revoke a single session
189
+ * @param {string} sessionId
190
+ * @returns {Promise<boolean>}
191
+ */
192
+ revoke: async (sessionId) => {
193
+ if (!sessionId) return false;
194
+ const count = await knex(tableName).where({ session_id: sessionId }).delete();
195
+ return count > 0;
196
+ },
197
+
198
+ /**
199
+ * Revoke all other sessions for user, keeping the current session active
200
+ * @param {string|number} userId
201
+ * @param {string} currentSessionId
202
+ * @returns {Promise<number>} Number of revoked sessions
203
+ */
204
+ revokeOthers: async (userId, currentSessionId) => {
205
+ if (!userId) return 0;
206
+ let query = knex(tableName).where({ user_id: userId });
207
+ if (currentSessionId) {
208
+ query = query.whereNot({ session_id: currentSessionId });
209
+ }
210
+ return await query.delete();
211
+ },
212
+
213
+ /**
214
+ * Revoke all sessions for a user (force logout from all devices)
215
+ * @param {string|number} userId
216
+ * @returns {Promise<number>} Number of revoked sessions
217
+ */
218
+ revokeAll: async (userId) => {
219
+ if (!userId) return 0;
220
+ return await knex(tableName).where({ user_id: userId }).delete();
221
+ },
222
+
223
+ /**
224
+ * Purge sessions older than maxAgeMs
225
+ * @param {number} maxAgeMs
226
+ * @returns {Promise<number>}
227
+ */
228
+ purgeExpired: async (maxAgeMs) => {
229
+ const cutoff = new Date(Date.now() - maxAgeMs);
230
+ return await knex(tableName).where('last_active_at', '<', cutoff).delete();
231
+ },
232
+ };
233
+ }
234
+
235
+ /**
236
+ * In-memory session tracker for testing or development without a database table
237
+ * @returns {Object} Session tracker adapter
238
+ */
239
+ function createMemorySessionTracker() {
240
+ /** @type {Map<string, Object>} */
241
+ const sessions = new Map();
242
+
243
+ return {
244
+ track: async (sessionId, userId, metadata = {}) => {
245
+ if (!sessionId || !userId) return null;
246
+
247
+ const ua = metadata.userAgent || '';
248
+ const parsed = parseUserAgent(ua);
249
+ const ip = metadata.ip || '127.0.0.1';
250
+ const browser = metadata.browser || parsed.browser;
251
+ const os = metadata.os || parsed.os;
252
+ const device = metadata.device || parsed.device;
253
+ const now = new Date();
254
+
255
+ const existing = sessions.get(sessionId);
256
+ const record = {
257
+ id: existing?.id || sessions.size + 1,
258
+ session_id: sessionId,
259
+ user_id: userId,
260
+ ip_address: ip,
261
+ user_agent: ua ? ua.slice(0, 1000) : null,
262
+ browser,
263
+ os,
264
+ device,
265
+ last_active_at: now,
266
+ created_at: existing?.created_at || now,
267
+ };
268
+
269
+ sessions.set(sessionId, record);
270
+ return record;
271
+ },
272
+
273
+ touch: async (sessionId) => {
274
+ const record = sessions.get(sessionId);
275
+ if (!record) return false;
276
+ record.last_active_at = new Date();
277
+ return true;
278
+ },
279
+
280
+ get: async (sessionId) => {
281
+ return sessions.get(sessionId) || null;
282
+ },
283
+
284
+ list: async (userId, currentSessionId = null) => {
285
+ const list = [];
286
+ for (const row of sessions.values()) {
287
+ if (String(row.user_id) === String(userId)) {
288
+ list.push({
289
+ ...row,
290
+ is_current: Boolean(currentSessionId && row.session_id === currentSessionId),
291
+ });
292
+ }
293
+ }
294
+ return list.sort((a, b) => b.last_active_at.getTime() - a.last_active_at.getTime());
295
+ },
296
+
297
+ isSessionValid: async (sessionId, userId = null) => {
298
+ const record = sessions.get(sessionId);
299
+ if (!record) return false;
300
+ if (userId && String(record.user_id) !== String(userId)) return false;
301
+ return true;
302
+ },
303
+
304
+ revoke: async (sessionId) => {
305
+ return sessions.delete(sessionId);
306
+ },
307
+
308
+ revokeOthers: async (userId, currentSessionId) => {
309
+ let count = 0;
310
+ for (const [sid, row] of sessions.entries()) {
311
+ if (String(row.user_id) === String(userId) && sid !== currentSessionId) {
312
+ sessions.delete(sid);
313
+ count++;
314
+ }
315
+ }
316
+ return count;
317
+ },
318
+
319
+ revokeAll: async (userId) => {
320
+ let count = 0;
321
+ for (const [sid, row] of sessions.entries()) {
322
+ if (String(row.user_id) === String(userId)) {
323
+ sessions.delete(sid);
324
+ count++;
325
+ }
326
+ }
327
+ return count;
328
+ },
329
+
330
+ purgeExpired: async (maxAgeMs) => {
331
+ const cutoff = Date.now() - maxAgeMs;
332
+ let count = 0;
333
+ for (const [sid, row] of sessions.entries()) {
334
+ if (row.last_active_at.getTime() < cutoff) {
335
+ sessions.delete(sid);
336
+ count++;
337
+ }
338
+ }
339
+ return count;
340
+ },
341
+ };
342
+ }
343
+
344
+ module.exports = {
345
+ createUserSessionsTable,
346
+ dropUserSessionsTable,
347
+ createKnexSessionTracker,
348
+ createMemorySessionTracker,
349
+ getClientIp,
350
+ parseUserAgent,
351
+ };
@@ -8,6 +8,7 @@ const { generateToken, hashToken, hash } = require('./hash');
8
8
  const TOKEN_TYPES = {
9
9
  PASSWORD_RESET: 'password_reset',
10
10
  EMAIL_VERIFY: 'email_verify',
11
+ MAGIC_LINK: 'magic_link',
11
12
  };
12
13
 
13
14
  /**