@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.
package/dist/stores.mjs CHANGED
@@ -167,6 +167,8 @@ function detectDialect(client) {
167
167
  * smembers: (key: string) => Promise<string[]>,
168
168
  * setWithTTL: (key: string, value: string, ttlMs: number) => Promise<void>,
169
169
  * setPlain: (key: string, value: string) => Promise<void>,
170
+ * setKeepTTL: (key: string, value: string) => Promise<void>,
171
+ * setIfAbsent: (key: string, value: string, ttlMs: number) => Promise<boolean>,
170
172
  * parseRecord: (raw: string|object|null) => object|null,
171
173
  * }}
172
174
  */
@@ -236,6 +238,29 @@ function createRedisHelpers(client) {
236
238
  await client.set(key, value);
237
239
  },
238
240
 
241
+ // Overwrite a value but keep whatever expiry the key already has
242
+ // (Redis ≥ 6.0 `SET … KEEPTTL`). A plain SET clears the TTL, so an
243
+ // update to an expiring record would otherwise make it immortal.
244
+ async setKeepTTL(key, value) {
245
+ if (dialect === 'ioredis') {
246
+ await client.set(key, value, 'KEEPTTL');
247
+ } else {
248
+ await client.set(key, value, { KEEPTTL: true });
249
+ }
250
+ },
251
+
252
+ // Atomic claim: `SET key value PX ttl NX`. Resolves true when this call
253
+ // created the key, false when it already existed — a cross-process
254
+ // compare-and-set for "only the first caller wins".
255
+ async setIfAbsent(key, value, ttlMs) {
256
+ const px = Math.max(1, Math.ceil(ttlMs));
257
+ const reply =
258
+ dialect === 'ioredis'
259
+ ? await client.set(key, value, 'PX', px, 'NX')
260
+ : await client.set(key, value, { PX: px, NX: true });
261
+ return reply === 'OK';
262
+ },
263
+
239
264
  parseRecord(raw) {
240
265
  if (!raw) {
241
266
  return null;
@@ -331,8 +356,15 @@ function createMemoryRecordStore(config) {
331
356
  byIndex,
332
357
 
333
358
  async put(record) {
334
- byId.set(record[idField], copy(record));
335
- addIndex(record[indexField], record[idField]);
359
+ const id = record[idField];
360
+ const previous = byId.get(id);
361
+ // Overwriting a record under a different owner must drop the old
362
+ // owner's index entry, or the old owner keeps listing it.
363
+ if (previous && previous[indexField] !== record[indexField]) {
364
+ removeIndex(previous[indexField], id);
365
+ }
366
+ byId.set(id, copy(record));
367
+ addIndex(record[indexField], id);
336
368
  },
337
369
 
338
370
  async getById(id) {
@@ -397,7 +429,7 @@ function createMemoryRecordStore(config) {
397
429
  * @returns {{
398
430
  * put: (record: object, ttlMs?: number) => Promise<void>,
399
431
  * getById: (id: string) => Promise<object|null>,
400
- * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>,
432
+ * update: (id: string, patch: object, ttlMs?: number) => Promise<object|null>, without `ttlMs` a TTL store keeps the key's remaining expiry
401
433
  * listByIndex: (indexValue: string) => Promise<object[]>,
402
434
  * readRecord: (id: string) => Promise<object|null>,
403
435
  * writeRecord: (record: object, ttlMs?: number) => Promise<void>,
@@ -462,6 +494,16 @@ function createRedisRecordStore(client, config) {
462
494
  }
463
495
  }
464
496
 
497
+ // An update with no explicit TTL keeps the key's remaining expiry
498
+ // instead of clearing it.
499
+ async function rewriteRecord(record, ttlMs) {
500
+ if (ttl && ttlMs === undefined) {
501
+ await helpers.setKeepTTL(rk(record[idField]), JSON.stringify(record));
502
+ return;
503
+ }
504
+ await writeRecord(record, ttlMs);
505
+ }
506
+
465
507
  async function writeTombstone(id, data, ttlMs) {
466
508
  const value = JSON.stringify(data);
467
509
  if (ttl && ttlMs !== undefined) {
@@ -498,7 +540,10 @@ function createRedisRecordStore(client, config) {
498
540
  const raw = rows[i * stride];
499
541
  const rawTomb = tombstones ? rows[i * stride + 1] : null;
500
542
  const record = helpers.parseRecord(raw);
501
- if (!record) {
543
+ // Gone, or re-put under another owner (`put` does not read the old
544
+ // record, so it cannot unlink the old index entry itself) — prune the
545
+ // stale entry and never hand the record to this owner.
546
+ if (!record || record[indexField] !== indexValue) {
502
547
  await indexRemove(indexValue, ids[i]);
503
548
  pairs.push([ids[i], null]);
504
549
  continue;
@@ -527,7 +572,7 @@ function createRedisRecordStore(client, config) {
527
572
  }
528
573
  const oldIndex = existing[indexField];
529
574
  const next = { ...existing, ...patch };
530
- await writeRecord(next, ttlMs);
575
+ await rewriteRecord(next, ttlMs);
531
576
  if (!isUndefined(patch[indexField]) && patch[indexField] !== oldIndex) {
532
577
  if (isString(oldIndex)) {
533
578
  await indexRemove(oldIndex, id);
@@ -895,37 +940,99 @@ function redisStore(client, options = {}) {
895
940
  }
896
941
 
897
942
  /**
898
- * Wrap a user-supplied store into the `ApiKeyStore` interface.
899
- * Validates that the required methods exist so a misconfiguration
900
- * surfaces at construction time, not on the first `createApiKey` /
901
- * `verifyApiKey` call. Sync implementations are wrapped transparently
902
- * — return a plain value or a Promise, either works.
943
+ * Family factory for the `customStore(impl)` wrapper every store-shipping
944
+ * package exposes.
903
945
  *
904
- * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
946
+ * Each package used to hand-roll the same three steps: assert `impl` is an
947
+ * object, assert its required methods are functions, then `Promise.resolve()`
948
+ * -wrap every call so a sync implementation is transparently promoted to the
949
+ * async store interface. Only the method-name arrays and the typed error
950
+ * differ. This collapses all of that into one binding per package.
905
951
  *
906
- * @param {Partial<import('../index.js').ApiKeyStore>} impl
907
- * @returns {import('../index.js').ApiKeyStore}
952
+ * Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
908
953
  */
909
954
 
910
- const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
911
955
 
912
- function customStore(impl) {
956
+ /**
957
+ * Assert `impl` is an object exposing every method in `required` as a function.
958
+ * Throws the caller's typed error (via `wrap`) on the first failure. Used on
959
+ * its own by stores that keep a caller's implementation verbatim (jwt / paseto
960
+ * token registries), and internally by {@link createCustomStoreValidator}.
961
+ *
962
+ * @param {unknown} impl
963
+ * @param {readonly string[]} required
964
+ * @param {(message: string) => Error} wrap
965
+ * The package's error factory; its returned error is thrown. A `wrap` that
966
+ * throws directly also works (the outer `throw` is then unreachable).
967
+ * @returns {void}
968
+ */
969
+ function assertStoreShape(impl, required, wrap) {
913
970
  if (!isObject(impl)) {
914
- throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
971
+ throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
915
972
  }
916
- for (const name of REQUIRED) {
917
- if (!isFunction(impl[name])) {
918
- throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
973
+ for (const name of required) {
974
+ if (!isFunction(/** @type {any} */ (impl)[name])) {
975
+ throw wrap(`customStore: impl.${name} is required and must be a function`);
919
976
  }
920
977
  }
921
- return {
922
- put: record => Promise.resolve(impl.put(record)),
923
- getById: id => Promise.resolve(impl.getById(id)),
924
- update: (id, patch) => Promise.resolve(impl.update(id, patch)),
925
- revoke: (id, reason) => Promise.resolve(impl.revoke(id, reason)),
926
- revokeAllForUser: (userId, reason) => Promise.resolve(impl.revokeAllForUser(userId, reason)),
927
- listByUser: userId => Promise.resolve(impl.listByUser(userId)),
978
+ }
979
+
980
+ /**
981
+ * @param {object} spec
982
+ * @param {readonly string[]} spec.required
983
+ * Methods that must exist on `impl`; missing ones fail at construction time.
984
+ * @param {readonly string[]} [spec.optional]
985
+ * Methods wrapped through only when present (feature-gated store extras).
986
+ * @param {(message: string) => Error} spec.wrap
987
+ * The package's error factory (its `invalidArgument`) — called with a
988
+ * diagnostic message; its returned typed error is thrown so
989
+ * `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
990
+ * works (the outer `throw` is then unreachable).
991
+ * @param {Record<string, (result: any) => any>} [spec.coerce]
992
+ * Optional per-method post-processor applied to the resolved result — e.g.
993
+ * opaque coerces `set` to `undefined` so a native `Map.set` return value
994
+ * can't leak out as `Promise<Map>`.
995
+ * @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
996
+ */
997
+ function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
998
+ return function customStore(impl) {
999
+ assertStoreShape(impl, required, wrap);
1000
+
1001
+ /** @type {Record<string, (...args: any[]) => Promise<any>>} */
1002
+ const store = {};
1003
+ const bind = name => {
1004
+ const post = coerce[name];
1005
+ store[name] = post
1006
+ ? (...args) => Promise.resolve(impl[name](...args)).then(post)
1007
+ : (...args) => Promise.resolve(impl[name](...args));
1008
+ };
1009
+
1010
+ for (const name of required) {
1011
+ bind(name);
1012
+ }
1013
+ for (const name of optional) {
1014
+ if (isFunction(impl[name])) {
1015
+ bind(name);
1016
+ }
1017
+ }
1018
+ return store;
928
1019
  };
929
1020
  }
930
1021
 
1022
+ /**
1023
+ * Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
1024
+ * required methods at construction time (not on first use) and promotes sync
1025
+ * implementations to the async interface.
1026
+ *
1027
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
1028
+ *
1029
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
1030
+ * @returns {import('../index.js').ApiKeyStore}
1031
+ */
1032
+
1033
+ const customStore = createCustomStoreValidator({
1034
+ required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
1035
+ wrap: invalidArgument,
1036
+ });
1037
+
931
1038
  export { customStore, memoryStore, redisStore };
package/dist/token.d.ts CHANGED
@@ -84,5 +84,6 @@ export function candidateHashesFor(rawSecret: string, peppers: Buffer[] | null):
84
84
  * @returns {string}
85
85
  */
86
86
  export function mask(key: string): string;
87
+ export const _nodeTimingSafeEqual: any;
88
+ export const _isBytes: typeof isBytes;
87
89
  import { isBytes } from '@exortek/shared/predicates';
88
- export { nodeTimingSafeEqual as _nodeTimingSafeEqual, isBytes as _isBytes };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exortek/apikey",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Stripe-style prefixed API keys for Node.js — 3-segment tokens (prefix_id_secret), HMAC-SHA256 storage hash with pepper rotation, scope allowlists with wildcard match, opt-in lastUsedAt tracking, memory + Redis stores, Express + Fastify middleware. Server-only. Zero non-@exortek runtime dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -36,7 +36,7 @@
36
36
  "/LICENSE"
37
37
  ],
38
38
  "scripts": {
39
- "build": "rm -rf dist tsconfig.tsbuildinfo && rollup -c rollup.config.js && tsc -p tsconfig.json",
39
+ "build": "rm -rf dist tsconfig.tsbuildinfo && rollup -c rollup.config.js && tsc -p tsconfig.json && rollup -c ../../rollup.dts.config.mjs",
40
40
  "build:watch": "rollup -c rollup.config.js --watch",
41
41
  "build:types": "tsc -p tsconfig.json",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",