@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.
@@ -0,0 +1,1363 @@
1
+ 'use strict';
2
+
3
+ var node_crypto = require('node:crypto');
4
+
5
+ /**
6
+ * Non-throwing type predicates. Companion to `asserts.js`.
7
+ *
8
+ * ### When to reach for which
9
+ *
10
+ * - Writing a **throw-guard** at a function boundary — a bad shape
11
+ * should fail with the caller's typed error class? Use
12
+ * `asserts` + `defineGuards` (`assertObject`, `assertString`, …).
13
+ * The `assert*` family bundles the check with the correctly-bound
14
+ * throw and preserves the "one error class per package" contract.
15
+ *
16
+ * - Writing a **dispatch / branch / fallback** where the wrong shape
17
+ * is meant to be handled, not thrown? Use these predicates.
18
+ * Typical shapes:
19
+ *
20
+ * if (isObject(config.store)) { ... } else { ... }
21
+ *
22
+ * const isManager = isObject(v) && isFunction(v.issue);
23
+ *
24
+ * The two APIs deliberately do not overlap in intent: `assert*` throws,
25
+ * `is*` never does. Bypassing an `assert*` with an `if (!is*(x)) throw
26
+ * new PkgError(...)` pattern skips the package's guards binding and
27
+ * duplicates work the assert family already handles — don't.
28
+ */
29
+
30
+ /**
31
+ * `true` when `value` is a plain object *or* a class instance — anything
32
+ * whose property access is safe. Rejects `null`, arrays, and every
33
+ * primitive.
34
+ *
35
+ * The Node convention `typeof x === 'object' && x !== null` accepts
36
+ * arrays, which almost never matches caller intent (an array is not a
37
+ * "config object"). This predicate additionally rules arrays out, so
38
+ * `isObject(x)` really means "I can read `x.foo` without exploding on
39
+ * a numeric index".
40
+ *
41
+ * Class instances (Node `KeyObject`, `URL`, user-defined classes) pass
42
+ * because they carry `typeof === 'object'` — the check is duck-typed,
43
+ * not prototype-restricted.
44
+ *
45
+ * @param {unknown} value
46
+ * @returns {boolean}
47
+ */
48
+ function isObject(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * `true` when `value` is a string primitive. `String` boxed objects
54
+ * (from `new String('x')`) are refused — those are a footgun almost no
55
+ * one wants, and every place in this codebase reads strings by
56
+ * primitive access.
57
+ *
58
+ * @param {unknown} value
59
+ * @returns {boolean}
60
+ */
61
+ function isString(value) {
62
+ return typeof value === 'string';
63
+ }
64
+
65
+ /**
66
+ * `true` when `value` is a function. Class constructors, arrow
67
+ * functions, and generator functions all pass — `typeof` on any
68
+ * callable is `'function'`.
69
+ *
70
+ * @param {unknown} value
71
+ * @returns {boolean}
72
+ */
73
+ function isFunction(value) {
74
+ return typeof value === 'function';
75
+ }
76
+
77
+ /**
78
+ * `true` when `value` is a `Buffer` or a `Uint8Array` — the two byte
79
+ * shapes every crypto surface in this repo accepts interchangeably.
80
+ * Rejects strings so dispatch code can decide whether a string input
81
+ * means "already-encoded bytes" (decode it) or "wrong type" (branch).
82
+ *
83
+ * @param {unknown} value
84
+ * @returns {boolean}
85
+ */
86
+ function isBytes(value) {
87
+ return Buffer.isBuffer(value) || value instanceof Uint8Array;
88
+ }
89
+
90
+ /**
91
+ * `true` when `value` is a number primitive. Rejects `NaN`, which is
92
+ * technically a number but almost never what the caller intends.
93
+ *
94
+ * @param {unknown} value
95
+ * @returns {boolean}
96
+ */
97
+ function isNumber(value) {
98
+ return typeof value === 'number' && !Number.isNaN(value);
99
+ }
100
+
101
+ /**
102
+ * `true` when `value` is `undefined`.
103
+ *
104
+ * @param {unknown} value
105
+ * @returns {boolean}
106
+ */
107
+ function isUndefined(value) {
108
+ return typeof value === 'undefined';
109
+ }
110
+
111
+ /**
112
+ * `true` for arrays. Thin wrapper over `Array.isArray` so predicates
113
+ * live under one import; identical behaviour.
114
+ *
115
+ * @param {unknown} value
116
+ * @returns {boolean}
117
+ */
118
+ function isArray(value) {
119
+ return Array.isArray(value);
120
+ }
121
+
122
+ /**
123
+ * `true` when `value` is a Node.js `Buffer`. Uint8Array-only shapes are
124
+ * refused — use {@link isBytes} when either satisfies the contract.
125
+ *
126
+ * @param {unknown} value
127
+ * @returns {boolean}
128
+ */
129
+ function isBuffer(value) {
130
+ return Buffer.isBuffer(value);
131
+ }
132
+
133
+ /**
134
+ * Imperative single-argument guard helpers — the everyday
135
+ * `assertPositiveInt(x, 'name')` shape used at API boundaries.
136
+ * Companion to the compound schema builder in
137
+ * `@exortek/shared/validate`: **schema** for whole options objects,
138
+ * **asserts** for one-liner argument guards at the call site.
139
+ *
140
+ * The consumer surface is {@link defineGuards} (or the raw
141
+ * {@link bindAsserts} for full control). Each package binds the assert
142
+ * set to its own typed error class once (in `internal/guards.js`), so
143
+ * every argument failure throws that package's class —
144
+ * `err instanceof CryptoError` holds and users see at a glance which
145
+ * package raised the error. The bound `parse` bridges
146
+ * `@exortek/shared/validate` schemas to the same error class.
147
+ *
148
+ * **Path naming convention** (used as the `name` argument):
149
+ *
150
+ * `<publicFunction>[.options|.config][.<field>]`
151
+ *
152
+ * - `assertString(name, 'createUser.name')` — top-level arg
153
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
154
+ * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
155
+ *
156
+ * Keep the path short: the emitted error is `"<name> must be <desc>"`,
157
+ * so a stack-line grep already tells the caller which function fired.
158
+ * Reserve `hint` for actionable follow-ups ("pass the bytes returned by
159
+ * encryptSymmetric()"), not for restating the field.
160
+ */
161
+
162
+ /**
163
+ * @typedef {{ cause?: unknown }} WrapExtra
164
+ * Extra options forwarded to the wrap function, so `invalidArgument`
165
+ * sites that catch an underlying error can preserve the `cause` chain
166
+ * without falling back to a raw `new PackageError(...)`.
167
+ *
168
+ * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
169
+ * Constructs (never throws) the binding package's error, e.g.
170
+ * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
171
+ *
172
+ * @typedef {{ hint?: string }} AssertOptions
173
+ * `hint` is appended to the message after an em-dash — use it for
174
+ * actionable guidance ("pass the exact bytes returned by …").
175
+ *
176
+ * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
177
+ */
178
+
179
+ /**
180
+ * Build the failure message. `description` completes the sentence
181
+ * `"<name> must be <description>"`; `hint` (optional) follows an
182
+ * em-dash.
183
+ *
184
+ * @param {string} name
185
+ * @param {string} description
186
+ * @param {string} [hint]
187
+ * @returns {string}
188
+ */
189
+ function message(name, description, hint) {
190
+ return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
191
+ }
192
+
193
+ const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
194
+
195
+ /**
196
+ * Bind the assert set to a package's error class. Call once per
197
+ * package (conventionally in `internal/guards.js`) and import the
198
+ * bound object everywhere:
199
+ *
200
+ * import { bindAsserts } from '@exortek/shared/asserts';
201
+ * import { CryptoError, ErrorCode } from '../errors.js';
202
+ * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
203
+ *
204
+ * // call sites:
205
+ * g.assertPositiveInt(options.iterations, 'options.iterations');
206
+ * const cfg = g.parse(OptionsSchema, options, 'options');
207
+ *
208
+ * @param {WrapFn} wrap
209
+ */
210
+ function bindAsserts(wrap) {
211
+ /**
212
+ * @param {string} name
213
+ * @param {string} description
214
+ * @param {AssertOptions} [opts]
215
+ * @returns {never}
216
+ */
217
+ function fail(name, description, opts) {
218
+ throw wrap(message(name, description, opts?.hint));
219
+ }
220
+
221
+ /**
222
+ * Assert that `value` is a plain object (not `null`, not an array,
223
+ * not a primitive).
224
+ * @param {unknown} value
225
+ * @param {string} name
226
+ * @param {AssertOptions} [opts]
227
+ */
228
+ function assertObject(value, name, opts) {
229
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
230
+ fail(name, 'an object', opts);
231
+ }
232
+ }
233
+
234
+ return {
235
+ /**
236
+ * Construct (not throw) the bound error with a free-form message —
237
+ * for `throw g.invalidArgument('…')` sites that don't fit the
238
+ * `X must be Y` shape (canonicalisation errors, cross-field
239
+ * constraint failures). Pass `{ cause }` when the argument-shape
240
+ * failure was triggered by an underlying throw whose chain matters
241
+ * (e.g. `JSON.stringify` on a bigint).
242
+ *
243
+ * @param {string} msg
244
+ * @param {WrapExtra} [extra]
245
+ * @returns {Error}
246
+ */
247
+ invalidArgument(msg, extra) {
248
+ return wrap(msg, extra);
249
+ },
250
+
251
+ /**
252
+ * Validate `input` against a `@exortek/shared/validate` schema;
253
+ * failures throw the bound error carrying every collected message.
254
+ * The bridge that keeps schema validation on the package's own
255
+ * error surface.
256
+ *
257
+ * @param {ParseableSchema} schema
258
+ * @param {unknown} input
259
+ * @param {string} [path='options']
260
+ * @returns {unknown} the parsed (possibly defaulted) value
261
+ */
262
+ parse(schema, input, path = 'options') {
263
+ const r = schema.safeParse(input, path);
264
+ if (!r.ok) {
265
+ throw wrap(r.errors.join('; '));
266
+ }
267
+ return r.value;
268
+ },
269
+
270
+ /**
271
+ * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
272
+ * @param {unknown} value
273
+ * @param {string} name Argument name to include in the error message.
274
+ * @param {AssertOptions} [opts]
275
+ */
276
+ assertNonNegativeInt(value, name, opts) {
277
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
278
+ fail(name, 'a non-negative safe integer', opts);
279
+ }
280
+ },
281
+
282
+ /**
283
+ * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
284
+ * @param {unknown} value
285
+ * @param {string} name
286
+ * @param {AssertOptions} [opts]
287
+ */
288
+ assertPositiveInt(value, name, opts) {
289
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
290
+ fail(name, 'a positive integer', opts);
291
+ }
292
+ },
293
+
294
+ /**
295
+ * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
296
+ * Used for Unix millisecond timestamps in UUID v7 / ULID.
297
+ * @param {unknown} value
298
+ * @param {string} name
299
+ * @param {AssertOptions} [opts]
300
+ */
301
+ assertUint48(value, name, opts) {
302
+ if (
303
+ !Number.isSafeInteger(value) ||
304
+ /** @type {number} */ (value) < 0 ||
305
+ /** @type {number} */ (value) > 0xffffffffffff
306
+ ) {
307
+ fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
308
+ }
309
+ },
310
+
311
+ /**
312
+ * Assert that `value` is a string (may be empty).
313
+ * @param {unknown} value
314
+ * @param {string} name
315
+ * @param {AssertOptions} [opts]
316
+ */
317
+ assertString(value, name, opts) {
318
+ if (typeof value !== 'string') {
319
+ fail(name, 'a string', opts);
320
+ }
321
+ },
322
+
323
+ /**
324
+ * Assert that `value` is a non-empty string.
325
+ * @param {unknown} value
326
+ * @param {string} name
327
+ * @param {AssertOptions} [opts]
328
+ */
329
+ assertNonEmptyString(value, name, opts) {
330
+ if (typeof value !== 'string' || value.length === 0) {
331
+ fail(name, 'a non-empty string', opts);
332
+ }
333
+ },
334
+
335
+ /**
336
+ * Assert that `value` is a boolean.
337
+ * @param {unknown} value
338
+ * @param {string} name
339
+ * @param {AssertOptions} [opts]
340
+ */
341
+ assertBoolean(value, name, opts) {
342
+ if (typeof value !== 'boolean') {
343
+ fail(name, 'a boolean', opts);
344
+ }
345
+ },
346
+
347
+ /**
348
+ * Assert that `value` is a function.
349
+ * @param {unknown} value
350
+ * @param {string} name
351
+ * @param {AssertOptions} [opts]
352
+ */
353
+ assertFunction(value, name, opts) {
354
+ if (typeof value !== 'function') {
355
+ fail(name, 'a function', opts);
356
+ }
357
+ },
358
+
359
+ assertObject,
360
+
361
+ /**
362
+ * Assert that `value` is either `undefined` or a plain object —
363
+ * the "optional options object" pattern.
364
+ * @param {unknown} value
365
+ * @param {string} name
366
+ * @param {AssertOptions} [opts]
367
+ */
368
+ assertOptionalObject(value, name, opts) {
369
+ if (value === undefined) {
370
+ return;
371
+ }
372
+ assertObject(value, name, opts);
373
+ },
374
+
375
+ /**
376
+ * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
377
+ * strings are NOT accepted. For already-encoded material where a
378
+ * string would be ambiguous (ciphertext, signatures, raw key bytes).
379
+ * @param {unknown} value
380
+ * @param {string} name
381
+ * @param {AssertOptions} [opts]
382
+ */
383
+ assertBytes(value, name, opts) {
384
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
385
+ fail(name, 'a Buffer or Uint8Array', opts);
386
+ }
387
+ },
388
+
389
+ /**
390
+ * Assert that `value` is either a string or a byte buffer (`Buffer`
391
+ * or `Uint8Array`).
392
+ * @param {unknown} value
393
+ * @param {string} name
394
+ * @param {AssertOptions} [opts]
395
+ */
396
+ assertBytesOrString(value, name, opts) {
397
+ if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
398
+ fail(name, 'a string or Buffer', opts);
399
+ }
400
+ },
401
+
402
+ /**
403
+ * Assert that `encoding` is one of the accepted output/input
404
+ * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
405
+ * no sense (verifying a string signature, decoding a token payload).
406
+ *
407
+ * @param {unknown} encoding
408
+ * @param {string} name
409
+ * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
410
+ */
411
+ assertEncoding(encoding, name, opts) {
412
+ const allowBuffer = opts?.allowBuffer !== false;
413
+ const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
414
+ if (!valid) {
415
+ const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
416
+ fail(name, list, opts);
417
+ }
418
+ },
419
+ };
420
+ }
421
+
422
+ /**
423
+ * One-line sugar over {@link bindAsserts} — the overwhelmingly common
424
+ * pattern where a package binds every argument-shape failure to its
425
+ * own error class + `INVALID_ARGUMENT` code. Emits the standard
426
+ * `new ErrorClass(code, message, { cause })` shape, so the underlying
427
+ * `BaseError` `{ cause, status, details }` options object is respected.
428
+ *
429
+ * // packages/<pkg>/src/internal/guards.js
430
+ * import { defineGuards } from '@exortek/shared/asserts';
431
+ * import { PasswordError, ErrorCode } from '../errors.js';
432
+ *
433
+ * export const { assertString, invalidArgument, parse } =
434
+ * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
435
+ *
436
+ * Reach for the raw {@link bindAsserts} when you need a different
437
+ * mapping (an alt code, a decorated message) — it stays available.
438
+ *
439
+ * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
440
+ * @param {string} code
441
+ */
442
+ function defineGuards(ErrorClass, code) {
443
+ return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
444
+ }
445
+
446
+ /**
447
+ * Shared base error class — the single error structure behind every
448
+ * `@exortek/*` package's `errors.js`.
449
+ *
450
+ * Every package keeps its own class identity with a one-liner subclass;
451
+ * codes stay per-package frozen maps, status mapping is declared as a
452
+ * static field:
453
+ *
454
+ * import { BaseError } from '@exortek/shared/errors';
455
+ *
456
+ * export const ErrorCode = Object.freeze({
457
+ * INVALID_ARGUMENT: 'INVALID_ARGUMENT',
458
+ * INVALID_TOKEN: 'INVALID_TOKEN',
459
+ * });
460
+ *
461
+ * export class JwtError extends BaseError {
462
+ * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
463
+ * static defaultStatus = 500;
464
+ * }
465
+ *
466
+ * Instances carry a stable machine-readable `code` (branch on this,
467
+ * never on the message), an optional HTTP `status`, an optional
468
+ * `details` object, and the standard `cause` chain.
469
+ */
470
+ class BaseError extends Error {
471
+ /**
472
+ * Optional `code → HTTP status` map declared on the subclass. When
473
+ * absent the instance carries no `status` at all — for HTTP-agnostic
474
+ * packages like `@exortek/crypto`.
475
+ *
476
+ * @type {Record<string, number> | undefined}
477
+ */
478
+ static statuses = undefined;
479
+
480
+ /**
481
+ * Fallback status for codes missing from `statuses`.
482
+ *
483
+ * @type {number}
484
+ */
485
+ static defaultStatus = 500;
486
+
487
+ /**
488
+ * @param {string} code Stable machine-readable code; branch on this.
489
+ * @param {string} message Human-readable diagnostic. Free-form; may
490
+ * change across versions.
491
+ * @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
492
+ */
493
+ constructor(code, message, options = {}) {
494
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
495
+ this.name = new.target.name;
496
+ /** @type {string} */
497
+ this.code = code;
498
+ const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
499
+ if (options.status !== undefined) {
500
+ /** @type {number | undefined} */
501
+ this.status = options.status;
502
+ } else if (statuses !== undefined) {
503
+ // Object.hasOwn guards against inherited-property lookups for
504
+ // exotic code values ('toString', 'constructor', …).
505
+ this.status = Object.hasOwn(statuses, code)
506
+ ? statuses[code]
507
+ : /** @type {typeof BaseError} */ (new.target).defaultStatus;
508
+ }
509
+ if (options.details) {
510
+ /** @type {Record<string, unknown> | undefined} */
511
+ this.details = options.details;
512
+ }
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Stable machine-readable codes for every programmer-error failure that
518
+ * `@exortek/apikey` can raise. Branch on `code`, never on the message.
519
+ * Note: expected verify failures (bad secret, expired, revoked,
520
+ * missing scope) are NOT thrown — `verifyApiKey` returns
521
+ * `{ valid: false, reason }` so a wrong or stale key is a normal auth
522
+ * outcome, not an exception. Errors below fire only when the caller
523
+ * configured something wrong or the store misbehaved.
524
+ */
525
+
526
+ const ErrorCode = Object.freeze({
527
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
528
+ INVALID_PREFIX: 'INVALID_PREFIX',
529
+ INVALID_PEPPER: 'INVALID_PEPPER',
530
+ STORE_ERROR: 'STORE_ERROR',
531
+ });
532
+
533
+ /**
534
+ * Every recoverable failure raised by this package. Carries a stable
535
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
536
+ * status a middleware layer would use when translating the error.
537
+ */
538
+ class ApiKeyError extends BaseError {
539
+ static statuses = {
540
+ [ErrorCode.INVALID_ARGUMENT]: 400,
541
+ [ErrorCode.INVALID_PREFIX]: 400,
542
+ [ErrorCode.INVALID_PEPPER]: 400,
543
+ [ErrorCode.STORE_ERROR]: 500,
544
+ };
545
+ static defaultStatus = 500;
546
+ }
547
+
548
+ /**
549
+ * Argument guards bound to `ApiKeyError` — the package-wide binding of
550
+ * `@exortek/shared/asserts`. Import guards from here, never from the
551
+ * shared module directly.
552
+ */
553
+
554
+
555
+ const { invalidArgument} = defineGuards(
556
+ ApiKeyError,
557
+ ErrorCode.INVALID_ARGUMENT,
558
+ );
559
+
560
+ /**
561
+ * Scope match helpers.
562
+ *
563
+ * A **granted** scope authorises access. A **required** scope names the
564
+ * access an endpoint needs. `hasAll` / `hasAny` decide whether the
565
+ * granted set covers the requirement, with wildcard support:
566
+ *
567
+ * - `'*'` in the granted list → matches every required scope.
568
+ * - A `'ns:*'` suffix wildcard → matches any scope under the namespace
569
+ * (`'read:*'` covers `'read:users'` and `'read:posts'`).
570
+ * - Exact string match otherwise.
571
+ *
572
+ * Wildcards apply only on the granted side. `required` values must be
573
+ * concrete — an API cannot demand "some read:* scope"; it must state
574
+ * exactly which one it needs.
575
+ */
576
+
577
+
578
+ /**
579
+ * Does `granted` cover a single `required` scope?
580
+ * @param {string[]} granted
581
+ * @param {string} required
582
+ * @returns {boolean}
583
+ */
584
+ function covers(granted, required) {
585
+ if (!isArray(granted) || !isString(required) || required.length === 0) {
586
+ return false;
587
+ }
588
+ for (const g of granted) {
589
+ if (!isString(g)) {
590
+ continue;
591
+ }
592
+ if (g === '*' || g === required) {
593
+ return true;
594
+ }
595
+ // Suffix wildcard: `read:*` matches `read:users`.
596
+ if (g.endsWith(':*')) {
597
+ const ns = g.slice(0, -1); // 'read:'
598
+ if (required.startsWith(ns) && required.length > ns.length) {
599
+ return true;
600
+ }
601
+ }
602
+ }
603
+ return false;
604
+ }
605
+
606
+ /**
607
+ * All required scopes must be covered by granted.
608
+ * @param {string[]} granted
609
+ * @param {string[]} required
610
+ * @returns {boolean}
611
+ */
612
+ function hasAll(granted, required) {
613
+ if (!isArray(required)) {
614
+ return false;
615
+ }
616
+ if (required.length === 0) {
617
+ return true;
618
+ }
619
+ for (const r of required) {
620
+ if (!covers(granted, r)) {
621
+ return false;
622
+ }
623
+ }
624
+ return true;
625
+ }
626
+
627
+ /**
628
+ * RFC 4648 §5 base64url codec.
629
+ *
630
+ * Node's `Buffer.from(str, 'base64url')` is lenient: it accepts
631
+ * padding, `+`, `/`, and whitespace. Wire-level use (JWS / JWT
632
+ * compact serialisation, JWK ingest, etc.) requires strict rejection
633
+ * of everything that isn't a canonical unpadded encoding — hence the
634
+ * roundtrip check in `decode`.
635
+ *
636
+ * Throws plain `Error` / `TypeError` on malformed input. Callers that
637
+ * need typed errors wrap the call at their surface boundary.
638
+ */
639
+
640
+ const ALPHABET$1 = /^[A-Za-z0-9_-]*$/;
641
+
642
+ /**
643
+ * @param {Buffer | Uint8Array} bytes
644
+ * @returns {string}
645
+ */
646
+ function encode(bytes) {
647
+ if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
648
+ throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
649
+ }
650
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
651
+ }
652
+
653
+ /**
654
+ * @param {string} text
655
+ * @returns {Buffer}
656
+ */
657
+ function decode$1(text) {
658
+ if (typeof text !== 'string') {
659
+ throw new TypeError('base64url.decode: expected a string');
660
+ }
661
+ if (!ALPHABET$1.test(text)) {
662
+ throw new Error('base64url.decode: input contains characters outside the RFC 4648 §5 alphabet');
663
+ }
664
+ const bytes = Buffer.from(text, 'base64url');
665
+ if (bytes.toString('base64url') !== text) {
666
+ throw new Error('base64url.decode: input is not a canonical encoding');
667
+ }
668
+ return bytes;
669
+ }
670
+
671
+ /**
672
+ * Crockford Base32 codec — RFC 4648-style but with the four look-alike
673
+ * glyphs `I`, `L`, `O`, `U` dropped for human legibility. Uppercase on
674
+ * encode, case-insensitive on decode with Crockford check aliases:
675
+ * `I` / `L` decode as `1`, `O` decodes as `0`. The four uppercase check
676
+ * symbols (`*`, `~`, `$`, `=`) that some tooling appends are **not**
677
+ * supported.
678
+ *
679
+ * Consumers (crypto public codec, otp backup codes, jwt polymorphic
680
+ * encoding) all reach for the same alphabet + encode loop; this file
681
+ * is the single implementation. Errors here are plain `Error` /
682
+ * `TypeError`; each consumer wraps them into its own typed error at
683
+ * the surface boundary.
684
+ */
685
+
686
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
687
+
688
+ const DECODE_TABLE = new Int8Array(128).fill(-1);
689
+ for (let i = 0; i < ALPHABET.length; i++) {
690
+ DECODE_TABLE[ALPHABET.charCodeAt(i)] = i;
691
+ const c = ALPHABET[i];
692
+ if (c >= 'A' && c <= 'Z') {
693
+ // Lowercase alias for letters only. Digits share code point rows
694
+ // that would collide with the +32 shift used for uppercase.
695
+ DECODE_TABLE[ALPHABET.charCodeAt(i) + 32] = i;
696
+ }
697
+ }
698
+ // Crockford check aliases: I / L → 1, O → 0 (case-insensitive).
699
+ for (const [alias, canonical] of [
700
+ ['I', '1'],
701
+ ['i', '1'],
702
+ ['L', '1'],
703
+ ['l', '1'],
704
+ ['O', '0'],
705
+ ['o', '0'],
706
+ ]) {
707
+ DECODE_TABLE[alias.charCodeAt(0)] = DECODE_TABLE[canonical.charCodeAt(0)];
708
+ }
709
+
710
+ /**
711
+ * Decode a Crockford base32 string into a Buffer.
712
+ *
713
+ * Case-insensitive. `I` / `L` → `1`, `O` → `0`. Any other non-alphabet
714
+ * character throws.
715
+ *
716
+ * @param {string} input
717
+ * @returns {Buffer}
718
+ * @throws {TypeError} if `input` is not a string.
719
+ * @throws {Error} if `input` contains a character outside the alphabet.
720
+ */
721
+ function decode(input) {
722
+ if (typeof input !== 'string') {
723
+ throw new TypeError('decode: input must be a string.');
724
+ }
725
+ if (input.length === 0) {
726
+ return Buffer.alloc(0);
727
+ }
728
+
729
+ const outLength = Math.floor((input.length * 5) / 8);
730
+ const out = Buffer.alloc(outLength);
731
+ let bits = 0;
732
+ let value = 0;
733
+ let index = 0;
734
+ for (let i = 0; i < input.length; i++) {
735
+ const code = input.charCodeAt(i);
736
+ const v = code < 128 ? DECODE_TABLE[code] : -1;
737
+ if (v === -1) {
738
+ throw new Error(
739
+ `input contains a non-Crockford character '${input[i]}' at index ${i}. Crockford base32 alphabet is 0-9 A-Z minus I L O U (case-insensitive); I/L decode as 1, O decodes as 0.`,
740
+ );
741
+ }
742
+ value = (value << 5) | v;
743
+ bits += 5;
744
+ if (bits >= 8) {
745
+ bits -= 8;
746
+ out[index++] = (value >>> bits) & 0xff;
747
+ value &= (1 << bits) - 1;
748
+ }
749
+ }
750
+ return out;
751
+ }
752
+
753
+ /**
754
+ * Length-safe `timingSafeEqual`.
755
+ *
756
+ * `crypto.timingSafeEqual` throws when its two arguments have
757
+ * different lengths — the exception path is itself a side channel
758
+ * that leaks "lengths differ" to a timing attacker even when the
759
+ * caller catches the throw. This wrapper closes that channel by
760
+ * always running a `timingSafeEqual` (against the shorter buffer
761
+ * padded to itself) and returning `false` on length mismatch, so the
762
+ * caller sees a boolean either way and total runtime does not
763
+ * short-circuit on length alone.
764
+ */
765
+
766
+
767
+ /**
768
+ * @param {Buffer | Uint8Array} a
769
+ * @param {Buffer | Uint8Array} b
770
+ * @returns {boolean}
771
+ */
772
+ function timingSafeEqual(a, b) {
773
+ const aBuf = Buffer.isBuffer(a) ? a : Buffer.from(a.buffer, a.byteOffset, a.byteLength);
774
+ const bBuf = Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.byteLength);
775
+ if (aBuf.length !== bBuf.length) {
776
+ // Burn a comparison so total time doesn't short-circuit on the
777
+ // length gate — the attacker can't distinguish "length mismatch"
778
+ // from "content mismatch" by observed runtime.
779
+ node_crypto.timingSafeEqual(aBuf, aBuf);
780
+ return false;
781
+ }
782
+ return node_crypto.timingSafeEqual(aBuf, bBuf);
783
+ }
784
+
785
+ /**
786
+ * Token codec for `@exortek/apikey`.
787
+ *
788
+ * **Wire format:**
789
+ *
790
+ * <prefix>_<base64url(16 random bytes)>_<base64url(32 random bytes)>
791
+ * ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
792
+ * type id (128-bit — plaintext DB key) secret (256-bit — HMAC'd)
793
+ *
794
+ * sk_live_QVI0RExDX01Fbg_pTk9UX0FfUkVBTF9LRVlfNDJfQVJUSVNBTkFMX1BJWk
795
+ *
796
+ * Stripe / GitHub / Anthropic all use this three-segment shape. The
797
+ * split matters:
798
+ *
799
+ * - The **id** is stored in the clear so a request's incoming key can
800
+ * be found in the DB with a single O(1) primary-key lookup. It is not
801
+ * secret: an attacker who guesses an id alone cannot verify — the
802
+ * secret half is still gated by the HMAC compare.
803
+ * - The **secret** is never stored in the clear. Storage keeps only
804
+ * `HMAC-SHA256(secret, pepper)` (or plain SHA-256 when no pepper is
805
+ * configured). API keys are random 256-bit values — a fast hash is
806
+ * fine for storage; slow KDFs like Argon2 exist for low-entropy
807
+ * passwords, not for cryptographically random secrets.
808
+ * - The **pepper** (optional) is a server-side secret held in the app
809
+ * env, not the DB. A stolen database on its own does not let an
810
+ * attacker offline-brute the stored hashes; they also need the
811
+ * pepper. Peppers rotate via a newest-first array.
812
+ *
813
+ * The whole key is opaque to holders — do not parse the segments in
814
+ * client code; only the server's `parseApiKey` sees them.
815
+ */
816
+
817
+
818
+ // Base32-Crockford alphabet is [0-9A-HJKMNP-TV-Z] — no ambiguous
819
+ // glyphs (`I`, `L`, `O`, `U` excluded), no separators (`_` reserved as
820
+ // our own delimiter), URL-safe, human-transcribable. 16 bytes → 26
821
+ // chars for the id; 32 bytes → 52 chars for the secret. The wire key
822
+ // is unambiguously three underscore-separated segments.
823
+ const ID_CHARS = 26;
824
+ const SECRET_CHARS = 52;
825
+ const CROCKFORD_RE = /^[0-9A-HJKMNP-TV-Z]+$/;
826
+
827
+ /**
828
+ * Prefix grammar — Stripe-style: lowercase alpha, digits, underscore
829
+ * separators between 1-4 lower-alphanumeric segments. Keeps the token
830
+ * unambiguously parseable and safe to embed in URLs / headers.
831
+ *
832
+ * sk_live ✓
833
+ * pk_test ✓
834
+ * svc_prod_v2 ✓
835
+ * sk__live ✗ (empty segment)
836
+ * sk_live_ ✗ (trailing underscore)
837
+ * Sk_Live ✗ (uppercase)
838
+ */
839
+ const PREFIX_RE = /^[a-z][a-z0-9]{0,15}(_[a-z0-9]{1,15}){0,3}$/;
840
+
841
+ /**
842
+ * @param {string} prefix
843
+ * @param {string} name
844
+ * @param {(msg: string) => Error} invalidArg
845
+ * @returns {string}
846
+ */
847
+ function assertPrefix(prefix, name, invalidArg) {
848
+ if (!isString(prefix) || !PREFIX_RE.test(prefix)) {
849
+ throw invalidArg(
850
+ `${name} must match /^[a-z][a-z0-9]{0,15}(_[a-z0-9]{1,15}){0,3}$/ — Stripe-style lowercase with underscore-separated segments (e.g. 'sk_live', 'pk_test'); got ${JSON.stringify(prefix)}`,
851
+ );
852
+ }
853
+ return prefix;
854
+ }
855
+
856
+ /**
857
+ * Compute the storage hash of a raw secret. With a pepper: HMAC-SHA256
858
+ * keyed by the pepper. Without: plain SHA-256. Both return the digest
859
+ * as a 43-char base64url string.
860
+ *
861
+ * @param {Buffer} secretBytes
862
+ * @param {Buffer | null} pepper
863
+ * @returns {string}
864
+ */
865
+ function hashSecret(secretBytes, pepper) {
866
+ const digest = pepper
867
+ ? node_crypto.createHmac('sha256', pepper).update(secretBytes).digest()
868
+ : node_crypto.createHash('sha256').update(secretBytes).digest();
869
+ return encode(digest);
870
+ }
871
+
872
+ /**
873
+ * Timing-safe compare of a candidate hash against a stored hash. Both
874
+ * inputs are base64url strings of equal length — a length mismatch is
875
+ * treated as a mismatch after a fixed decode step, so an outside
876
+ * observer cannot learn the stored hash's length via timing.
877
+ *
878
+ * @param {string} candidate
879
+ * @param {string} stored
880
+ * @returns {boolean}
881
+ */
882
+ function hashesMatch(candidate, stored) {
883
+ if (!isString(candidate) || !isString(stored) || candidate.length !== stored.length) {
884
+ return false;
885
+ }
886
+ let a;
887
+ let b;
888
+ try {
889
+ a = decode$1(candidate);
890
+ b = decode$1(stored);
891
+ } catch {
892
+ return false;
893
+ }
894
+ return timingSafeEqual(a, b);
895
+ }
896
+
897
+ /**
898
+ * Parse a raw key without verifying it. Returns the segments, or
899
+ * `null` when the shape is wrong. Never throws.
900
+ *
901
+ * Callers can use `parseApiKey(key).prefix` to route by key family
902
+ * in a middleware before hitting the store — but the parse result
903
+ * MUST NOT be trusted as authenticated. Only `verifyApiKey` proves
904
+ * the holder possesses the real secret.
905
+ *
906
+ * @param {string} key
907
+ * @returns {{ prefix: string, id: string, secret: string } | null}
908
+ */
909
+ function parseApiKey(key) {
910
+ if (!isString(key)) {
911
+ return null;
912
+ }
913
+ // The prefix may contain '_' (`sk_live`, `svc_prod_v2`), but id and
914
+ // secret use the base32-crockford alphabet which contains no
915
+ // underscores — so the last two '_' cleanly bound them.
916
+ const lastUnderscore = key.lastIndexOf('_');
917
+ if (lastUnderscore <= 0) {return null;}
918
+ const prevUnderscore = key.lastIndexOf('_', lastUnderscore - 1);
919
+ if (prevUnderscore <= 0) {return null;}
920
+ const prefix = key.slice(0, prevUnderscore);
921
+ const id = key.slice(prevUnderscore + 1, lastUnderscore);
922
+ const secret = key.slice(lastUnderscore + 1);
923
+ if (!PREFIX_RE.test(prefix)) {return null;}
924
+ if (id.length !== ID_CHARS || !CROCKFORD_RE.test(id)) {return null;}
925
+ if (secret.length !== SECRET_CHARS || !CROCKFORD_RE.test(secret)) {return null;}
926
+ return { prefix, id, secret };
927
+ }
928
+
929
+ /**
930
+ * Compute the storage hash for a candidate raw key's secret half,
931
+ * trying peppers newest-first. Returns the hash + which pepper matched
932
+ * (or null if no pepper array was supplied).
933
+ *
934
+ * Used by `verifyApiKey` — it hashes the incoming secret with each
935
+ * pepper in the rotation and lets the caller decide which one to
936
+ * compare against. The needsRehash signal (secret matched an older
937
+ * pepper) is exposed to consumers so they can silently upgrade
938
+ * storage on the next successful auth.
939
+ *
940
+ * @param {string} rawSecret The base64url secret segment.
941
+ * @param {Buffer[] | null} peppers
942
+ * @returns {{ candidateHashes: string[] }}
943
+ */
944
+ function candidateHashesFor(rawSecret, peppers) {
945
+ let secretBytes;
946
+ try {
947
+ secretBytes = decode(rawSecret);
948
+ } catch {
949
+ return { candidateHashes: [] };
950
+ }
951
+ if (!peppers || peppers.length === 0) {
952
+ return { candidateHashes: [hashSecret(secretBytes, null)] };
953
+ }
954
+ return {
955
+ candidateHashes: peppers.map(p => hashSecret(secretBytes, p)),
956
+ };
957
+ }
958
+
959
+ /**
960
+ * `@exortek/apikey` — Stripe-style prefixed API keys with HMAC-hashed
961
+ * storage, scope allowlists, and opt-in pepper rotation.
962
+ *
963
+ * See the README for the full API and worked examples.
964
+ */
965
+
966
+
967
+ /** Prefix-specific factory so `assertPrefix` throws `INVALID_PREFIX`,
968
+ * not the generic `INVALID_ARGUMENT`. Callers can `catch (err)` and
969
+ * branch on `err.code === ErrorCode.INVALID_PREFIX` — a specific
970
+ * code beats a generic one for pinpointing config drift. */
971
+ const invalidPrefix = msg => new ApiKeyError(ErrorCode.INVALID_PREFIX, msg);
972
+
973
+ const MIN_PEPPER_BYTES = 16;
974
+
975
+ /**
976
+ * Verify a raw API key. Returns `{ valid: true, ... }` on success, or
977
+ * `{ valid: false, reason }` on any expected failure. Never throws on
978
+ * a bad key — a wrong or stale key is a normal auth outcome.
979
+ *
980
+ * On success, callers get the stored `userId` / `scopes` / `metadata`
981
+ * and can pass them straight to their app's auth context.
982
+ * `needsRehash: true` in the success result signals the secret matched
983
+ * an older pepper — call `rehashApiKey(id, options)` to silently
984
+ * migrate storage to the newest pepper on the next natural chance.
985
+ *
986
+ * @param {string} rawKey
987
+ * @param {VerifyApiKeyOptions} options
988
+ * @returns {Promise<VerifyApiKeyResult>}
989
+ */
990
+ async function verifyApiKey(rawKey, options) {
991
+ if (!isObject(options)) {
992
+ throw invalidArgument('verifyApiKey.options must be an object');
993
+ }
994
+ _assertStore(options.store, 'verifyApiKey.options.store');
995
+ const peppers = _normalizePeppers(options.peppers, 'verifyApiKey.options.peppers');
996
+ const requiredScopes = options.requiredScopes;
997
+ if (!isUndefined(requiredScopes) && !_isStringArray(requiredScopes)) {
998
+ throw invalidArgument('verifyApiKey.options.requiredScopes must be a string[] when provided');
999
+ }
1000
+ if (!isUndefined(options.expectedPrefix)) {
1001
+ assertPrefix(options.expectedPrefix, 'verifyApiKey.options.expectedPrefix', invalidPrefix);
1002
+ }
1003
+
1004
+ const parsed = parseApiKey(rawKey);
1005
+ if (!parsed) {
1006
+ return { valid: false, reason: 'malformed' };
1007
+ }
1008
+ if (!isUndefined(options.expectedPrefix) && parsed.prefix !== options.expectedPrefix) {
1009
+ return { valid: false, reason: 'prefix_mismatch' };
1010
+ }
1011
+
1012
+ let record;
1013
+ try {
1014
+ record = await options.store.getById(parsed.id);
1015
+ } catch {
1016
+ return { valid: false, reason: 'store_unavailable' };
1017
+ }
1018
+ if (!record) {
1019
+ return { valid: false, reason: 'not_found' };
1020
+ }
1021
+ const now = _now(options.now);
1022
+ if (record.revokedAt) {
1023
+ return { valid: false, reason: 'revoked' };
1024
+ }
1025
+ if (record.expiresAt && record.expiresAt <= now) {
1026
+ return { valid: false, reason: 'expired' };
1027
+ }
1028
+ if (parsed.prefix !== record.prefix) {
1029
+ // Someone submitted a key whose id belongs to a different prefix —
1030
+ // treat as bad, not merely 'prefix_mismatch', because the id half
1031
+ // was correct: the attacker guessed the id and swapped the prefix.
1032
+ return { valid: false, reason: 'prefix_mismatch' };
1033
+ }
1034
+ const { candidateHashes } = candidateHashesFor(parsed.secret, peppers);
1035
+ let matched = false;
1036
+ let matchedIndex = -1;
1037
+ for (let i = 0; i < candidateHashes.length; i++) {
1038
+ if (hashesMatch(candidateHashes[i], record.hash)) {
1039
+ matched = true;
1040
+ matchedIndex = i;
1041
+ break;
1042
+ }
1043
+ }
1044
+ if (!matched) {
1045
+ return { valid: false, reason: 'bad_secret' };
1046
+ }
1047
+ if (isArray(requiredScopes) && requiredScopes.length > 0 && !hasAll(record.scopes, requiredScopes)) {
1048
+ return { valid: false, reason: 'missing_scope' };
1049
+ }
1050
+ if (options.updateLastUsed === true) {
1051
+ // Fire-and-forget on the store side — failure to bump lastUsedAt
1052
+ // must not fail the verify itself. Await it so callers can wait
1053
+ // for consistency if they want.
1054
+ try {
1055
+ await options.store.update(parsed.id, { lastUsedAt: now });
1056
+ } catch {
1057
+ // Ignore — lastUsedAt is telemetry, not correctness.
1058
+ }
1059
+ }
1060
+
1061
+ /** @type {VerifyApiKeyResult} */
1062
+ const result = {
1063
+ valid: true,
1064
+ id: record.id,
1065
+ userId: record.userId,
1066
+ scopes: record.scopes,
1067
+ prefix: record.prefix,
1068
+ };
1069
+ if (record.name) {
1070
+ result.name = record.name;
1071
+ }
1072
+ if (record.environment) {
1073
+ result.environment = record.environment;
1074
+ }
1075
+ if (record.metadata) {
1076
+ result.metadata = record.metadata;
1077
+ }
1078
+ // Peppers rotate newest-first; non-zero index means the secret
1079
+ // matched an older pepper — the storage hash should migrate.
1080
+ if (peppers && matchedIndex > 0) {
1081
+ result.needsRehash = true;
1082
+ }
1083
+ return result;
1084
+ }
1085
+
1086
+ // ---------------------------------------------------------------------
1087
+ // internals
1088
+
1089
+ function _now(override) {
1090
+ return isNumber(override) ? override : Date.now();
1091
+ }
1092
+
1093
+ function _isStringArray(v) {
1094
+ if (!isArray(v)) {
1095
+ return false;
1096
+ }
1097
+ for (const item of v) {
1098
+ if (!isString(item)) {
1099
+ return false;
1100
+ }
1101
+ }
1102
+ return true;
1103
+ }
1104
+
1105
+ function _assertStore(v, name) {
1106
+ if (
1107
+ !isObject(v) ||
1108
+ !isFunction(v.put) ||
1109
+ !isFunction(v.getById) ||
1110
+ !isFunction(v.update) ||
1111
+ !isFunction(v.revoke) ||
1112
+ !isFunction(v.revokeAllForUser) ||
1113
+ !isFunction(v.listByUser)
1114
+ ) {
1115
+ throw invalidArgument(
1116
+ `${name} must be an object with put / getById / update / revoke / revokeAllForUser / listByUser methods`,
1117
+ );
1118
+ }
1119
+ }
1120
+
1121
+ function _normalizePeppers(input, name) {
1122
+ if (isUndefined(input)) {
1123
+ return null;
1124
+ }
1125
+ if (!isArray(input) || input.length === 0) {
1126
+ throw new ApiKeyError(ErrorCode.INVALID_PEPPER, `${name} must be a non-empty array when provided (newest first)`);
1127
+ }
1128
+ const out = [];
1129
+ for (let i = 0; i < input.length; i++) {
1130
+ const p = input[i];
1131
+ const buf = isString(p) ? Buffer.from(p, 'utf8') : isBytes(p) ? (isBuffer(p) ? p : Buffer.from(p)) : null;
1132
+ if (!buf) {
1133
+ throw new ApiKeyError(ErrorCode.INVALID_PEPPER, `${name}[${i}] must be a string / Buffer / Uint8Array`);
1134
+ }
1135
+ if (buf.length < MIN_PEPPER_BYTES) {
1136
+ throw new ApiKeyError(
1137
+ ErrorCode.INVALID_PEPPER,
1138
+ `${name}[${i}] must be at least ${MIN_PEPPER_BYTES} bytes; got ${buf.length}`,
1139
+ );
1140
+ }
1141
+ out.push(buf);
1142
+ }
1143
+ return out;
1144
+ }
1145
+
1146
+ /**
1147
+ * Adapter-kit core for `@exortek/apikey` middleware.
1148
+ *
1149
+ * The framework-neutral logic lives here. The Express and Fastify
1150
+ * adapters (`middleware/express.js`, `middleware/fastify.js`) are
1151
+ * thin translators: request → `AdapterContext`, then hand off to
1152
+ * `runApiKey`, then translate the returned `AdapterResult` back into
1153
+ * the framework's own reply / next call.
1154
+ *
1155
+ * External integrators writing their own framework binding only need
1156
+ * to implement the two-line translation — every auth decision,
1157
+ * error-code, and header choice already lives here.
1158
+ */
1159
+
1160
+
1161
+ /**
1162
+ * @typedef {object} AdapterContext
1163
+ * @property {(name: string) => string | undefined} getHeader
1164
+ * @property {string} [method]
1165
+ * @property {string} [ip]
1166
+ * @property {Record<string, unknown>} [query]
1167
+ */
1168
+
1169
+ /**
1170
+ * @typedef {object} ApiKeyMiddlewareOptions
1171
+ * @property {import('../index.js').ApiKeyStore} store
1172
+ * @property {(Buffer | Uint8Array | string)[]} [peppers]
1173
+ * @property {string[]} [requiredScopes]
1174
+ * @property {string} [expectedPrefix]
1175
+ * @property {boolean} [updateLastUsed=false]
1176
+ * @property {string} [headerName='authorization']
1177
+ * Case-insensitive. The default reads `Authorization: Bearer <key>`;
1178
+ * a `x-api-key` config reads `X-API-Key: <key>` directly.
1179
+ * @property {'bearer' | 'raw'} [scheme='bearer']
1180
+ * `'bearer'` expects `Bearer <key>`; `'raw'` uses the header value as-is.
1181
+ * @property {boolean} [allowQueryParam=false]
1182
+ * When true, falls back to `?api_key=<key>` if no matching header is
1183
+ * found. Discouraged because query strings leak into access logs and
1184
+ * referer headers — off by default.
1185
+ * @property {string} [queryParamName='api_key']
1186
+ * @property {string} [attach='apiKey']
1187
+ * Property name attached to the request object on success.
1188
+ * @property {(ctx: AdapterContext) => string | undefined} [tokenFromRequest]
1189
+ * Override the default extraction entirely.
1190
+ */
1191
+
1192
+ /**
1193
+ * @typedef {{
1194
+ * verifyResult: import('../index.js').VerifyApiKeyResult,
1195
+ * response?: { status: number, body: { error: string, reason?: string } }
1196
+ * }} AdapterResult
1197
+ */
1198
+
1199
+ const DEFAULT_HEADER = 'authorization';
1200
+ const DEFAULT_QUERY = 'api_key';
1201
+
1202
+ /**
1203
+ * Normalize + validate middleware options. Throws `ApiKeyError` on
1204
+ * misconfig so the mistake surfaces at boot, not per-request.
1205
+ *
1206
+ * @param {ApiKeyMiddlewareOptions} options
1207
+ */
1208
+ function normalizeOptions(options) {
1209
+ if (!options || typeof options !== 'object') {
1210
+ throw invalidArgument('apiKey middleware options must be an object');
1211
+ }
1212
+ if (!options.store) {
1213
+ throw invalidArgument('apiKey middleware options.store is required');
1214
+ }
1215
+ if (!isUndefined(options.tokenFromRequest) && !isFunction(options.tokenFromRequest)) {
1216
+ throw invalidArgument('apiKey middleware options.tokenFromRequest must be a function when provided');
1217
+ }
1218
+ const attach = options.attach ?? 'apiKey';
1219
+ if (!isString(attach) || attach.length === 0) {
1220
+ throw invalidArgument('apiKey middleware options.attach must be a non-empty string');
1221
+ }
1222
+ return {
1223
+ store: options.store,
1224
+ peppers: options.peppers,
1225
+ requiredScopes: options.requiredScopes,
1226
+ expectedPrefix: options.expectedPrefix,
1227
+ updateLastUsed: options.updateLastUsed === true,
1228
+ headerName: (options.headerName ?? DEFAULT_HEADER).toLowerCase(),
1229
+ scheme: options.scheme ?? 'bearer',
1230
+ allowQueryParam: options.allowQueryParam === true,
1231
+ queryParamName: options.queryParamName ?? DEFAULT_QUERY,
1232
+ attach,
1233
+ tokenFromRequest: options.tokenFromRequest,
1234
+ };
1235
+ }
1236
+
1237
+ /**
1238
+ * Extract the raw key from a request context per the configured
1239
+ * scheme. Returns `null` when no candidate was found (the caller then
1240
+ * responds with 401 `missing_key`).
1241
+ *
1242
+ * @param {AdapterContext} ctx
1243
+ * @param {ReturnType<typeof normalizeOptions>} config
1244
+ * @returns {string | null}
1245
+ */
1246
+ function extractKey(ctx, config) {
1247
+ if (config.tokenFromRequest) {
1248
+ const custom = config.tokenFromRequest(ctx);
1249
+ return isString(custom) && custom.length > 0 ? custom : null;
1250
+ }
1251
+ const raw = ctx.getHeader(config.headerName);
1252
+ if (isString(raw) && raw.length > 0) {
1253
+ if (config.scheme === 'bearer') {
1254
+ const m = /^bearer\s+(.+)$/i.exec(raw.trim());
1255
+ if (m) {
1256
+ return m[1].trim();
1257
+ }
1258
+ } else {
1259
+ return raw.trim();
1260
+ }
1261
+ }
1262
+ if (config.allowQueryParam && ctx.query) {
1263
+ const q = ctx.query[config.queryParamName];
1264
+ if (isString(q) && q.length > 0) {
1265
+ return q;
1266
+ }
1267
+ }
1268
+ return null;
1269
+ }
1270
+
1271
+ /**
1272
+ * Run the verify against the request. Returns a discriminated result:
1273
+ * on success the caller attaches `verifyResult` to the request and
1274
+ * proceeds; on failure the caller sends the returned `response`.
1275
+ *
1276
+ * @param {AdapterContext} ctx
1277
+ * @param {ReturnType<typeof normalizeOptions>} config
1278
+ * @returns {Promise<AdapterResult>}
1279
+ */
1280
+ async function runApiKey(ctx, config) {
1281
+ const raw = extractKey(ctx, config);
1282
+ if (!raw) {
1283
+ return {
1284
+ verifyResult: { valid: false, reason: 'malformed' },
1285
+ response: { status: 401, body: { error: 'missing_api_key' } },
1286
+ };
1287
+ }
1288
+ const verifyResult = await verifyApiKey(raw, {
1289
+ store: config.store,
1290
+ peppers: config.peppers,
1291
+ requiredScopes: config.requiredScopes,
1292
+ expectedPrefix: config.expectedPrefix,
1293
+ updateLastUsed: config.updateLastUsed,
1294
+ });
1295
+ if (!verifyResult.valid) {
1296
+ const status = _statusFor(verifyResult.reason);
1297
+ return {
1298
+ verifyResult,
1299
+ response: { status, body: { error: 'invalid_api_key', reason: verifyResult.reason } },
1300
+ };
1301
+ }
1302
+ return { verifyResult };
1303
+ }
1304
+
1305
+ function _statusFor(reason) {
1306
+ switch (reason) {
1307
+ case 'missing_scope':
1308
+ return 403;
1309
+ case 'store_unavailable':
1310
+ return 503;
1311
+ default:
1312
+ return 401;
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Express adapter for `@exortek/apikey`.
1318
+ *
1319
+ * import express from 'express';
1320
+ * import { apiKeyMiddleware } from '@exortek/apikey/middleware/express';
1321
+ * import { memoryStore } from '@exortek/apikey/stores';
1322
+ *
1323
+ * const app = express();
1324
+ * const store = memoryStore();
1325
+ *
1326
+ * app.use('/v1', apiKeyMiddleware({
1327
+ * store,
1328
+ * requiredScopes: ['read'],
1329
+ * updateLastUsed: true,
1330
+ * }));
1331
+ *
1332
+ * app.get('/v1/whoami', (req, res) => res.json(req.apiKey));
1333
+ */
1334
+
1335
+
1336
+ /**
1337
+ * @param {import('./core.js').ApiKeyMiddlewareOptions} options
1338
+ */
1339
+ function apiKeyMiddleware(options) {
1340
+ const config = normalizeOptions(options);
1341
+ return async function apikeyExpressMiddleware(req, res, next) {
1342
+ const ctx = {
1343
+ getHeader: name => req.headers?.[name],
1344
+ method: req.method,
1345
+ ip: req.ip,
1346
+ query: req.query,
1347
+ };
1348
+ let result;
1349
+ try {
1350
+ result = await runApiKey(ctx, config);
1351
+ } catch (err) {
1352
+ return next(err);
1353
+ }
1354
+ if (result.response) {
1355
+ res.status(result.response.status).json(result.response.body);
1356
+ return;
1357
+ }
1358
+ req[config.attach] = result.verifyResult;
1359
+ next();
1360
+ };
1361
+ }
1362
+
1363
+ exports.apiKeyMiddleware = apiKeyMiddleware;