webspresso 0.0.106 → 0.0.107
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.
- package/bin/webspresso.js +17 -7
- package/core/app.js +38 -6
- package/core/auth/index.js +4 -1
- package/core/auth/jwt.js +85 -13
- package/core/auth/magic-link.js +23 -5
- package/core/auth/manager.js +7 -1
- package/core/auth/redis-session.js +62 -25
- package/core/auth/session-manager.js +28 -2
- package/core/auth/sessions.js +182 -52
- package/core/console/eval.js +4 -1
- package/core/content/service.js +1 -0
- package/core/errors/domain.js +20 -2
- package/core/errors/http.js +1 -0
- package/core/http/hono-adapter.js +1 -1
- package/core/http/pipeline.js +36 -2
- package/core/index.js +12 -1
- package/core/mail/mailer.js +1 -1
- package/core/orm/eager-loader.js +81 -1
- package/core/orm/index.js +27 -13
- package/core/orm/json-fields.js +44 -13
- package/core/orm/model.js +12 -0
- package/core/orm/query-builder.js +33 -15
- package/core/orm/repository.js +59 -12
- package/core/orm/scopes.js +54 -4
- package/core/services/index.js +9 -0
- package/core/shutdown/shutdown-manager.js +57 -38
- package/core/studio/inspector.js +9 -1
- package/core/studio/server.js +24 -8
- package/core/utils/string.js +77 -16
- package/core/validation/index.js +18 -0
- package/index.d.ts +172 -8
- package/index.js +4 -0
- package/package.json +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
- package/plugins/admin-panel/index.js +24 -11
- package/plugins/admin-panel/modules/user-management.js +3 -2
- package/plugins/csrf/index.js +2 -1
- package/plugins/dashboard/index.js +27 -7
- package/src/app-context.js +9 -1
- package/src/file-router.js +12 -3
- package/src/plugin-manager.js +273 -48
- package/src/server.js +26 -33
- package/src/services/discovery.js +9 -1
- package/src/services/executor.js +271 -58
- package/src/services/index.js +52 -3
- package/src/services/loader.js +120 -0
- package/src/services/memoize.js +223 -49
- package/src/services/registry.js +492 -94
- package/src/services/validator.js +164 -13
package/bin/webspresso.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Command-line interface for Webspresso framework
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function createProgram() {
|
|
9
|
+
const { Command } = require('commander');
|
|
10
|
+
const program = new Command();
|
|
9
11
|
|
|
10
|
-
program
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
program
|
|
13
|
+
.name('webspresso')
|
|
14
|
+
.description('Webspresso CLI - Minimal file-based SSR framework')
|
|
15
|
+
.version(require('../package.json').version);
|
|
14
16
|
|
|
15
17
|
// Register commands
|
|
16
18
|
const { registerCommand: registerNew } = require('./commands/new');
|
|
@@ -125,6 +127,14 @@ registerTypegen(program);
|
|
|
125
127
|
registerClientGen(program);
|
|
126
128
|
registerConsole(program);
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
return program;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Parse arguments if executed directly
|
|
134
|
+
if (require.main === module) {
|
|
135
|
+
const program = createProgram();
|
|
136
|
+
program.parse();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { createProgram };
|
|
130
140
|
|
package/core/app.js
CHANGED
|
@@ -123,14 +123,34 @@ class CoreApp {
|
|
|
123
123
|
? path.join(process.cwd(), 'services')
|
|
124
124
|
: null));
|
|
125
125
|
|
|
126
|
-
this.serviceRegistry =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
this.serviceRegistry = (options.serviceRegistry && typeof options.serviceRegistry.call === 'function')
|
|
127
|
+
? options.serviceRegistry
|
|
128
|
+
: ((options.services && typeof options.services.call === 'function')
|
|
129
|
+
? options.services
|
|
130
|
+
: createServiceRegistry({
|
|
131
|
+
servicesDir: resolvedServicesDir,
|
|
132
|
+
isDev: this.isDev,
|
|
133
|
+
logger: this.logger,
|
|
134
|
+
timeouts: this.timeouts,
|
|
135
|
+
cacheProvider: options.cacheProvider || (options.cache && typeof options.cache === 'object' && options.cache.provider ? options.cache.provider : null),
|
|
136
|
+
}));
|
|
132
137
|
this.services = this.serviceRegistry;
|
|
133
138
|
|
|
139
|
+
this.service.call = (name, input, ctxOrOpts, maybeOpts) =>
|
|
140
|
+
this.service(name, input, ctxOrOpts, maybeOpts);
|
|
141
|
+
this.service.execute = (name, input, ctxOrOpts, maybeOpts) =>
|
|
142
|
+
this.service(name, input, ctxOrOpts, maybeOpts);
|
|
143
|
+
this.service.invalidate = (name, input, ctx) =>
|
|
144
|
+
this.serviceRegistry.invalidate(name, input, ctx || this.context);
|
|
145
|
+
this.service.clearCache = (name) =>
|
|
146
|
+
this.serviceRegistry.clearCache(name);
|
|
147
|
+
this.service.invalidateByTag = (tag, serviceName) =>
|
|
148
|
+
this.serviceRegistry.invalidateByTag(tag, serviceName);
|
|
149
|
+
this.service.invalidateByTags = (tags, serviceName) =>
|
|
150
|
+
this.serviceRegistry.invalidateByTags(tags, serviceName);
|
|
151
|
+
this.service.invalidateTag = this.service.invalidateByTag;
|
|
152
|
+
this.service.invalidateTags = this.service.invalidateByTags;
|
|
153
|
+
|
|
134
154
|
// Auto-register built-in auth services if database is present
|
|
135
155
|
if (options.db) {
|
|
136
156
|
const { createAuthServices } = require('../src/services/builtins/auth');
|
|
@@ -348,6 +368,18 @@ class CoreApp {
|
|
|
348
368
|
Object.assign(ctx, extra);
|
|
349
369
|
ctx.service = (subName, subInput, subOpts) =>
|
|
350
370
|
this.service(subName, subInput, ctx, subOpts);
|
|
371
|
+
ctx.service.call = ctx.service;
|
|
372
|
+
ctx.service.execute = ctx.service;
|
|
373
|
+
ctx.service.invalidate = (subName, subInput, subCtx) =>
|
|
374
|
+
this.serviceRegistry.invalidate(subName, subInput, subCtx || ctx);
|
|
375
|
+
ctx.service.clearCache = (subName) =>
|
|
376
|
+
this.serviceRegistry.clearCache(subName);
|
|
377
|
+
ctx.service.invalidateByTag = (tag, serviceName) =>
|
|
378
|
+
this.serviceRegistry.invalidateByTag(tag, serviceName);
|
|
379
|
+
ctx.service.invalidateByTags = (tags, serviceName) =>
|
|
380
|
+
this.serviceRegistry.invalidateByTags(tags, serviceName);
|
|
381
|
+
ctx.service.invalidateTag = ctx.service.invalidateByTag;
|
|
382
|
+
ctx.service.invalidateTags = ctx.service.invalidateByTags;
|
|
351
383
|
return ctx;
|
|
352
384
|
}
|
|
353
385
|
|
package/core/auth/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
header
|
|
107
|
-
}
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
};
|
package/core/auth/magic-link.js
CHANGED
|
@@ -88,10 +88,14 @@ function createMagicLinkVerifyMiddleware(authManager, options = {}) {
|
|
|
88
88
|
} = options;
|
|
89
89
|
|
|
90
90
|
return async function magicLinkVerifyMiddleware(req, res, next) {
|
|
91
|
-
|
|
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 (!
|
|
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(
|
|
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
|
|
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
|
|
174
|
+
expiresAt,
|
|
157
175
|
});
|
|
158
176
|
} catch (err) {
|
|
159
177
|
return res.status(500).json({ error: err.message || 'Failed to send magic link' });
|
package/core/auth/manager.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
955
|
-
|
|
1000
|
+
const toRemove = sids.filter((sid) => sid !== currentSessionId);
|
|
1001
|
+
if (toRemove.length === 0) return 0;
|
|
956
1002
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
983
|
-
|
|
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.
|
|
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.
|
|
108
|
+
this._recordTouch(sessionId, now);
|
|
83
109
|
return await this.tracker.touch(sessionId);
|
|
84
110
|
}
|
|
85
111
|
|