@exortek/apikey 1.0.0 → 1.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.
package/dist/stores.mjs CHANGED
@@ -23,27 +23,6 @@
23
23
  * duplicates work the assert family already handles — don't.
24
24
  */
25
25
 
26
- /**
27
- * `true` when `value` is a plain object *or* a class instance — anything
28
- * whose property access is safe. Rejects `null`, arrays, and every
29
- * primitive.
30
- *
31
- * The Node convention `typeof x === 'object' && x !== null` accepts
32
- * arrays, which almost never matches caller intent (an array is not a
33
- * "config object"). This predicate additionally rules arrays out, so
34
- * `isObject(x)` really means "I can read `x.foo` without exploding on
35
- * a numeric index".
36
- *
37
- * Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
38
- * because they carry `typeof === 'object'` — the check is duck-typed,
39
- * not prototype-restricted.
40
- *
41
- * @param {unknown} value
42
- * @returns {boolean}
43
- */
44
- function isObject(value) {
45
- return value !== null && typeof value === 'object' && !Array.isArray(value);
46
- }
47
26
 
48
27
  /**
49
28
  * `true` when `value` is a string primitive. `String` boxed objects
@@ -80,6 +59,503 @@ function isUndefined(value) {
80
59
  return typeof value === 'undefined';
81
60
  }
82
61
 
62
+ /**
63
+ * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
64
+ * package that ships a Redis store (jwt blacklist, session store,
65
+ * security rate-limit) does the same "not null + required methods
66
+ * are functions" check up front so a missing dependency surfaces as
67
+ * a clean typed error instead of `TypeError: client.get is not a
68
+ * function` deep in a store operation.
69
+ *
70
+ * Callers pass their own `wrap` callback that throws their typed
71
+ * error class with the message — this file has no opinion on the
72
+ * error surface.
73
+ */
74
+
75
+ /**
76
+ * @param {unknown} client
77
+ * @param {readonly string[]} methods Method names that must be functions on the client.
78
+ * @param {(message: string) => never} wrap
79
+ * Called with a diagnostic message when the check fails; must throw.
80
+ * The caller's typed error class is emitted from inside this callback.
81
+ * @returns {void}
82
+ */
83
+ function assertRedisClient(client, methods, wrap) {
84
+ if (!client || typeof client !== 'object') {
85
+ wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
86
+ }
87
+ const c = /** @type {Record<string, unknown>} */ (client);
88
+ for (const method of methods) {
89
+ if (typeof c[method] !== 'function') {
90
+ wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Redis client-shape compatibility helpers.
97
+ *
98
+ * ioredis, node-redis v4+, and @upstash/redis all expose the same
99
+ * operations under slightly different names and calling conventions.
100
+ * This module creates a normalised adapter so every store in the repo
101
+ * can share a single detection path instead of copy-pasting the same
102
+ * three-way dispatch.
103
+ *
104
+ * Internal to `@exortek/shared` — consumed by `incr-store.js` and
105
+ * `record-store.js`, not re-exported to consuming packages.
106
+ */
107
+
108
+
109
+ /**
110
+ * @param {object} client A Redis-compatible client instance.
111
+ * @returns {{
112
+ * mget: (keys: string[]) => Promise<(string|null)[]>,
113
+ * sadd: (key: string, member: string) => Promise<any>,
114
+ * srem: (key: string, member: string) => Promise<any>,
115
+ * smembers: (key: string) => Promise<string[]>,
116
+ * setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
117
+ * setPlain: (key: string, value: string) => Promise<void>,
118
+ * parseRecord: (raw: string|object|null) => object|null,
119
+ * }}
120
+ */
121
+ function createRedisHelpers(client) {
122
+ return {
123
+ async mget(keys) {
124
+ if (keys.length === 0) {
125
+ return [];
126
+ }
127
+ if (isFunction(client.mget)) {
128
+ return client.mget(...keys);
129
+ }
130
+ if (isFunction(client.mGet)) {
131
+ return client.mGet(keys);
132
+ }
133
+ return Promise.all(keys.map(k => client.get(k)));
134
+ },
135
+
136
+ async sadd(key, member) {
137
+ if (isFunction(client.sadd)) {
138
+ return client.sadd(key, member);
139
+ }
140
+ if (isFunction(client.sAdd)) {
141
+ return client.sAdd(key, member);
142
+ }
143
+ return null;
144
+ },
145
+
146
+ async srem(key, member) {
147
+ if (isFunction(client.srem)) {
148
+ return client.srem(key, member);
149
+ }
150
+ if (isFunction(client.sRem)) {
151
+ return client.sRem(key, member);
152
+ }
153
+ return null;
154
+ },
155
+
156
+ async smembers(key) {
157
+ if (isFunction(client.smembers)) {
158
+ return client.smembers(key);
159
+ }
160
+ if (isFunction(client.sMembers)) {
161
+ return client.sMembers(key);
162
+ }
163
+ return [];
164
+ },
165
+
166
+ async setWithTTL(key, value, ttlMs) {
167
+ const px = Math.max(1, Math.ceil(ttlMs));
168
+ try {
169
+ await client.set(key, value, 'PX', px);
170
+ } catch {
171
+ await client.set(key, value, { PX: px });
172
+ }
173
+ },
174
+
175
+ async setPlain(key, value) {
176
+ await client.set(key, value);
177
+ },
178
+
179
+ parseRecord(raw) {
180
+ if (!raw) {
181
+ return null;
182
+ }
183
+ try {
184
+ return isString(raw) ? JSON.parse(raw) : raw;
185
+ } catch {
186
+ return null;
187
+ }
188
+ },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Shared indexed record store — primary key + secondary owner-index.
194
+ *
195
+ * Memory and Redis factories for the CRUD + index pattern used by
196
+ * `@exortek/apikey`, `@exortek/magic-link`, and `@exortek/session`.
197
+ *
198
+ * Each consuming package wraps these factories and adds its own
199
+ * domain-specific methods (revoke, consume, etc.). The factories
200
+ * provide the common put/get/update/list + index management.
201
+ */
202
+
203
+
204
+ // Memory
205
+
206
+ /**
207
+ * @typedef {object} MemoryRecordStoreConfig
208
+ * @property {string} idField Primary key field name ('id' | 'sid').
209
+ * @property {string} indexField Secondary index field name ('userId' | 'email' | 'uid').
210
+ * @property {'shallow' | 'deep' | 'none'} [copyStrategy='shallow']
211
+ */
212
+
213
+ /**
214
+ * @param {MemoryRecordStoreConfig} config
215
+ * @returns {{
216
+ * put: (record: object) => Promise<void>,
217
+ * getById: (id: string) => Promise<object|null>,
218
+ * update: (id: string, patch: object) => Promise<object|null>,
219
+ * listByIndex: (indexValue: string) => Promise<object[]>,
220
+ * byId: Map<string, object>,
221
+ * byIndex: Map<string, Set<string>>,
222
+ * _size: () => number,
223
+ * }}
224
+ */
225
+ function createMemoryRecordStore(config) {
226
+ const { idField, indexField, copyStrategy = 'shallow' } = config;
227
+
228
+ /** @type {Map<string, object>} */
229
+ const byId = new Map();
230
+ /** @type {Map<string, Set<string>>} */
231
+ const byIndex = new Map();
232
+
233
+ function copy(record) {
234
+ if (copyStrategy === 'deep') {
235
+ return structuredClone(record);
236
+ }
237
+ if (copyStrategy === 'shallow') {
238
+ return { ...record };
239
+ }
240
+ return record;
241
+ }
242
+
243
+ function addIndex(indexValue, id) {
244
+ if (!isString(indexValue)) {
245
+ return;
246
+ }
247
+ let set = byIndex.get(indexValue);
248
+ if (!set) {
249
+ set = new Set();
250
+ byIndex.set(indexValue, set);
251
+ }
252
+ set.add(id);
253
+ }
254
+
255
+ function removeIndex(indexValue, id) {
256
+ if (!isString(indexValue)) {
257
+ return;
258
+ }
259
+ const set = byIndex.get(indexValue);
260
+ if (!set) {
261
+ return;
262
+ }
263
+ set.delete(id);
264
+ if (set.size === 0) {
265
+ byIndex.delete(indexValue);
266
+ }
267
+ }
268
+
269
+ return {
270
+ byId,
271
+ byIndex,
272
+
273
+ async put(record) {
274
+ byId.set(record[idField], copy(record));
275
+ addIndex(record[indexField], record[idField]);
276
+ },
277
+
278
+ async getById(id) {
279
+ const record = byId.get(id);
280
+ return record ? copy(record) : null;
281
+ },
282
+
283
+ async update(id, patch) {
284
+ const existing = byId.get(id);
285
+ if (!existing) {
286
+ return null;
287
+ }
288
+ const oldIndex = existing[indexField];
289
+ const next = { ...existing, ...patch };
290
+ byId.set(id, next);
291
+ if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
292
+ removeIndex(oldIndex, id);
293
+ addIndex(next[indexField], id);
294
+ }
295
+ return copy(next);
296
+ },
297
+
298
+ async listByIndex(indexValue) {
299
+ const set = byIndex.get(indexValue);
300
+ if (!set) {
301
+ return [];
302
+ }
303
+ const out = [];
304
+ for (const id of set) {
305
+ const record = byId.get(id);
306
+ if (record) {
307
+ out.push(copy(record));
308
+ }
309
+ }
310
+ return out;
311
+ },
312
+
313
+ _size: () => byId.size,
314
+ };
315
+ }
316
+
317
+ // Redis
318
+
319
+ /**
320
+ * @typedef {object} RedisRecordStoreConfig
321
+ * @property {string} idField Primary key field name.
322
+ * @property {string} indexField Secondary index field name.
323
+ * @property {string} [keyPrefix='']
324
+ * @property {boolean} [tombstones=false]
325
+ * @property {string} [tombstonePrefix='rev:']
326
+ * @property {string} [indexPrefix='u:']
327
+ * @property {boolean} [ttl=false] Whether records have TTL.
328
+ * @property {(record: object, parsedTomb: object) => object} [applyTombstone]
329
+ * Required when `tombstones: true`. Receives the parsed record and
330
+ * the parsed tombstone — must mutate and return the record.
331
+ * @property {(msg: string) => never} [wrap]
332
+ */
333
+
334
+ /**
335
+ * @param {object} client
336
+ * @param {RedisRecordStoreConfig} config
337
+ * @returns {{
338
+ * put: (record: object, ttlMs?: number) => Promise<void>,
339
+ * getById: (id: string) => Promise<object|null>,
340
+ * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
341
+ * listByIndex: (indexValue: string) => Promise<object[]>,
342
+ * readRecord: (id: string) => Promise<object|null>,
343
+ * writeRecord: (record: object, ttlMs?: number) => Promise<void>,
344
+ * writeTombstone: (id: string, data: object, ttlMs?: number) => Promise<void>,
345
+ * fetchIndexRecords: (indexValue: string) => Promise<[string, object|null][]>,
346
+ * indexAdd: (indexValue: string, id: string) => Promise<void>,
347
+ * indexRemove: (indexValue: string, id: string) => Promise<void>,
348
+ * helpers: ReturnType<typeof createRedisHelpers>,
349
+ * }}
350
+ */
351
+ function createRedisRecordStore(client, config) {
352
+ const wrap = config.wrap ?? (msg => { throw new TypeError(msg); });
353
+ assertRedisClient(client, ['get', 'set', 'del'], wrap);
354
+
355
+ const {
356
+ idField,
357
+ indexField,
358
+ keyPrefix = '',
359
+ tombstones = false,
360
+ tombstonePrefix = 'rev:',
361
+ indexPrefix = 'u:',
362
+ ttl = false,
363
+ applyTombstone,
364
+ } = config;
365
+
366
+ const helpers = createRedisHelpers(client);
367
+
368
+ const rk = id => `${keyPrefix}${id}`;
369
+ const revk = id => `${keyPrefix}${tombstonePrefix}${id}`;
370
+ const ik = indexValue => `${keyPrefix}${indexPrefix}${indexValue}`;
371
+
372
+ function overlayTombstone(record, rawTomb) {
373
+ if (!record || !rawTomb || !applyTombstone) {
374
+ return record;
375
+ }
376
+ const tomb = helpers.parseRecord(rawTomb);
377
+ if (!tomb) {
378
+ return record;
379
+ }
380
+ return applyTombstone(record, tomb);
381
+ }
382
+
383
+ async function readRecord(id) {
384
+ if (tombstones) {
385
+ const [rawRecord, rawTomb] = await helpers.mget([rk(id), revk(id)]);
386
+ const record = helpers.parseRecord(rawRecord);
387
+ return record ? overlayTombstone(record, rawTomb) : null;
388
+ }
389
+ return helpers.parseRecord(await client.get(rk(id)));
390
+ }
391
+
392
+ async function writeRecord(record, ttlMs) {
393
+ const value = JSON.stringify(record);
394
+ if (ttl && ttlMs !== undefined) {
395
+ await helpers.setWithTTL(rk(record[idField]), value, ttlMs);
396
+ } else {
397
+ await helpers.setPlain(rk(record[idField]), value);
398
+ }
399
+ }
400
+
401
+ async function writeTombstone(id, data, ttlMs) {
402
+ const value = JSON.stringify(data);
403
+ if (ttl && ttlMs !== undefined) {
404
+ await helpers.setWithTTL(revk(id), value, ttlMs);
405
+ } else {
406
+ await helpers.setPlain(revk(id), value);
407
+ }
408
+ }
409
+
410
+ async function indexAdd(indexValue, id) {
411
+ await helpers.sadd(ik(indexValue), id);
412
+ }
413
+
414
+ async function indexRemove(indexValue, id) {
415
+ await helpers.srem(ik(indexValue), id);
416
+ }
417
+
418
+ async function fetchIndexRecords(indexValue) {
419
+ const ids = await helpers.smembers(ik(indexValue));
420
+ if (!ids || ids.length === 0) {
421
+ return [];
422
+ }
423
+ const keys = [];
424
+ for (const id of ids) {
425
+ keys.push(rk(id));
426
+ if (tombstones) {
427
+ keys.push(revk(id));
428
+ }
429
+ }
430
+ const rows = await helpers.mget(keys);
431
+ const stride = tombstones ? 2 : 1;
432
+ const pairs = [];
433
+ for (let i = 0; i < ids.length; i++) {
434
+ const raw = rows[i * stride];
435
+ const rawTomb = tombstones ? rows[i * stride + 1] : null;
436
+ const record = helpers.parseRecord(raw);
437
+ if (!record) {
438
+ await indexRemove(indexValue, ids[i]);
439
+ pairs.push([ids[i], null]);
440
+ continue;
441
+ }
442
+ pairs.push([ids[i], overlayTombstone(record, rawTomb)]);
443
+ }
444
+ return pairs;
445
+ }
446
+
447
+ return {
448
+ async put(record, ttlMs) {
449
+ await writeRecord(record, ttlMs);
450
+ if (isString(record[indexField])) {
451
+ await indexAdd(record[indexField], record[idField]);
452
+ }
453
+ },
454
+
455
+ async getById(id) {
456
+ return readRecord(id);
457
+ },
458
+
459
+ async update(id, patch, ttlMs) {
460
+ const existing = await readRecord(id);
461
+ if (!existing) {
462
+ return null;
463
+ }
464
+ const oldIndex = existing[indexField];
465
+ const next = { ...existing, ...patch };
466
+ await writeRecord(next, ttlMs);
467
+ if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
468
+ if (isString(oldIndex)) {
469
+ await indexRemove(oldIndex, id);
470
+ }
471
+ if (isString(next[indexField])) {
472
+ await indexAdd(next[indexField], id);
473
+ }
474
+ }
475
+ return next;
476
+ },
477
+
478
+ async listByIndex(indexValue) {
479
+ const pairs = await fetchIndexRecords(indexValue);
480
+ return pairs.filter(([, r]) => r !== null).map(([, r]) => r);
481
+ },
482
+
483
+ readRecord,
484
+ writeRecord,
485
+ writeTombstone,
486
+ fetchIndexRecords,
487
+ indexAdd,
488
+ indexRemove,
489
+ helpers,
490
+ };
491
+ }
492
+
493
+ /**
494
+ * In-process memory store for API keys.
495
+ *
496
+ * Not cluster-safe — every worker has its own state. Use for dev,
497
+ * single-node deployments, and tests. For multi-worker production use
498
+ * {@link redisStore} or bring your own DB-backed implementation of
499
+ * `ApiKeyStore`.
500
+ *
501
+ * Backed by two Maps: one keyed by `id` (the primary lookup path),
502
+ * one keyed by `userId` holding a `Set<id>` reverse index for
503
+ * `listByUser` / `revokeAllForUser`. Both are pruned on write.
504
+ */
505
+
506
+
507
+ /**
508
+ * @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
509
+ */
510
+ function memoryStore() {
511
+ const store = createMemoryRecordStore({
512
+ idField: 'id',
513
+ indexField: 'userId',
514
+ copyStrategy: 'shallow',
515
+ });
516
+
517
+ return {
518
+ put: record => store.put(record),
519
+ getById: id => store.getById(id),
520
+ update: (id, patch) => store.update(id, patch),
521
+ listByUser: userId => store.listByIndex(userId),
522
+
523
+ async revoke(id, reason) {
524
+ const existing = store.byId.get(id);
525
+ if (!existing || existing.revokedAt) {
526
+ return false;
527
+ }
528
+ existing.revokedAt = Date.now();
529
+ if (isString(reason)) {
530
+ existing.revokedReason = reason;
531
+ }
532
+ return true;
533
+ },
534
+
535
+ async revokeAllForUser(userId, reason) {
536
+ const set = store.byIndex.get(userId);
537
+ if (!set) {
538
+ return 0;
539
+ }
540
+ const now = Date.now();
541
+ let count = 0;
542
+ for (const id of set) {
543
+ const record = store.byId.get(id);
544
+ if (record && !record.revokedAt) {
545
+ record.revokedAt = now;
546
+ if (isString(reason)) {
547
+ record.revokedReason = reason;
548
+ }
549
+ count += 1;
550
+ }
551
+ }
552
+ return count;
553
+ },
554
+
555
+ _size: () => store._size(),
556
+ };
557
+ }
558
+
83
559
  /**
84
560
  * Imperative single-argument guard helpers — the everyday
85
561
  * `assertPositiveInt(x, 'name')` shape used at API boundaries.
@@ -507,177 +983,13 @@ const { invalidArgument} = defineGuards(
507
983
  ErrorCode.INVALID_ARGUMENT,
508
984
  );
509
985
 
510
- /**
511
- * In-process memory store for API keys.
512
- *
513
- * Not cluster-safe — every worker has its own state. Use for dev,
514
- * single-node deployments, and tests. For multi-worker production use
515
- * {@link redisStore} or bring your own DB-backed implementation of
516
- * `ApiKeyStore`.
517
- *
518
- * Backed by two Maps: one keyed by `id` (the primary lookup path),
519
- * one keyed by `userId` holding a `Set<id>` reverse index for
520
- * `listByUser` / `revokeAllForUser`. Both are pruned on write.
521
- */
522
-
523
-
524
- /**
525
- * @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
526
- */
527
- function memoryStore() {
528
- /** @type {Map<string, import('../index.js').ApiKeyRecord>} */
529
- const byId = new Map();
530
- /** @type {Map<string, Set<string>>} */
531
- const byUser = new Map();
532
-
533
- function indexUser(uid, id) {
534
- if (!isString(uid)) {
535
- return;
536
- }
537
- let set = byUser.get(uid);
538
- if (!set) {
539
- set = new Set();
540
- byUser.set(uid, set);
541
- }
542
- set.add(id);
543
- }
544
-
545
- function unindexUser(uid, id) {
546
- if (!isString(uid)) {
547
- return;
548
- }
549
- const set = byUser.get(uid);
550
- if (!set) {
551
- return;
552
- }
553
- set.delete(id);
554
- if (set.size === 0) {
555
- byUser.delete(uid);
556
- }
557
- }
558
-
559
- return {
560
- async put(record) {
561
- if (!isObject(record) || !isString(record.id)) {
562
- throw invalidArgument('memoryStore.put.record.id must be a string');
563
- }
564
- // Store a shallow copy so a caller mutating the record they
565
- // passed in doesn't retroactively mutate what we've stored —
566
- // matches the copy-on-read contract of getById / update.
567
- byId.set(record.id, { ...record });
568
- indexUser(record.userId, record.id);
569
- },
570
-
571
- async getById(id) {
572
- const record = byId.get(id);
573
- return record ? { ...record } : null;
574
- },
575
-
576
- async update(id, patch) {
577
- const existing = byId.get(id);
578
- if (!existing) {
579
- return null;
580
- }
581
- const oldUserId = existing.userId;
582
- const next = { ...existing, ...patch };
583
- byId.set(id, next);
584
- if (!isUndefined(patch.userId) && patch.userId !== oldUserId) {
585
- unindexUser(oldUserId, id);
586
- indexUser(next.userId, id);
587
- }
588
- return { ...next };
589
- },
590
-
591
- async revoke(id, reason) {
592
- const existing = byId.get(id);
593
- if (!existing || existing.revokedAt) {
594
- return false;
595
- }
596
- existing.revokedAt = Date.now();
597
- if (isString(reason)) {
598
- existing.revokedReason = reason;
599
- }
600
- return true;
601
- },
602
-
603
- async revokeAllForUser(userId, reason) {
604
- const set = byUser.get(userId);
605
- if (!set) {
606
- return 0;
607
- }
608
- const now = Date.now();
609
- let count = 0;
610
- for (const id of set) {
611
- const record = byId.get(id);
612
- if (record && !record.revokedAt) {
613
- record.revokedAt = now;
614
- if (isString(reason)) {
615
- record.revokedReason = reason;
616
- }
617
- count += 1;
618
- }
619
- }
620
- return count;
621
- },
622
-
623
- async listByUser(userId) {
624
- const set = byUser.get(userId);
625
- if (!set) {
626
- return [];
627
- }
628
- const out = [];
629
- for (const id of set) {
630
- const record = byId.get(id);
631
- if (record) {
632
- out.push({ ...record });
633
- }
634
- }
635
- return out;
636
- },
637
-
638
- _size: () => byId.size,
639
- };
640
- }
641
-
642
- /**
643
- * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
644
- * package that ships a Redis store (jwt blacklist, session store,
645
- * security rate-limit) does the same "not null + required methods
646
- * are functions" check up front so a missing dependency surfaces as
647
- * a clean typed error instead of `TypeError: client.get is not a
648
- * function` deep in a store operation.
649
- *
650
- * Callers pass their own `wrap` callback that throws their typed
651
- * error class with the message — this file has no opinion on the
652
- * error surface.
653
- */
654
-
655
- /**
656
- * @param {unknown} client
657
- * @param {readonly string[]} methods Method names that must be functions on the client.
658
- * @param {(message: string) => never} wrap
659
- * Called with a diagnostic message when the check fails; must throw.
660
- * The caller's typed error class is emitted from inside this callback.
661
- * @returns {void}
662
- */
663
- function assertRedisClient(client, methods, wrap) {
664
- if (!client || typeof client !== 'object') {
665
- wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
666
- }
667
- const c = /** @type {Record<string, unknown>} */ (client);
668
- for (const method of methods) {
669
- if (typeof c[method] !== 'function') {
670
- wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
671
- }
672
- }
673
- }
674
-
675
986
  /**
676
987
  * Redis-backed store for API keys.
677
988
  *
678
989
  * Layout:
679
990
  *
680
991
  * <keyPrefix><id> — JSON blob of the record (primary lookup)
992
+ * <keyPrefix>rev:<id> — revocation tombstone (see below)
681
993
  * <keyPrefix>u:<userId> — SADD-set of the user's key ids
682
994
  *
683
995
  * Cluster-safe: state lives in Redis, so a key put on one worker is
@@ -686,9 +998,15 @@ function assertRedisClient(client, methods, wrap) {
686
998
  * `ioredis`, `node-redis@4+`, and `@upstash/redis`.
687
999
  *
688
1000
  * There is no TTL on the record — API keys are meant to be long-lived
689
- * and their `expiresAt` field is authoritative. Revocation is a
690
- * flip-in-place on the record itself, not a tombstone, since
691
- * `verifyApiKey` re-reads the full record every time.
1001
+ * and their `expiresAt` field is authoritative.
1002
+ *
1003
+ * **Why tombstones:** `update()` is a read-modify-write (GET → SET), so
1004
+ * a concurrent `revoke()` on another worker could be overwritten by an
1005
+ * in-flight update carrying the pre-revoke copy — silently un-revoking
1006
+ * the key. Revocations are therefore written to a separate
1007
+ * `<keyPrefix>rev:<id>` key that no update path ever touches; `getById()`
1008
+ * and `listByUser()` overlay it onto the record. A revocation can never
1009
+ * be lost to a lost-update race.
692
1010
  */
693
1011
 
694
1012
 
@@ -703,174 +1021,64 @@ function assertRedisClient(client, methods, wrap) {
703
1021
  * @returns {import('../index.js').ApiKeyStore}
704
1022
  */
705
1023
  function redisStore(client, options = {}) {
706
- assertRedisClient(client, ['get', 'set', 'del'], msg => {
707
- throw invalidArgument(`redisStore.client: ${msg}`);
708
- });
709
1024
  const keyPrefix = options.keyPrefix ?? 'apikey:';
710
- const rk = id => `${keyPrefix}${id}`;
711
- const uk = uid => `${keyPrefix}u:${uid}`;
712
-
713
- async function readRecord(id) {
714
- const raw = await client.get(rk(id));
715
- if (!raw) {
716
- return null;
717
- }
718
- try {
719
- return isString(raw) ? JSON.parse(raw) : raw;
720
- } catch {
721
- return null;
722
- }
723
- }
724
-
725
- async function writeRecord(record) {
726
- await client.set(rk(record.id), JSON.stringify(record));
727
- }
728
-
729
- async function sadd(uid, id) {
730
- if (isFunction(client.sadd)) {
731
- return client.sadd(uk(uid), id);
732
- }
733
- if (isFunction(client.sAdd)) {
734
- return client.sAdd(uk(uid), id);
735
- }
736
- return null;
737
- }
738
-
739
- async function srem(uid, id) {
740
- if (isFunction(client.srem)) {
741
- return client.srem(uk(uid), id);
742
- }
743
- if (isFunction(client.sRem)) {
744
- return client.sRem(uk(uid), id);
745
- }
746
- return null;
747
- }
748
-
749
- async function smembers(uid) {
750
- if (isFunction(client.smembers)) {
751
- return client.smembers(uk(uid));
752
- }
753
- if (isFunction(client.sMembers)) {
754
- return client.sMembers(uk(uid));
755
- }
756
- return [];
757
- }
758
-
759
- async function mget(keys) {
760
- if (keys.length === 0) {
761
- return [];
762
- }
763
- if (isFunction(client.mget)) {
764
- return client.mget(...keys);
765
- }
766
- if (isFunction(client.mGet)) {
767
- return client.mGet(keys);
768
- }
769
- return Promise.all(keys.map(k => client.get(k)));
770
- }
771
1025
 
772
- return {
773
- async put(record) {
774
- if (!isObject(record) || !isString(record.id)) {
775
- throw invalidArgument('redisStore.put.record.id must be a string');
776
- }
777
- await writeRecord(record);
778
- if (record.userId) {
779
- await sadd(record.userId, record.id);
1026
+ const store = createRedisRecordStore(client, {
1027
+ idField: 'id',
1028
+ indexField: 'userId',
1029
+ keyPrefix,
1030
+ tombstones: true,
1031
+ tombstonePrefix: 'rev:',
1032
+ indexPrefix: 'u:',
1033
+ applyTombstone(record, tomb) {
1034
+ record.revokedAt = record.revokedAt ?? Date.now();
1035
+ if (isString(tomb.reason) && record.revokedReason === undefined) {
1036
+ record.revokedReason = tomb.reason;
780
1037
  }
1038
+ return record;
781
1039
  },
1040
+ wrap: msg => { throw invalidArgument(msg); },
1041
+ });
782
1042
 
783
- async getById(id) {
784
- return readRecord(id);
785
- },
786
-
787
- async update(id, patch) {
788
- const existing = await readRecord(id);
789
- if (!existing) {
790
- return null;
791
- }
792
- const oldUserId = existing.userId;
793
- const next = { ...existing, ...patch };
794
- await writeRecord(next);
795
- if (patch.userId !== undefined && patch.userId !== oldUserId) {
796
- if (oldUserId) {
797
- await srem(oldUserId, id);
798
- }
799
- if (next.userId) {
800
- await sadd(next.userId, id);
801
- }
802
- }
803
- return next;
804
- },
1043
+ return {
1044
+ put: record => store.put(record),
1045
+ getById: id => store.getById(id),
1046
+ update: (id, patch) => store.update(id, patch),
1047
+ listByUser: userId => store.listByIndex(userId),
805
1048
 
806
1049
  async revoke(id, reason) {
807
- const existing = await readRecord(id);
1050
+ const existing = await store.readRecord(id);
808
1051
  if (!existing || existing.revokedAt) {
809
1052
  return false;
810
1053
  }
1054
+ await store.writeTombstone(id, { reason: reason ?? null, at: Date.now() });
811
1055
  existing.revokedAt = Date.now();
812
1056
  if (isString(reason)) {
813
1057
  existing.revokedReason = reason;
814
1058
  }
815
- await writeRecord(existing);
1059
+ await store.writeRecord(existing);
816
1060
  return true;
817
1061
  },
818
1062
 
819
1063
  async revokeAllForUser(userId, reason) {
820
- const ids = await smembers(userId);
821
- if (!ids || ids.length === 0) {
822
- return 0;
823
- }
824
- const rows = await mget(ids.map(rk));
1064
+ const pairs = await store.fetchIndexRecords(userId);
825
1065
  let count = 0;
826
1066
  const writes = [];
827
- for (let i = 0; i < ids.length; i++) {
828
- const raw = rows[i];
829
- if (!raw) {
830
- continue;
831
- }
832
- let record;
833
- try {
834
- record = isString(raw) ? JSON.parse(raw) : raw;
835
- } catch {
836
- continue;
837
- }
838
- if (record.revokedAt) {
1067
+ for (const [id, record] of pairs) {
1068
+ if (!record || record.revokedAt) {
839
1069
  continue;
840
1070
  }
841
1071
  record.revokedAt = Date.now();
842
1072
  if (isString(reason)) {
843
1073
  record.revokedReason = reason;
844
1074
  }
845
- writes.push(writeRecord(record));
1075
+ writes.push(store.writeTombstone(id, { reason: reason ?? null, at: Date.now() }));
1076
+ writes.push(store.writeRecord(record));
846
1077
  count += 1;
847
1078
  }
848
1079
  await Promise.all(writes);
849
1080
  return count;
850
1081
  },
851
-
852
- async listByUser(userId) {
853
- const ids = await smembers(userId);
854
- if (!ids || ids.length === 0) {
855
- return [];
856
- }
857
- const rows = await mget(ids.map(rk));
858
- const out = [];
859
- for (let i = 0; i < ids.length; i++) {
860
- const raw = rows[i];
861
- if (!raw) {
862
- // Dead index reference — prune.
863
- await srem(userId, ids[i]);
864
- continue;
865
- }
866
- try {
867
- out.push(isString(raw) ? JSON.parse(raw) : raw);
868
- } catch {
869
- // Skip corrupt row.
870
- }
871
- }
872
- return out;
873
- },
874
1082
  };
875
1083
  }
876
1084