@exortek/apikey 1.0.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 +38 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/errors.d.ts +20 -0
- package/dist/index.cjs +1632 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.mjs +1617 -0
- package/dist/internal/guards.d.ts +4 -0
- package/dist/middleware/core.d.ts +86 -0
- package/dist/middleware/express.cjs +1363 -0
- package/dist/middleware/express.d.ts +4 -0
- package/dist/middleware/express.mjs +1361 -0
- package/dist/middleware/fastify.cjs +1374 -0
- package/dist/middleware/fastify.d.ts +1 -0
- package/dist/middleware/fastify.mjs +1372 -0
- package/dist/scopes.d.ts +21 -0
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/memory.d.ts +6 -0
- package/dist/stores/redis.d.ts +13 -0
- package/dist/stores.cjs +880 -0
- package/dist/stores.mjs +877 -0
- package/dist/token.d.ts +88 -0
- package/package.json +98 -0
package/dist/stores.cjs
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-throwing type predicates. Companion to `asserts.js`.
|
|
5
|
+
*
|
|
6
|
+
* ### When to reach for which
|
|
7
|
+
*
|
|
8
|
+
* - Writing a **throw-guard** at a function boundary — a bad shape
|
|
9
|
+
* should fail with the caller's typed error class? Use
|
|
10
|
+
* `asserts` + `defineGuards` (`assertObject`, `assertString`, …).
|
|
11
|
+
* The `assert*` family bundles the check with the correctly-bound
|
|
12
|
+
* throw and preserves the "one error class per package" contract.
|
|
13
|
+
*
|
|
14
|
+
* - Writing a **dispatch / branch / fallback** where the wrong shape
|
|
15
|
+
* is meant to be handled, not thrown? Use these predicates.
|
|
16
|
+
* Typical shapes:
|
|
17
|
+
*
|
|
18
|
+
* if (isObject(config.store)) { ... } else { ... }
|
|
19
|
+
*
|
|
20
|
+
* const isManager = isObject(v) && isFunction(v.issue);
|
|
21
|
+
*
|
|
22
|
+
* The two APIs deliberately do not overlap in intent: `assert*` throws,
|
|
23
|
+
* `is*` never does. Bypassing an `assert*` with an `if (!is*(x)) throw
|
|
24
|
+
* new PkgError(...)` pattern skips the package's guards binding and
|
|
25
|
+
* duplicates work the assert family already handles — don't.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `true` when `value` is a plain object *or* a class instance — anything
|
|
30
|
+
* whose property access is safe. Rejects `null`, arrays, and every
|
|
31
|
+
* primitive.
|
|
32
|
+
*
|
|
33
|
+
* The Node convention `typeof x === 'object' && x !== null` accepts
|
|
34
|
+
* arrays, which almost never matches caller intent (an array is not a
|
|
35
|
+
* "config object"). This predicate additionally rules arrays out, so
|
|
36
|
+
* `isObject(x)` really means "I can read `x.foo` without exploding on
|
|
37
|
+
* a numeric index".
|
|
38
|
+
*
|
|
39
|
+
* Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
|
|
40
|
+
* because they carry `typeof === 'object'` — the check is duck-typed,
|
|
41
|
+
* not prototype-restricted.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function isObject(value) {
|
|
47
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `true` when `value` is a string primitive. `String` boxed objects
|
|
52
|
+
* (from `new String('x')`) are refused — those are a footgun almost no
|
|
53
|
+
* one wants, and every place in this codebase reads strings by
|
|
54
|
+
* primitive access.
|
|
55
|
+
*
|
|
56
|
+
* @param {unknown} value
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
function isString(value) {
|
|
60
|
+
return typeof value === 'string';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `true` when `value` is a function. Class constructors, arrow
|
|
65
|
+
* functions, and generator functions all pass — `typeof` on any
|
|
66
|
+
* callable is `'function'`.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} value
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function isFunction(value) {
|
|
72
|
+
return typeof value === 'function';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `true` when `value` is `undefined`.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} value
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
function isUndefined(value) {
|
|
82
|
+
return typeof value === 'undefined';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Imperative single-argument guard helpers — the everyday
|
|
87
|
+
* `assertPositiveInt(x, 'name')` shape used at API boundaries.
|
|
88
|
+
* Companion to the compound schema builder in
|
|
89
|
+
* `@exortek/shared/validate`: **schema** for whole options objects,
|
|
90
|
+
* **asserts** for one-liner argument guards at the call site.
|
|
91
|
+
*
|
|
92
|
+
* The consumer surface is {@link defineGuards} (or the raw
|
|
93
|
+
* {@link bindAsserts} for full control). Each package binds the assert
|
|
94
|
+
* set to its own typed error class once (in `internal/guards.js`), so
|
|
95
|
+
* every argument failure throws that package's class —
|
|
96
|
+
* `err instanceof CryptoError` holds and users see at a glance which
|
|
97
|
+
* package raised the error. The bound `parse` bridges
|
|
98
|
+
* `@exortek/shared/validate` schemas to the same error class.
|
|
99
|
+
*
|
|
100
|
+
* **Path naming convention** (used as the `name` argument):
|
|
101
|
+
*
|
|
102
|
+
* `<publicFunction>[.options|.config][.<field>]`
|
|
103
|
+
*
|
|
104
|
+
* - `assertString(name, 'createUser.name')` — top-level arg
|
|
105
|
+
* - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
|
|
106
|
+
* - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
|
|
107
|
+
*
|
|
108
|
+
* Keep the path short: the emitted error is `"<name> must be <desc>"`,
|
|
109
|
+
* so a stack-line grep already tells the caller which function fired.
|
|
110
|
+
* Reserve `hint` for actionable follow-ups ("pass the bytes returned by
|
|
111
|
+
* encryptSymmetric()"), not for restating the field.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {{ cause?: unknown }} WrapExtra
|
|
116
|
+
* Extra options forwarded to the wrap function, so `invalidArgument`
|
|
117
|
+
* sites that catch an underlying error can preserve the `cause` chain
|
|
118
|
+
* without falling back to a raw `new PackageError(...)`.
|
|
119
|
+
*
|
|
120
|
+
* @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
|
|
121
|
+
* Constructs (never throws) the binding package's error, e.g.
|
|
122
|
+
* `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
|
|
123
|
+
*
|
|
124
|
+
* @typedef {{ hint?: string }} AssertOptions
|
|
125
|
+
* `hint` is appended to the message after an em-dash — use it for
|
|
126
|
+
* actionable guidance ("pass the exact bytes returned by …").
|
|
127
|
+
*
|
|
128
|
+
* @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the failure message. `description` completes the sentence
|
|
133
|
+
* `"<name> must be <description>"`; `hint` (optional) follows an
|
|
134
|
+
* em-dash.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name
|
|
137
|
+
* @param {string} description
|
|
138
|
+
* @param {string} [hint]
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
function message(name, description, hint) {
|
|
142
|
+
return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Bind the assert set to a package's error class. Call once per
|
|
149
|
+
* package (conventionally in `internal/guards.js`) and import the
|
|
150
|
+
* bound object everywhere:
|
|
151
|
+
*
|
|
152
|
+
* import { bindAsserts } from '@exortek/shared/asserts';
|
|
153
|
+
* import { CryptoError, ErrorCode } from '../errors.js';
|
|
154
|
+
* export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
|
|
155
|
+
*
|
|
156
|
+
* // call sites:
|
|
157
|
+
* g.assertPositiveInt(options.iterations, 'options.iterations');
|
|
158
|
+
* const cfg = g.parse(OptionsSchema, options, 'options');
|
|
159
|
+
*
|
|
160
|
+
* @param {WrapFn} wrap
|
|
161
|
+
*/
|
|
162
|
+
function bindAsserts(wrap) {
|
|
163
|
+
/**
|
|
164
|
+
* @param {string} name
|
|
165
|
+
* @param {string} description
|
|
166
|
+
* @param {AssertOptions} [opts]
|
|
167
|
+
* @returns {never}
|
|
168
|
+
*/
|
|
169
|
+
function fail(name, description, opts) {
|
|
170
|
+
throw wrap(message(name, description, opts?.hint));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Assert that `value` is a plain object (not `null`, not an array,
|
|
175
|
+
* not a primitive).
|
|
176
|
+
* @param {unknown} value
|
|
177
|
+
* @param {string} name
|
|
178
|
+
* @param {AssertOptions} [opts]
|
|
179
|
+
*/
|
|
180
|
+
function assertObject(value, name, opts) {
|
|
181
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
182
|
+
fail(name, 'an object', opts);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
/**
|
|
188
|
+
* Construct (not throw) the bound error with a free-form message —
|
|
189
|
+
* for `throw g.invalidArgument('…')` sites that don't fit the
|
|
190
|
+
* `X must be Y` shape (canonicalisation errors, cross-field
|
|
191
|
+
* constraint failures). Pass `{ cause }` when the argument-shape
|
|
192
|
+
* failure was triggered by an underlying throw whose chain matters
|
|
193
|
+
* (e.g. `JSON.stringify` on a bigint).
|
|
194
|
+
*
|
|
195
|
+
* @param {string} msg
|
|
196
|
+
* @param {WrapExtra} [extra]
|
|
197
|
+
* @returns {Error}
|
|
198
|
+
*/
|
|
199
|
+
invalidArgument(msg, extra) {
|
|
200
|
+
return wrap(msg, extra);
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Validate `input` against a `@exortek/shared/validate` schema;
|
|
205
|
+
* failures throw the bound error carrying every collected message.
|
|
206
|
+
* The bridge that keeps schema validation on the package's own
|
|
207
|
+
* error surface.
|
|
208
|
+
*
|
|
209
|
+
* @param {ParseableSchema} schema
|
|
210
|
+
* @param {unknown} input
|
|
211
|
+
* @param {string} [path='options']
|
|
212
|
+
* @returns {unknown} the parsed (possibly defaulted) value
|
|
213
|
+
*/
|
|
214
|
+
parse(schema, input, path = 'options') {
|
|
215
|
+
const r = schema.safeParse(input, path);
|
|
216
|
+
if (!r.ok) {
|
|
217
|
+
throw wrap(r.errors.join('; '));
|
|
218
|
+
}
|
|
219
|
+
return r.value;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
|
|
224
|
+
* @param {unknown} value
|
|
225
|
+
* @param {string} name Argument name to include in the error message.
|
|
226
|
+
* @param {AssertOptions} [opts]
|
|
227
|
+
*/
|
|
228
|
+
assertNonNegativeInt(value, name, opts) {
|
|
229
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
|
|
230
|
+
fail(name, 'a non-negative safe integer', opts);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
|
|
236
|
+
* @param {unknown} value
|
|
237
|
+
* @param {string} name
|
|
238
|
+
* @param {AssertOptions} [opts]
|
|
239
|
+
*/
|
|
240
|
+
assertPositiveInt(value, name, opts) {
|
|
241
|
+
if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
|
|
242
|
+
fail(name, 'a positive integer', opts);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
|
|
248
|
+
* Used for Unix millisecond timestamps in UUID v7 / ULID.
|
|
249
|
+
* @param {unknown} value
|
|
250
|
+
* @param {string} name
|
|
251
|
+
* @param {AssertOptions} [opts]
|
|
252
|
+
*/
|
|
253
|
+
assertUint48(value, name, opts) {
|
|
254
|
+
if (
|
|
255
|
+
!Number.isSafeInteger(value) ||
|
|
256
|
+
/** @type {number} */ (value) < 0 ||
|
|
257
|
+
/** @type {number} */ (value) > 0xffffffffffff
|
|
258
|
+
) {
|
|
259
|
+
fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Assert that `value` is a string (may be empty).
|
|
265
|
+
* @param {unknown} value
|
|
266
|
+
* @param {string} name
|
|
267
|
+
* @param {AssertOptions} [opts]
|
|
268
|
+
*/
|
|
269
|
+
assertString(value, name, opts) {
|
|
270
|
+
if (typeof value !== 'string') {
|
|
271
|
+
fail(name, 'a string', opts);
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Assert that `value` is a non-empty string.
|
|
277
|
+
* @param {unknown} value
|
|
278
|
+
* @param {string} name
|
|
279
|
+
* @param {AssertOptions} [opts]
|
|
280
|
+
*/
|
|
281
|
+
assertNonEmptyString(value, name, opts) {
|
|
282
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
283
|
+
fail(name, 'a non-empty string', opts);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Assert that `value` is a boolean.
|
|
289
|
+
* @param {unknown} value
|
|
290
|
+
* @param {string} name
|
|
291
|
+
* @param {AssertOptions} [opts]
|
|
292
|
+
*/
|
|
293
|
+
assertBoolean(value, name, opts) {
|
|
294
|
+
if (typeof value !== 'boolean') {
|
|
295
|
+
fail(name, 'a boolean', opts);
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Assert that `value` is a function.
|
|
301
|
+
* @param {unknown} value
|
|
302
|
+
* @param {string} name
|
|
303
|
+
* @param {AssertOptions} [opts]
|
|
304
|
+
*/
|
|
305
|
+
assertFunction(value, name, opts) {
|
|
306
|
+
if (typeof value !== 'function') {
|
|
307
|
+
fail(name, 'a function', opts);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
assertObject,
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Assert that `value` is either `undefined` or a plain object —
|
|
315
|
+
* the "optional options object" pattern.
|
|
316
|
+
* @param {unknown} value
|
|
317
|
+
* @param {string} name
|
|
318
|
+
* @param {AssertOptions} [opts]
|
|
319
|
+
*/
|
|
320
|
+
assertOptionalObject(value, name, opts) {
|
|
321
|
+
if (value === undefined) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
assertObject(value, name, opts);
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
|
|
329
|
+
* strings are NOT accepted. For already-encoded material where a
|
|
330
|
+
* string would be ambiguous (ciphertext, signatures, raw key bytes).
|
|
331
|
+
* @param {unknown} value
|
|
332
|
+
* @param {string} name
|
|
333
|
+
* @param {AssertOptions} [opts]
|
|
334
|
+
*/
|
|
335
|
+
assertBytes(value, name, opts) {
|
|
336
|
+
if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
|
|
337
|
+
fail(name, 'a Buffer or Uint8Array', opts);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Assert that `value` is either a string or a byte buffer (`Buffer`
|
|
343
|
+
* or `Uint8Array`).
|
|
344
|
+
* @param {unknown} value
|
|
345
|
+
* @param {string} name
|
|
346
|
+
* @param {AssertOptions} [opts]
|
|
347
|
+
*/
|
|
348
|
+
assertBytesOrString(value, name, opts) {
|
|
349
|
+
if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
|
|
350
|
+
fail(name, 'a string or Buffer', opts);
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Assert that `encoding` is one of the accepted output/input
|
|
356
|
+
* encodings. Pass `allowBuffer: false` where a `Buffer` output makes
|
|
357
|
+
* no sense (verifying a string signature, decoding a token payload).
|
|
358
|
+
*
|
|
359
|
+
* @param {unknown} encoding
|
|
360
|
+
* @param {string} name
|
|
361
|
+
* @param {AssertOptions & { allowBuffer?: boolean }} [opts]
|
|
362
|
+
*/
|
|
363
|
+
assertEncoding(encoding, name, opts) {
|
|
364
|
+
const allowBuffer = opts?.allowBuffer !== false;
|
|
365
|
+
const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
|
|
366
|
+
if (!valid) {
|
|
367
|
+
const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
|
|
368
|
+
fail(name, list, opts);
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One-line sugar over {@link bindAsserts} — the overwhelmingly common
|
|
376
|
+
* pattern where a package binds every argument-shape failure to its
|
|
377
|
+
* own error class + `INVALID_ARGUMENT` code. Emits the standard
|
|
378
|
+
* `new ErrorClass(code, message, { cause })` shape, so the underlying
|
|
379
|
+
* `BaseError` `{ cause, status, details }` options object is respected.
|
|
380
|
+
*
|
|
381
|
+
* // packages/<pkg>/src/internal/guards.js
|
|
382
|
+
* import { defineGuards } from '@exortek/shared/asserts';
|
|
383
|
+
* import { PasswordError, ErrorCode } from '../errors.js';
|
|
384
|
+
*
|
|
385
|
+
* export const { assertString, invalidArgument, parse } =
|
|
386
|
+
* defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
|
|
387
|
+
*
|
|
388
|
+
* Reach for the raw {@link bindAsserts} when you need a different
|
|
389
|
+
* mapping (an alt code, a decorated message) — it stays available.
|
|
390
|
+
*
|
|
391
|
+
* @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
|
|
392
|
+
* @param {string} code
|
|
393
|
+
*/
|
|
394
|
+
function defineGuards(ErrorClass, code) {
|
|
395
|
+
return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Shared base error class — the single error structure behind every
|
|
400
|
+
* `@exortek/*` package's `errors.js`.
|
|
401
|
+
*
|
|
402
|
+
* Every package keeps its own class identity with a one-liner subclass;
|
|
403
|
+
* codes stay per-package frozen maps, status mapping is declared as a
|
|
404
|
+
* static field:
|
|
405
|
+
*
|
|
406
|
+
* import { BaseError } from '@exortek/shared/errors';
|
|
407
|
+
*
|
|
408
|
+
* export const ErrorCode = Object.freeze({
|
|
409
|
+
* INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
410
|
+
* INVALID_TOKEN: 'INVALID_TOKEN',
|
|
411
|
+
* });
|
|
412
|
+
*
|
|
413
|
+
* export class JwtError extends BaseError {
|
|
414
|
+
* static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
|
|
415
|
+
* static defaultStatus = 500;
|
|
416
|
+
* }
|
|
417
|
+
*
|
|
418
|
+
* Instances carry a stable machine-readable `code` (branch on this,
|
|
419
|
+
* never on the message), an optional HTTP `status`, an optional
|
|
420
|
+
* `details` object, and the standard `cause` chain.
|
|
421
|
+
*/
|
|
422
|
+
class BaseError extends Error {
|
|
423
|
+
/**
|
|
424
|
+
* Optional `code → HTTP status` map declared on the subclass. When
|
|
425
|
+
* absent the instance carries no `status` at all — for HTTP-agnostic
|
|
426
|
+
* packages like `@exortek/crypto`.
|
|
427
|
+
*
|
|
428
|
+
* @type {Record<string, number> | undefined}
|
|
429
|
+
*/
|
|
430
|
+
static statuses = undefined;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Fallback status for codes missing from `statuses`.
|
|
434
|
+
*
|
|
435
|
+
* @type {number}
|
|
436
|
+
*/
|
|
437
|
+
static defaultStatus = 500;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @param {string} code Stable machine-readable code; branch on this.
|
|
441
|
+
* @param {string} message Human-readable diagnostic. Free-form; may
|
|
442
|
+
* change across versions.
|
|
443
|
+
* @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
|
|
444
|
+
*/
|
|
445
|
+
constructor(code, message, options = {}) {
|
|
446
|
+
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
447
|
+
this.name = new.target.name;
|
|
448
|
+
/** @type {string} */
|
|
449
|
+
this.code = code;
|
|
450
|
+
const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
|
|
451
|
+
if (options.status !== undefined) {
|
|
452
|
+
/** @type {number | undefined} */
|
|
453
|
+
this.status = options.status;
|
|
454
|
+
} else if (statuses !== undefined) {
|
|
455
|
+
// Object.hasOwn guards against inherited-property lookups for
|
|
456
|
+
// exotic code values ('toString', 'constructor', …).
|
|
457
|
+
this.status = Object.hasOwn(statuses, code)
|
|
458
|
+
? statuses[code]
|
|
459
|
+
: /** @type {typeof BaseError} */ (new.target).defaultStatus;
|
|
460
|
+
}
|
|
461
|
+
if (options.details) {
|
|
462
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
463
|
+
this.details = options.details;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Stable machine-readable codes for every programmer-error failure that
|
|
470
|
+
* `@exortek/apikey` can raise. Branch on `code`, never on the message.
|
|
471
|
+
* Note: expected verify failures (bad secret, expired, revoked,
|
|
472
|
+
* missing scope) are NOT thrown — `verifyApiKey` returns
|
|
473
|
+
* `{ valid: false, reason }` so a wrong or stale key is a normal auth
|
|
474
|
+
* outcome, not an exception. Errors below fire only when the caller
|
|
475
|
+
* configured something wrong or the store misbehaved.
|
|
476
|
+
*/
|
|
477
|
+
|
|
478
|
+
const ErrorCode = Object.freeze({
|
|
479
|
+
INVALID_ARGUMENT: 'INVALID_ARGUMENT',
|
|
480
|
+
INVALID_PREFIX: 'INVALID_PREFIX',
|
|
481
|
+
INVALID_PEPPER: 'INVALID_PEPPER',
|
|
482
|
+
STORE_ERROR: 'STORE_ERROR',
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Every recoverable failure raised by this package. Carries a stable
|
|
487
|
+
* `code` (from {@link ErrorCode}) and a `status` — the HTTP response
|
|
488
|
+
* status a middleware layer would use when translating the error.
|
|
489
|
+
*/
|
|
490
|
+
class ApiKeyError extends BaseError {
|
|
491
|
+
static statuses = {
|
|
492
|
+
[ErrorCode.INVALID_ARGUMENT]: 400,
|
|
493
|
+
[ErrorCode.INVALID_PREFIX]: 400,
|
|
494
|
+
[ErrorCode.INVALID_PEPPER]: 400,
|
|
495
|
+
[ErrorCode.STORE_ERROR]: 500,
|
|
496
|
+
};
|
|
497
|
+
static defaultStatus = 500;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Argument guards bound to `ApiKeyError` — the package-wide binding of
|
|
502
|
+
* `@exortek/shared/asserts`. Import guards from here, never from the
|
|
503
|
+
* shared module directly.
|
|
504
|
+
*/
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
const { invalidArgument} = defineGuards(
|
|
508
|
+
ApiKeyError,
|
|
509
|
+
ErrorCode.INVALID_ARGUMENT,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* In-process memory store for API keys.
|
|
514
|
+
*
|
|
515
|
+
* Not cluster-safe — every worker has its own state. Use for dev,
|
|
516
|
+
* single-node deployments, and tests. For multi-worker production use
|
|
517
|
+
* {@link redisStore} or bring your own DB-backed implementation of
|
|
518
|
+
* `ApiKeyStore`.
|
|
519
|
+
*
|
|
520
|
+
* Backed by two Maps: one keyed by `id` (the primary lookup path),
|
|
521
|
+
* one keyed by `userId` holding a `Set<id>` reverse index for
|
|
522
|
+
* `listByUser` / `revokeAllForUser`. Both are pruned on write.
|
|
523
|
+
*/
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @returns {import('../index.js').ApiKeyStore & { _size: () => number }}
|
|
528
|
+
*/
|
|
529
|
+
function memoryStore() {
|
|
530
|
+
/** @type {Map<string, import('../index.js').ApiKeyRecord>} */
|
|
531
|
+
const byId = new Map();
|
|
532
|
+
/** @type {Map<string, Set<string>>} */
|
|
533
|
+
const byUser = new Map();
|
|
534
|
+
|
|
535
|
+
function indexUser(uid, id) {
|
|
536
|
+
if (!isString(uid)) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
let set = byUser.get(uid);
|
|
540
|
+
if (!set) {
|
|
541
|
+
set = new Set();
|
|
542
|
+
byUser.set(uid, set);
|
|
543
|
+
}
|
|
544
|
+
set.add(id);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function unindexUser(uid, id) {
|
|
548
|
+
if (!isString(uid)) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const set = byUser.get(uid);
|
|
552
|
+
if (!set) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
set.delete(id);
|
|
556
|
+
if (set.size === 0) {
|
|
557
|
+
byUser.delete(uid);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
async put(record) {
|
|
563
|
+
if (!isObject(record) || !isString(record.id)) {
|
|
564
|
+
throw invalidArgument('memoryStore.put.record.id must be a string');
|
|
565
|
+
}
|
|
566
|
+
// Store a shallow copy so a caller mutating the record they
|
|
567
|
+
// passed in doesn't retroactively mutate what we've stored —
|
|
568
|
+
// matches the copy-on-read contract of getById / update.
|
|
569
|
+
byId.set(record.id, { ...record });
|
|
570
|
+
indexUser(record.userId, record.id);
|
|
571
|
+
},
|
|
572
|
+
|
|
573
|
+
async getById(id) {
|
|
574
|
+
const record = byId.get(id);
|
|
575
|
+
return record ? { ...record } : null;
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
async update(id, patch) {
|
|
579
|
+
const existing = byId.get(id);
|
|
580
|
+
if (!existing) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
const oldUserId = existing.userId;
|
|
584
|
+
const next = { ...existing, ...patch };
|
|
585
|
+
byId.set(id, next);
|
|
586
|
+
if (!isUndefined(patch.userId) && patch.userId !== oldUserId) {
|
|
587
|
+
unindexUser(oldUserId, id);
|
|
588
|
+
indexUser(next.userId, id);
|
|
589
|
+
}
|
|
590
|
+
return { ...next };
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
async revoke(id, reason) {
|
|
594
|
+
const existing = byId.get(id);
|
|
595
|
+
if (!existing || existing.revokedAt) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
existing.revokedAt = Date.now();
|
|
599
|
+
if (isString(reason)) {
|
|
600
|
+
existing.revokedReason = reason;
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
async revokeAllForUser(userId, reason) {
|
|
606
|
+
const set = byUser.get(userId);
|
|
607
|
+
if (!set) {
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
const now = Date.now();
|
|
611
|
+
let count = 0;
|
|
612
|
+
for (const id of set) {
|
|
613
|
+
const record = byId.get(id);
|
|
614
|
+
if (record && !record.revokedAt) {
|
|
615
|
+
record.revokedAt = now;
|
|
616
|
+
if (isString(reason)) {
|
|
617
|
+
record.revokedReason = reason;
|
|
618
|
+
}
|
|
619
|
+
count += 1;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return count;
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
async listByUser(userId) {
|
|
626
|
+
const set = byUser.get(userId);
|
|
627
|
+
if (!set) {
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
const out = [];
|
|
631
|
+
for (const id of set) {
|
|
632
|
+
const record = byId.get(id);
|
|
633
|
+
if (record) {
|
|
634
|
+
out.push({ ...record });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
_size: () => byId.size,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Duck-type guard for a Redis-compatible client. Every `@exortek/*`
|
|
646
|
+
* package that ships a Redis store (jwt blacklist, session store,
|
|
647
|
+
* security rate-limit) does the same "not null + required methods
|
|
648
|
+
* are functions" check up front so a missing dependency surfaces as
|
|
649
|
+
* a clean typed error instead of `TypeError: client.get is not a
|
|
650
|
+
* function` deep in a store operation.
|
|
651
|
+
*
|
|
652
|
+
* Callers pass their own `wrap` callback that throws their typed
|
|
653
|
+
* error class with the message — this file has no opinion on the
|
|
654
|
+
* error surface.
|
|
655
|
+
*/
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* @param {unknown} client
|
|
659
|
+
* @param {readonly string[]} methods Method names that must be functions on the client.
|
|
660
|
+
* @param {(message: string) => never} wrap
|
|
661
|
+
* Called with a diagnostic message when the check fails; must throw.
|
|
662
|
+
* The caller's typed error class is emitted from inside this callback.
|
|
663
|
+
* @returns {void}
|
|
664
|
+
*/
|
|
665
|
+
function assertRedisClient(client, methods, wrap) {
|
|
666
|
+
if (!client || typeof client !== 'object') {
|
|
667
|
+
wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
|
|
668
|
+
}
|
|
669
|
+
const c = /** @type {Record<string, unknown>} */ (client);
|
|
670
|
+
for (const method of methods) {
|
|
671
|
+
if (typeof c[method] !== 'function') {
|
|
672
|
+
wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Redis-backed store for API keys.
|
|
679
|
+
*
|
|
680
|
+
* Layout:
|
|
681
|
+
*
|
|
682
|
+
* <keyPrefix><id> — JSON blob of the record (primary lookup)
|
|
683
|
+
* <keyPrefix>u:<userId> — SADD-set of the user's key ids
|
|
684
|
+
*
|
|
685
|
+
* Cluster-safe: state lives in Redis, so a key put on one worker is
|
|
686
|
+
* immediately visible to every other. Works with any client exposing
|
|
687
|
+
* `get / set / del / sadd / srem / smembers / mget` — verified against
|
|
688
|
+
* `ioredis`, `node-redis@4+`, and `@upstash/redis`.
|
|
689
|
+
*
|
|
690
|
+
* There is no TTL on the record — API keys are meant to be long-lived
|
|
691
|
+
* and their `expiresAt` field is authoritative. Revocation is a
|
|
692
|
+
* flip-in-place on the record itself, not a tombstone, since
|
|
693
|
+
* `verifyApiKey` re-reads the full record every time.
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* @typedef {object} RedisStoreOptions
|
|
699
|
+
* @property {string} [keyPrefix='apikey:']
|
|
700
|
+
*/
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* @param {any} client
|
|
704
|
+
* @param {RedisStoreOptions} [options]
|
|
705
|
+
* @returns {import('../index.js').ApiKeyStore}
|
|
706
|
+
*/
|
|
707
|
+
function redisStore(client, options = {}) {
|
|
708
|
+
assertRedisClient(client, ['get', 'set', 'del'], msg => {
|
|
709
|
+
throw invalidArgument(`redisStore.client: ${msg}`);
|
|
710
|
+
});
|
|
711
|
+
const keyPrefix = options.keyPrefix ?? 'apikey:';
|
|
712
|
+
const rk = id => `${keyPrefix}${id}`;
|
|
713
|
+
const uk = uid => `${keyPrefix}u:${uid}`;
|
|
714
|
+
|
|
715
|
+
async function readRecord(id) {
|
|
716
|
+
const raw = await client.get(rk(id));
|
|
717
|
+
if (!raw) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
return isString(raw) ? JSON.parse(raw) : raw;
|
|
722
|
+
} catch {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function writeRecord(record) {
|
|
728
|
+
await client.set(rk(record.id), JSON.stringify(record));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function sadd(uid, id) {
|
|
732
|
+
if (isFunction(client.sadd)) {
|
|
733
|
+
return client.sadd(uk(uid), id);
|
|
734
|
+
}
|
|
735
|
+
if (isFunction(client.sAdd)) {
|
|
736
|
+
return client.sAdd(uk(uid), id);
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function srem(uid, id) {
|
|
742
|
+
if (isFunction(client.srem)) {
|
|
743
|
+
return client.srem(uk(uid), id);
|
|
744
|
+
}
|
|
745
|
+
if (isFunction(client.sRem)) {
|
|
746
|
+
return client.sRem(uk(uid), id);
|
|
747
|
+
}
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function smembers(uid) {
|
|
752
|
+
if (isFunction(client.smembers)) {
|
|
753
|
+
return client.smembers(uk(uid));
|
|
754
|
+
}
|
|
755
|
+
if (isFunction(client.sMembers)) {
|
|
756
|
+
return client.sMembers(uk(uid));
|
|
757
|
+
}
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function mget(keys) {
|
|
762
|
+
if (keys.length === 0) {
|
|
763
|
+
return [];
|
|
764
|
+
}
|
|
765
|
+
if (isFunction(client.mget)) {
|
|
766
|
+
return client.mget(...keys);
|
|
767
|
+
}
|
|
768
|
+
if (isFunction(client.mGet)) {
|
|
769
|
+
return client.mGet(keys);
|
|
770
|
+
}
|
|
771
|
+
return Promise.all(keys.map(k => client.get(k)));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
async put(record) {
|
|
776
|
+
if (!isObject(record) || !isString(record.id)) {
|
|
777
|
+
throw invalidArgument('redisStore.put.record.id must be a string');
|
|
778
|
+
}
|
|
779
|
+
await writeRecord(record);
|
|
780
|
+
if (record.userId) {
|
|
781
|
+
await sadd(record.userId, record.id);
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
async getById(id) {
|
|
786
|
+
return readRecord(id);
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
async update(id, patch) {
|
|
790
|
+
const existing = await readRecord(id);
|
|
791
|
+
if (!existing) {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
const oldUserId = existing.userId;
|
|
795
|
+
const next = { ...existing, ...patch };
|
|
796
|
+
await writeRecord(next);
|
|
797
|
+
if (patch.userId !== undefined && patch.userId !== oldUserId) {
|
|
798
|
+
if (oldUserId) {
|
|
799
|
+
await srem(oldUserId, id);
|
|
800
|
+
}
|
|
801
|
+
if (next.userId) {
|
|
802
|
+
await sadd(next.userId, id);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return next;
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
async revoke(id, reason) {
|
|
809
|
+
const existing = await readRecord(id);
|
|
810
|
+
if (!existing || existing.revokedAt) {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
existing.revokedAt = Date.now();
|
|
814
|
+
if (isString(reason)) {
|
|
815
|
+
existing.revokedReason = reason;
|
|
816
|
+
}
|
|
817
|
+
await writeRecord(existing);
|
|
818
|
+
return true;
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
async revokeAllForUser(userId, reason) {
|
|
822
|
+
const ids = await smembers(userId);
|
|
823
|
+
if (!ids || ids.length === 0) {
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
const rows = await mget(ids.map(rk));
|
|
827
|
+
let count = 0;
|
|
828
|
+
const writes = [];
|
|
829
|
+
for (let i = 0; i < ids.length; i++) {
|
|
830
|
+
const raw = rows[i];
|
|
831
|
+
if (!raw) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
let record;
|
|
835
|
+
try {
|
|
836
|
+
record = isString(raw) ? JSON.parse(raw) : raw;
|
|
837
|
+
} catch {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (record.revokedAt) {
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
record.revokedAt = Date.now();
|
|
844
|
+
if (isString(reason)) {
|
|
845
|
+
record.revokedReason = reason;
|
|
846
|
+
}
|
|
847
|
+
writes.push(writeRecord(record));
|
|
848
|
+
count += 1;
|
|
849
|
+
}
|
|
850
|
+
await Promise.all(writes);
|
|
851
|
+
return count;
|
|
852
|
+
},
|
|
853
|
+
|
|
854
|
+
async listByUser(userId) {
|
|
855
|
+
const ids = await smembers(userId);
|
|
856
|
+
if (!ids || ids.length === 0) {
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
const rows = await mget(ids.map(rk));
|
|
860
|
+
const out = [];
|
|
861
|
+
for (let i = 0; i < ids.length; i++) {
|
|
862
|
+
const raw = rows[i];
|
|
863
|
+
if (!raw) {
|
|
864
|
+
// Dead index reference — prune.
|
|
865
|
+
await srem(userId, ids[i]);
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
out.push(isString(raw) ? JSON.parse(raw) : raw);
|
|
870
|
+
} catch {
|
|
871
|
+
// Skip corrupt row.
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return out;
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
exports.memoryStore = memoryStore;
|
|
880
|
+
exports.redisStore = redisStore;
|