ioredis-toolkit 0.0.10 → 0.5.0

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 (267) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +1 -1
  3. package/README.md +68 -1613
  4. package/dist/cache/cache.d.ts +30 -0
  5. package/dist/cache/cache.d.ts.map +1 -0
  6. package/dist/cache/cache.js +59 -0
  7. package/dist/cache/cache.js.map +1 -0
  8. package/dist/cache/config.d.ts +12 -0
  9. package/dist/cache/config.d.ts.map +1 -0
  10. package/dist/cache/config.js +13 -0
  11. package/dist/cache/config.js.map +1 -0
  12. package/dist/cache/types.d.ts +32 -0
  13. package/dist/cache/types.d.ts.map +1 -0
  14. package/dist/cache/types.js +5 -0
  15. package/dist/cache/types.js.map +1 -0
  16. package/dist/index.d.ts +43 -51
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +32 -44
  19. package/dist/index.js.map +1 -0
  20. package/dist/lock/config.d.ts +12 -0
  21. package/dist/lock/config.d.ts.map +1 -0
  22. package/dist/lock/config.js +8 -0
  23. package/dist/lock/config.js.map +1 -0
  24. package/dist/lock/lock.d.ts +20 -0
  25. package/dist/lock/lock.d.ts.map +1 -0
  26. package/dist/lock/lock.js +44 -0
  27. package/dist/lock/lock.js.map +1 -0
  28. package/dist/lock/types.d.ts +19 -0
  29. package/dist/lock/types.d.ts.map +1 -0
  30. package/dist/lock/types.js +2 -0
  31. package/dist/lock/types.js.map +1 -0
  32. package/dist/modules-config.d.ts +3 -0
  33. package/dist/modules-config.d.ts.map +1 -0
  34. package/dist/modules-config.js +2 -0
  35. package/dist/modules-config.js.map +1 -0
  36. package/dist/pubsub/config.d.ts +11 -0
  37. package/dist/pubsub/config.d.ts.map +1 -0
  38. package/dist/pubsub/config.js +6 -0
  39. package/dist/pubsub/config.js.map +1 -0
  40. package/dist/pubsub/pubsub.d.ts +20 -0
  41. package/dist/pubsub/pubsub.d.ts.map +1 -0
  42. package/dist/pubsub/pubsub.js +54 -0
  43. package/dist/pubsub/pubsub.js.map +1 -0
  44. package/dist/pubsub/types.d.ts +24 -0
  45. package/dist/pubsub/types.d.ts.map +1 -0
  46. package/dist/pubsub/types.js +2 -0
  47. package/dist/pubsub/types.js.map +1 -0
  48. package/dist/rate-limit/config.d.ts +12 -0
  49. package/dist/rate-limit/config.d.ts.map +1 -0
  50. package/dist/rate-limit/config.js +6 -0
  51. package/dist/rate-limit/config.js.map +1 -0
  52. package/dist/rate-limit/rate-limiter.d.ts +18 -0
  53. package/dist/rate-limit/rate-limiter.d.ts.map +1 -0
  54. package/dist/rate-limit/rate-limiter.js +37 -0
  55. package/dist/rate-limit/rate-limiter.js.map +1 -0
  56. package/dist/rate-limit/types.d.ts +27 -0
  57. package/dist/rate-limit/types.d.ts.map +1 -0
  58. package/dist/rate-limit/types.js +2 -0
  59. package/dist/rate-limit/types.js.map +1 -0
  60. package/dist/redis/client-facade.d.ts +77 -0
  61. package/dist/redis/client-facade.d.ts.map +1 -0
  62. package/dist/redis/client-facade.js +102 -0
  63. package/dist/redis/client-facade.js.map +1 -0
  64. package/dist/redis/client.d.ts +10 -0
  65. package/dist/redis/client.d.ts.map +1 -0
  66. package/dist/redis/client.js +29 -0
  67. package/dist/redis/client.js.map +1 -0
  68. package/dist/redis/cluster.d.ts +7 -0
  69. package/dist/redis/cluster.d.ts.map +1 -0
  70. package/dist/redis/cluster.js +47 -0
  71. package/dist/redis/cluster.js.map +1 -0
  72. package/dist/redis/config.d.ts +39 -0
  73. package/dist/redis/config.d.ts.map +1 -0
  74. package/dist/redis/config.js +52 -0
  75. package/dist/redis/config.js.map +1 -0
  76. package/dist/redis/errors.d.ts +5 -0
  77. package/dist/redis/errors.d.ts.map +1 -0
  78. package/dist/redis/errors.js +5 -0
  79. package/dist/redis/errors.js.map +1 -0
  80. package/dist/redis/types.d.ts +135 -0
  81. package/dist/redis/types.d.ts.map +1 -0
  82. package/dist/redis/types.js +2 -0
  83. package/dist/redis/types.js.map +1 -0
  84. package/dist/redis/wrapper.d.ts +88 -0
  85. package/dist/redis/wrapper.d.ts.map +1 -0
  86. package/dist/redis/wrapper.js +206 -0
  87. package/dist/redis/wrapper.js.map +1 -0
  88. package/dist/session/config.d.ts +47 -0
  89. package/dist/session/config.d.ts.map +1 -0
  90. package/dist/session/config.js +101 -0
  91. package/dist/session/config.js.map +1 -0
  92. package/dist/session/cookie.d.ts +16 -0
  93. package/dist/session/cookie.d.ts.map +1 -0
  94. package/dist/session/cookie.js +28 -0
  95. package/dist/session/cookie.js.map +1 -0
  96. package/dist/session/errors.d.ts +56 -0
  97. package/dist/session/errors.d.ts.map +1 -0
  98. package/dist/session/errors.js +58 -0
  99. package/dist/session/errors.js.map +1 -0
  100. package/dist/session/factory.d.ts +21 -0
  101. package/dist/session/factory.d.ts.map +1 -0
  102. package/dist/session/factory.js +30 -0
  103. package/dist/session/factory.js.map +1 -0
  104. package/dist/session/health.d.ts +12 -0
  105. package/dist/session/health.d.ts.map +1 -0
  106. package/dist/session/health.js +23 -0
  107. package/dist/session/health.js.map +1 -0
  108. package/dist/session/keys.d.ts +23 -0
  109. package/dist/session/keys.d.ts.map +1 -0
  110. package/dist/session/keys.js +27 -0
  111. package/dist/session/keys.js.map +1 -0
  112. package/dist/session/manager.d.ts +34 -0
  113. package/dist/session/manager.d.ts.map +1 -0
  114. package/dist/session/manager.js +31 -0
  115. package/dist/session/manager.js.map +1 -0
  116. package/dist/session/metrics.d.ts +11 -0
  117. package/dist/session/metrics.d.ts.map +1 -0
  118. package/dist/session/metrics.js +10 -0
  119. package/dist/session/metrics.js.map +1 -0
  120. package/dist/session/repository.d.ts +49 -0
  121. package/dist/session/repository.d.ts.map +1 -0
  122. package/dist/session/repository.js +203 -0
  123. package/dist/session/repository.js.map +1 -0
  124. package/dist/session/revocation.d.ts +22 -0
  125. package/dist/session/revocation.d.ts.map +1 -0
  126. package/dist/session/revocation.js +41 -0
  127. package/dist/session/revocation.js.map +1 -0
  128. package/dist/session/script-sources.d.ts +11 -0
  129. package/dist/session/script-sources.d.ts.map +1 -0
  130. package/dist/session/script-sources.js +140 -0
  131. package/dist/session/script-sources.js.map +1 -0
  132. package/dist/session/scripts.d.ts +15 -0
  133. package/dist/session/scripts.d.ts.map +1 -0
  134. package/dist/session/scripts.js +41 -0
  135. package/dist/session/scripts.js.map +1 -0
  136. package/dist/session/serializer.d.ts +12 -0
  137. package/dist/session/serializer.d.ts.map +1 -0
  138. package/dist/session/serializer.js +77 -0
  139. package/dist/session/serializer.js.map +1 -0
  140. package/dist/session/service.d.ts +48 -0
  141. package/dist/session/service.d.ts.map +1 -0
  142. package/dist/session/service.js +235 -0
  143. package/dist/session/service.js.map +1 -0
  144. package/dist/session/token.d.ts +16 -0
  145. package/dist/session/token.d.ts.map +1 -0
  146. package/dist/session/token.js +32 -0
  147. package/dist/session/token.js.map +1 -0
  148. package/dist/session/types.d.ts +134 -0
  149. package/dist/session/types.d.ts.map +1 -0
  150. package/dist/session/types.js +2 -0
  151. package/dist/session/types.js.map +1 -0
  152. package/dist/streams/config.d.ts +12 -0
  153. package/dist/streams/config.d.ts.map +1 -0
  154. package/dist/streams/config.js +6 -0
  155. package/dist/streams/config.js.map +1 -0
  156. package/dist/streams/streams.d.ts +24 -0
  157. package/dist/streams/streams.d.ts.map +1 -0
  158. package/dist/streams/streams.js +55 -0
  159. package/dist/streams/streams.js.map +1 -0
  160. package/dist/streams/types.d.ts +32 -0
  161. package/dist/streams/types.d.ts.map +1 -0
  162. package/dist/streams/types.js +2 -0
  163. package/dist/streams/types.js.map +1 -0
  164. package/docs/ACCEPTANCE-REPORT.md +70 -0
  165. package/docs/ARCHITECTURE.md +61 -0
  166. package/docs/CAPACITY.md +33 -0
  167. package/docs/DEPLOYMENT.md +22 -0
  168. package/docs/README-API.md +15 -0
  169. package/docs/STATE-MACHINE.md +38 -0
  170. package/docs/TESTING.md +37 -0
  171. package/docs/THREAT-MODEL.md +23 -0
  172. package/docs/TYPE-SAFETY.md +34 -0
  173. package/docs/modules/cache/README.md +7 -0
  174. package/docs/modules/cache/usage.md +156 -0
  175. package/docs/modules/lock/README.md +7 -0
  176. package/docs/modules/lock/usage.md +105 -0
  177. package/docs/modules/pubsub/README.md +7 -0
  178. package/docs/modules/pubsub/usage.md +106 -0
  179. package/docs/modules/rate-limit/README.md +7 -0
  180. package/docs/modules/rate-limit/usage.md +100 -0
  181. package/docs/modules/sessions/README.md +7 -0
  182. package/docs/modules/sessions/usage.md +262 -0
  183. package/docs/modules/streams/README.md +7 -0
  184. package/docs/modules/streams/usage.md +141 -0
  185. package/package.json +50 -60
  186. package/src/scripts/cleanup-index.lua +4 -0
  187. package/src/scripts/conditional-update.lua +21 -0
  188. package/src/scripts/consume-session.lua +21 -0
  189. package/src/scripts/create-session.lua +28 -0
  190. package/src/scripts/delete.lua +2 -0
  191. package/src/scripts/destroy-user.lua +13 -0
  192. package/src/scripts/enforce-limit.lua +17 -0
  193. package/src/scripts/revoke-session.lua +13 -0
  194. package/src/scripts/rotate.lua +24 -0
  195. package/src/scripts/touch-session.lua +28 -0
  196. package/src/scripts/update-session.lua +18 -0
  197. package/dist/cache.d.ts +0 -797
  198. package/dist/cache.js +0 -1115
  199. package/dist/client.d.ts +0 -287
  200. package/dist/client.js +0 -1113
  201. package/dist/cluster-slot.d.ts +0 -4
  202. package/dist/cluster-slot.js +0 -31
  203. package/dist/cluster.d.ts +0 -79
  204. package/dist/cluster.js +0 -156
  205. package/dist/errors.d.ts +0 -30
  206. package/dist/errors.js +0 -63
  207. package/dist/health.d.ts +0 -180
  208. package/dist/health.js +0 -239
  209. package/dist/lock.d.ts +0 -233
  210. package/dist/lock.js +0 -440
  211. package/dist/logger.d.ts +0 -12
  212. package/dist/logger.js +0 -40
  213. package/dist/pubsub.d.ts +0 -423
  214. package/dist/pubsub.js +0 -537
  215. package/dist/ratelimiter.d.ts +0 -441
  216. package/dist/ratelimiter.js +0 -539
  217. package/dist/session/index.d.ts +0 -23
  218. package/dist/session/index.js +0 -16
  219. package/dist/session/revocation-store.d.ts +0 -176
  220. package/dist/session/revocation-store.js +0 -318
  221. package/dist/session/scripts/cleanup-index.lua +0 -21
  222. package/dist/session/scripts/conditional-update-encrypted.lua +0 -60
  223. package/dist/session/scripts/conditional-update.lua +0 -63
  224. package/dist/session/scripts/create.lua +0 -83
  225. package/dist/session/scripts/delete-by-user.lua +0 -29
  226. package/dist/session/scripts/delete.lua +0 -15
  227. package/dist/session/scripts/enforce-limit.lua +0 -38
  228. package/dist/session/scripts/revoke.lua +0 -61
  229. package/dist/session/scripts/rotate-encrypted.lua +0 -149
  230. package/dist/session/scripts/rotate.lua +0 -167
  231. package/dist/session/scripts/touch-encrypted.lua +0 -89
  232. package/dist/session/scripts/touch.lua +0 -72
  233. package/dist/session/scripts/validate.lua +0 -90
  234. package/dist/session/session-circuit-breaker.d.ts +0 -42
  235. package/dist/session/session-circuit-breaker.js +0 -129
  236. package/dist/session/session-config.d.ts +0 -355
  237. package/dist/session/session-config.js +0 -171
  238. package/dist/session/session-cookie.d.ts +0 -72
  239. package/dist/session/session-cookie.js +0 -101
  240. package/dist/session/session-encryption.d.ts +0 -87
  241. package/dist/session/session-encryption.js +0 -139
  242. package/dist/session/session-errors.d.ts +0 -85
  243. package/dist/session/session-errors.js +0 -145
  244. package/dist/session/session-health.d.ts +0 -38
  245. package/dist/session/session-health.js +0 -60
  246. package/dist/session/session-keys.d.ts +0 -64
  247. package/dist/session/session-keys.js +0 -128
  248. package/dist/session/session-manager.d.ts +0 -73
  249. package/dist/session/session-manager.js +0 -94
  250. package/dist/session/session-metrics.d.ts +0 -41
  251. package/dist/session/session-metrics.js +0 -135
  252. package/dist/session/session-repository.d.ts +0 -184
  253. package/dist/session/session-repository.js +0 -763
  254. package/dist/session/session-scripts.d.ts +0 -36
  255. package/dist/session/session-scripts.js +0 -130
  256. package/dist/session/session-serializer.d.ts +0 -42
  257. package/dist/session/session-serializer.js +0 -267
  258. package/dist/session/session-service.d.ts +0 -123
  259. package/dist/session/session-service.js +0 -670
  260. package/dist/session/session-token.d.ts +0 -38
  261. package/dist/session/session-token.js +0 -86
  262. package/dist/session/session-types.d.ts +0 -281
  263. package/dist/session/session-types.js +0 -16
  264. package/dist/types.d.ts +0 -924
  265. package/dist/types.js +0 -151
  266. package/dist/utils/deepmerge.d.ts +0 -9
  267. package/dist/utils/deepmerge.js +0 -61
@@ -1,670 +0,0 @@
1
- import { mapWithConcurrency } from '../cluster.js';
2
- import { SessionConfigurationError, SessionConcurrencyError, SessionError, SessionExpiredError, SessionInvalidError, SessionNotFoundError, SessionReplayError, SessionRevokedError, SessionRotationError, SessionSerializationError, SessionStorageError, } from './session-errors.js';
3
- import { SessionMetrics } from './session-metrics.js';
4
- import { assertHeaderMatches, deserializeSession } from './session-serializer.js';
5
- const IDEMPOTENCY_MIN_LENGTH = 8;
6
- const IDEMPOTENCY_MAX_LENGTH = 256;
7
- const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
8
- const MAX_THROTTLE_ENTRIES = 10_000;
9
- export class SessionService {
10
- deps;
11
- throttle = new Map();
12
- constructor(deps) {
13
- this.deps = deps;
14
- }
15
- get config() {
16
- return this.deps.config;
17
- }
18
- get repository() {
19
- return this.deps.repository;
20
- }
21
- get metrics() {
22
- return this.deps.metrics ?? new SessionMetrics();
23
- }
24
- get breaker() {
25
- return this.deps.circuitBreaker ?? null;
26
- }
27
- get healthChecker() {
28
- return this.deps.health ?? null;
29
- }
30
- now() {
31
- return this.deps.now ? this.deps.now() : Math.floor(Date.now() / 1000);
32
- }
33
- /* ------------------------------------------------------------------------ */
34
- /* Guard: metrics + circuit breaker + error normalization per operation. */
35
- /* ------------------------------------------------------------------------ */
36
- async guard(op, fn, classify) {
37
- const breaker = this.breaker;
38
- const started = performance.now();
39
- const outcome = () => this.metrics.latency(op, Math.round(performance.now() - started));
40
- const run = async () => {
41
- try {
42
- const result = await fn();
43
- outcome();
44
- this.metrics.operation(op, classify ? classify(result) : 'ok');
45
- this.healthChecker?.recordOp(true);
46
- return result;
47
- }
48
- catch (error) {
49
- outcome();
50
- if (error instanceof SessionError) {
51
- this.metrics.operation(op, 'error');
52
- this.healthChecker?.recordOp(false);
53
- throw error;
54
- }
55
- // Unknown errors are infrastructure failures: fail closed, typed.
56
- this.healthChecker?.recordOp(false);
57
- this.metrics.operation(op, 'error', 'storage');
58
- throw new SessionStorageError(undefined, { operation: op, cause: String(error) });
59
- }
60
- };
61
- if (!breaker)
62
- return run();
63
- if (!breaker.tryAcquire()) {
64
- outcome();
65
- this.metrics.operation(op, 'error', 'circuit_open');
66
- this.healthChecker?.recordOp(false);
67
- throw new SessionStorageError(undefined, { operation: op, reason: 'circuit_open' });
68
- }
69
- try {
70
- const result = await run();
71
- breaker.recordSuccess();
72
- return result;
73
- }
74
- catch (error) {
75
- // Only infrastructure (storage) failures trip the breaker: business
76
- // errors (not_found, invalid, revoked, concurrency, ...) are expected
77
- // outcomes of bad input and must never open the circuit.
78
- if (error instanceof SessionStorageError) {
79
- breaker.recordFailure();
80
- }
81
- throw error;
82
- }
83
- }
84
- /* ------------------------------------------------------------------------ */
85
- /* Create */
86
- /* ------------------------------------------------------------------------ */
87
- /**
88
- * Creates a session and returns the raw token exactly once.
89
- *
90
- * Idempotent creation: when `input.idempotencyKey` is provided (and
91
- * config.enableCreateIdempotency is on), the idempotencyKey IS the token.
92
- * A retry with the same key returns the existing session with
93
- * `replayed: true` instead of creating a duplicate.
94
- */
95
- create(input) {
96
- return this.guard('create', async () => {
97
- validateUserId(input.userId);
98
- let token;
99
- let jti;
100
- if (input.idempotencyKey !== undefined) {
101
- if (!this.config.enableCreateIdempotency) {
102
- throw new SessionConfigurationError('idempotencyKey requires enableCreateIdempotency.');
103
- }
104
- validateIdempotencyToken(input.idempotencyKey);
105
- token = input.idempotencyKey;
106
- jti = this.deps.token.hash(token);
107
- }
108
- else {
109
- token = this.deps.token.generate();
110
- jti = this.deps.token.hash(token);
111
- }
112
- if (input.metadata !== undefined) {
113
- let serialized;
114
- try {
115
- serialized = JSON.stringify(input.metadata);
116
- }
117
- catch {
118
- throw new SessionInvalidError({ reason: 'metadata_cyclic' });
119
- }
120
- const size = Buffer.byteLength(serialized);
121
- if (size > this.config.limits.maxMetadataSize) {
122
- throw new SessionInvalidError({
123
- reason: 'metadata_too_large',
124
- size,
125
- max: this.config.limits.maxMetadataSize,
126
- });
127
- }
128
- }
129
- const now = this.now();
130
- const securityVersion = this.config.securityVersion.enabled
131
- ? await this.repository.getSecurityVersion(input.userId)
132
- : null;
133
- const record = {
134
- jti,
135
- userId: input.userId,
136
- createdAt: now,
137
- lastAccessedAt: now,
138
- absoluteExpiresAt: now + this.config.ttl,
139
- idleExpiresAt: this.config.idleTimeout !== null
140
- ? Math.min(now + this.config.idleTimeout, now + this.config.ttl)
141
- : null,
142
- status: 'active',
143
- version: 1,
144
- securityVersion,
145
- deviceId: this.config.storeDeviceId ? (input.deviceId ?? null) : null,
146
- ipAddress: this.config.storeIpAddress ? (input.ipAddress ?? null) : null,
147
- userAgent: this.config.storeUserAgent ? (input.userAgent ?? null) : null,
148
- metadata: input.metadata ?? null,
149
- rotatedFrom: null,
150
- rotatedTo: null,
151
- consumedAt: null,
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,
157
- };
158
- const ttl = Math.max(1, record.absoluteExpiresAt - now);
159
- const result = await this.repository.create(record, ttl);
160
- if (result.status === 'replayed') {
161
- const existing = await this.repository.get(input.userId, result.jti);
162
- if (!existing) {
163
- // Claim exists but the record vanished (TTL race); create afresh.
164
- const retry = { userId: input.userId };
165
- if (input.deviceId !== undefined)
166
- retry.deviceId = input.deviceId;
167
- if (input.ipAddress !== undefined)
168
- retry.ipAddress = input.ipAddress;
169
- if (input.userAgent !== undefined)
170
- retry.userAgent = input.userAgent;
171
- if (input.metadata !== undefined)
172
- retry.metadata = input.metadata;
173
- return this.create(retry);
174
- }
175
- return { token, session: existing, replayed: true };
176
- }
177
- const indexed = await this.repository.writeJtiIndex(jti, input.userId, ttl);
178
- if (!indexed)
179
- this.metrics.jtiIndexWriteFailure();
180
- return { token, session: record };
181
- });
182
- }
183
- /* ------------------------------------------------------------------------ */
184
- /* Validate */
185
- /* ------------------------------------------------------------------------ */
186
- /**
187
- * Validates a session token. Single Redis round trip when userId is known.
188
- * Never throws for invalid sessions; throws only for infrastructure
189
- * failures (fail closed) and configuration errors.
190
- */
191
- validate(token, options = {}) {
192
- return this.guard('validate', async () => {
193
- if (!this.isAcceptableToken(token)) {
194
- return { valid: false, reason: 'invalid' };
195
- }
196
- const jti = this.deps.token.hash(token);
197
- const userId = await this.resolveUserId(jti, options.userId);
198
- if (userId === null) {
199
- return { valid: false, reason: 'not_found' };
200
- }
201
- const result = await this.repository.validateRead(userId, jti);
202
- if (!result.found) {
203
- return { valid: false, reason: 'not_found' };
204
- }
205
- if ('code' in result) {
206
- if (result.code === -1) {
207
- return { valid: false, reason: result.status === 'revoked' ? 'revoked' : 'invalid' };
208
- }
209
- if (result.code === -2)
210
- return { valid: false, reason: 'expired' };
211
- if (result.code === -3)
212
- return { valid: false, reason: 'idle_timeout' };
213
- if (result.code === -4)
214
- return { valid: false, reason: 'revoked' };
215
- }
216
- // Found and passed script checks. App-side checks on the payload.
217
- let session;
218
- try {
219
- session = deserializeSession(result.raw, this.repository.keyProvider ?? undefined);
220
- if (this.config.encryption.enabled) {
221
- assertHeaderMatches(parseEncryptedHeader(result.raw), session);
222
- // Security version (plain path is checked inside the script).
223
- if (result.currentSecurityVersion !== null &&
224
- session.securityVersion !== result.currentSecurityVersion) {
225
- return { valid: false, reason: 'revoked' };
226
- }
227
- }
228
- }
229
- catch (error) {
230
- if (error instanceof SessionSerializationError) {
231
- await this.bestEffortCleanup(userId, jti);
232
- return { valid: false, reason: 'invalid' };
233
- }
234
- throw error;
235
- }
236
- // Binding policy.
237
- const binding = this.checkBinding(session, options);
238
- if (binding && this.config.bindingPolicy === 'strict') {
239
- return { valid: false, reason: 'binding_mismatch' };
240
- }
241
- // External revocation store (JWT jti denylists etc.).
242
- if (this.config.checkRevocationStore && this.deps.revocationStore) {
243
- const revoked = await this.deps.revocationStore.isRevoked(jti);
244
- if (revoked)
245
- return { valid: false, reason: 'revoked' };
246
- }
247
- return binding ? { valid: true, session, binding } : { valid: true, session };
248
- }, (result) => (result.valid ? 'ok' : 'invalid'));
249
- }
250
- /* ------------------------------------------------------------------------ */
251
- /* Touch */
252
- /* ------------------------------------------------------------------------ */
253
- /**
254
- * Refreshes activity. Throttled by touchInterval (in-script + in-memory
255
- * optimizations). Never resurrects an idle-expired session.
256
- */
257
- touch(token, options = {}) {
258
- return this.guard('touch', async () => {
259
- if (!this.isAcceptableToken(token))
260
- return 'not_found';
261
- const jti = this.deps.token.hash(token);
262
- const userId = await this.resolveUserId(jti, options.userId);
263
- if (userId === null)
264
- return 'not_found';
265
- if (!options.force) {
266
- const last = this.throttle.get(jti);
267
- if (last !== undefined && this.now() - last < this.config.touchInterval) {
268
- return 'skipped_throttled';
269
- }
270
- }
271
- const outcome = await this.repository.touch(userId, jti, options.force ?? false);
272
- if (outcome === 'touched') {
273
- if (this.throttle.size >= MAX_THROTTLE_ENTRIES)
274
- this.throttle.clear();
275
- this.throttle.set(jti, this.now());
276
- }
277
- return outcome;
278
- });
279
- }
280
- /* ------------------------------------------------------------------------ */
281
- /* Rotate */
282
- /* ------------------------------------------------------------------------ */
283
- /**
284
- * Single-use atomic rotation with retry-safe idempotency (rotationNonce).
285
- */
286
- rotate(token, options = {}) {
287
- return this.guard('rotate', async () => {
288
- if (!this.isAcceptableToken(token)) {
289
- throw new SessionNotFoundError({ reason: 'invalid_token' });
290
- }
291
- const oldJti = this.deps.token.hash(token);
292
- const userId = await this.resolveUserId(oldJti, options.userId);
293
- if (userId === null) {
294
- throw new SessionNotFoundError({ reason: 'jti_index_miss' });
295
- }
296
- const rotationNonceHash = options.rotationNonce
297
- ? this.deps.token.hash(options.rotationNonce)
298
- : undefined;
299
- const now = this.now();
300
- const successorToken = this.deps.token.generate();
301
- const successorJti = this.deps.token.hash(successorToken);
302
- const successor = {
303
- jti: successorJti,
304
- userId,
305
- createdAt: now,
306
- lastAccessedAt: now,
307
- absoluteExpiresAt: now + this.config.ttl,
308
- idleExpiresAt: this.config.idleTimeout !== null
309
- ? Math.min(now + this.config.idleTimeout, now + this.config.ttl)
310
- : null,
311
- status: 'active',
312
- version: 1,
313
- securityVersion: null,
314
- deviceId: null,
315
- ipAddress: null,
316
- userAgent: null,
317
- metadata: null,
318
- rotatedFrom: oldJti,
319
- rotatedTo: null,
320
- consumedAt: null,
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,
330
- };
331
- const result = await this.repository.rotate({
332
- userId,
333
- oldJti,
334
- successor,
335
- ...(options.expectedVersion !== undefined
336
- ? { expectedVersion: options.expectedVersion }
337
- : {}),
338
- ...(rotationNonceHash !== undefined ? { rotationNonceHash } : {}),
339
- retainTombstone: this.config.retainConsumedTombstones,
340
- revokeFamilyOnReplay: this.config.revokeFamilyOnReplay,
341
- });
342
- if (result.code === 1 || result.code === 2) {
343
- const replayed = result.code === 2;
344
- const session = replayed
345
- ? await this.repository.get(userId, result.successorJti)
346
- : successor;
347
- if (!session) {
348
- throw new SessionRotationError({ reason: 'successor_unavailable', replayed });
349
- }
350
- // The old index entry is intentionally kept: it is derived state
351
- // with its own TTL, and keeping it lets retry-safe rotation replays
352
- // resolve the consumed jti without a userId for the tombstone
353
- // window. Validation of a consumed session fails regardless.
354
- const indexed = await this.repository.writeJtiIndex(session.jti, userId, Math.max(1, session.absoluteExpiresAt - this.now()));
355
- if (!indexed)
356
- this.metrics.jtiIndexWriteFailure();
357
- // On replay the successor's raw token is unrecoverable (only its
358
- // hash is stored): the caller must treat the outcome as ambiguous
359
- // and re-authenticate rather than reusing the old token.
360
- return replayed ? { session, replayed } : { token: successorToken, session, replayed };
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
- }
379
- throw rotationError(result.code, result.status);
380
- });
381
- }
382
- /* ------------------------------------------------------------------------ */
383
- /* Update */
384
- /* ------------------------------------------------------------------------ */
385
- /**
386
- * Patch update of non-security fields (device/ip/ua/metadata) with
387
- * optimistic concurrency.
388
- */
389
- update(token, patch, options = {}) {
390
- return this.guard('update', async () => {
391
- if (!this.isAcceptableToken(token)) {
392
- throw new SessionNotFoundError({ reason: 'invalid_token' });
393
- }
394
- const jti = this.deps.token.hash(token);
395
- const userId = await this.resolveUserId(jti, options.userId);
396
- if (userId === null) {
397
- throw new SessionNotFoundError({ reason: 'jti_index_miss' });
398
- }
399
- validatePatch(patch, this.config.limits.maxMetadataSize);
400
- const record = await this.repository.update(userId, jti, patch, options.expectedVersion);
401
- if (!record) {
402
- throw new SessionNotFoundError({});
403
- }
404
- return record;
405
- });
406
- }
407
- /* ------------------------------------------------------------------------ */
408
- /* Destroy / revoke */
409
- /* ------------------------------------------------------------------------ */
410
- /** Physically deletes a session (idempotent). */
411
- destroy(token, options = {}) {
412
- return this.guard('destroy', async () => {
413
- if (!this.isAcceptableToken(token))
414
- return false;
415
- const jti = this.deps.token.hash(token);
416
- const userId = await this.resolveUserId(jti, options.userId);
417
- if (userId === null)
418
- return false;
419
- const deleted = await this.repository.destroy(userId, jti);
420
- if (deleted)
421
- await this.repository.deleteJtiIndex(jti);
422
- return deleted;
423
- });
424
- }
425
- /**
426
- * Logically revokes a session (keeps a bounded tombstone).
427
- * Returns 'revoked' | 'already_revoked' | 'not_found'.
428
- */
429
- revoke(token, options = {}) {
430
- return this.guard('revoke', async () => {
431
- if (!this.isAcceptableToken(token))
432
- return 'not_found';
433
- const jti = this.deps.token.hash(token);
434
- const userId = await this.resolveUserId(jti, options.userId);
435
- if (userId === null)
436
- return 'not_found';
437
- const outcome = await this.repository.revoke(userId, jti, this.config.ttl);
438
- await this.repository.deleteJtiIndex(jti);
439
- return outcome;
440
- });
441
- }
442
- /** Revokes every session of a user (bounded, fail-closed on partial). */
443
- async revokeAll(userId) {
444
- return this.guard('revoke_all', async () => {
445
- validateUserId(userId);
446
- const jtis = await this.repository.listJtis(userId, this.config.limits.maxSessionsPerUserHardCap);
447
- let revoked = 0;
448
- await mapWithConcurrency(jtis, this.config.limits.maxFanOutConcurrency, async (jti) => {
449
- await this.repository.revoke(userId, jti, this.config.ttl);
450
- revoked += 1;
451
- });
452
- return revoked;
453
- });
454
- }
455
- /** Deletes every session of a user (physical, bounded). */
456
- async deleteByUser(userId) {
457
- return this.guard('delete_by_user', async () => {
458
- validateUserId(userId);
459
- const jtis = await this.repository.listJtis(userId, this.config.limits.maxSessionsPerUserHardCap);
460
- const deleted = await this.repository.deleteByUser(userId);
461
- await this.repository.deleteJtiIndexMany(jtis);
462
- return deleted;
463
- });
464
- }
465
- /* ------------------------------------------------------------------------ */
466
- /* Listing */
467
- /* ------------------------------------------------------------------------ */
468
- /** Lists a user's sessions (oldest first). */
469
- findByUser(userId, options = {}) {
470
- return this.guard('find_by_user', async () => {
471
- validateUserId(userId);
472
- const includeInactive = options.includeInactive ?? false;
473
- const sessions = await this.repository.listByUser(userId, {
474
- ...(options.limit !== undefined ? { limit: options.limit } : {}),
475
- ...(options.offset !== undefined ? { offset: options.offset } : {}),
476
- });
477
- if (includeInactive)
478
- return sessions;
479
- return sessions.filter((s) => s.status === 'active');
480
- });
481
- }
482
- /** Alias of {@link findByUser} for listing. */
483
- list(userId, options = {}) {
484
- return this.findByUser(userId, options);
485
- }
486
- /* ------------------------------------------------------------------------ */
487
- /* Security version */
488
- /* ------------------------------------------------------------------------ */
489
- /**
490
- * Sets (or bumps) the user's security version, invalidating every session
491
- * captured at an older version. Use after password/MFA changes.
492
- */
493
- setSecurityVersion(userId, version) {
494
- return this.guard('set_security_version', async () => {
495
- validateUserId(userId);
496
- const next = version !== undefined
497
- ? version
498
- : ((await this.repository.getSecurityVersion(userId)) ?? 0) + 1;
499
- await this.repository.setSecurityVersion(userId, next);
500
- return next;
501
- });
502
- }
503
- getSecurityVersion(userId) {
504
- return this.guard('set_security_version', async () => {
505
- validateUserId(userId);
506
- return this.repository.getSecurityVersion(userId);
507
- });
508
- }
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
- /* ------------------------------------------------------------------------ */
539
- /* Health */
540
- /* ------------------------------------------------------------------------ */
541
- /** Dependency health (PING latency + recent error rate). */
542
- async health() {
543
- if (!this.deps.health) {
544
- throw new SessionConfigurationError('Session health checker is not configured.');
545
- }
546
- return this.deps.health.check();
547
- }
548
- /* ------------------------------------------------------------------------ */
549
- /* Internals */
550
- /* ------------------------------------------------------------------------ */
551
- /**
552
- * Resolves the userId for a jti: explicit when provided (fast path), via
553
- * the JTI index otherwise. Returns null when the index has no entry.
554
- */
555
- async resolveUserId(jti, explicitUserId) {
556
- if (explicitUserId !== undefined && explicitUserId !== '') {
557
- return explicitUserId;
558
- }
559
- if (!this.config.jtiIndex.enabled) {
560
- throw new SessionConfigurationError('Operation requires userId (jtiIndex is disabled and no userId was provided).');
561
- }
562
- return this.repository.readJtiIndex(jti);
563
- }
564
- /**
565
- * Accepts tokens in the strict issued format (base64url of the configured
566
- * entropy) and caller-supplied idempotency keys (bounded printable ASCII),
567
- * which are used as tokens for idempotent creation. Rejects everything
568
- * else (DoS guard: bounded length, bounded alphabet).
569
- */
570
- isAcceptableToken(token) {
571
- if (this.deps.token.validateFormat(token))
572
- return true;
573
- return (token.length >= IDEMPOTENCY_MIN_LENGTH &&
574
- token.length <= IDEMPOTENCY_MAX_LENGTH &&
575
- PRINTABLE_ASCII.test(token));
576
- }
577
- checkBinding(session, options) {
578
- if (this.config.bindingPolicy === 'disabled')
579
- return null;
580
- const mismatch = {
581
- ipAddress: session.ipAddress !== null &&
582
- options.ipAddress !== undefined &&
583
- session.ipAddress !== options.ipAddress,
584
- userAgent: session.userAgent !== null &&
585
- options.userAgent !== undefined &&
586
- session.userAgent !== options.userAgent,
587
- deviceId: session.deviceId !== null &&
588
- options.deviceId !== undefined &&
589
- session.deviceId !== options.deviceId,
590
- };
591
- if (!mismatch.ipAddress && !mismatch.userAgent && !mismatch.deviceId)
592
- return null;
593
- return mismatch;
594
- }
595
- async bestEffortCleanup(userId, jti) {
596
- try {
597
- await this.repository.destroy(userId, jti);
598
- await this.repository.deleteJtiIndex(jti);
599
- }
600
- catch {
601
- // Best-effort: a failed cleanup must not mask the invalid result.
602
- }
603
- }
604
- }
605
- /* -------------------------------------------------------------------------- */
606
- /* Helpers */
607
- /* -------------------------------------------------------------------------- */
608
- function rotationError(code, status) {
609
- switch (code) {
610
- case 0:
611
- return new SessionNotFoundError({});
612
- case -1:
613
- return new SessionRevokedError({ status });
614
- case -2:
615
- return new SessionExpiredError({});
616
- case -3:
617
- return new SessionConcurrencyError({ reason: 'version_conflict' });
618
- case -4:
619
- return new SessionRotationError({ reason: 'successor_collision' });
620
- case 5:
621
- case 6:
622
- return new SessionSerializationError({ reason: 'envelope_mode_mismatch' });
623
- default:
624
- return new SessionStorageError(undefined, { code });
625
- }
626
- }
627
- function validateUserId(userId) {
628
- if (typeof userId !== 'string' || userId.length === 0 || userId.length > 512) {
629
- throw new SessionConfigurationError('userId must be a non-empty string of at most 512 chars.');
630
- }
631
- }
632
- function validateIdempotencyToken(token) {
633
- if (token.length < IDEMPOTENCY_MIN_LENGTH ||
634
- token.length > IDEMPOTENCY_MAX_LENGTH ||
635
- !PRINTABLE_ASCII.test(token)) {
636
- throw new SessionConfigurationError(`idempotencyKey must be ${IDEMPOTENCY_MIN_LENGTH}-${IDEMPOTENCY_MAX_LENGTH} printable ASCII chars.`);
637
- }
638
- }
639
- function validatePatch(patch, maxMetadataSize) {
640
- if (patch.deviceId !== undefined && patch.deviceId !== null && patch.deviceId.length > 1024) {
641
- throw new SessionInvalidError({ reason: 'device_id_too_long' });
642
- }
643
- if (patch.ipAddress !== undefined && patch.ipAddress !== null && patch.ipAddress.length > 64) {
644
- throw new SessionInvalidError({ reason: 'ip_address_too_long' });
645
- }
646
- if (patch.userAgent !== undefined &&
647
- patch.userAgent !== null &&
648
- patch.userAgent.length > 1024) {
649
- throw new SessionInvalidError({ reason: 'user_agent_too_long' });
650
- }
651
- if (patch.metadata !== undefined && patch.metadata !== null) {
652
- let serialized;
653
- try {
654
- serialized = JSON.stringify(patch.metadata);
655
- }
656
- catch {
657
- throw new SessionInvalidError({ reason: 'metadata_cyclic' });
658
- }
659
- const size = Buffer.byteLength(serialized);
660
- if (size > maxMetadataSize) {
661
- throw new SessionInvalidError({ reason: 'metadata_too_large', size, max: maxMetadataSize });
662
- }
663
- }
664
- }
665
- function parseEncryptedHeader(raw) {
666
- const parsed = JSON.parse(raw);
667
- if (parsed.v !== 2)
668
- throw new SessionSerializationError({ reason: 'envelope_mode_mismatch' });
669
- return parsed;
670
- }