webspresso 0.0.106 → 0.0.108

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 (118) hide show
  1. package/.agents/AGENTS.md +4 -1
  2. package/.agents/CACHE.md +1 -0
  3. package/.agents/CONTEXT.md +152 -0
  4. package/.agents/ORM.md +74 -0
  5. package/.agents/SHUTDOWN.md +8 -1
  6. package/.agents/skills/webspresso-framework/SKILL.md +2 -1
  7. package/.agents/skills/webspresso-orm-and-database/SKILL.md +9 -0
  8. package/.agents/skills/webspresso-routing-and-api/SKILL.md +18 -2
  9. package/.agents/skills/webspresso-services/SKILL.md +36 -3
  10. package/AGENTS.md +6 -3
  11. package/bin/webspresso.js +17 -7
  12. package/core/app.js +45 -11
  13. package/core/applySchema.js +3 -72
  14. package/core/auth/index.js +4 -1
  15. package/core/auth/jwt.js +85 -13
  16. package/core/auth/magic-link.js +23 -5
  17. package/core/auth/manager.js +7 -1
  18. package/core/auth/redis-session.js +62 -25
  19. package/core/auth/session-manager.js +28 -2
  20. package/core/auth/sessions.js +182 -52
  21. package/core/cache/lru.js +10 -0
  22. package/core/cache/tagged-lru.js +4 -0
  23. package/core/compileSchema.js +3 -68
  24. package/core/compression/compressible.js +53 -6
  25. package/core/compression/negotiate.js +31 -12
  26. package/core/console/eval.js +4 -1
  27. package/core/content/service.js +1 -0
  28. package/core/context/app-context.js +201 -0
  29. package/core/context/index.js +55 -0
  30. package/core/context/request.js +241 -0
  31. package/core/errors/domain.js +20 -2
  32. package/core/errors/http.js +1 -0
  33. package/core/events/dispatcher.js +144 -15
  34. package/core/events/signal.js +9 -0
  35. package/core/health/probes.js +12 -2
  36. package/core/http/hono-adapter.js +1 -1
  37. package/core/http/index.js +10 -1
  38. package/core/http/pipeline.js +519 -334
  39. package/core/index.js +36 -2
  40. package/core/mail/mailer.js +1 -1
  41. package/core/orm/eager-loader.js +170 -55
  42. package/core/orm/index.js +25 -47
  43. package/core/orm/json-fields.js +44 -13
  44. package/core/orm/model.js +18 -1
  45. package/core/orm/query-builder.js +33 -15
  46. package/core/orm/repository.js +59 -12
  47. package/core/orm/schema-helpers.js +59 -0
  48. package/core/orm/scopes.js +54 -4
  49. package/core/orm/transaction.js +109 -14
  50. package/core/plugins/index.js +8 -0
  51. package/core/plugins/plugin-manager.js +1098 -0
  52. package/core/queue/manager.js +55 -27
  53. package/core/rate-limit/stores/memory.js +23 -10
  54. package/core/resilience/circuit-breaker.js +34 -14
  55. package/core/schema-cache.js +2 -64
  56. package/core/services/builtins/auth.js +273 -0
  57. package/core/services/builtins/exchange.js +229 -0
  58. package/core/services/builtins/file-manager.js +178 -0
  59. package/core/services/builtins/mail.js +134 -0
  60. package/core/services/builtins/media.js +196 -0
  61. package/core/services/builtins/system.js +219 -0
  62. package/core/services/discovery.js +133 -0
  63. package/core/services/executor.js +629 -0
  64. package/core/services/index.js +114 -0
  65. package/core/services/loader.js +161 -0
  66. package/core/services/memoize.js +408 -0
  67. package/core/services/registry.js +731 -0
  68. package/core/services/validator.js +424 -0
  69. package/core/shutdown/shutdown-manager.js +194 -114
  70. package/core/ssr/stream.js +109 -16
  71. package/core/stream/text-stream.js +45 -4
  72. package/core/studio/inspector.js +9 -1
  73. package/core/studio/server.js +24 -8
  74. package/core/utils/index.js +110 -0
  75. package/core/utils/string.js +77 -16
  76. package/core/utils/timeout.js +4 -0
  77. package/core/validation/apply.js +74 -0
  78. package/core/validation/cache.js +66 -0
  79. package/core/validation/compiler.js +77 -0
  80. package/core/validation/index.js +40 -1
  81. package/index.d.ts +249 -12
  82. package/index.js +68 -20
  83. package/package.json +18 -8
  84. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  85. package/plugins/admin-panel/index.js +24 -11
  86. package/plugins/admin-panel/modules/user-management.js +3 -2
  87. package/plugins/csrf/index.js +2 -1
  88. package/plugins/dashboard/index.js +27 -7
  89. package/src/app-context.js +4 -188
  90. package/src/dev/typegen.js +1 -1
  91. package/src/file-router.js +12 -3
  92. package/src/plugin-manager.js +5 -861
  93. package/src/server.js +30 -34
  94. package/src/services/builtins/auth.js +2 -273
  95. package/src/services/builtins/exchange.js +2 -229
  96. package/src/services/builtins/file-manager.js +2 -178
  97. package/src/services/builtins/mail.js +2 -134
  98. package/src/services/builtins/media.js +2 -196
  99. package/src/services/builtins/system.js +2 -219
  100. package/src/services/discovery.js +2 -125
  101. package/src/services/executor.js +2 -362
  102. package/src/services/index.js +4 -60
  103. package/src/services/loader.js +2 -0
  104. package/src/services/memoize.js +2 -234
  105. package/src/services/registry.js +2 -314
  106. package/src/services/validator.js +2 -273
  107. package/core/utils/array.js +0 -12
  108. package/core/utils/base64url.js +0 -8
  109. package/core/utils/ensure-leading-slash.js +0 -6
  110. package/core/utils/escape-html.js +0 -7
  111. package/core/utils/escape-regexp.js +0 -7
  112. package/core/utils/get-client-ip.js +0 -6
  113. package/core/utils/hmac.js +0 -6
  114. package/core/utils/random.js +0 -9
  115. package/core/utils/safe-compare.js +0 -3
  116. package/core/utils/sha256.js +0 -6
  117. package/core/utils/strip-trailing-slash.js +0 -6
  118. package/core/utils/walk.js +0 -7
@@ -1,73 +1,4 @@
1
- /**
2
- * Schema Applicator
3
- * Parses and validates request input against compiled schemas
4
- */
5
-
6
- const EMPTY_INPUT = Object.freeze({
7
- body: undefined,
8
- params: undefined,
9
- query: undefined,
10
- });
11
-
12
- /**
13
- * Apply compiled schema to request
14
- * Parses body, params, and query against their respective schemas
15
- * Stores validated results in req.input
16
- *
17
- * @param {Object} req - Express request object
18
- * @param {Object|null} compiledSchema - Compiled schema from compileSchema
19
- * @returns {void}
20
- * @throws {ZodError} If validation fails
21
- */
22
- function applySchema(req, compiledSchema) {
23
- // No schema means no validation
24
- if (!compiledSchema) {
25
- req.input = EMPTY_INPUT;
26
- return;
27
- }
28
-
29
- // Initialize req.input
30
- req.input = {
31
- body: undefined,
32
- params: undefined,
33
- query: undefined,
34
- };
35
-
36
- // Parse body if schema exists
37
- if (compiledSchema.body) {
38
- req.input.body = compiledSchema.body.parse(req.body);
39
- }
40
-
41
- // Parse params if schema exists
42
- if (compiledSchema.params) {
43
- req.input.params = compiledSchema.params.parse(req.params);
44
- }
45
-
46
- // Parse query if schema exists
47
- if (compiledSchema.query) {
48
- req.input.query = compiledSchema.query.parse(req.query);
49
- }
50
- }
51
-
52
- let _translateValidationIssues = null;
53
- function getTranslateValidationIssues() {
54
- if (!_translateValidationIssues) {
55
- try {
56
- _translateValidationIssues = require('../src/api/validation-i18n').translateValidationIssues;
57
- } catch {
58
- _translateValidationIssues = (issues) => issues;
59
- }
60
- }
61
- return _translateValidationIssues;
62
- }
63
-
64
- function translateValidationIssues(issues, options) {
65
- return getTranslateValidationIssues()(issues, options);
66
- }
67
-
68
- module.exports = {
69
- applySchema,
70
- translateValidationIssues,
71
- };
72
-
1
+ 'use strict';
73
2
 
3
+ // Backward-compatibility shim
4
+ module.exports = require('./validation/apply');
@@ -16,7 +16,7 @@ function getMiddleware() {
16
16
  return _middleware;
17
17
  }
18
18
  const { hash, verify, needsRehash, generateToken, hashToken } = require('./hash');
19
- const { signJwt, verifyJwt, decodeJwt } = require('./jwt');
19
+ const { signJwt, verifyJwt, decodeJwt, clearJwtCache, HS256_HEADER_BASE64URL, MAX_PAYLOAD_SIZE } = require('./jwt');
20
20
  const {
21
21
  TOKEN_TYPES,
22
22
  createAuthTokensTable,
@@ -271,6 +271,9 @@ module.exports = {
271
271
  signJwt,
272
272
  verifyJwt,
273
273
  decodeJwt,
274
+ clearJwtCache,
275
+ HS256_HEADER_BASE64URL,
276
+ MAX_PAYLOAD_SIZE,
274
277
 
275
278
  // Migration helpers
276
279
  createRememberTokensTable,
package/core/auth/jwt.js CHANGED
@@ -72,6 +72,17 @@ function signJwt(payload, secret, options = {}) {
72
72
  return `${signingInput}.${encodedSignature}`;
73
73
  }
74
74
 
75
+ const VERIFIED_JWT_CACHE_LIMIT = 1024;
76
+ const MAX_PAYLOAD_SIZE = 1048576; // 1 MB
77
+ const verifiedJwtCache = new Map();
78
+
79
+ /**
80
+ * Clear verified JWT cache
81
+ */
82
+ function clearJwtCache() {
83
+ verifiedJwtCache.clear();
84
+ }
85
+
75
86
  /**
76
87
  * Verify and decode HS256 JWT string
77
88
  * @param {string} token - JWT string
@@ -80,6 +91,8 @@ function signJwt(payload, secret, options = {}) {
80
91
  * @param {boolean} [options.ignoreExpiration=false] - Ignore token expiration
81
92
  * @param {string} [options.issuer] - Expected issuer
82
93
  * @param {string} [options.audience] - Expected audience
94
+ * @param {boolean} [options.cache=true] - Cache verified payload in bounded LRU cache
95
+ * @param {number} [options.maxPayloadSize=1048576] - Maximum allowed payload length in bytes
83
96
  * @returns {Object} Decoded payload
84
97
  * @throws {Error} If token is invalid or expired
85
98
  */
@@ -92,6 +105,38 @@ function verifyJwt(token, secret, options = {}) {
92
105
  throw new Error('JWT token must be a non-empty string');
93
106
  }
94
107
 
108
+ // Fast-path: Check bounded verification LRU cache
109
+ const cacheKey = options.cache !== false && typeof secret === 'string'
110
+ ? `${token}##${secret}`
111
+ : null;
112
+
113
+ if (cacheKey) {
114
+ const cached = verifiedJwtCache.get(cacheKey);
115
+ if (cached) {
116
+ // Refresh LRU order
117
+ verifiedJwtCache.delete(cacheKey);
118
+ verifiedJwtCache.set(cacheKey, cached);
119
+
120
+ const nowSec = Math.floor(Date.now() / 1000);
121
+ if (!options.ignoreExpiration && typeof cached.exp === 'number') {
122
+ if (nowSec >= cached.exp) {
123
+ verifiedJwtCache.delete(cacheKey);
124
+ throw new Error('JWT token has expired');
125
+ }
126
+ }
127
+
128
+ if (options.issuer && cached.iss !== options.issuer) {
129
+ throw new Error(`Invalid JWT issuer (expected ${options.issuer}, got ${cached.iss})`);
130
+ }
131
+
132
+ if (options.audience && cached.aud !== options.audience) {
133
+ throw new Error(`Invalid JWT audience (expected ${options.audience}, got ${cached.aud})`);
134
+ }
135
+
136
+ return { ...cached };
137
+ }
138
+ }
139
+
95
140
  const parts = token.split('.');
96
141
  if (parts.length !== 3) {
97
142
  throw new Error('Invalid JWT format');
@@ -99,26 +144,39 @@ function verifyJwt(token, secret, options = {}) {
99
144
 
100
145
  const [encodedHeader, encodedPayload, encodedSignature] = parts;
101
146
 
147
+ // 1. Fast-path header check: bypass decoding and JSON parsing for standard HS256 header
102
148
  let header;
103
- let payload;
149
+ if (encodedHeader === HS256_HEADER_BASE64URL) {
150
+ header = { alg: 'HS256', typ: 'JWT' };
151
+ } else {
152
+ try {
153
+ header = JSON.parse(base64UrlDecode(encodedHeader));
154
+ } catch (e) {
155
+ throw new Error('Invalid JWT header JSON');
156
+ }
157
+ }
104
158
 
105
- try {
106
- header = JSON.parse(base64UrlDecode(encodedHeader));
107
- } catch (e) {
108
- throw new Error('Invalid JWT header JSON');
159
+ if (!header || typeof header !== 'object' || header.alg !== 'HS256') {
160
+ throw new Error(`Unsupported algorithm '${header?.alg}' in JWT header`);
161
+ }
162
+
163
+ // 2. DoS Protection: verify payload size limit before JSON parsing
164
+ const maxPayloadSize = typeof options.maxPayloadSize === 'number'
165
+ ? options.maxPayloadSize
166
+ : MAX_PAYLOAD_SIZE;
167
+
168
+ if (encodedPayload.length > maxPayloadSize) {
169
+ throw new Error('JWT payload exceeds maximum allowed size');
109
170
  }
110
171
 
172
+ let payload;
111
173
  try {
112
174
  payload = JSON.parse(base64UrlDecode(encodedPayload));
113
175
  } catch (e) {
114
176
  throw new Error('Invalid JWT payload JSON');
115
177
  }
116
178
 
117
- if (header.alg !== 'HS256') {
118
- throw new Error(`Unsupported algorithm '${header.alg}' in JWT header`);
119
- }
120
-
121
- // Re-compute signature
179
+ // 3. Re-compute and constant-time compare signature
122
180
  const signingInput = `${encodedHeader}.${encodedPayload}`;
123
181
  const expectedSignatureBuf = hmacSha256(signingInput, secret, 'buffer');
124
182
 
@@ -128,7 +186,7 @@ function verifyJwt(token, secret, options = {}) {
128
186
  throw new Error('Invalid JWT signature');
129
187
  }
130
188
 
131
- // Check claims
189
+ // 4. Check claims
132
190
  const nowSec = Math.floor(Date.now() / 1000);
133
191
 
134
192
  if (!options.ignoreExpiration && typeof payload.exp === 'number') {
@@ -145,7 +203,16 @@ function verifyJwt(token, secret, options = {}) {
145
203
  throw new Error(`Invalid JWT audience (expected ${options.audience}, got ${payload.aud})`);
146
204
  }
147
205
 
148
- return payload;
206
+ // 5. Store in bounded verification LRU cache
207
+ if (cacheKey) {
208
+ if (verifiedJwtCache.size >= VERIFIED_JWT_CACHE_LIMIT) {
209
+ const oldestKey = verifiedJwtCache.keys().next().value;
210
+ verifiedJwtCache.delete(oldestKey);
211
+ }
212
+ verifiedJwtCache.set(cacheKey, payload);
213
+ }
214
+
215
+ return { ...payload };
149
216
  }
150
217
 
151
218
  /**
@@ -159,7 +226,9 @@ function decodeJwt(token) {
159
226
  if (parts.length !== 3) return null;
160
227
 
161
228
  try {
162
- const header = JSON.parse(base64UrlDecode(parts[0]));
229
+ const header = parts[0] === HS256_HEADER_BASE64URL
230
+ ? { alg: 'HS256', typ: 'JWT' }
231
+ : JSON.parse(base64UrlDecode(parts[0]));
163
232
  const payload = JSON.parse(base64UrlDecode(parts[1]));
164
233
  return { header, payload };
165
234
  } catch (e) {
@@ -174,4 +243,7 @@ module.exports = {
174
243
  base64UrlEncode,
175
244
  base64UrlDecode,
176
245
  parseDuration,
246
+ clearJwtCache,
247
+ HS256_HEADER_BASE64URL,
248
+ MAX_PAYLOAD_SIZE,
177
249
  };
@@ -88,10 +88,14 @@ function createMagicLinkVerifyMiddleware(authManager, options = {}) {
88
88
  } = options;
89
89
 
90
90
  return async function magicLinkVerifyMiddleware(req, res, next) {
91
- const rawToken = req.query?.[tokenParam] || req.body?.[tokenParam];
91
+ let rawToken = req.query?.[tokenParam] || req.body?.[tokenParam];
92
+ if (Array.isArray(rawToken)) {
93
+ rawToken = rawToken[0];
94
+ }
95
+ const token = typeof rawToken === 'string' ? rawToken.trim() : null;
92
96
  const isHtml = req.accepts && req.accepts('html') && req.method === 'GET';
93
97
 
94
- if (!rawToken) {
98
+ if (!token) {
95
99
  if (isHtml) {
96
100
  return res.redirect(failureRedirect);
97
101
  }
@@ -99,7 +103,7 @@ function createMagicLinkVerifyMiddleware(authManager, options = {}) {
99
103
  }
100
104
 
101
105
  try {
102
- const { user } = await authManager.verifyMagicLink(rawToken, { markEmailVerified });
106
+ const { user } = await authManager.verifyMagicLink(token, { markEmailVerified });
103
107
 
104
108
  if (req.auth && typeof req.auth.login === 'function') {
105
109
  await req.auth.login(user);
@@ -139,21 +143,35 @@ function createMagicLinkVerifyMiddleware(authManager, options = {}) {
139
143
  *
140
144
  * @param {import('./manager').AuthManager} authManager
141
145
  * @param {Object} [options]
146
+ * @param {number} [options.tokenTtlMs]
147
+ * @param {boolean} [options.hideUserNotFound=true]
142
148
  * @returns {Function} Express route handler
143
149
  */
144
150
  function createMagicLinkRequestHandler(authManager, options = {}) {
145
151
  return async function magicLinkRequestHandler(req, res) {
146
- const identifier = req.body?.email || req.body?.identifier || req.query?.email;
152
+ const raw = req.body?.email || req.body?.identifier || req.query?.email;
153
+ const identifier = typeof raw === 'string' ? raw.trim() : raw;
147
154
  if (!identifier) {
148
155
  return res.status(400).json({ error: 'Email or identifier is required' });
149
156
  }
150
157
 
151
158
  try {
152
159
  const result = await authManager.requestMagicLink(identifier, options);
160
+
161
+ if (options.hideUserNotFound === false && result?.userFound === false) {
162
+ return res.status(404).json({
163
+ error: 'User not found',
164
+ code: 'USER_NOT_FOUND',
165
+ });
166
+ }
167
+
168
+ const tokenTtlMs = options.tokenTtlMs ?? (authManager?.config?.magicLink?.tokenTtlMs || 15 * 60 * 1000);
169
+ const expiresAt = result.expiresAt || new Date(Date.now() + tokenTtlMs);
170
+
153
171
  return res.status(200).json({
154
172
  ok: true,
155
173
  message: 'If the account exists, a magic link has been sent.',
156
- expiresAt: result.expiresAt,
174
+ expiresAt,
157
175
  });
158
176
  } catch (err) {
159
177
  return res.status(500).json({ error: err.message || 'Failed to send magic link' });
@@ -145,7 +145,7 @@ class AuthManager extends EventEmitter {
145
145
 
146
146
  // Session tracking and revocation manager
147
147
  const { SessionManager } = require('./session-manager');
148
- const { createKnexSessionTracker, createMemorySessionTracker } = require('./sessions');
148
+ const { createKnexSessionTracker, createMemorySessionTracker, createKvSessionTracker } = require('./sessions');
149
149
 
150
150
  let tracker = config.sessionTracker || null;
151
151
  if (!tracker && (config.sessionTracking || config.trackSessions)) {
@@ -161,6 +161,11 @@ class AuthManager extends EventEmitter {
161
161
  client: config.session?.redisClient,
162
162
  prefix: config.session?.redisPrefix || 'sess:',
163
163
  });
164
+ } else if (config.session?.storeType === 'kv' || config.session?.kv) {
165
+ tracker = createKvSessionTracker(config.session?.kv, {
166
+ prefix: config.session?.kvPrefix || 'sess:',
167
+ ttlSeconds: config.session?.ttl,
168
+ });
164
169
  } else if (config.db || this.config.db) {
165
170
  tracker = createKnexSessionTracker(config.db || this.config.db);
166
171
  } else {
@@ -173,6 +178,7 @@ class AuthManager extends EventEmitter {
173
178
  tracker: tracker || createMemorySessionTracker(),
174
179
  sessionStore: config.sessionStore || this.config.session?.store,
175
180
  touchThrottleMs: config.sessionTouchThrottleMs,
181
+ maxTouchEntries: config.sessionMaxTouchEntries,
176
182
  emitter: this,
177
183
  });
178
184
  } else {
@@ -60,7 +60,7 @@ class InMemoryRedisClient extends EventEmitter {
60
60
  }
61
61
 
62
62
  async del(...keys) {
63
- const flatKeys = keys.flat();
63
+ const flatKeys = keys.flat().filter((k) => typeof k !== 'function');
64
64
  let count = 0;
65
65
  for (const k of flatKeys) {
66
66
  const sk = String(k);
@@ -100,6 +100,7 @@ class InMemoryRedisClient extends EventEmitter {
100
100
  }
101
101
  let added = 0;
102
102
  for (const m of members.flat()) {
103
+ if (typeof m === 'function') continue;
103
104
  const sm = String(m);
104
105
  if (!set.has(sm)) {
105
106
  set.add(sm);
@@ -115,6 +116,7 @@ class InMemoryRedisClient extends EventEmitter {
115
116
  if (!set) return 0;
116
117
  let removed = 0;
117
118
  for (const m of members.flat()) {
119
+ if (typeof m === 'function') continue;
118
120
  if (set.delete(String(m))) removed++;
119
121
  }
120
122
  if (set.size === 0) {
@@ -205,6 +207,13 @@ class MiniRedisClient extends EventEmitter {
205
207
  password = urlOrOptions.password || null;
206
208
  db = urlOrOptions.db || 0;
207
209
  useTls = Boolean(urlOrOptions.tls);
210
+ if (typeof urlOrOptions.tls === 'object' && urlOrOptions.tls !== null) {
211
+ this.tlsOptions = { ...urlOrOptions.tls };
212
+ } else {
213
+ this.tlsOptions = {};
214
+ }
215
+ } else {
216
+ this.tlsOptions = {};
208
217
  }
209
218
 
210
219
  this.host = host;
@@ -273,7 +282,15 @@ class MiniRedisClient extends EventEmitter {
273
282
 
274
283
  try {
275
284
  if (this.useTls) {
276
- this.socket = tls.connect({ host: this.host, port: this.port, rejectUnauthorized: false }, onConnect);
285
+ const tlsConnectOpts = {
286
+ host: this.host,
287
+ port: this.port,
288
+ rejectUnauthorized: this.tlsOptions?.rejectUnauthorized !== undefined
289
+ ? Boolean(this.tlsOptions.rejectUnauthorized)
290
+ : true,
291
+ ...(this.tlsOptions || {}),
292
+ };
293
+ this.socket = tls.connect(tlsConnectOpts, onConnect);
277
294
  } else {
278
295
  this.socket = net.createConnection({ host: this.host, port: this.port }, onConnect);
279
296
  }
@@ -520,11 +537,18 @@ async function invokeRedis(client, method, args) {
520
537
  }
521
538
  }
522
539
 
540
+ if (client instanceof InMemoryRedisClient || client instanceof MiniRedisClient || typeof client.sendCommand === 'function') {
541
+ return await fn.call(client, ...args);
542
+ }
543
+
523
544
  // Handle callback vs Promise-based clients
524
545
  return new Promise((resolve, reject) => {
546
+ let called = false;
525
547
  let result;
526
548
  try {
527
549
  result = fn.call(client, ...args, (err, val) => {
550
+ if (called) return;
551
+ called = true;
528
552
  if (err) return reject(err);
529
553
  resolve(val);
530
554
  });
@@ -533,7 +557,20 @@ async function invokeRedis(client, method, args) {
533
557
  }
534
558
 
535
559
  if (result && typeof result.then === 'function') {
536
- result.then(resolve, reject);
560
+ result.then(
561
+ (val) => {
562
+ if (!called) {
563
+ called = true;
564
+ resolve(val);
565
+ }
566
+ },
567
+ (err) => {
568
+ if (!called) {
569
+ called = true;
570
+ reject(err);
571
+ }
572
+ }
573
+ );
537
574
  }
538
575
  });
539
576
  }
@@ -679,9 +716,11 @@ class RedisSessionStore extends BaseStore {
679
716
  if (!Array.isArray(keys) || keys.length === 0) {
680
717
  return callback(null, []);
681
718
  }
719
+ const rawResults = await Promise.all(
720
+ keys.map((k) => invokeRedis(this.client, 'get', [k]).catch(() => null))
721
+ );
682
722
  const sessions = [];
683
- for (const k of keys) {
684
- const raw = await invokeRedis(this.client, 'get', [k]);
723
+ for (const raw of rawResults) {
685
724
  if (raw) {
686
725
  try {
687
726
  sessions.push(this.serializer.parse(raw));
@@ -867,11 +906,18 @@ function createRedisSessionTracker(options = {}) {
867
906
  const sids = await invokeRedis(client, 'smembers', [userKey(userId)]);
868
907
  if (!Array.isArray(sids) || sids.length === 0) return [];
869
908
 
909
+ const rawResults = await Promise.all(
910
+ sids.map((sid) =>
911
+ invokeRedis(client, 'get', [deviceKey(sid)])
912
+ .then((raw) => ({ sid, raw }))
913
+ .catch(() => ({ sid, raw: null }))
914
+ )
915
+ );
916
+
870
917
  const list = [];
871
918
  const expiredSids = [];
872
919
 
873
- for (const sid of sids) {
874
- const raw = await invokeRedis(client, 'get', [deviceKey(sid)]);
920
+ for (const { sid, raw } of rawResults) {
875
921
  if (!raw) {
876
922
  expiredSids.push(sid);
877
923
  continue;
@@ -951,22 +997,14 @@ function createRedisSessionTracker(options = {}) {
951
997
  const sids = await invokeRedis(client, 'smembers', [userKey(userId)]);
952
998
  if (!Array.isArray(sids) || sids.length === 0) return 0;
953
999
 
954
- let count = 0;
955
- const toRemove = [];
1000
+ const toRemove = sids.filter((sid) => sid !== currentSessionId);
1001
+ if (toRemove.length === 0) return 0;
956
1002
 
957
- for (const sid of sids) {
958
- if (sid !== currentSessionId) {
959
- await invokeRedis(client, 'del', [deviceKey(sid)]);
960
- toRemove.push(sid);
961
- count++;
962
- }
963
- }
1003
+ const keysToDel = toRemove.map((sid) => deviceKey(sid));
1004
+ await invokeRedis(client, 'del', keysToDel);
1005
+ await invokeRedis(client, 'srem', [userKey(userId), ...toRemove]);
964
1006
 
965
- if (toRemove.length > 0) {
966
- await invokeRedis(client, 'srem', [userKey(userId), ...toRemove]);
967
- }
968
-
969
- return count;
1007
+ return toRemove.length;
970
1008
  },
971
1009
 
972
1010
  /**
@@ -979,11 +1017,10 @@ function createRedisSessionTracker(options = {}) {
979
1017
  const sids = await invokeRedis(client, 'smembers', [userKey(userId)]);
980
1018
  if (!Array.isArray(sids) || sids.length === 0) return 0;
981
1019
 
982
- for (const sid of sids) {
983
- await invokeRedis(client, 'del', [deviceKey(sid)]);
984
- }
1020
+ const keysToDel = sids.map((sid) => deviceKey(sid));
1021
+ keysToDel.push(userKey(userId));
1022
+ await invokeRedis(client, 'del', keysToDel);
985
1023
 
986
- await invokeRedis(client, 'del', [userKey(userId)]);
987
1024
  return sids.length;
988
1025
  },
989
1026
 
@@ -15,17 +15,43 @@ class SessionManager {
15
15
  * @param {Object} [options.tracker] - Session tracker adapter (knex or memory)
16
16
  * @param {Object} [options.sessionStore] - Optional express-session store instance
17
17
  * @param {number} [options.touchThrottleMs=60000] - Throttle last_active_at updates (default 1 min)
18
+ * @param {number} [options.maxTouchEntries=5000] - Max entries in memory-bounded last touch map
18
19
  * @param {import('events').EventEmitter} [options.emitter] - AuthManager instance for event dispatching
19
20
  */
20
21
  constructor(options = {}) {
21
22
  this.tracker = options.tracker || createMemorySessionTracker();
22
23
  this.sessionStore = options.sessionStore || null;
23
24
  this.touchThrottleMs = options.touchThrottleMs ?? 60000;
25
+ this.maxTouchEntries = options.maxTouchEntries ?? 5000;
24
26
  this.emitter = options.emitter || null;
25
27
  /** @type {Map<string, number>} */
26
28
  this._lastTouchMap = new Map();
27
29
  }
28
30
 
31
+ /**
32
+ * Record touch timestamp with bounded LRU eviction
33
+ * @param {string} sessionId
34
+ * @param {number} [timestamp=Date.now()]
35
+ * @private
36
+ */
37
+ _recordTouch(sessionId, timestamp = Date.now()) {
38
+ if (!sessionId) return;
39
+ if (this._lastTouchMap.has(sessionId)) {
40
+ this._lastTouchMap.delete(sessionId);
41
+ } else if (this._lastTouchMap.size >= this.maxTouchEntries) {
42
+ const oldestKey = this._lastTouchMap.keys().next().value;
43
+ this._lastTouchMap.delete(oldestKey);
44
+ }
45
+ this._lastTouchMap.set(sessionId, timestamp);
46
+ }
47
+
48
+ /**
49
+ * Clear touch throttle cache
50
+ */
51
+ clearTouchCache() {
52
+ this._lastTouchMap.clear();
53
+ }
54
+
29
55
  /**
30
56
  * Record or update session for a user request
31
57
  * @param {string} sessionId
@@ -48,7 +74,7 @@ class SessionManager {
48
74
  device: parsed.device,
49
75
  });
50
76
 
51
- this._lastTouchMap.set(sessionId, Date.now());
77
+ this._recordTouch(sessionId, Date.now());
52
78
 
53
79
  if (this.emitter) {
54
80
  this.emitter.emit('session:created', {
@@ -79,7 +105,7 @@ class SessionManager {
79
105
  return false; // Throttled
80
106
  }
81
107
 
82
- this._lastTouchMap.set(sessionId, now);
108
+ this._recordTouch(sessionId, now);
83
109
  return await this.tracker.touch(sessionId);
84
110
  }
85
111