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