ioredis-toolkit 0.0.1

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +645 -0
  3. package/dist/cache.d.ts +298 -0
  4. package/dist/cache.js +606 -0
  5. package/dist/client.d.ts +177 -0
  6. package/dist/client.js +958 -0
  7. package/dist/cluster-slot.d.ts +4 -0
  8. package/dist/cluster-slot.js +31 -0
  9. package/dist/cluster.d.ts +79 -0
  10. package/dist/cluster.js +156 -0
  11. package/dist/errors.d.ts +30 -0
  12. package/dist/errors.js +63 -0
  13. package/dist/health.d.ts +39 -0
  14. package/dist/health.js +106 -0
  15. package/dist/index.d.ts +51 -0
  16. package/dist/index.js +44 -0
  17. package/dist/lock.d.ts +215 -0
  18. package/dist/lock.js +385 -0
  19. package/dist/logger.d.ts +12 -0
  20. package/dist/logger.js +40 -0
  21. package/dist/pubsub.d.ts +171 -0
  22. package/dist/pubsub.js +285 -0
  23. package/dist/ratelimiter.d.ts +162 -0
  24. package/dist/ratelimiter.js +289 -0
  25. package/dist/session/index.d.ts +23 -0
  26. package/dist/session/index.js +16 -0
  27. package/dist/session/revocation-store.d.ts +171 -0
  28. package/dist/session/revocation-store.js +310 -0
  29. package/dist/session/scripts/cleanup-index.lua +21 -0
  30. package/dist/session/scripts/conditional-update-encrypted.lua +60 -0
  31. package/dist/session/scripts/conditional-update.lua +63 -0
  32. package/dist/session/scripts/create.lua +68 -0
  33. package/dist/session/scripts/delete-by-user.lua +29 -0
  34. package/dist/session/scripts/delete.lua +15 -0
  35. package/dist/session/scripts/enforce-limit.lua +38 -0
  36. package/dist/session/scripts/revoke.lua +61 -0
  37. package/dist/session/scripts/rotate-encrypted.lua +107 -0
  38. package/dist/session/scripts/rotate.lua +119 -0
  39. package/dist/session/scripts/touch-encrypted.lua +89 -0
  40. package/dist/session/scripts/touch.lua +72 -0
  41. package/dist/session/scripts/validate.lua +90 -0
  42. package/dist/session/session-circuit-breaker.d.ts +42 -0
  43. package/dist/session/session-circuit-breaker.js +129 -0
  44. package/dist/session/session-config.d.ts +335 -0
  45. package/dist/session/session-config.js +162 -0
  46. package/dist/session/session-cookie.d.ts +72 -0
  47. package/dist/session/session-cookie.js +101 -0
  48. package/dist/session/session-encryption.d.ts +87 -0
  49. package/dist/session/session-encryption.js +139 -0
  50. package/dist/session/session-errors.d.ts +85 -0
  51. package/dist/session/session-errors.js +145 -0
  52. package/dist/session/session-health.d.ts +38 -0
  53. package/dist/session/session-health.js +60 -0
  54. package/dist/session/session-keys.d.ts +51 -0
  55. package/dist/session/session-keys.js +113 -0
  56. package/dist/session/session-manager.d.ts +59 -0
  57. package/dist/session/session-manager.js +94 -0
  58. package/dist/session/session-metrics.d.ts +33 -0
  59. package/dist/session/session-metrics.js +112 -0
  60. package/dist/session/session-repository.d.ts +161 -0
  61. package/dist/session/session-repository.js +683 -0
  62. package/dist/session/session-scripts.d.ts +36 -0
  63. package/dist/session/session-scripts.js +130 -0
  64. package/dist/session/session-serializer.d.ts +42 -0
  65. package/dist/session/session-serializer.js +248 -0
  66. package/dist/session/session-service.d.ts +104 -0
  67. package/dist/session/session-service.js +611 -0
  68. package/dist/session/session-token.d.ts +38 -0
  69. package/dist/session/session-token.js +86 -0
  70. package/dist/session/session-types.d.ts +253 -0
  71. package/dist/session/session-types.js +16 -0
  72. package/dist/types.d.ts +782 -0
  73. package/dist/types.js +140 -0
  74. package/package.json +97 -0
@@ -0,0 +1,683 @@
1
+ import { chunk, executeBySlot, mapWithConcurrency } from '../cluster.js';
2
+ import { SessionConcurrencyError, SessionNotFoundError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
3
+ import { deserializeSession, serializeEncryptedSession, serializeSession, validateSessionRecord, } from './session-serializer.js';
4
+ /* -------------------------------------------------------------------------- */
5
+ /* SessionRepository: Redis data access for sessions. */
6
+ /* */
7
+ /* Responsibilities: */
8
+ /* - key derivation (via SessionKeyStrategy) */
9
+ /* - serialization/encryption (via SessionSerializer + key provider) */
10
+ /* - atomic state transitions (via SessionScriptRegistry) */
11
+ /* - bounded fan-out for cross-slot cleanup (via executeBySlot) */
12
+ /* */
13
+ /* The repository contains NO topology branching (isCluster, node selection, */
14
+ /* MOVED/ASK handling). All cluster behavior is provided by RedisClientWrapper */
15
+ /* and the cluster.ts primitives. */
16
+ /* */
17
+ /* Time semantics: */
18
+ /* - state decisions that must be ordered (touch, rotate, expiry) use the */
19
+ /* Redis server clock inside Lua scripts. */
20
+ /* - TTL arguments are computed from the app clock and clamped; the record's */
21
+ /* absoluteExpiresAt remains authoritative for validity. */
22
+ /* - encrypted envelopes carry app-stamped timestamps guarded by script- */
23
+ /* enforced monotonicity (see docs/architecture.md, "Clock skew"). */
24
+ /* -------------------------------------------------------------------------- */
25
+ export class SessionRepository {
26
+ client;
27
+ keys;
28
+ config;
29
+ scripts;
30
+ /** Encryption key provider, or null when encryption is disabled. */
31
+ keyProvider;
32
+ constructor(options) {
33
+ this.client = options.client;
34
+ this.keys = options.keys;
35
+ this.config = options.config;
36
+ this.scripts = options.scripts;
37
+ this.keyProvider = options.keyProvider ?? null;
38
+ }
39
+ /** True when the repository can decrypt stored sessions. */
40
+ hasKeyProvider() {
41
+ return this.keyProvider !== null;
42
+ }
43
+ get encrypted() {
44
+ return this.config.encryption.enabled;
45
+ }
46
+ get jtiIndexEnabled() {
47
+ return this.config.jtiIndex.enabled;
48
+ }
49
+ get maxBatchSize() {
50
+ return this.config.limits.maxBatchSize;
51
+ }
52
+ get maxFanOut() {
53
+ return this.config.limits.maxFanOutConcurrency;
54
+ }
55
+ /* ------------------------------------------------------------------------ */
56
+ /* Create */
57
+ /* ------------------------------------------------------------------------ */
58
+ /**
59
+ * Stores a session atomically with its user index entry and (bounded)
60
+ * max-session eviction. Returns the outcome; on idempotent replays the
61
+ * claim's jti identifies the pre-existing session.
62
+ */
63
+ async create(record, ttl) {
64
+ const key = this.keys.sessionKey(record.userId, record.jti);
65
+ const indexKey = this.keys.userIndexKey(record.userId);
66
+ const serialized = this.encrypted
67
+ ? serializeEncryptedSession(record, this.keyProvider)
68
+ : serializeSession(record);
69
+ const claimKey = this.keys.createClaimKey(record.userId, record.jti);
70
+ const claimTtl = this.config.enableCreateIdempotency ? Math.min(60, Math.max(1, ttl)) : '';
71
+ const result = await this.scripts.eval('create', this.config.enableCreateIdempotency ? 3 : 2, key, indexKey, ...(this.config.enableCreateIdempotency ? [claimKey] : []), serialized, record.jti, String(record.createdAt), String(ttl), String(this.config.maxSessionsPerUser), this.keys.sessionKeyPrefix(record.userId), String(this.config.limits.maxEvictionsPerCall), String(claimTtl));
72
+ if (Array.isArray(result)) {
73
+ const code = Number(result[0]);
74
+ if (code === 3) {
75
+ const jti = String(result[1] ?? '');
76
+ return { status: 'replayed', jti };
77
+ }
78
+ if (code === 1) {
79
+ return { status: 'created' };
80
+ }
81
+ }
82
+ const code = Number(result);
83
+ if (code === -5) {
84
+ throw new SessionConcurrencyError({ reason: 'jti_collision' });
85
+ }
86
+ throw new SessionStorageError('Session create script returned an unexpected result.', {
87
+ code,
88
+ });
89
+ }
90
+ /* ------------------------------------------------------------------------ */
91
+ /* Read paths */
92
+ /* ------------------------------------------------------------------------ */
93
+ /**
94
+ * Reads and deserializes a session. Returns null when missing.
95
+ * Throws SessionSerializationError when the stored payload is corrupt.
96
+ */
97
+ async get(userId, jti) {
98
+ const raw = await this.client.get(this.keys.sessionKey(userId, jti));
99
+ if (raw === null)
100
+ return null;
101
+ return deserializeSession(raw, this.keyProvider ?? undefined);
102
+ }
103
+ /**
104
+ * Single-round-trip validation read (session + security version).
105
+ * Returns { found: false } for missing records, or the raw envelope plus
106
+ * the current security version for further app-side checks.
107
+ */
108
+ async validateRead(userId, jti) {
109
+ const result = await this.scripts.eval('validate', 3, this.keys.sessionKey(userId, jti), this.keys.securityVersionKey(userId), this.keys.userIndexKey(userId), jti);
110
+ if (!Array.isArray(result)) {
111
+ throw new SessionStorageError('Validate script returned an unexpected result.', {
112
+ result: String(result),
113
+ });
114
+ }
115
+ const code = Number(result[0]);
116
+ if (code === 0) {
117
+ // The record is gone: any jti index entry for this jti is stale by
118
+ // definition (the record is authoritative). Remove it best-effort —
119
+ // the not_found outcome is already decided and a failed cleanup must
120
+ // never turn it into a storage error.
121
+ await this.cleanupJtiIndex(jti);
122
+ return { found: false };
123
+ }
124
+ if (code === 1) {
125
+ const raw = result[1];
126
+ const currentVersion = result[2] !== undefined && result[2] !== null ? Number(result[2]) : null;
127
+ if (typeof raw !== 'string') {
128
+ throw new SessionSerializationError({ reason: 'validate_raw_missing' });
129
+ }
130
+ return { found: true, raw, currentSecurityVersion: currentVersion };
131
+ }
132
+ if (code === -1) {
133
+ return { found: true, code: -1, status: String(result[1] ?? 'unknown') };
134
+ }
135
+ if (code === -2) {
136
+ await this.cleanupJtiIndex(jti);
137
+ return { found: true, code: -2 };
138
+ }
139
+ if (code === -3) {
140
+ await this.cleanupJtiIndex(jti);
141
+ return { found: true, code: -3 };
142
+ }
143
+ return { found: true, code: -4 };
144
+ }
145
+ /** Best-effort removal of a stale jti index entry (never throws). */
146
+ async cleanupJtiIndex(jti) {
147
+ if (!this.config.jtiIndex.enabled)
148
+ return;
149
+ try {
150
+ await this.deleteJtiIndex(jti);
151
+ }
152
+ catch {
153
+ // Swallowed: derived-state hygiene, not an auth decision.
154
+ }
155
+ }
156
+ /* ------------------------------------------------------------------------ */
157
+ /* Touch */
158
+ /* ------------------------------------------------------------------------ */
159
+ /**
160
+ * Throttled monotonic activity refresh.
161
+ *
162
+ * Plain sessions: one atomic script (server time). Encrypted sessions:
163
+ * read + decrypt + re-encrypt + atomic CAS (two round trips).
164
+ */
165
+ async touch(userId, jti, force) {
166
+ const key = this.keys.sessionKey(userId, jti);
167
+ const interval = String(this.config.touchInterval);
168
+ const idleTimeout = this.config.idleTimeout !== null ? String(this.config.idleTimeout) : '';
169
+ if (!this.encrypted) {
170
+ const result = await this.scripts.eval('touch', 1, key, interval, idleTimeout, force ? '1' : '0');
171
+ const outcome = mapTouchCode(Number(result));
172
+ if (outcome === 'not_found') {
173
+ await this.cleanupJtiIndex(jti);
174
+ }
175
+ return outcome;
176
+ }
177
+ // Encrypted path: read, decrypt, re-encrypt with the current key.
178
+ const raw = await this.client.get(key);
179
+ if (raw === null) {
180
+ await this.cleanupJtiIndex(jti);
181
+ return 'not_found';
182
+ }
183
+ let record;
184
+ try {
185
+ record = deserializeSession(raw, this.keyProvider);
186
+ }
187
+ catch (error) {
188
+ if (error instanceof SessionSerializationError) {
189
+ await this.cleanupJtiIndex(jti);
190
+ return 'not_found';
191
+ }
192
+ throw error;
193
+ }
194
+ if (record.status !== 'active')
195
+ return 'consumed';
196
+ const now = Math.floor(Date.now() / 1000);
197
+ if (record.absoluteExpiresAt <= now) {
198
+ await this.client.del(key);
199
+ return 'expired';
200
+ }
201
+ if (record.idleExpiresAt !== null && record.idleExpiresAt <= now) {
202
+ return 'idle_expired';
203
+ }
204
+ if (!force && now - record.lastAccessedAt < this.config.touchInterval) {
205
+ return 'skipped_throttled';
206
+ }
207
+ const updated = {
208
+ ...record,
209
+ lastAccessedAt: now,
210
+ idleExpiresAt: this.config.idleTimeout !== null
211
+ ? Math.min(now + this.config.idleTimeout, record.absoluteExpiresAt)
212
+ : record.idleExpiresAt,
213
+ };
214
+ const serialized = serializeEncryptedSession(updated, this.keyProvider);
215
+ const ttl = Math.max(1, updated.absoluteExpiresAt - now);
216
+ const result = await this.scripts.eval('touchEncrypted', 1, key, interval, idleTimeout, force ? '1' : '0', serialized, String(now), updated.idleExpiresAt !== null ? String(updated.idleExpiresAt) : '', String(ttl));
217
+ return mapTouchCode(Number(result));
218
+ }
219
+ /* ------------------------------------------------------------------------ */
220
+ /* Rotate */
221
+ /* ------------------------------------------------------------------------ */
222
+ /**
223
+ * Atomic single-use rotation. Returns the successor jti on success.
224
+ *
225
+ * Plain sessions: one script (server time). Encrypted sessions: read +
226
+ * decrypt + build + atomic script (two round trips).
227
+ */
228
+ async rotate(options) {
229
+ const { userId, oldJti, successor } = options;
230
+ const oldKey = this.keys.sessionKey(userId, oldJti);
231
+ const newKey = this.keys.sessionKey(userId, successor.jti);
232
+ const indexKey = this.keys.userIndexKey(userId);
233
+ const expected = options.expectedVersion !== undefined ? String(options.expectedVersion) : '';
234
+ const nonce = options.rotationNonceHash ?? '';
235
+ const retain = options.retainTombstone ? '1' : '0';
236
+ if (!this.encrypted) {
237
+ const serialized = serializeSession(successor);
238
+ const result = await this.scripts.eval('rotate', 3, oldKey, newKey, indexKey, serialized, successor.jti, expected, nonce, retain, oldJti);
239
+ const outcome = parseRotateResult(result, successor.jti);
240
+ if (outcome.code === 0) {
241
+ await this.cleanupJtiIndex(oldJti);
242
+ }
243
+ return outcome;
244
+ }
245
+ // Encrypted path.
246
+ const raw = await this.client.get(oldKey);
247
+ if (raw === null) {
248
+ await this.cleanupJtiIndex(oldJti);
249
+ return { code: 0 };
250
+ }
251
+ let current;
252
+ try {
253
+ current = deserializeSession(raw, this.keyProvider);
254
+ }
255
+ catch (error) {
256
+ if (error instanceof SessionSerializationError) {
257
+ return { code: 3 };
258
+ }
259
+ throw error;
260
+ }
261
+ if (current.status !== 'active') {
262
+ // Retry-safe replay: the stored rotatedTo jti is authoritative; the
263
+ // retry's freshly generated successor jti must never be compared
264
+ // against it (it is discarded, exactly like the plain path).
265
+ if (nonce !== '' && current.rotationNonceHash === nonce && current.rotatedTo !== null) {
266
+ return { code: 2, successorJti: current.rotatedTo };
267
+ }
268
+ return { code: -1, status: current.status };
269
+ }
270
+ const now = Math.floor(Date.now() / 1000);
271
+ if (current.absoluteExpiresAt <= now) {
272
+ await this.client.del(oldKey);
273
+ return { code: -2 };
274
+ }
275
+ if (options.expectedVersion !== undefined && current.version !== options.expectedVersion) {
276
+ return { code: -3 };
277
+ }
278
+ const consumed = {
279
+ ...current,
280
+ status: 'consumed',
281
+ consumedAt: now,
282
+ rotatedTo: successor.jti,
283
+ rotationNonceHash: nonce !== '' ? nonce : null,
284
+ };
285
+ const consumedSerialized = serializeEncryptedSession(consumed, this.keyProvider);
286
+ const successorSerialized = serializeEncryptedSession(successor, this.keyProvider);
287
+ const successorTtl = Math.max(1, successor.absoluteExpiresAt - now);
288
+ const consumedTtl = Math.max(1, consumed.absoluteExpiresAt - now);
289
+ const result = await this.scripts.eval('rotateEncrypted', 3, oldKey, newKey, indexKey, consumedSerialized, successorSerialized, successor.jti, expected, nonce, retain, oldJti, String(successorTtl), String(consumedTtl));
290
+ return parseRotateResult(result, successor.jti);
291
+ }
292
+ /* ------------------------------------------------------------------------ */
293
+ /* Update */
294
+ /* ------------------------------------------------------------------------ */
295
+ /**
296
+ * Optimistic-concurrency patch update. Returns the updated record, or
297
+ * null when missing. Throws SessionConcurrencyError on version conflict.
298
+ */
299
+ async update(userId, jti, patch, expectedVersion) {
300
+ const key = this.keys.sessionKey(userId, jti);
301
+ const expected = expectedVersion !== undefined ? String(expectedVersion) : '';
302
+ if (!this.encrypted) {
303
+ const result = await this.scripts.eval('conditionalUpdate', 1, key, expected, JSON.stringify(patch));
304
+ if (Array.isArray(result) && Number(result[0]) === 1) {
305
+ return this.get(userId, jti);
306
+ }
307
+ if (Number(Array.isArray(result) ? result[0] : result) === 0) {
308
+ await this.cleanupJtiIndex(jti);
309
+ return null;
310
+ }
311
+ throwOrNull(Number(Array.isArray(result) ? result[0] : result));
312
+ return null;
313
+ }
314
+ // Encrypted path: read, patch, re-encrypt, CAS.
315
+ const raw = await this.client.get(key);
316
+ if (raw === null) {
317
+ await this.cleanupJtiIndex(jti);
318
+ return null;
319
+ }
320
+ let current;
321
+ try {
322
+ current = deserializeSession(raw, this.keyProvider);
323
+ }
324
+ catch (error) {
325
+ if (error instanceof SessionSerializationError) {
326
+ return null;
327
+ }
328
+ throw error;
329
+ }
330
+ if (current.status !== 'active')
331
+ throwOrNull(-1);
332
+ const now = Math.floor(Date.now() / 1000);
333
+ if (current.absoluteExpiresAt <= now) {
334
+ await this.client.del(key);
335
+ throwOrNull(-2);
336
+ }
337
+ if (expectedVersion !== undefined && current.version !== expectedVersion) {
338
+ throwOrNull(-3);
339
+ }
340
+ const nextVersion = current.version + 1;
341
+ const updated = {
342
+ ...current,
343
+ deviceId: patch.deviceId !== undefined ? patch.deviceId : current.deviceId,
344
+ ipAddress: patch.ipAddress !== undefined ? patch.ipAddress : current.ipAddress,
345
+ userAgent: patch.userAgent !== undefined ? patch.userAgent : current.userAgent,
346
+ metadata: patch.metadata !== undefined ? patch.metadata : current.metadata,
347
+ version: nextVersion,
348
+ };
349
+ const serialized = serializeEncryptedSession(updated, this.keyProvider);
350
+ const ttl = Math.max(1, updated.absoluteExpiresAt - now);
351
+ const result = await this.scripts.eval('conditionalUpdateEncrypted', 1, key, expected, serialized, String(nextVersion), String(ttl));
352
+ if (Array.isArray(result) && Number(result[0]) === 1) {
353
+ return updated;
354
+ }
355
+ throwOrNull(Number(Array.isArray(result) ? result[0] : result));
356
+ return null;
357
+ }
358
+ /* ------------------------------------------------------------------------ */
359
+ /* Destroy / revoke */
360
+ /* ------------------------------------------------------------------------ */
361
+ /** Physically deletes a session and its index entry. Idempotent. */
362
+ async destroy(userId, jti) {
363
+ const result = await this.scripts.eval('delete', 2, this.keys.sessionKey(userId, jti), this.keys.userIndexKey(userId), jti);
364
+ const deleted = Number(result) === 1;
365
+ if (!deleted) {
366
+ await this.cleanupJtiIndex(jti);
367
+ }
368
+ return deleted;
369
+ }
370
+ /**
371
+ * Logically revokes a session with a bounded tombstone TTL.
372
+ * Returns 'revoked' | 'already_revoked' | 'not_found'.
373
+ */
374
+ async revoke(userId, jti, tombstoneTtl) {
375
+ const key = this.keys.sessionKey(userId, jti);
376
+ const ttl = String(Math.max(1, tombstoneTtl));
377
+ let payloadArg = '';
378
+ if (this.encrypted) {
379
+ const raw = await this.client.get(key);
380
+ if (raw === null) {
381
+ await this.cleanupJtiIndex(jti);
382
+ return 'not_found';
383
+ }
384
+ let record;
385
+ try {
386
+ record = deserializeSession(raw, this.keyProvider);
387
+ }
388
+ catch (error) {
389
+ if (error instanceof SessionSerializationError) {
390
+ await this.cleanupJtiIndex(jti);
391
+ return 'not_found';
392
+ }
393
+ throw error;
394
+ }
395
+ const revoked = { ...record, status: 'revoked' };
396
+ payloadArg = serializeEncryptedSession(revoked, this.keyProvider);
397
+ }
398
+ const result = await this.scripts.eval('revoke', 1, key, payloadArg, ttl);
399
+ const code = Number(result);
400
+ if (code === 1)
401
+ return 'revoked';
402
+ if (code === 2)
403
+ return 'already_revoked';
404
+ if (code === 0) {
405
+ await this.cleanupJtiIndex(jti);
406
+ return 'not_found';
407
+ }
408
+ throw new SessionStorageError('Revoke script returned an unexpected result.', { code });
409
+ }
410
+ /* ------------------------------------------------------------------------ */
411
+ /* User index operations */
412
+ /* ------------------------------------------------------------------------ */
413
+ /**
414
+ * Lists a user's sessions (oldest first), lazily cleaning stale index
415
+ * members. Bounded: fetches at most `limit` members plus cleanup batches.
416
+ */
417
+ async listByUser(userId, options = {}) {
418
+ const indexKey = this.keys.userIndexKey(userId);
419
+ const limit = Math.max(1, options.limit ?? this.config.limits.maxListPageSize);
420
+ const offset = Math.max(0, options.offset ?? 0);
421
+ // ZRANGE WITHSCORES is unnecessary: scores are createdAt and we have
422
+ // the records. Fetch only the requested window.
423
+ const jtis = await this.client.zrange(indexKey, offset, offset + limit - 1);
424
+ if (jtis.length === 0)
425
+ return [];
426
+ const sessionKeys = jtis.map((jti) => this.keys.sessionKey(userId, jti));
427
+ const values = await this.client.mget(...sessionKeys);
428
+ const sessions = [];
429
+ const stale = [];
430
+ for (let i = 0; i < jtis.length; i++) {
431
+ const raw = values[i];
432
+ if (raw === null || raw === undefined) {
433
+ stale.push(jtis[i]);
434
+ continue;
435
+ }
436
+ try {
437
+ sessions.push(deserializeSession(raw, this.keyProvider ?? undefined));
438
+ }
439
+ catch (error) {
440
+ if (error instanceof SessionSerializationError) {
441
+ // Corrupt record: skip it, clean it up, never fail the list.
442
+ stale.push(jtis[i]);
443
+ continue;
444
+ }
445
+ throw error;
446
+ }
447
+ }
448
+ if (stale.length > 0) {
449
+ await this.cleanupIndexEntries(userId, stale);
450
+ }
451
+ return sessions;
452
+ }
453
+ /**
454
+ * Deletes all of a user's sessions in bounded same-slot batches.
455
+ * Returns the jtis whose records were deleted.
456
+ */
457
+ async deleteByUser(userId) {
458
+ const indexKey = this.keys.userIndexKey(userId);
459
+ const jtis = await this.client.zrange(indexKey, 0, -1);
460
+ if (jtis.length === 0) {
461
+ await this.client.del(indexKey);
462
+ return [];
463
+ }
464
+ const deleted = [];
465
+ const batchSize = Math.min(this.maxBatchSize, this.config.limits.maxSessionsPerUserHardCap || this.maxBatchSize);
466
+ for (const batch of chunk(jtis, batchSize)) {
467
+ const sessionKeys = batch.map((jti) => this.keys.sessionKey(userId, jti));
468
+ const result = await this.scripts.eval('deleteByUser', 1 + batch.length, indexKey, ...sessionKeys, ...batch);
469
+ if (Array.isArray(result)) {
470
+ deleted.push(...result.map(String));
471
+ }
472
+ }
473
+ return deleted;
474
+ }
475
+ /**
476
+ * Removes stale (missing) members from a user's index. Bounded, safe to
477
+ * run repeatedly.
478
+ */
479
+ async cleanupUserIndex(userId) {
480
+ const indexKey = this.keys.userIndexKey(userId);
481
+ const jtis = await this.client.zrange(indexKey, 0, -1);
482
+ if (jtis.length === 0)
483
+ return 0;
484
+ let removed = 0;
485
+ for (const batch of chunk(jtis, this.maxBatchSize)) {
486
+ const sessionKeys = batch.map((jti) => this.keys.sessionKey(userId, jti));
487
+ const result = await this.scripts.eval('cleanupIndex', 1 + batch.length, indexKey, ...sessionKeys, ...batch);
488
+ if (Array.isArray(result)) {
489
+ removed += result.length;
490
+ }
491
+ }
492
+ return removed;
493
+ }
494
+ /** Removes specific stale entries from a user's index (bounded). */
495
+ async cleanupIndexEntries(userId, jtis) {
496
+ if (jtis.length === 0)
497
+ return [];
498
+ const indexKey = this.keys.userIndexKey(userId);
499
+ const removed = [];
500
+ for (const batch of chunk(jtis, this.maxBatchSize)) {
501
+ const sessionKeys = batch.map((jti) => this.keys.sessionKey(userId, jti));
502
+ const result = await this.scripts.eval('cleanupIndex', 1 + batch.length, indexKey, ...sessionKeys, ...batch);
503
+ if (Array.isArray(result)) {
504
+ removed.push(...result.map(String));
505
+ }
506
+ }
507
+ return removed;
508
+ }
509
+ /**
510
+ * Standalone max-session enforcement (used after revokeAll-style bulk
511
+ * operations and by admin repair). Loops in bounded steps until the
512
+ * index fits the limit or a hard cap is reached.
513
+ */
514
+ async enforceLimit(userId) {
515
+ const maxSessions = this.config.maxSessionsPerUser;
516
+ if (maxSessions <= 0)
517
+ return 0;
518
+ const indexKey = this.keys.userIndexKey(userId);
519
+ let totalEvicted = 0;
520
+ const hardCap = this.config.limits.maxSessionsPerUserHardCap;
521
+ for (let i = 0; i < 100 && totalEvicted < hardCap; i++) {
522
+ const result = await this.scripts.eval('enforceLimit', 1, indexKey, String(maxSessions), String(this.config.limits.maxEvictionsPerCall), this.keys.sessionKeyPrefix(userId));
523
+ if (!Array.isArray(result) || result.length < 2) {
524
+ throw new SessionStorageError('Enforce-limit script returned an unexpected result.');
525
+ }
526
+ const evicted = Number(result[0]);
527
+ totalEvicted += evicted;
528
+ if (evicted === 0)
529
+ break;
530
+ }
531
+ return totalEvicted;
532
+ }
533
+ /* ------------------------------------------------------------------------ */
534
+ /* Security version */
535
+ /* ------------------------------------------------------------------------ */
536
+ /** Sets the current security version for a user (invalidates older sessions). */
537
+ async setSecurityVersion(userId, version) {
538
+ if (!Number.isSafeInteger(version) || version < 0) {
539
+ throw new SessionConcurrencyError({ reason: 'invalid_security_version' });
540
+ }
541
+ await this.client.set(this.keys.securityVersionKey(userId), String(version));
542
+ }
543
+ /** Reads the current security version for a user, or null when unset. */
544
+ async getSecurityVersion(userId) {
545
+ const raw = await this.client.get(this.keys.securityVersionKey(userId));
546
+ if (raw === null)
547
+ return null;
548
+ const version = Number(raw);
549
+ return Number.isSafeInteger(version) && version >= 0 ? version : null;
550
+ }
551
+ /* ------------------------------------------------------------------------ */
552
+ /* Optional global JTI index (derived state, never authoritative) */
553
+ /* ------------------------------------------------------------------------ */
554
+ /** Best-effort write of the JTI lookup index (cross-slot, self-healing). */
555
+ async writeJtiIndex(jti, userId, ttl) {
556
+ if (!this.jtiIndexEnabled)
557
+ return true;
558
+ try {
559
+ await this.client.set(this.keys.jtiIndexKey(jti), userId, ttl);
560
+ return true;
561
+ }
562
+ catch {
563
+ // The index is derived state: a failed write degrades JTI-only lookup,
564
+ // never authentication. Callers should surface a metric.
565
+ return false;
566
+ }
567
+ }
568
+ /** Reads the JTI lookup index (only valid when the index is enabled). */
569
+ async readJtiIndex(jti) {
570
+ if (!this.jtiIndexEnabled)
571
+ return null;
572
+ return this.client.get(this.keys.jtiIndexKey(jti));
573
+ }
574
+ /** Deletes one JTI index entry (idempotent, cross-slot). */
575
+ async deleteJtiIndex(jti) {
576
+ if (!this.jtiIndexEnabled)
577
+ return;
578
+ await this.client.del(this.keys.jtiIndexKey(jti));
579
+ }
580
+ /**
581
+ * Deletes JTI index entries through bounded slot-grouped pipelines
582
+ * (cross-slot fan-out lives here, not in the session layer).
583
+ */
584
+ async deleteJtiIndexMany(jtis) {
585
+ if (!this.jtiIndexEnabled || jtis.length === 0)
586
+ return;
587
+ const commands = jtis.map((jti) => {
588
+ const key = this.keys.jtiIndexKey(jti);
589
+ return { command: 'del', args: [key], slot: this.client.calculateSlot(key) };
590
+ });
591
+ await mapWithConcurrency([...chunk(commands, this.maxBatchSize)], this.maxFanOut, async (batch) => {
592
+ const results = await executeBySlot(this.client, batch, {
593
+ concurrency: this.maxFanOut,
594
+ retry: 1,
595
+ });
596
+ // Command-level errors are swallowed here on purpose: the index is
597
+ // derived state and stale entries self-heal. Never fail auth flows.
598
+ void results;
599
+ });
600
+ }
601
+ /** Returns the user id behind a jti via the index, or null. */
602
+ async resolveUserIdByJti(jti) {
603
+ return this.readJtiIndex(jti);
604
+ }
605
+ /* ------------------------------------------------------------------------ */
606
+ /* Misc */
607
+ /* ------------------------------------------------------------------------ */
608
+ /** Server time in seconds (authoritative clock). */
609
+ async serverTime() {
610
+ return this.client.time();
611
+ }
612
+ /** Number of sessions currently in a user's index. */
613
+ async countByUser(userId) {
614
+ return this.client.zcard(this.keys.userIndexKey(userId));
615
+ }
616
+ /**
617
+ * Lists the jtis of a user's sessions (oldest first), bounded to `max`.
618
+ * Used by bulk operations (revokeAll, deleteByUser).
619
+ */
620
+ async listJtis(userId, max) {
621
+ if (max <= 0)
622
+ return [];
623
+ const indexKey = this.keys.userIndexKey(userId);
624
+ const cardinality = await this.client.zcard(indexKey);
625
+ if (cardinality === 0)
626
+ return [];
627
+ return this.client.zrange(indexKey, 0, Math.min(max, cardinality) - 1);
628
+ }
629
+ }
630
+ /* -------------------------------------------------------------------------- */
631
+ /* Helpers */
632
+ /* -------------------------------------------------------------------------- */
633
+ function mapTouchCode(code) {
634
+ switch (code) {
635
+ case 1:
636
+ return 'touched';
637
+ case 2:
638
+ return 'skipped_throttled';
639
+ case 3:
640
+ case 5:
641
+ return 'skipped_stale';
642
+ case 0:
643
+ return 'not_found';
644
+ case -1:
645
+ return 'consumed';
646
+ case -2:
647
+ return 'expired';
648
+ case -3:
649
+ return 'idle_expired';
650
+ case 4:
651
+ throw new SessionSerializationError({ reason: 'envelope_mode_mismatch' });
652
+ default:
653
+ throw new SessionStorageError('Touch script returned an unexpected result.', { code });
654
+ }
655
+ }
656
+ function parseRotateResult(result, successorJti) {
657
+ if (Array.isArray(result) && result.length >= 1) {
658
+ const code = Number(result[0]);
659
+ if (code === 1 || code === 2) {
660
+ const jti = result[1] !== undefined ? String(result[1]) : successorJti;
661
+ return { code, successorJti: jti };
662
+ }
663
+ const status = result[1] !== undefined ? String(result[1]) : undefined;
664
+ return status !== undefined ? { code, status } : { code };
665
+ }
666
+ return { code: Number(result) };
667
+ }
668
+ function throwOrNull(code) {
669
+ switch (code) {
670
+ case -1:
671
+ throw new SessionConcurrencyError({ reason: 'session_not_active' });
672
+ case -2:
673
+ throw new SessionNotFoundError({ reason: 'expired' });
674
+ case -3:
675
+ throw new SessionConcurrencyError({ reason: 'version_conflict' });
676
+ default:
677
+ break;
678
+ }
679
+ }
680
+ /** Validates and normalizes an externally provided record (defense in depth). */
681
+ export function normalizeRecord(value) {
682
+ return validateSessionRecord(value);
683
+ }