ioredis-toolkit 0.0.9 → 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.js CHANGED
@@ -7,7 +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().min(1).default("cache"),
10
+ namespace: z.string().default('')
11
11
  });
12
12
  // ============================================================================
13
13
  // Rate Limiting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ioredis-toolkit",
3
- "version": "0.0.9",
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",