@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/CHANGELOG.md +26 -0
- package/dist/index.cjs +32 -10
- package/dist/index.d.ts +145 -18
- package/dist/index.mjs +32 -10
- package/dist/middleware/express.cjs +51 -9
- package/dist/middleware/express.d.ts +96 -1
- package/dist/middleware/express.mjs +51 -9
- package/dist/middleware/fastify.cjs +51 -9
- package/dist/middleware/fastify.d.ts +3 -1
- package/dist/middleware/fastify.mjs +51 -9
- package/dist/stores/custom.d.ts +1 -8
- package/dist/stores/index.d.ts +79 -3
- package/dist/stores.cjs +133 -26
- package/dist/stores.mjs +133 -26
- package/dist/token.d.ts +2 -1
- package/package.json +2 -2
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
899
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
971
|
+
throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
|
|
915
972
|
}
|
|
916
|
-
for (const name of
|
|
917
|
-
if (!isFunction(impl[name])) {
|
|
918
|
-
throw
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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.
|
|
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",
|