@exortek/apikey 1.1.2 → 1.2.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.
@@ -387,7 +387,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
387
387
  * @param {Buffer | Uint8Array} bytes
388
388
  * @returns {string}
389
389
  */
390
- function encode(bytes) {
390
+ function encode$1(bytes) {
391
391
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
392
392
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
393
393
  }
@@ -451,6 +451,39 @@ for (const [alias, canonical] of [
451
451
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
452
452
  }
453
453
 
454
+ /**
455
+ * Encode bytes as a Crockford base32 string.
456
+ *
457
+ * @param {Buffer | Uint8Array} bytes
458
+ * @returns {string}
459
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
460
+ */
461
+ function encode(bytes) {
462
+ if (!(bytes instanceof Uint8Array)) {
463
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
464
+ }
465
+ if (bytes.length === 0) {
466
+ return '';
467
+ }
468
+
469
+ let out = '';
470
+ let bits = 0;
471
+ let value = 0;
472
+ for (let i = 0; i < bytes.length; i++) {
473
+ value = (value << 8) | bytes[i];
474
+ bits += 8;
475
+ while (bits >= 5) {
476
+ bits -= 5;
477
+ out += ALPHABET[(value >>> bits) & 0x1f];
478
+ value &= (1 << bits) - 1;
479
+ }
480
+ }
481
+ if (bits > 0) {
482
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
483
+ }
484
+ return out;
485
+ }
486
+
454
487
  /**
455
488
  * Decode a Crockford base32 string into a Buffer.
456
489
  *
@@ -610,7 +643,7 @@ function hashSecret(secretBytes, pepper) {
610
643
  const digest = pepper
611
644
  ? createHmac('sha256', pepper).update(secretBytes).digest()
612
645
  : createHash('sha256').update(secretBytes).digest();
613
- return encode(digest);
646
+ return encode$1(digest);
614
647
  }
615
648
 
616
649
  /**
@@ -702,6 +735,12 @@ function candidateHashesFor(rawSecret, peppers) {
702
735
  } catch {
703
736
  return { candidateHashes: [] };
704
737
  }
738
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
739
+ // many accepted spellings. A key must have exactly one: anything keyed on
740
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
741
+ if (encode(secretBytes) !== rawSecret) {
742
+ return { candidateHashes: [] };
743
+ }
705
744
  if (!peppers || peppers.length === 0) {
706
745
  return { candidateHashes: [hashSecret(secretBytes, null)] };
707
746
  }
@@ -772,19 +811,15 @@ async function verifyApiKey(rawKey, options) {
772
811
  if (!record) {
773
812
  return { valid: false, reason: 'not_found' };
774
813
  }
775
- const now = _now(options.now);
776
- if (record.revokedAt) {
777
- return { valid: false, reason: 'revoked' };
778
- }
779
- if (record.expiresAt && record.expiresAt <= now) {
780
- return { valid: false, reason: 'expired' };
781
- }
782
814
  if (parsed.prefix !== record.prefix) {
783
815
  // Someone submitted a key whose id belongs to a different prefix —
784
816
  // treat as bad, not merely 'prefix_mismatch', because the id half
785
817
  // was correct: the attacker guessed the id and swapped the prefix.
786
818
  return { valid: false, reason: 'prefix_mismatch' };
787
819
  }
820
+ // The secret is checked before lifecycle state: the id is not secret (it
821
+ // is in every masked key), so answering 'revoked' / 'expired' first
822
+ // would tell anyone holding an id the key's status without its secret.
788
823
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
789
824
  let matched = false;
790
825
  let matchedIndex = -1;
@@ -798,6 +833,13 @@ async function verifyApiKey(rawKey, options) {
798
833
  if (!matched) {
799
834
  return { valid: false, reason: 'bad_secret' };
800
835
  }
836
+ const now = _now(options.now);
837
+ if (record.revokedAt) {
838
+ return { valid: false, reason: 'revoked' };
839
+ }
840
+ if (record.expiresAt && record.expiresAt <= now) {
841
+ return { valid: false, reason: 'expired' };
842
+ }
801
843
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
802
844
  return { valid: false, reason: 'missing_scope' };
803
845
  }
@@ -450,7 +450,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
450
450
  * @param {Buffer | Uint8Array} bytes
451
451
  * @returns {string}
452
452
  */
453
- function encode(bytes) {
453
+ function encode$1(bytes) {
454
454
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
455
455
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
456
456
  }
@@ -514,6 +514,39 @@ for (const [alias, canonical] of [
514
514
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
515
515
  }
516
516
 
517
+ /**
518
+ * Encode bytes as a Crockford base32 string.
519
+ *
520
+ * @param {Buffer | Uint8Array} bytes
521
+ * @returns {string}
522
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
523
+ */
524
+ function encode(bytes) {
525
+ if (!(bytes instanceof Uint8Array)) {
526
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
527
+ }
528
+ if (bytes.length === 0) {
529
+ return '';
530
+ }
531
+
532
+ let out = '';
533
+ let bits = 0;
534
+ let value = 0;
535
+ for (let i = 0; i < bytes.length; i++) {
536
+ value = (value << 8) | bytes[i];
537
+ bits += 8;
538
+ while (bits >= 5) {
539
+ bits -= 5;
540
+ out += ALPHABET[(value >>> bits) & 0x1f];
541
+ value &= (1 << bits) - 1;
542
+ }
543
+ }
544
+ if (bits > 0) {
545
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
546
+ }
547
+ return out;
548
+ }
549
+
517
550
  /**
518
551
  * Decode a Crockford base32 string into a Buffer.
519
552
  *
@@ -673,7 +706,7 @@ function hashSecret(secretBytes, pepper) {
673
706
  const digest = pepper
674
707
  ? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
675
708
  : node_crypto.createHash('sha256').update(secretBytes).digest();
676
- return encode(digest);
709
+ return encode$1(digest);
677
710
  }
678
711
 
679
712
  /**
@@ -765,6 +798,12 @@ function candidateHashesFor(rawSecret, peppers) {
765
798
  } catch {
766
799
  return { candidateHashes: [] };
767
800
  }
801
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
802
+ // many accepted spellings. A key must have exactly one: anything keyed on
803
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
804
+ if (encode(secretBytes) !== rawSecret) {
805
+ return { candidateHashes: [] };
806
+ }
768
807
  if (!peppers || peppers.length === 0) {
769
808
  return { candidateHashes: [hashSecret(secretBytes, null)] };
770
809
  }
@@ -835,19 +874,15 @@ async function verifyApiKey(rawKey, options) {
835
874
  if (!record) {
836
875
  return { valid: false, reason: 'not_found' };
837
876
  }
838
- const now = _now(options.now);
839
- if (record.revokedAt) {
840
- return { valid: false, reason: 'revoked' };
841
- }
842
- if (record.expiresAt && record.expiresAt <= now) {
843
- return { valid: false, reason: 'expired' };
844
- }
845
877
  if (parsed.prefix !== record.prefix) {
846
878
  // Someone submitted a key whose id belongs to a different prefix —
847
879
  // treat as bad, not merely 'prefix_mismatch', because the id half
848
880
  // was correct: the attacker guessed the id and swapped the prefix.
849
881
  return { valid: false, reason: 'prefix_mismatch' };
850
882
  }
883
+ // The secret is checked before lifecycle state: the id is not secret (it
884
+ // is in every masked key), so answering 'revoked' / 'expired' first
885
+ // would tell anyone holding an id the key's status without its secret.
851
886
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
852
887
  let matched = false;
853
888
  let matchedIndex = -1;
@@ -861,6 +896,13 @@ async function verifyApiKey(rawKey, options) {
861
896
  if (!matched) {
862
897
  return { valid: false, reason: 'bad_secret' };
863
898
  }
899
+ const now = _now(options.now);
900
+ if (record.revokedAt) {
901
+ return { valid: false, reason: 'revoked' };
902
+ }
903
+ if (record.expiresAt && record.expiresAt <= now) {
904
+ return { valid: false, reason: 'expired' };
905
+ }
864
906
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
865
907
  return { valid: false, reason: 'missing_scope' };
866
908
  }
@@ -1 +1,3 @@
1
- export const apiKeyPlugin: Function;
1
+ declare const apiKeyPlugin: Function;
2
+
3
+ export { apiKeyPlugin };
@@ -448,7 +448,7 @@ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
448
448
  * @param {Buffer | Uint8Array} bytes
449
449
  * @returns {string}
450
450
  */
451
- function encode(bytes) {
451
+ function encode$1(bytes) {
452
452
  if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
453
453
  throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
454
454
  }
@@ -512,6 +512,39 @@ for (const [alias, canonical] of [
512
512
  DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
513
513
  }
514
514
 
515
+ /**
516
+ * Encode bytes as a Crockford base32 string.
517
+ *
518
+ * @param {Buffer | Uint8Array} bytes
519
+ * @returns {string}
520
+ * @throws {TypeError} if `bytes` is not a Buffer or Uint8Array.
521
+ */
522
+ function encode(bytes) {
523
+ if (!(bytes instanceof Uint8Array)) {
524
+ throw new TypeError('encode: input must be a Buffer or Uint8Array.');
525
+ }
526
+ if (bytes.length === 0) {
527
+ return '';
528
+ }
529
+
530
+ let out = '';
531
+ let bits = 0;
532
+ let value = 0;
533
+ for (let i = 0; i < bytes.length; i++) {
534
+ value = (value << 8) | bytes[i];
535
+ bits += 8;
536
+ while (bits >= 5) {
537
+ bits -= 5;
538
+ out += ALPHABET[(value >>> bits) & 0x1f];
539
+ value &= (1 << bits) - 1;
540
+ }
541
+ }
542
+ if (bits > 0) {
543
+ out += ALPHABET[(value << (5 - bits)) & 0x1f];
544
+ }
545
+ return out;
546
+ }
547
+
515
548
  /**
516
549
  * Decode a Crockford base32 string into a Buffer.
517
550
  *
@@ -671,7 +704,7 @@ function hashSecret(secretBytes, pepper) {
671
704
  const digest = pepper
672
705
  ? createHmac('sha256', pepper).update(secretBytes).digest()
673
706
  : createHash('sha256').update(secretBytes).digest();
674
- return encode(digest);
707
+ return encode$1(digest);
675
708
  }
676
709
 
677
710
  /**
@@ -763,6 +796,12 @@ function candidateHashesFor(rawSecret, peppers) {
763
796
  } catch {
764
797
  return { candidateHashes: [] };
765
798
  }
799
+ // Crockford decoding is forgiving (case, I/L→1, O→0), so one secret has
800
+ // many accepted spellings. A key must have exactly one: anything keyed on
801
+ // the wire string (logs, deny lists, dedupe) would otherwise miss variants.
802
+ if (encode(secretBytes) !== rawSecret) {
803
+ return { candidateHashes: [] };
804
+ }
766
805
  if (!peppers || peppers.length === 0) {
767
806
  return { candidateHashes: [hashSecret(secretBytes, null)] };
768
807
  }
@@ -833,19 +872,15 @@ async function verifyApiKey(rawKey, options) {
833
872
  if (!record) {
834
873
  return { valid: false, reason: 'not_found' };
835
874
  }
836
- const now = _now(options.now);
837
- if (record.revokedAt) {
838
- return { valid: false, reason: 'revoked' };
839
- }
840
- if (record.expiresAt && record.expiresAt <= now) {
841
- return { valid: false, reason: 'expired' };
842
- }
843
875
  if (parsed.prefix !== record.prefix) {
844
876
  // Someone submitted a key whose id belongs to a different prefix —
845
877
  // treat as bad, not merely 'prefix_mismatch', because the id half
846
878
  // was correct: the attacker guessed the id and swapped the prefix.
847
879
  return { valid: false, reason: 'prefix_mismatch' };
848
880
  }
881
+ // The secret is checked before lifecycle state: the id is not secret (it
882
+ // is in every masked key), so answering 'revoked' / 'expired' first
883
+ // would tell anyone holding an id the key's status without its secret.
849
884
  const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
850
885
  let matched = false;
851
886
  let matchedIndex = -1;
@@ -859,6 +894,13 @@ async function verifyApiKey(rawKey, options) {
859
894
  if (!matched) {
860
895
  return { valid: false, reason: 'bad_secret' };
861
896
  }
897
+ const now = _now(options.now);
898
+ if (record.revokedAt) {
899
+ return { valid: false, reason: 'revoked' };
900
+ }
901
+ if (record.expiresAt && record.expiresAt <= now) {
902
+ return { valid: false, reason: 'expired' };
903
+ }
862
904
  if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
863
905
  return { valid: false, reason: 'missing_scope' };
864
906
  }
@@ -1,8 +1 @@
1
- export function customStore(impl: any): {
2
- put: (record: any) => Promise<any>;
3
- getById: (id: any) => Promise<any>;
4
- update: (id: any, patch: any) => Promise<any>;
5
- revoke: (id: any, reason: any) => Promise<any>;
6
- revokeAllForUser: (userId: any, reason: any) => Promise<any>;
7
- listByUser: (userId: any) => Promise<any>;
8
- };
1
+ export const customStore: (impl: object) => Record<string, (...args: any[]) => Promise<any>>;
@@ -1,3 +1,79 @@
1
- export { memoryStore } from "./memory.js";
2
- export { redisStore } from "./redis.js";
3
- export { customStore } from "./custom.js";
1
+ type ApiKeyRecord = {
2
+ /**
3
+ * Plaintext lookup key (128-bit b64u).
4
+ */
5
+ id: string;
6
+ /**
7
+ * Storage hash of the secret (43-char b64u).
8
+ */
9
+ hash: string;
10
+ /**
11
+ * The prefix the key was minted with.
12
+ */
13
+ prefix: string;
14
+ userId: string;
15
+ scopes: string[];
16
+ /**
17
+ * Human label ("Production Backend").
18
+ */
19
+ name?: string | undefined;
20
+ /**
21
+ * `'live'` / `'test'` / caller-defined.
22
+ */
23
+ environment?: string | undefined;
24
+ metadata?: Record<string, unknown> | undefined;
25
+ /**
26
+ * ms epoch.
27
+ */
28
+ createdAt: number;
29
+ /**
30
+ * ms epoch; absent = no expiry.
31
+ */
32
+ expiresAt?: number | undefined;
33
+ /**
34
+ * ms epoch when the record was revoked.
35
+ */
36
+ revokedAt?: number | undefined;
37
+ revokedReason?: string | undefined;
38
+ /**
39
+ * ms epoch of the last successful verify.
40
+ */
41
+ lastUsedAt?: number | undefined;
42
+ /**
43
+ * Index into the peppers array that was used at mint. 0 = newest.
44
+ */
45
+ pepperVersion?: number | undefined;
46
+ };
47
+ type ApiKeyStore = {
48
+ put: (record: ApiKeyRecord) => Promise<void>;
49
+ getById: (id: string) => Promise<ApiKeyRecord | null>;
50
+ update: (id: string, patch: Partial<ApiKeyRecord>) => Promise<ApiKeyRecord | null>;
51
+ revoke: (id: string, reason?: string) => Promise<boolean>;
52
+ revokeAllForUser: (userId: string, reason?: string) => Promise<number>;
53
+ listByUser: (userId: string) => Promise<ApiKeyRecord[]>;
54
+ };
55
+
56
+ /**
57
+ * @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
58
+ */
59
+ declare function memoryStore(): ApiKeyStore & {
60
+ _size: () => number;
61
+ };
62
+
63
+ /**
64
+ * @typedef {object} RedisStoreOptions
65
+ * @property {string} [keyPrefix='apikey:']
66
+ */
67
+ /**
68
+ * @param {any} client
69
+ * @param {RedisStoreOptions} [options]
70
+ * @returns {import('../index.js').ApiKeyStore}
71
+ */
72
+ declare function redisStore(client: any, options?: RedisStoreOptions): ApiKeyStore;
73
+ type RedisStoreOptions = {
74
+ keyPrefix?: string | undefined;
75
+ };
76
+
77
+ declare const customStore: (impl: object) => Record<string, (...args: any[]) => Promise<any>>;
78
+
79
+ export { customStore, memoryStore, redisStore };
package/dist/stores.cjs CHANGED
@@ -169,6 +169,8 @@ function detectDialect(client) {
169
169
  * smembers: (key: string) => Promise<string[]>,
170
170
  * setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
171
171
  * setPlain: (key: string, value: string) => Promise<void>,
172
+ * setKeepTTL: (key: string, value: string) => Promise<void>,
173
+ * setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
172
174
  * parseRecord: (raw: string|object|null) => object|null,
173
175
  * }}
174
176
  */
@@ -238,6 +240,29 @@ function createRedisHelpers(client) {
238
240
  await client.set(key, value);
239
241
  },
240
242
 
243
+ // Overwrite a value but keep whatever expiry the key already has
244
+ // (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
245
+ // update to an expiring record would otherwise make it immortal.
246
+ async setKeepTTL(key, value) {
247
+ if (dialect === 'ioredis') {
248
+ await client.set(key, value, 'KEEPTTL');
249
+ } else {
250
+ await client.set(key, value, { KEEPTTL: true });
251
+ }
252
+ },
253
+
254
+ // Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
255
+ // created the key, false when it already existed — a cross-process
256
+ // compare-and-set for "only the first caller wins".
257
+ async setIfAbsent(key, value, ttlMs) {
258
+ const px = Math.max(1, Math.ceil(ttlMs));
259
+ const reply =
260
+ dialect === 'ioredis'
261
+ ? await client.set(key, value, 'PX', px, 'NX')
262
+ : await client.set(key, value, { PX: px, NX: true });
263
+ return reply === 'OK';
264
+ },
265
+
241
266
  parseRecord(raw) {
242
267
  if (!raw) {
243
268
  return null;
@@ -333,8 +358,15 @@ function createMemoryRecordStore(config) {
333
358
  byIndex,
334
359
 
335
360
  async put(record) {
336
- byId.set(record[idField], copy(record));
337
- addIndex(record[indexField], record[idField]);
361
+ const id = record[idField];
362
+ const previous = byId.get(id);
363
+ // Overwriting a record under a different owner must drop the old
364
+ // owner's index entry, or the old owner keeps listing it.
365
+ if (previous && previous[indexField] !== record[indexField]) {
366
+ removeIndex(previous[indexField], id);
367
+ }
368
+ byId.set(id, copy(record));
369
+ addIndex(record[indexField], id);
338
370
  },
339
371
 
340
372
  async getById(id) {
@@ -399,7 +431,7 @@ function createMemoryRecordStore(config) {
399
431
  * @returns {{
400
432
  * put: (record: object, ttlMs?: number) => Promise<void>,
401
433
  * getById: (id: string) => Promise<object|null>,
402
- * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
434
+ * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
403
435
  * listByIndex: (indexValue: string) => Promise<object[]>,
404
436
  * readRecord: (id: string) => Promise<object|null>,
405
437
  * writeRecord: (record: object, ttlMs?: number) => Promise<void>,
@@ -464,6 +496,16 @@ function createRedisRecordStore(client, config) {
464
496
  }
465
497
  }
466
498
 
499
+ // An update with no explicit TTL keeps the key's remaining expiry
500
+ // instead of clearing it.
501
+ async function rewriteRecord(record, ttlMs) {
502
+ if (ttl && ttlMs === undefined) {
503
+ await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
504
+ return;
505
+ }
506
+ await writeRecord(record, ttlMs);
507
+ }
508
+
467
509
  async function writeTombstone(id, data, ttlMs) {
468
510
  const value = JSON.stringify(data);
469
511
  if (ttl && ttlMs !== undefined) {
@@ -500,7 +542,10 @@ function createRedisRecordStore(client, config) {
500
542
  const raw = rows[i * stride];
501
543
  const rawTomb = tombstones ? rows[i * stride + 1] : null;
502
544
  const record = helpers.parseRecord(raw);
503
- if (!record) {
545
+ // Gone, or re-put under another owner (`put` does not read the old
546
+ // record, so it cannot unlink the old index entry itself) — prune the
547
+ // stale entry and never hand the record to this owner.
548
+ if (!record || record[indexField] !== indexValue) {
504
549
  await indexRemove(indexValue, ids[i]);
505
550
  pairs.push([ids[i], null]);
506
551
  continue;
@@ -529,7 +574,7 @@ function createRedisRecordStore(client, config) {
529
574
  }
530
575
  const oldIndex = existing[indexField];
531
576
  const next = { ...existing, ...patch };
532
- await writeRecord(next, ttlMs);
577
+ await rewriteRecord(next, ttlMs);
533
578
  if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
534
579
  if (isString(oldIndex)) {
535
580
  await indexRemove(oldIndex, id);
@@ -897,39 +942,101 @@ function redisStore(client, options = {}) {
897
942
  }
898
943
 
899
944
  /**
900
- * Wrap a user-supplied store into the `ApiKeyStore` interface.
901
- * Validates that the required methods exist so a misconfiguration
902
- * surfaces at construction time, not on the first `createApiKey` /
903
- * `verifyApiKey` call. Sync implementations are wrapped transparently
904
- * — return a plain value or a Promise, either works.
945
+ * Family factory for the `customStore(impl)` wrapper every store-shipping
946
+ * package exposes.
905
947
  *
906
- * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
948
+ * Each package used to hand-roll the same three steps: assert `impl` is an
949
+ * object, assert its required methods are functions, then `Promise.resolve()`
950
+ * -wrap every call so a sync implementation is transparently promoted to the
951
+ * async store interface. Only the method-name arrays and the typed error
952
+ * differ. This collapses all of that into one binding per package.
907
953
  *
908
- * @param {Partial<import('../index.js').ApiKeyStore>} impl
909
- * @returns {import('../index.js').ApiKeyStore}
954
+ * Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
910
955
  */
911
956
 
912
- const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
913
957
 
914
- function customStore(impl) {
958
+ /**
959
+ * Assert `impl` is an object exposing every method in `required` as a function.
960
+ * Throws the caller's typed error (via `wrap`) on the first failure. Used on
961
+ * its own by stores that keep a caller's implementation verbatim (jwt / paseto
962
+ * token registries), and internally by {@link createCustomStoreValidator}.
963
+ *
964
+ * @param {unknown} impl
965
+ * @param {readonly string[]} required
966
+ * @param {(message: string) => Error} wrap
967
+ * The package's error factory; its returned error is thrown. A `wrap` that
968
+ * throws directly also works (the outer `throw` is then unreachable).
969
+ * @returns {void}
970
+ */
971
+ function assertStoreShape(impl, required, wrap) {
915
972
  if (!isObject(impl)) {
916
- throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
973
+ throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
917
974
  }
918
- for (const name of REQUIRED) {
919
- if (!isFunction(impl[name])) {
920
- throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
975
+ for (const name of required) {
976
+ if (!isFunction(/** @type {any} */ (impl)[name])) {
977
+ throw wrap(`customStore: impl.${name} is required and must be a function`);
921
978
  }
922
979
  }
923
- return {
924
- put: record => Promise.resolve(impl.put(record)),
925
- getById: id => Promise.resolve(impl.getById(id)),
926
- update: (id, patch) => Promise.resolve(impl.update(id, patch)),
927
- revoke: (id, reason) => Promise.resolve(impl.revoke(id, reason)),
928
- revokeAllForUser: (userId, reason) => Promise.resolve(impl.revokeAllForUser(userId, reason)),
929
- listByUser: userId => Promise.resolve(impl.listByUser(userId)),
980
+ }
981
+
982
+ /**
983
+ * @param {object} spec
984
+ * @param {readonly string[]} spec.required
985
+ * Methods that must exist on `impl`; missing ones fail at construction time.
986
+ * @param {readonly string[]} [spec.optional]
987
+ * Methods wrapped through only when present (feature-gated store extras).
988
+ * @param {(message: string) => Error} spec.wrap
989
+ * The package's error factory (its `invalidArgument`) — called with a
990
+ * diagnostic message; its returned typed error is thrown so
991
+ * `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
992
+ * works (the outer `throw` is then unreachable).
993
+ * @param {Record<string, (result: any) => any>} [spec.coerce]
994
+ * Optional per-method post-processor applied to the resolved result — e.g.
995
+ * opaque coerces `set` to `undefined` so a native `Map.set` return value
996
+ * can't leak out as `Promise<Map>`.
997
+ * @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
998
+ */
999
+ function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
1000
+ return function customStore(impl) {
1001
+ assertStoreShape(impl, required, wrap);
1002
+
1003
+ /** @type {Record<string, (...args: any[]) => Promise<any>>} */
1004
+ const store = {};
1005
+ const bind = name => {
1006
+ const post = coerce[name];
1007
+ store[name] = post
1008
+ ? (...args) => Promise.resolve(impl[name](...args)).then(post)
1009
+ : (...args) => Promise.resolve(impl[name](...args));
1010
+ };
1011
+
1012
+ for (const name of required) {
1013
+ bind(name);
1014
+ }
1015
+ for (const name of optional) {
1016
+ if (isFunction(impl[name])) {
1017
+ bind(name);
1018
+ }
1019
+ }
1020
+ return store;
930
1021
  };
931
1022
  }
932
1023
 
1024
+ /**
1025
+ * Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
1026
+ * required methods at construction time (not on first use) and promotes sync
1027
+ * implementations to the async interface.
1028
+ *
1029
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
1030
+ *
1031
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
1032
+ * @returns {import('../index.js').ApiKeyStore}
1033
+ */
1034
+
1035
+ const customStore = createCustomStoreValidator({
1036
+ required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
1037
+ wrap: invalidArgument,
1038
+ });
1039
+
933
1040
  exports.customStore = customStore;
934
1041
  exports.memoryStore = memoryStore;
935
1042
  exports.redisStore = redisStore;