ioredis-toolkit 0.0.8 → 0.0.10

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.
@@ -7,7 +7,7 @@ import type { SessionKeyStrategy } from './session-keys.js';
7
7
  import { SessionMetrics } from './session-metrics.js';
8
8
  import { SessionRepository } from './session-repository.js';
9
9
  import type { SessionTokenManager } from './session-token.js';
10
- import type { CreatedSession, ListOptions, RotateOptions, RotatedSession, SessionCreateInput, SessionRecord, SessionUpdatePatch, SessionValidationResult, TouchOptions, TouchOutcome, UpdateOptions, ValidateOptions } from './session-types.js';
10
+ import type { CreatedSession, ListOptions, ReconcileUserResult, RotateOptions, RotatedSession, SessionCreateInput, SessionRecord, SessionUpdatePatch, SessionValidationResult, TouchOptions, TouchOutcome, UpdateOptions, ValidateOptions } from './session-types.js';
11
11
  export interface SessionServiceDeps {
12
12
  config: SessionConfig;
13
13
  client: RedisClientWrapper;
@@ -85,6 +85,25 @@ export declare class SessionService {
85
85
  */
86
86
  setSecurityVersion(userId: string, version?: number): Promise<number>;
87
87
  getSecurityVersion(userId: string): Promise<number | null>;
88
+ /**
89
+ * Bounded administrative repair pass for one user: prunes stale
90
+ * user-index entries and, when the global jti index is enabled, rewrites
91
+ * any missing/stale jti-index entry for that user's live active sessions.
92
+ *
93
+ * This is NOT required for authentication correctness - every read path
94
+ * (validate/touch/rotate) already treats the session record as
95
+ * authoritative and self-heals stale index entries lazily. This exists
96
+ * purely to shrink the window during which JTI-only lookup (`find(jti)`
97
+ * without a known userId) can miss a live session after a partial write
98
+ * (ยง67), and to give operators a way to proactively repair known drift
99
+ * (e.g. after a Redis incident) instead of waiting for it to be hit
100
+ * randomly. Safe to call repeatedly; every effect is idempotent.
101
+ *
102
+ * Bounded by config.limits.maxSessionsPerUserHardCap, same as
103
+ * revokeAll/deleteByUser - never scans the cluster and is not called
104
+ * from a hot auth path.
105
+ */
106
+ reconcileUser(userId: string): Promise<ReconcileUserResult>;
88
107
  /** Dependency health (PING latency + recent error rate). */
89
108
  health(): Promise<ReturnType<SessionHealthChecker['check']>>;
90
109
  /**
@@ -1,5 +1,5 @@
1
1
  import { mapWithConcurrency } from '../cluster.js';
2
- import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
2
+ import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionReplayError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
3
3
  import { SessionMetrics } from './session-metrics.js';
4
4
  import { assertHeaderMatches, deserializeSession } from './session-serializer.js';
5
5
  const IDEMPOTENCY_MIN_LENGTH = 8;
@@ -150,6 +150,10 @@ export class SessionService {
150
150
  rotatedTo: null,
151
151
  consumedAt: null,
152
152
  rotationNonceHash: null,
153
+ // First generation of a lineage: familyId equals its own jti (the
154
+ // convention rotate.lua's self-heal also falls back to for legacy
155
+ // records missing the field - see session-types.ts).
156
+ familyId: jti,
153
157
  };
154
158
  const ttl = Math.max(1, record.absoluteExpiresAt - now);
155
159
  const result = await this.repository.create(record, ttl);
@@ -315,6 +319,14 @@ export class SessionService {
315
319
  rotatedTo: null,
316
320
  consumedAt: null,
317
321
  rotationNonceHash: null,
322
+ // Placeholder only: familyId is an identity field decided
323
+ // authoritatively from the OLD session, not the app. The plain-path
324
+ // script (rotate.lua) always overwrites this before writing; the
325
+ // encrypted path resolves the real value from the just-decrypted
326
+ // predecessor in SessionRepository.rotate() (Lua can't rewrite
327
+ // ciphertext, so that's the only place it can be fixed up). Any
328
+ // syntactically valid jti-shaped string is fine here.
329
+ familyId: oldJti,
318
330
  };
319
331
  const result = await this.repository.rotate({
320
332
  userId,
@@ -325,6 +337,7 @@ export class SessionService {
325
337
  : {}),
326
338
  ...(rotationNonceHash !== undefined ? { rotationNonceHash } : {}),
327
339
  retainTombstone: this.config.retainConsumedTombstones,
340
+ revokeFamilyOnReplay: this.config.revokeFamilyOnReplay,
328
341
  });
329
342
  if (result.code === 1 || result.code === 2) {
330
343
  const replayed = result.code === 2;
@@ -346,6 +359,23 @@ export class SessionService {
346
359
  // and re-authenticate rather than reusing the old token.
347
360
  return replayed ? { session, replayed } : { token: successorToken, session, replayed };
348
361
  }
362
+ if (result.code === -6) {
363
+ // Genuine reuse of an already-rotated-away token: the entire
364
+ // lineage's currently active generation was atomically revoked
365
+ // (this is a strong security signal, never an infra/storage
366
+ // failure - see SessionReplayError, not SessionStorageError, so it
367
+ // never trips the circuit breaker per guard()'s classification).
368
+ // The old jti-index entry (if any) no longer points anywhere
369
+ // useful; best-effort clean it up.
370
+ await this.repository.deleteJtiIndex(oldJti);
371
+ throw new SessionReplayError({
372
+ reason: 'family_revoked',
373
+ ...(result.familyId !== undefined ? { familyId: result.familyId } : {}),
374
+ ...(result.headJtiRevoked !== undefined
375
+ ? { headJtiRevoked: result.headJtiRevoked }
376
+ : {}),
377
+ });
378
+ }
349
379
  throw rotationError(result.code, result.status);
350
380
  });
351
381
  }
@@ -477,6 +507,35 @@ export class SessionService {
477
507
  });
478
508
  }
479
509
  /* ------------------------------------------------------------------------ */
510
+ /* Reconciliation (ยง25 / ยง67 / ยง68) */
511
+ /* ------------------------------------------------------------------------ */
512
+ /**
513
+ * Bounded administrative repair pass for one user: prunes stale
514
+ * user-index entries and, when the global jti index is enabled, rewrites
515
+ * any missing/stale jti-index entry for that user's live active sessions.
516
+ *
517
+ * This is NOT required for authentication correctness - every read path
518
+ * (validate/touch/rotate) already treats the session record as
519
+ * authoritative and self-heals stale index entries lazily. This exists
520
+ * purely to shrink the window during which JTI-only lookup (`find(jti)`
521
+ * without a known userId) can miss a live session after a partial write
522
+ * (ยง67), and to give operators a way to proactively repair known drift
523
+ * (e.g. after a Redis incident) instead of waiting for it to be hit
524
+ * randomly. Safe to call repeatedly; every effect is idempotent.
525
+ *
526
+ * Bounded by config.limits.maxSessionsPerUserHardCap, same as
527
+ * revokeAll/deleteByUser - never scans the cluster and is not called
528
+ * from a hot auth path.
529
+ */
530
+ reconcileUser(userId) {
531
+ return this.guard('reconcile_user', async () => {
532
+ validateUserId(userId);
533
+ const result = await this.repository.reconcileUser(userId, this.config.limits.maxSessionsPerUserHardCap, this.now());
534
+ this.metrics.reconcileUser(result.jtiIndexRepaired, result.staleIndexRemoved);
535
+ return { userId, ...result };
536
+ });
537
+ }
538
+ /* ------------------------------------------------------------------------ */
480
539
  /* Health */
481
540
  /* ------------------------------------------------------------------------ */
482
541
  /** Dependency health (PING latency + recent error rate). */
@@ -42,6 +42,15 @@ export type SessionRecord = {
42
42
  metadata: Record<string, unknown> | null;
43
43
  /** JTI this session was rotated from (rotation chains, reuse detection). */
44
44
  rotatedFrom: string | null;
45
+ /**
46
+ * Stable identifier for this session's rotation lineage ("family"),
47
+ * unchanged across every generation produced by rotate(). Equal to the
48
+ * first generation's own jti (unguessable, 256-bit) - no separate
49
+ * randomness is generated for it. Used only to detect and respond to
50
+ * stolen-refresh-token reuse (see rotate.lua); it is never consulted by
51
+ * validate() and can never by itself grant authentication (I7).
52
+ */
53
+ familyId: string;
45
54
  /** JTI this session was rotated to (enables retry-safe rotation). */
46
55
  rotatedTo: string | null;
47
56
  /** When the session was consumed by a rotation (Unix seconds), or null. */
@@ -189,6 +198,20 @@ export type BindingMismatch = {
189
198
  userAgent: boolean;
190
199
  deviceId: boolean;
191
200
  };
201
+ /**
202
+ * Result of a bounded {@link SessionService.reconcileUser} repair pass.
203
+ * `checked` is the number of live session records inspected (bounded by
204
+ * maxSessionsPerUserHardCap); `staleIndexRemoved` counts user-index entries
205
+ * removed because their session record no longer exists or was corrupt;
206
+ * `jtiIndexRepaired` counts global jti-index entries that were missing or
207
+ * stale for a live active session and were rewritten.
208
+ */
209
+ export type ReconcileUserResult = {
210
+ userId: string;
211
+ checked: number;
212
+ staleIndexRemoved: number;
213
+ jtiIndexRepaired: number;
214
+ };
192
215
  /** Schema version of the persisted envelope. */
193
216
  export type SerializedSchemaVersion = 1 | 2;
194
217
  /**
@@ -233,6 +256,11 @@ export type EncryptedSessionEnvelope = {
233
256
  rn: string | null;
234
257
  /** Plaintext rotated-to JTI mirror (script access). */
235
258
  rj: string | null;
259
+ /**
260
+ * Plaintext rotation-family-id mirror (script access - see rotate.lua).
261
+ * Immutable identity field, unchanged across rotations of one lineage.
262
+ */
263
+ fam: string;
236
264
  };
237
265
  export type SessionEnvelope = PlainSessionEnvelope | EncryptedSessionEnvelope;
238
266
  export type RevocationRecord = {
package/dist/types.d.ts CHANGED
@@ -50,6 +50,7 @@ export type CacheOptions = {
50
50
  export declare const CacheOptionsSchema: z.ZodObject<{
51
51
  defaultTTL: z.ZodDefault<z.ZodNumber>;
52
52
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
53
+ namespace: z.ZodDefault<z.ZodString>;
53
54
  }, z.core.$strip>;
54
55
  export type CacheInputConfig = z.input<typeof CacheOptionsSchema>;
55
56
  export type CacheStats = {
@@ -135,6 +136,7 @@ export declare const BaseRedisConfigSchema: z.ZodObject<{
135
136
  cacheOptions: z.ZodOptional<z.ZodObject<{
136
137
  defaultTTL: z.ZodDefault<z.ZodNumber>;
137
138
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
139
+ namespace: z.ZodDefault<z.ZodString>;
138
140
  }, z.core.$strip>>;
139
141
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
140
142
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -179,6 +181,7 @@ export declare const StandaloneRedisConfigSchema: z.ZodObject<{
179
181
  cacheOptions: z.ZodOptional<z.ZodObject<{
180
182
  defaultTTL: z.ZodDefault<z.ZodNumber>;
181
183
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
184
+ namespace: z.ZodDefault<z.ZodString>;
182
185
  }, z.core.$strip>>;
183
186
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
184
187
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -224,6 +227,7 @@ export declare const SentinelRedisConfigSchema: z.ZodObject<{
224
227
  cacheOptions: z.ZodOptional<z.ZodObject<{
225
228
  defaultTTL: z.ZodDefault<z.ZodNumber>;
226
229
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
230
+ namespace: z.ZodDefault<z.ZodString>;
227
231
  }, z.core.$strip>>;
228
232
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
229
233
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -271,6 +275,7 @@ export declare const ClusterRedisConfigSchema: z.ZodObject<{
271
275
  cacheOptions: z.ZodOptional<z.ZodObject<{
272
276
  defaultTTL: z.ZodDefault<z.ZodNumber>;
273
277
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
278
+ namespace: z.ZodDefault<z.ZodString>;
274
279
  }, z.core.$strip>>;
275
280
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
276
281
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -322,6 +327,7 @@ export declare const RedisConfigInputSchema: z.ZodUnion<readonly [z.ZodObject<{
322
327
  cacheOptions: z.ZodOptional<z.ZodObject<{
323
328
  defaultTTL: z.ZodDefault<z.ZodNumber>;
324
329
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
330
+ namespace: z.ZodDefault<z.ZodString>;
325
331
  }, z.core.$strip>>;
326
332
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
327
333
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -362,6 +368,7 @@ export declare const RedisConfigInputSchema: z.ZodUnion<readonly [z.ZodObject<{
362
368
  cacheOptions: z.ZodOptional<z.ZodObject<{
363
369
  defaultTTL: z.ZodDefault<z.ZodNumber>;
364
370
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
371
+ namespace: z.ZodDefault<z.ZodString>;
365
372
  }, z.core.$strip>>;
366
373
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
367
374
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -403,6 +410,7 @@ export declare const RedisConfigInputSchema: z.ZodUnion<readonly [z.ZodObject<{
403
410
  cacheOptions: z.ZodOptional<z.ZodObject<{
404
411
  defaultTTL: z.ZodDefault<z.ZodNumber>;
405
412
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
413
+ namespace: z.ZodDefault<z.ZodString>;
406
414
  }, z.core.$strip>>;
407
415
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
408
416
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -462,6 +470,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
462
470
  cacheOptions: z.ZodOptional<z.ZodObject<{
463
471
  defaultTTL: z.ZodDefault<z.ZodNumber>;
464
472
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
473
+ namespace: z.ZodDefault<z.ZodString>;
465
474
  }, z.core.$strip>>;
466
475
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
467
476
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -502,6 +511,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
502
511
  cacheOptions: z.ZodOptional<z.ZodObject<{
503
512
  defaultTTL: z.ZodDefault<z.ZodNumber>;
504
513
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
514
+ namespace: z.ZodDefault<z.ZodString>;
505
515
  }, z.core.$strip>>;
506
516
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
507
517
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -543,6 +553,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
543
553
  cacheOptions: z.ZodOptional<z.ZodObject<{
544
554
  defaultTTL: z.ZodDefault<z.ZodNumber>;
545
555
  compressionThreshold: z.ZodDefault<z.ZodNumber>;
556
+ namespace: z.ZodDefault<z.ZodString>;
546
557
  }, z.core.$strip>>;
547
558
  slowCommandThreshold: z.ZodDefault<z.ZodNumber>;
548
559
  rateLimit: z.ZodOptional<z.ZodObject<{
@@ -592,6 +603,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
592
603
  cacheOptions?: {
593
604
  defaultTTL: number;
594
605
  compressionThreshold: number;
606
+ namespace: string;
595
607
  } | undefined;
596
608
  rateLimit?: {
597
609
  limit: number;
@@ -630,6 +642,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
630
642
  cacheOptions?: {
631
643
  defaultTTL: number;
632
644
  compressionThreshold: number;
645
+ namespace: string;
633
646
  } | undefined;
634
647
  rateLimit?: {
635
648
  limit: number;
@@ -666,6 +679,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
666
679
  cacheOptions?: {
667
680
  defaultTTL: number;
668
681
  compressionThreshold: number;
682
+ namespace: string;
669
683
  } | undefined;
670
684
  rateLimit?: {
671
685
  limit: number;
@@ -702,6 +716,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
702
716
  cacheOptions?: {
703
717
  defaultTTL: number;
704
718
  compressionThreshold: number;
719
+ namespace: string;
705
720
  } | undefined;
706
721
  rateLimit?: {
707
722
  limit: number;
@@ -743,6 +758,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
743
758
  cacheOptions?: {
744
759
  defaultTTL: number;
745
760
  compressionThreshold: number;
761
+ namespace: string;
746
762
  } | undefined;
747
763
  rateLimit?: {
748
764
  limit: number;
@@ -781,6 +797,7 @@ export declare const RedisConfigSchema: z.ZodPipe<z.ZodUnion<readonly [z.ZodObje
781
797
  cacheOptions?: {
782
798
  defaultTTL: number;
783
799
  compressionThreshold: number;
800
+ namespace: string;
784
801
  } | undefined;
785
802
  rateLimit?: {
786
803
  limit: number;
package/dist/types.js CHANGED
@@ -7,6 +7,7 @@ export const DistributedLockOptionsSchema = z.object({
7
7
  export const CacheOptionsSchema = z.object({
8
8
  defaultTTL: z.number().int().min(0).default(3_600),
9
9
  compressionThreshold: z.number().int().min(1).default(1_024),
10
+ namespace: z.string().default('')
10
11
  });
11
12
  // ============================================================================
12
13
  // Rate Limiting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ioredis-toolkit",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Production-grade, type-safe Redis infrastructure for standalone, Sentinel, and Cluster deployments",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",