@exortek/apikey 1.0.0

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