@exortek/apikey 1.1.2 → 1.1.3

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @exortek/apikey
2
2
 
3
+ ## 1.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 89aea87: Ship self-contained TypeScript declarations. Every package's emitted `.d.ts` referenced `@exortek/shared`
8
+ (e.g. `import { BaseError } from '@exortek/shared/errors'`), but `@exortek/shared` is a private, never-published
9
+ workspace package that is inlined into each bundle at build time. A TypeScript consumer therefore hit
10
+ `Cannot find module '@exortek/shared/…'` (with `skipLibCheck` off) or silently degraded error-class types like
11
+ `ApiKeyError` — losing its constructor signature and `.code` / `.message` — with `skipLibCheck` on.
12
+
13
+ The build now runs a declaration-bundling pass (`rollup-plugin-dts`) after `tsc`, flattening each entry's `.d.ts` and
14
+ inlining the `@exortek/shared` types so the shipped declarations are fully self-contained. Runtime deps and `node:*`
15
+ stay external. No runtime or API change — types only.
16
+
3
17
  ## 1.1.2
4
18
 
5
19
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,3 +1,131 @@
1
+ /**
2
+ * Parse a raw key without verifying it. Returns the segments, or
3
+ * `null` when the shape is wrong. Never throws.
4
+ *
5
+ * Callers can use `parseApiKey(key).prefix` to route by key family
6
+ * in a middleware before hitting the store — but the parse result
7
+ * MUST NOT be trusted as authenticated. Only `verifyApiKey` proves
8
+ * the holder possesses the real secret.
9
+ *
10
+ * @param {string} key
11
+ * @returns {{ prefix: string, id: string, secret: string } | null}
12
+ */
13
+ declare function parseApiKey(key: string): {
14
+ prefix: string;
15
+ id: string;
16
+ secret: string;
17
+ } | null;
18
+ /**
19
+ * Log-safe display for a key. Returns `<prefix>_<first-N-of-id>…<last-M-of-secret>`
20
+ * so the value is recognisable in an audit log without disclosing the
21
+ * secret. The default masks all but the first 6 chars of the id and
22
+ * the last 4 chars of the secret.
23
+ *
24
+ * @param {string} key
25
+ * @returns {string}
26
+ */
27
+ declare function mask(key: string): string;
28
+
29
+ /**
30
+ * Shared base error class — the single error structure behind every
31
+ * `@exortek/*` package's `errors.js`.
32
+ *
33
+ * Every package keeps its own class identity with a one-liner subclass;
34
+ * codes stay per-package frozen maps, status mapping is declared as a
35
+ * static field:
36
+ *
37
+ * import { BaseError } from '@exortek/shared/errors';
38
+ *
39
+ * export const ErrorCode = Object.freeze({
40
+ * INVALID_ARGUMENT: 'INVALID_ARGUMENT',
41
+ * INVALID_TOKEN: 'INVALID_TOKEN',
42
+ * });
43
+ *
44
+ * export class JwtError extends BaseError {
45
+ * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
46
+ * static defaultStatus = 500;
47
+ * }
48
+ *
49
+ * Instances carry a stable machine-readable `code` (branch on this,
50
+ * never on the message), an optional HTTP `status`, an optional
51
+ * `details` object, and the standard `cause` chain.
52
+ */
53
+ declare class BaseError extends Error {
54
+ /**
55
+ * Optional `code → HTTP status` map declared on the subclass. When
56
+ * absent the instance carries no `status` at all — for HTTP-agnostic
57
+ * packages like `@exortek/crypto`.
58
+ *
59
+ * @type {Record<string, number> | undefined}
60
+ */
61
+ static statuses: Record<string, number> | undefined;
62
+ /**
63
+ * Fallback status for codes missing from `statuses`.
64
+ *
65
+ * @type {number}
66
+ */
67
+ static defaultStatus: number;
68
+ /**
69
+ * @param {string} code Stable machine-readable code; branch on this.
70
+ * @param {string} message Human-readable diagnostic. Free-form; may
71
+ * change across versions.
72
+ * @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
73
+ */
74
+ constructor(code: string, message: string, options?: {
75
+ cause?: unknown;
76
+ status?: number;
77
+ details?: Record<string, unknown>;
78
+ });
79
+ /** @type {string} */
80
+ code: string;
81
+ /** @type {number | undefined} */
82
+ status: number | undefined;
83
+ /** @type {Record<string, unknown> | undefined} */
84
+ details: Record<string, unknown> | undefined;
85
+ }
86
+
87
+ declare const ErrorCode: Readonly<{
88
+ INVALID_ARGUMENT: "INVALID_ARGUMENT";
89
+ INVALID_PREFIX: "INVALID_PREFIX";
90
+ INVALID_PEPPER: "INVALID_PEPPER";
91
+ STORE_ERROR: "STORE_ERROR";
92
+ }>;
93
+ /**
94
+ * Every recoverable failure raised by this package. Carries a stable
95
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
96
+ * status a middleware layer would use when translating the error.
97
+ */
98
+ declare class ApiKeyError extends BaseError {
99
+ static statuses: {
100
+ INVALID_ARGUMENT: number;
101
+ INVALID_PREFIX: number;
102
+ INVALID_PEPPER: number;
103
+ STORE_ERROR: number;
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Does `granted` cover a single `required` scope?
109
+ * @param {string[]} granted
110
+ * @param {string} required
111
+ * @returns {boolean}
112
+ */
113
+ declare function covers(granted: string[], required: string): boolean;
114
+ /**
115
+ * All required scopes must be covered by granted.
116
+ * @param {string[]} granted
117
+ * @param {string[]} required
118
+ * @returns {boolean}
119
+ */
120
+ declare function hasAll(granted: string[], required: string[]): boolean;
121
+ /**
122
+ * At least one required scope must be covered.
123
+ * @param {string[]} granted
124
+ * @param {string[]} required
125
+ * @returns {boolean}
126
+ */
127
+ declare function hasAny(granted: string[], required: string[]): boolean;
128
+
1
129
  /**
2
130
  * @typedef {object} ApiKeyRecord
3
131
  * @property {string} id Plaintext lookup key (128-bit b64u).
@@ -80,7 +208,7 @@
80
208
  * @param {CreateApiKeyOptions} options
81
209
  * @returns {Promise<CreateApiKeyResult>}
82
210
  */
83
- export function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKeyResult>;
211
+ declare function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKeyResult>;
84
212
  /**
85
213
  * Verify a raw API key. Returns `{ valid: true, ... }` on success, or
86
214
  * `{ valid: false, reason }` on any expected failure. Never throws on
@@ -96,7 +224,7 @@ export function createApiKey(options: CreateApiKeyOptions): Promise<CreateApiKey
96
224
  * @param {VerifyApiKeyOptions} options
97
225
  * @returns {Promise<VerifyApiKeyResult>}
98
226
  */
99
- export function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Promise<VerifyApiKeyResult>;
227
+ declare function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Promise<VerifyApiKeyResult>;
100
228
  /**
101
229
  * Revoke an API key by id or by the raw wire key. Returns `true` if
102
230
  * a record was actually revoked, `false` if the id was unknown or
@@ -106,7 +234,7 @@ export function verifyApiKey(rawKey: string, options: VerifyApiKeyOptions): Prom
106
234
  * @param {{ store: ApiKeyStore, reason?: string }} options
107
235
  * @returns {Promise<boolean>}
108
236
  */
109
- export function revokeApiKey(keyOrId: string, options: {
237
+ declare function revokeApiKey(keyOrId: string, options: {
110
238
  store: ApiKeyStore;
111
239
  reason?: string;
112
240
  }): Promise<boolean>;
@@ -119,7 +247,7 @@ export function revokeApiKey(keyOrId: string, options: {
119
247
  * @param {{ store: ApiKeyStore, reason?: string }} options
120
248
  * @returns {Promise<number>}
121
249
  */
122
- export function revokeAllForUser(userId: string, options: {
250
+ declare function revokeAllForUser(userId: string, options: {
123
251
  store: ApiKeyStore;
124
252
  reason?: string;
125
253
  }): Promise<number>;
@@ -133,7 +261,7 @@ export function revokeAllForUser(userId: string, options: {
133
261
  * @param {{ store: ApiKeyStore }} options
134
262
  * @returns {Promise<ApiKeyRecord[]>}
135
263
  */
136
- export function listApiKeys(userId: string, options: {
264
+ declare function listApiKeys(userId: string, options: {
137
265
  store: ApiKeyStore;
138
266
  }): Promise<ApiKeyRecord[]>;
139
267
  /**
@@ -146,12 +274,12 @@ export function listApiKeys(userId: string, options: {
146
274
  * @param {{ store: ApiKeyStore, peppers: (Buffer | Uint8Array | string)[] }} options
147
275
  * @returns {Promise<boolean>}
148
276
  */
149
- export function rehashApiKey(rawKey: string, options: {
277
+ declare function rehashApiKey(rawKey: string, options: {
150
278
  store: ApiKeyStore;
151
279
  peppers: (Buffer | Uint8Array | string)[];
152
280
  }): Promise<boolean>;
153
- export { maskKey as maskApiKey };
154
- export type ApiKeyRecord = {
281
+
282
+ type ApiKeyRecord = {
155
283
  /**
156
284
  * Plaintext lookup key (128-bit b64u).
157
285
  */
@@ -197,7 +325,7 @@ export type ApiKeyRecord = {
197
325
  */
198
326
  pepperVersion?: number | undefined;
199
327
  };
200
- export type ApiKeyStore = {
328
+ type ApiKeyStore = {
201
329
  put: (record: ApiKeyRecord) => Promise<void>;
202
330
  getById: (id: string) => Promise<ApiKeyRecord | null>;
203
331
  update: (id: string, patch: Partial<ApiKeyRecord>) => Promise<ApiKeyRecord | null>;
@@ -205,7 +333,7 @@ export type ApiKeyStore = {
205
333
  revokeAllForUser: (userId: string, reason?: string) => Promise<number>;
206
334
  listByUser: (userId: string) => Promise<ApiKeyRecord[]>;
207
335
  };
208
- export type CreateApiKeyOptions = {
336
+ type CreateApiKeyOptions = {
209
337
  /**
210
338
  * Stripe-style; `sk_live`, `pk_test`, `svc_prod_v2`.
211
339
  */
@@ -229,7 +357,7 @@ export type CreateApiKeyOptions = {
229
357
  */
230
358
  now?: number | undefined;
231
359
  };
232
- export type CreateApiKeyResult = {
360
+ type CreateApiKeyResult = {
233
361
  /**
234
362
  * Wire token — show ONCE to the caller and never again.
235
363
  */
@@ -243,7 +371,7 @@ export type CreateApiKeyResult = {
243
371
  */
244
372
  record: ApiKeyRecord;
245
373
  };
246
- export type VerifyApiKeyOptions = {
374
+ type VerifyApiKeyOptions = {
247
375
  store: ApiKeyStore;
248
376
  peppers?: any[] | undefined;
249
377
  requiredScopes?: string[] | undefined;
@@ -257,8 +385,8 @@ export type VerifyApiKeyOptions = {
257
385
  updateLastUsed?: boolean | undefined;
258
386
  now?: number | undefined;
259
387
  };
260
- export type VerifyApiKeyFailureReason = "malformed" | "not_found" | "expired" | "revoked" | "bad_secret" | "prefix_mismatch" | "missing_scope" | "store_unavailable";
261
- export type VerifyApiKeyResult = {
388
+ type VerifyApiKeyFailureReason = "malformed" | "not_found" | "expired" | "revoked" | "bad_secret" | "prefix_mismatch" | "missing_scope" | "store_unavailable";
389
+ type VerifyApiKeyResult = {
262
390
  valid: true;
263
391
  id: string;
264
392
  userId: string;
@@ -272,7 +400,6 @@ export type VerifyApiKeyResult = {
272
400
  valid: false;
273
401
  reason: VerifyApiKeyFailureReason;
274
402
  };
275
- import { mask as maskKey } from './token.js';
276
- export { ApiKeyError, ErrorCode } from "./errors.js";
277
- export { covers, hasAll, hasAny } from "./scopes.js";
278
- export { parseApiKey, mask } from "./token.js";
403
+
404
+ export { ApiKeyError, ErrorCode, covers, createApiKey, hasAll, hasAny, listApiKeys, mask, mask as maskApiKey, parseApiKey, rehashApiKey, revokeAllForUser, revokeApiKey, verifyApiKey };
405
+ export type { ApiKeyRecord, ApiKeyStore, CreateApiKeyOptions, CreateApiKeyResult, VerifyApiKeyFailureReason, VerifyApiKeyOptions, VerifyApiKeyResult };
@@ -1,4 +1,99 @@
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
+ type AdapterContext = {
57
+ getHeader: (name: string) => string | undefined;
58
+ method?: string | undefined;
59
+ ip?: string | undefined;
60
+ query?: Record<string, unknown> | undefined;
61
+ };
62
+ type ApiKeyMiddlewareOptions = {
63
+ store: ApiKeyStore;
64
+ peppers?: any[] | undefined;
65
+ requiredScopes?: string[] | undefined;
66
+ expectedPrefix?: string | undefined;
67
+ updateLastUsed?: boolean | undefined;
68
+ /**
69
+ * Case-insensitive. The default reads `Authorization: Bearer <key>`;
70
+ * a `x-api-key` config reads `X-API-Key: <key>` directly.
71
+ */
72
+ headerName?: string | undefined;
73
+ /**
74
+ * `'bearer'` expects `Bearer <key>`; `'raw'` uses the header value as-is.
75
+ */
76
+ scheme?: "bearer" | "raw" | undefined;
77
+ /**
78
+ * When true, falls back to `?api_key=<key>` if no matching header is
79
+ * found. Discouraged because query strings leak into access logs and
80
+ * referer headers — off by default.
81
+ */
82
+ allowQueryParam?: boolean | undefined;
83
+ queryParamName?: string | undefined;
84
+ /**
85
+ * Property name attached to the request object on success.
86
+ */
87
+ attach?: string | undefined;
88
+ /**
89
+ * Override the default extraction entirely.
90
+ */
91
+ tokenFromRequest?: ((ctx: AdapterContext) => string | undefined) | undefined;
92
+ };
93
+
1
94
  /**
2
95
  * @param {import('./core.js').ApiKeyMiddlewareOptions} options
3
96
  */
4
- export function apiKeyMiddleware(options: import("./core.js").ApiKeyMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
97
+ declare function apiKeyMiddleware(options: ApiKeyMiddlewareOptions): (req: any, res: any, next: any) => Promise<any>;
98
+
99
+ export { apiKeyMiddleware };
@@ -1 +1,3 @@
1
- export const apiKeyPlugin: Function;
1
+ declare const apiKeyPlugin: Function;
2
+
3
+ export { apiKeyPlugin };
@@ -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
@@ -897,39 +897,101 @@ function redisStore(client, options = {}) {
897
897
  }
898
898
 
899
899
  /**
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.
900
+ * Family factory for the `customStore(impl)` wrapper every store-shipping
901
+ * package exposes.
905
902
  *
906
- * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
903
+ * Each package used to hand-roll the same three steps: assert `impl` is an
904
+ * object, assert its required methods are functions, then `Promise.resolve()`
905
+ * -wrap every call so a sync implementation is transparently promoted to the
906
+ * async store interface. Only the method-name arrays and the typed error
907
+ * differ. This collapses all of that into one binding per package.
907
908
  *
908
- * @param {Partial<import('../index.js').ApiKeyStore>} impl
909
- * @returns {import('../index.js').ApiKeyStore}
909
+ * Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
910
910
  */
911
911
 
912
- const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
913
912
 
914
- function customStore(impl) {
913
+ /**
914
+ * Assert `impl` is an object exposing every method in `required` as a function.
915
+ * Throws the caller's typed error (via `wrap`) on the first failure. Used on
916
+ * its own by stores that keep a caller's implementation verbatim (jwt / paseto
917
+ * token registries), and internally by {@link createCustomStoreValidator}.
918
+ *
919
+ * @param {unknown} impl
920
+ * @param {readonly string[]} required
921
+ * @param {(message: string) => Error} wrap
922
+ * The package's error factory; its returned error is thrown. A `wrap` that
923
+ * throws directly also works (the outer `throw` is then unreachable).
924
+ * @returns {void}
925
+ */
926
+ function assertStoreShape(impl, required, wrap) {
915
927
  if (!isObject(impl)) {
916
- throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
928
+ throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
917
929
  }
918
- for (const name of REQUIRED) {
919
- if (!isFunction(impl[name])) {
920
- throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
930
+ for (const name of required) {
931
+ if (!isFunction(/** @type {any} */ (impl)[name])) {
932
+ throw wrap(`customStore: impl.${name} is required and must be a function`);
921
933
  }
922
934
  }
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)),
935
+ }
936
+
937
+ /**
938
+ * @param {object} spec
939
+ * @param {readonly string[]} spec.required
940
+ * Methods that must exist on `impl`; missing ones fail at construction time.
941
+ * @param {readonly string[]} [spec.optional]
942
+ * Methods wrapped through only when present (feature-gated store extras).
943
+ * @param {(message: string) => Error} spec.wrap
944
+ * The package's error factory (its `invalidArgument`) — called with a
945
+ * diagnostic message; its returned typed error is thrown so
946
+ * `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
947
+ * works (the outer `throw` is then unreachable).
948
+ * @param {Record<string, (result: any) => any>} [spec.coerce]
949
+ * Optional per-method post-processor applied to the resolved result — e.g.
950
+ * opaque coerces `set` to `undefined` so a native `Map.set` return value
951
+ * can't leak out as `Promise<Map>`.
952
+ * @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
953
+ */
954
+ function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
955
+ return function customStore(impl) {
956
+ assertStoreShape(impl, required, wrap);
957
+
958
+ /** @type {Record<string, (...args: any[]) => Promise<any>>} */
959
+ const store = {};
960
+ const bind = name => {
961
+ const post = coerce[name];
962
+ store[name] = post
963
+ ? (...args) => Promise.resolve(impl[name](...args)).then(post)
964
+ : (...args) => Promise.resolve(impl[name](...args));
965
+ };
966
+
967
+ for (const name of required) {
968
+ bind(name);
969
+ }
970
+ for (const name of optional) {
971
+ if (isFunction(impl[name])) {
972
+ bind(name);
973
+ }
974
+ }
975
+ return store;
930
976
  };
931
977
  }
932
978
 
979
+ /**
980
+ * Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
981
+ * required methods at construction time (not on first use) and promotes sync
982
+ * implementations to the async interface.
983
+ *
984
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
985
+ *
986
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
987
+ * @returns {import('../index.js').ApiKeyStore}
988
+ */
989
+
990
+ const customStore = createCustomStoreValidator({
991
+ required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
992
+ wrap: invalidArgument,
993
+ });
994
+
933
995
  exports.customStore = customStore;
934
996
  exports.memoryStore = memoryStore;
935
997
  exports.redisStore = redisStore;
package/dist/stores.mjs CHANGED
@@ -895,37 +895,99 @@ function redisStore(client, options = {}) {
895
895
  }
896
896
 
897
897
  /**
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.
898
+ * Family factory for the `customStore(impl)` wrapper every store-shipping
899
+ * package exposes.
903
900
  *
904
- * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
901
+ * Each package used to hand-roll the same three steps: assert `impl` is an
902
+ * object, assert its required methods are functions, then `Promise.resolve()`
903
+ * -wrap every call so a sync implementation is transparently promoted to the
904
+ * async store interface. Only the method-name arrays and the typed error
905
+ * differ. This collapses all of that into one binding per package.
905
906
  *
906
- * @param {Partial<import('../index.js').ApiKeyStore>} impl
907
- * @returns {import('../index.js').ApiKeyStore}
907
+ * Consumers: `apikey`, `challenge`, `magic-link`, `opaque`, `session`.
908
908
  */
909
909
 
910
- const REQUIRED = ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'];
911
910
 
912
- function customStore(impl) {
911
+ /**
912
+ * Assert `impl` is an object exposing every method in `required` as a function.
913
+ * Throws the caller's typed error (via `wrap`) on the first failure. Used on
914
+ * its own by stores that keep a caller's implementation verbatim (jwt / paseto
915
+ * token registries), and internally by {@link createCustomStoreValidator}.
916
+ *
917
+ * @param {unknown} impl
918
+ * @param {readonly string[]} required
919
+ * @param {(message: string) => Error} wrap
920
+ * The package's error factory; its returned error is thrown. A `wrap` that
921
+ * throws directly also works (the outer `throw` is then unreachable).
922
+ * @returns {void}
923
+ */
924
+ function assertStoreShape(impl, required, wrap) {
913
925
  if (!isObject(impl)) {
914
- throw invalidArgument(`customStore(impl) requires an object with { ${REQUIRED.join(', ')} } methods`);
926
+ throw wrap(`customStore(impl) requires an object with { ${required.join(', ')} } methods`);
915
927
  }
916
- for (const name of REQUIRED) {
917
- if (!isFunction(impl[name])) {
918
- throw invalidArgument(`customStore: impl.${name} is required and must be a function`);
928
+ for (const name of required) {
929
+ if (!isFunction(/** @type {any} */ (impl)[name])) {
930
+ throw wrap(`customStore: impl.${name} is required and must be a function`);
919
931
  }
920
932
  }
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)),
933
+ }
934
+
935
+ /**
936
+ * @param {object} spec
937
+ * @param {readonly string[]} spec.required
938
+ * Methods that must exist on `impl`; missing ones fail at construction time.
939
+ * @param {readonly string[]} [spec.optional]
940
+ * Methods wrapped through only when present (feature-gated store extras).
941
+ * @param {(message: string) => Error} spec.wrap
942
+ * The package's error factory (its `invalidArgument`) — called with a
943
+ * diagnostic message; its returned typed error is thrown so
944
+ * `err instanceof <Pkg>Error` holds. A `wrap` that throws directly also
945
+ * works (the outer `throw` is then unreachable).
946
+ * @param {Record<string, (result: any) => any>} [spec.coerce]
947
+ * Optional per-method post-processor applied to the resolved result — e.g.
948
+ * opaque coerces `set` to `undefined` so a native `Map.set` return value
949
+ * can't leak out as `Promise<Map>`.
950
+ * @returns {(impl: object) => Record<string, (...args: any[]) => Promise<any>>}
951
+ */
952
+ function createCustomStoreValidator({ required, optional = [], wrap, coerce = {} }) {
953
+ return function customStore(impl) {
954
+ assertStoreShape(impl, required, wrap);
955
+
956
+ /** @type {Record<string, (...args: any[]) => Promise<any>>} */
957
+ const store = {};
958
+ const bind = name => {
959
+ const post = coerce[name];
960
+ store[name] = post
961
+ ? (...args) => Promise.resolve(impl[name](...args)).then(post)
962
+ : (...args) => Promise.resolve(impl[name](...args));
963
+ };
964
+
965
+ for (const name of required) {
966
+ bind(name);
967
+ }
968
+ for (const name of optional) {
969
+ if (isFunction(impl[name])) {
970
+ bind(name);
971
+ }
972
+ }
973
+ return store;
928
974
  };
929
975
  }
930
976
 
977
+ /**
978
+ * Wrap a user-supplied store into the `ApiKeyStore` interface. Validates the
979
+ * required methods at construction time (not on first use) and promotes sync
980
+ * implementations to the async interface.
981
+ *
982
+ * Required: `put`, `getById`, `update`, `revoke`, `revokeAllForUser`, `listByUser`.
983
+ *
984
+ * @param {Partial<import('../index.js').ApiKeyStore>} impl
985
+ * @returns {import('../index.js').ApiKeyStore}
986
+ */
987
+
988
+ const customStore = createCustomStoreValidator({
989
+ required: ['put', 'getById', 'update', 'revoke', 'revokeAllForUser', 'listByUser'],
990
+ wrap: invalidArgument,
991
+ });
992
+
931
993
  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.1.3",
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",