@exortek/challenge 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,1245 @@
1
+ import { timingSafeEqual as timingSafeEqual$1, randomBytes, createHmac } 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` when `value` is a Node.js `Buffer`. Uint8Array-only shapes are
219
+ * refused — use {@link isBytes} when either satisfies the contract.
220
+ *
221
+ * @param {unknown} value
222
+ * @returns {boolean}
223
+ */
224
+ function isBuffer(value) {
225
+ return Buffer.isBuffer(value);
226
+ }
227
+
228
+ /**
229
+ * `true` for a finite `number` — `NaN`, `Infinity`, and `-Infinity` all
230
+ * fail. Use when the caller needs a number they can compute with;
231
+ * mirrors `Number.isFinite` semantics under a predicate-style name.
232
+ *
233
+ * @param {unknown} value
234
+ * @returns {boolean}
235
+ */
236
+ function isFiniteNumber(value) {
237
+ return typeof value === 'number' && Number.isFinite(value);
238
+ }
239
+
240
+ /**
241
+ * Shared base error class — the single error structure behind every
242
+ * `@exortek/*` package's `errors.js`.
243
+ *
244
+ * Every package keeps its own class identity with a one-liner subclass;
245
+ * codes stay per-package frozen maps, status mapping is declared as a
246
+ * static field:
247
+ *
248
+ * import { BaseError } from '@exortek/shared/errors';
249
+ *
250
+ * export const ErrorCode = Object.freeze({
251
+ * INVALID_ARGUMENT: 'INVALID_ARGUMENT',
252
+ * INVALID_TOKEN: 'INVALID_TOKEN',
253
+ * });
254
+ *
255
+ * export class JwtError extends BaseError {
256
+ * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
257
+ * static defaultStatus = 500;
258
+ * }
259
+ *
260
+ * Instances carry a stable machine-readable `code` (branch on this,
261
+ * never on the message), an optional HTTP `status`, an optional
262
+ * `details` object, and the standard `cause` chain.
263
+ */
264
+ class BaseError extends Error {
265
+ /**
266
+ * Optional `code → HTTP status` map declared on the subclass. When
267
+ * absent the instance carries no `status` at all — for HTTP-agnostic
268
+ * packages like `@exortek/crypto`.
269
+ *
270
+ * @type {Record<string, number> | undefined}
271
+ */
272
+ static statuses = undefined;
273
+
274
+ /**
275
+ * Fallback status for codes missing from `statuses`.
276
+ *
277
+ * @type {number}
278
+ */
279
+ static defaultStatus = 500;
280
+
281
+ /**
282
+ * @param {string} code Stable machine-readable code; branch on this.
283
+ * @param {string} message Human-readable diagnostic. Free-form; may
284
+ * change across versions.
285
+ * @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
286
+ */
287
+ constructor(code, message, options = {}) {
288
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
289
+ this.name = new.target.name;
290
+ /** @type {string} */
291
+ this.code = code;
292
+ const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
293
+ if (options.status !== undefined) {
294
+ /** @type {number | undefined} */
295
+ this.status = options.status;
296
+ } else if (statuses !== undefined) {
297
+ // Object.hasOwn guards against inherited-property lookups for
298
+ // exotic code values ('toString', 'constructor', …).
299
+ this.status = Object.hasOwn(statuses, code)
300
+ ? statuses[code]
301
+ : /** @type {typeof BaseError} */ (new.target).defaultStatus;
302
+ }
303
+ if (options.details) {
304
+ /** @type {Record<string, unknown> | undefined} */
305
+ this.details = options.details;
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Stable machine-readable codes for every programmer-error failure that
312
+ * `@exortek/challenge` can raise. Branch on `code`, never on the
313
+ * message. Note: expected verify failures (bad signature, expired,
314
+ * mismatched claim) are NOT thrown — `verifyChallenge` returns
315
+ * `{ valid: false, reason }` for those so a wrong or stale token is a
316
+ * normal auth outcome, not an exception. Errors below fire only when
317
+ * the caller configured something wrong.
318
+ */
319
+
320
+ const ErrorCode = Object.freeze({
321
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
322
+ INVALID_SECRET: 'INVALID_SECRET',
323
+ });
324
+
325
+ /**
326
+ * Every recoverable failure raised by this package. Carries a stable
327
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
328
+ * status a middleware layer would use when translating the error.
329
+ */
330
+ class ChallengeError extends BaseError {
331
+ static statuses = {
332
+ [ErrorCode.INVALID_ARGUMENT]: 400,
333
+ [ErrorCode.INVALID_SECRET]: 400,
334
+ };
335
+ static defaultStatus = 500;
336
+ }
337
+
338
+ /**
339
+ * Imperative single-argument guard helpers — the everyday
340
+ * `assertPositiveInt(x, 'name')` shape used at API boundaries.
341
+ * Companion to the compound schema builder in
342
+ * `@exortek/shared/validate`: **schema** for whole options objects,
343
+ * **asserts** for one-liner argument guards at the call site.
344
+ *
345
+ * The consumer surface is {@link defineGuards} (or the raw
346
+ * {@link bindAsserts} for full control). Each package binds the assert
347
+ * set to its own typed error class once (in `internal/guards.js`), so
348
+ * every argument failure throws that package's class —
349
+ * `err instanceof CryptoError` holds and users see at a glance which
350
+ * package raised the error. The bound `parse` bridges
351
+ * `@exortek/shared/validate` schemas to the same error class.
352
+ *
353
+ * **Path naming convention** (used as the `name` argument):
354
+ *
355
+ * `<publicFunction>[.options|.config][.<field>]`
356
+ *
357
+ * - `assertString(name, 'createUser.name')` — top-level arg
358
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
359
+ * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
360
+ *
361
+ * Keep the path short: the emitted error is `"<name> must be <desc>"`,
362
+ * so a stack-line grep already tells the caller which function fired.
363
+ * Reserve `hint` for actionable follow-ups ("pass the bytes returned by
364
+ * encryptSymmetric()"), not for restating the field.
365
+ */
366
+
367
+ /**
368
+ * @typedef {{ cause?: unknown }} WrapExtra
369
+ * Extra options forwarded to the wrap function, so `invalidArgument`
370
+ * sites that catch an underlying error can preserve the `cause` chain
371
+ * without falling back to a raw `new PackageError(...)`.
372
+ *
373
+ * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
374
+ * Constructs (never throws) the binding package's error, e.g.
375
+ * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
376
+ *
377
+ * @typedef {{ hint?: string }} AssertOptions
378
+ * `hint` is appended to the message after an em-dash — use it for
379
+ * actionable guidance ("pass the exact bytes returned by …").
380
+ *
381
+ * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
382
+ */
383
+
384
+ /**
385
+ * Build the failure message. `description` completes the sentence
386
+ * `"<name> must be <description>"`; `hint` (optional) follows an
387
+ * em-dash.
388
+ *
389
+ * @param {string} name
390
+ * @param {string} description
391
+ * @param {string} [hint]
392
+ * @returns {string}
393
+ */
394
+ function message(name, description, hint) {
395
+ return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
396
+ }
397
+
398
+ const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
399
+
400
+ /**
401
+ * Bind the assert set to a package's error class. Call once per
402
+ * package (conventionally in `internal/guards.js`) and import the
403
+ * bound object everywhere:
404
+ *
405
+ * import { bindAsserts } from '@exortek/shared/asserts';
406
+ * import { CryptoError, ErrorCode } from '../errors.js';
407
+ * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
408
+ *
409
+ * // call sites:
410
+ * g.assertPositiveInt(options.iterations, 'options.iterations');
411
+ * const cfg = g.parse(OptionsSchema, options, 'options');
412
+ *
413
+ * @param {WrapFn} wrap
414
+ */
415
+ function bindAsserts(wrap) {
416
+ /**
417
+ * @param {string} name
418
+ * @param {string} description
419
+ * @param {AssertOptions} [opts]
420
+ * @returns {never}
421
+ */
422
+ function fail(name, description, opts) {
423
+ throw wrap(message(name, description, opts?.hint));
424
+ }
425
+
426
+ /**
427
+ * Assert that `value` is a plain object (not `null`, not an array,
428
+ * not a primitive).
429
+ * @param {unknown} value
430
+ * @param {string} name
431
+ * @param {AssertOptions} [opts]
432
+ */
433
+ function assertObject(value, name, opts) {
434
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
435
+ fail(name, 'an object', opts);
436
+ }
437
+ }
438
+
439
+ return {
440
+ /**
441
+ * Construct (not throw) the bound error with a free-form message —
442
+ * for `throw g.invalidArgument('…')` sites that don't fit the
443
+ * `X must be Y` shape (canonicalisation errors, cross-field
444
+ * constraint failures). Pass `{ cause }` when the argument-shape
445
+ * failure was triggered by an underlying throw whose chain matters
446
+ * (e.g. `JSON.stringify` on a bigint).
447
+ *
448
+ * @param {string} msg
449
+ * @param {WrapExtra} [extra]
450
+ * @returns {Error}
451
+ */
452
+ invalidArgument(msg, extra) {
453
+ return wrap(msg, extra);
454
+ },
455
+
456
+ /**
457
+ * Validate `input` against a `@exortek/shared/validate` schema;
458
+ * failures throw the bound error carrying every collected message.
459
+ * The bridge that keeps schema validation on the package's own
460
+ * error surface.
461
+ *
462
+ * @param {ParseableSchema} schema
463
+ * @param {unknown} input
464
+ * @param {string} [path='options']
465
+ * @returns {unknown} the parsed (possibly defaulted) value
466
+ */
467
+ parse(schema, input, path = 'options') {
468
+ const r = schema.safeParse(input, path);
469
+ if (!r.ok) {
470
+ throw wrap(r.errors.join('; '));
471
+ }
472
+ return r.value;
473
+ },
474
+
475
+ /**
476
+ * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
477
+ * @param {unknown} value
478
+ * @param {string} name Argument name to include in the error message.
479
+ * @param {AssertOptions} [opts]
480
+ */
481
+ assertNonNegativeInt(value, name, opts) {
482
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
483
+ fail(name, 'a non-negative safe integer', opts);
484
+ }
485
+ },
486
+
487
+ /**
488
+ * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
489
+ * @param {unknown} value
490
+ * @param {string} name
491
+ * @param {AssertOptions} [opts]
492
+ */
493
+ assertPositiveInt(value, name, opts) {
494
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
495
+ fail(name, 'a positive integer', opts);
496
+ }
497
+ },
498
+
499
+ /**
500
+ * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
501
+ * Used for Unix millisecond timestamps in UUID v7 / ULID.
502
+ * @param {unknown} value
503
+ * @param {string} name
504
+ * @param {AssertOptions} [opts]
505
+ */
506
+ assertUint48(value, name, opts) {
507
+ if (
508
+ !Number.isSafeInteger(value) ||
509
+ /** @type {number} */ (value) < 0 ||
510
+ /** @type {number} */ (value) > 0xffffffffffff
511
+ ) {
512
+ fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
513
+ }
514
+ },
515
+
516
+ /**
517
+ * Assert that `value` is a string (may be empty).
518
+ * @param {unknown} value
519
+ * @param {string} name
520
+ * @param {AssertOptions} [opts]
521
+ */
522
+ assertString(value, name, opts) {
523
+ if (typeof value !== 'string') {
524
+ fail(name, 'a string', opts);
525
+ }
526
+ },
527
+
528
+ /**
529
+ * Assert that `value` is a non-empty string.
530
+ * @param {unknown} value
531
+ * @param {string} name
532
+ * @param {AssertOptions} [opts]
533
+ */
534
+ assertNonEmptyString(value, name, opts) {
535
+ if (typeof value !== 'string' || value.length === 0) {
536
+ fail(name, 'a non-empty string', opts);
537
+ }
538
+ },
539
+
540
+ /**
541
+ * Assert that `value` is a boolean.
542
+ * @param {unknown} value
543
+ * @param {string} name
544
+ * @param {AssertOptions} [opts]
545
+ */
546
+ assertBoolean(value, name, opts) {
547
+ if (typeof value !== 'boolean') {
548
+ fail(name, 'a boolean', opts);
549
+ }
550
+ },
551
+
552
+ /**
553
+ * Assert that `value` is a function.
554
+ * @param {unknown} value
555
+ * @param {string} name
556
+ * @param {AssertOptions} [opts]
557
+ */
558
+ assertFunction(value, name, opts) {
559
+ if (typeof value !== 'function') {
560
+ fail(name, 'a function', opts);
561
+ }
562
+ },
563
+
564
+ assertObject,
565
+
566
+ /**
567
+ * Assert that `value` is either `undefined` or a plain object —
568
+ * the "optional options object" pattern.
569
+ * @param {unknown} value
570
+ * @param {string} name
571
+ * @param {AssertOptions} [opts]
572
+ */
573
+ assertOptionalObject(value, name, opts) {
574
+ if (value === undefined) {
575
+ return;
576
+ }
577
+ assertObject(value, name, opts);
578
+ },
579
+
580
+ /**
581
+ * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
582
+ * strings are NOT accepted. For already-encoded material where a
583
+ * string would be ambiguous (ciphertext, signatures, raw key bytes).
584
+ * @param {unknown} value
585
+ * @param {string} name
586
+ * @param {AssertOptions} [opts]
587
+ */
588
+ assertBytes(value, name, opts) {
589
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
590
+ fail(name, 'a Buffer or Uint8Array', opts);
591
+ }
592
+ },
593
+
594
+ /**
595
+ * Assert that `value` is either a string or a byte buffer (`Buffer`
596
+ * or `Uint8Array`).
597
+ * @param {unknown} value
598
+ * @param {string} name
599
+ * @param {AssertOptions} [opts]
600
+ */
601
+ assertBytesOrString(value, name, opts) {
602
+ if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
603
+ fail(name, 'a string or Buffer', opts);
604
+ }
605
+ },
606
+
607
+ /**
608
+ * Assert that `encoding` is one of the accepted output/input
609
+ * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
610
+ * no sense (verifying a string signature, decoding a token payload).
611
+ *
612
+ * @param {unknown} encoding
613
+ * @param {string} name
614
+ * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
615
+ */
616
+ assertEncoding(encoding, name, opts) {
617
+ const allowBuffer = opts?.allowBuffer !== false;
618
+ const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
619
+ if (!valid) {
620
+ const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
621
+ fail(name, list, opts);
622
+ }
623
+ },
624
+ };
625
+ }
626
+
627
+ /**
628
+ * One-line sugar over {@link bindAsserts} — the overwhelmingly common
629
+ * pattern where a package binds every argument-shape failure to its
630
+ * own error class + `INVALID_ARGUMENT` code. Emits the standard
631
+ * `new ErrorClass(code, message, { cause })` shape, so the underlying
632
+ * `BaseError` `{ cause, status, details }` options object is respected.
633
+ *
634
+ * // packages/<pkg>/src/internal/guards.js
635
+ * import { defineGuards } from '@exortek/shared/asserts';
636
+ * import { PasswordError, ErrorCode } from '../errors.js';
637
+ *
638
+ * export const { assertString, invalidArgument, parse } =
639
+ * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
640
+ *
641
+ * Reach for the raw {@link bindAsserts} when you need a different
642
+ * mapping (an alt code, a decorated message) — it stays available.
643
+ *
644
+ * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
645
+ * @param {string} code
646
+ */
647
+ function defineGuards(ErrorClass, code) {
648
+ return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
649
+ }
650
+
651
+ /**
652
+ * Argument guards bound to `ChallengeError` — the package-wide binding
653
+ * of `@exortek/shared/asserts`. Import guards from here, never from the
654
+ * shared module directly: every argument-shape failure must throw
655
+ * `ChallengeError` with `ErrorCode.INVALID_ARGUMENT` so callers get one
656
+ * error class for the whole package.
657
+ */
658
+
659
+
660
+ const { invalidArgument} = defineGuards(
661
+ ChallengeError,
662
+ ErrorCode.INVALID_ARGUMENT,
663
+ );
664
+
665
+ /**
666
+ * RFC 4648 §5 base64url codec.
667
+ *
668
+ * Node's `Buffer.from(str, 'base64url')` is lenient: it accepts
669
+ * padding, `+`, `/`, and whitespace. Wire-level use (JWS / JWT
670
+ * compact serialisation, JWK ingest, etc.) requires strict rejection
671
+ * of everything that isn't a canonical unpadded encoding — hence the
672
+ * roundtrip check in `decode`.
673
+ *
674
+ * Throws plain `Error` / `TypeError` on malformed input. Callers that
675
+ * need typed errors wrap the call at their surface boundary.
676
+ */
677
+
678
+ const ALPHABET = /^[A-Za-z0-9_-]*$/;
679
+
680
+ /**
681
+ * @param {Buffer | Uint8Array} bytes
682
+ * @returns {string}
683
+ */
684
+ function encode(bytes) {
685
+ if (bytes == null || (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array))) {
686
+ throw new TypeError('base64url.encode: expected Buffer or Uint8Array');
687
+ }
688
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64url');
689
+ }
690
+
691
+ /**
692
+ * @param {string} text
693
+ * @returns {string}
694
+ */
695
+ function encodeString(text) {
696
+ if (typeof text !== 'string') {
697
+ throw new TypeError('base64url.encodeString: expected a string');
698
+ }
699
+ return Buffer.from(text, 'utf8').toString('base64url');
700
+ }
701
+
702
+ /**
703
+ * @param {unknown} value
704
+ * @returns {string}
705
+ */
706
+ function encodeJson(value) {
707
+ return encodeString(JSON.stringify(value));
708
+ }
709
+
710
+ /**
711
+ * @param {string} text
712
+ * @returns {Buffer}
713
+ */
714
+ function decode$1(text) {
715
+ if (typeof text !== 'string') {
716
+ throw new TypeError('base64url.decode: expected a string');
717
+ }
718
+ if (!ALPHABET.test(text)) {
719
+ throw new Error('base64url.decode: input contains characters outside the RFC 4648 §5 alphabet');
720
+ }
721
+ const bytes = Buffer.from(text, 'base64url');
722
+ if (bytes.toString('base64url') !== text) {
723
+ throw new Error('base64url.decode: input is not a canonical encoding');
724
+ }
725
+ return bytes;
726
+ }
727
+
728
+ /**
729
+ * @param {string} text
730
+ * @returns {string}
731
+ */
732
+ function decodeToString(text) {
733
+ return decode$1(text).toString('utf8');
734
+ }
735
+
736
+ /**
737
+ * @param {string} text
738
+ * @returns {unknown}
739
+ */
740
+ function decodeJson(text) {
741
+ return JSON.parse(decodeToString(text));
742
+ }
743
+
744
+ /**
745
+ * Length-safe `timingSafeEqual`.
746
+ *
747
+ * `crypto.timingSafeEqual` throws when its two arguments have
748
+ * different lengths — the exception path is itself a side channel
749
+ * that leaks "lengths differ" to a timing attacker even when the
750
+ * caller catches the throw. This wrapper closes that channel by
751
+ * always running a `timingSafeEqual` (against the shorter buffer
752
+ * padded to itself) and returning `false` on length mismatch, so the
753
+ * caller sees a boolean either way and total runtime does not
754
+ * short-circuit on length alone.
755
+ */
756
+
757
+
758
+ /**
759
+ * @param {Buffer | Uint8Array} a
760
+ * @param {Buffer | Uint8Array} b
761
+ * @returns {boolean}
762
+ */
763
+ function timingSafeEqual(a, b) {
764
+ const aBuf = Buffer.isBuffer(a) ? a : Buffer.from(a.buffer, a.byteOffset, a.byteLength);
765
+ const bBuf = Buffer.isBuffer(b) ? b : Buffer.from(b.buffer, b.byteOffset, b.byteLength);
766
+ if (aBuf.length !== bBuf.length) {
767
+ // Burn a comparison so total time doesn't short-circuit on the
768
+ // length gate — the attacker can't distinguish "length mismatch"
769
+ // from "content mismatch" by observed runtime.
770
+ timingSafeEqual$1(aBuf, aBuf);
771
+ return false;
772
+ }
773
+ return timingSafeEqual$1(aBuf, bBuf);
774
+ }
775
+
776
+ /**
777
+ * Token codec for `@exortek/challenge`.
778
+ *
779
+ * Format:
780
+ *
781
+ * <prefix>.<base64url(JSON payload)>.<base64url(HMAC-SHA256 tag)>
782
+ *
783
+ * The default prefix is {@link DEFAULT_PREFIX} (`chall_v1`) — deliberately
784
+ * unlike a JWT so the two token families cannot be confused at a call
785
+ * site. Callers may override it (e.g. `'server_challenge'`,
786
+ * `'myapp_v1'`) to brand the wire format for a specific service; the
787
+ * same prefix must be used at create and verify time, or verification
788
+ * returns `reason: 'malformed'`.
789
+ *
790
+ * HMAC covers `<prefix>.<b64u payload>` — the same string the caller
791
+ * received minus the trailing tag. Any change to prefix or payload
792
+ * invalidates the signature; a token minted with one prefix cannot be
793
+ * accepted under another even by the same secret.
794
+ */
795
+
796
+
797
+ const DEFAULT_PREFIX = 'chall_v1';
798
+ const HMAC_ALG = 'sha256';
799
+
800
+ // Printable ASCII, no `.` (delimiter), no whitespace. Length capped so a
801
+ // misused prefix can't grow the token unboundedly.
802
+ const PREFIX_RE = /^[A-Za-z0-9_-]{1,32}$/;
803
+
804
+ /**
805
+ * Assert a prefix is well-shaped. Throws via the caller's `invalidArg`
806
+ * function so `createChallenge` / `verifyChallenge` can raise
807
+ * `ChallengeError` with the right code — this module stays free of the
808
+ * error class dependency.
809
+ *
810
+ * @param {string} prefix
811
+ * @param {string} name
812
+ * @param {(msg: string) => Error} invalidArg
813
+ * @returns {string}
814
+ */
815
+ function assertPrefix(prefix, name, invalidArg) {
816
+ if (!isString(prefix) || !PREFIX_RE.test(prefix)) {
817
+ throw invalidArg(
818
+ `${name} must match /^[A-Za-z0-9_-]{1,32}$/ (letters, digits, '_' or '-', 1-32 chars — no '.' since it's the token delimiter); got ${JSON.stringify(prefix)}`,
819
+ );
820
+ }
821
+ return prefix;
822
+ }
823
+
824
+ /**
825
+ * Random ID for the token's `jti` claim — 128 bits of entropy, encoded
826
+ * as 22 base64url characters. Used as the store key for single-use
827
+ * enforcement, so it must be unpredictable and unique per token.
828
+ *
829
+ * @returns {string}
830
+ */
831
+ function newJti() {
832
+ return encode(randomBytes(16));
833
+ }
834
+
835
+ /**
836
+ * Sign a payload with `secret` and return the compact token string.
837
+ *
838
+ * @param {object} payload
839
+ * @param {Buffer} secret 32+ raw bytes; caller validates length.
840
+ * @param {string} [prefix] Wire prefix; defaults to {@link DEFAULT_PREFIX}.
841
+ * @returns {string}
842
+ */
843
+ function sign(payload, secret, prefix = DEFAULT_PREFIX) {
844
+ const body = `${prefix}.${encodeJson(payload)}`;
845
+ const tag = createHmac(HMAC_ALG, secret).update(body).digest();
846
+ return `${body}.${encode(tag)}`;
847
+ }
848
+
849
+ /**
850
+ * Parse + HMAC-verify a token. Returns the decoded payload on success,
851
+ * or a reason string on any failure. Never throws on user-input
852
+ * problems — a wrong token is a normal auth outcome.
853
+ *
854
+ * @param {string} token
855
+ * @param {Buffer} secret
856
+ * @param {string} [prefix] Expected prefix; defaults to {@link DEFAULT_PREFIX}.
857
+ * @returns {{ payload: object } | { reason: 'malformed' | 'bad_signature' }}
858
+ */
859
+ function decode(token, secret, prefix = DEFAULT_PREFIX) {
860
+ if (!isString(token) || token.length === 0) {
861
+ return { reason: 'malformed' };
862
+ }
863
+ const parts = token.split('.');
864
+ if (parts.length !== 3 || parts[0] !== prefix) {
865
+ return { reason: 'malformed' };
866
+ }
867
+ const [, encodedPayload, encodedTag] = parts;
868
+ let tag;
869
+ try {
870
+ tag = decode$1(encodedTag);
871
+ } catch {
872
+ return { reason: 'malformed' };
873
+ }
874
+ const expected = createHmac(HMAC_ALG, secret).update(`${prefix}.${encodedPayload}`).digest();
875
+ if (!timingSafeEqual(tag, expected)) {
876
+ return { reason: 'bad_signature' };
877
+ }
878
+ let payload;
879
+ try {
880
+ payload = decodeJson(encodedPayload);
881
+ } catch {
882
+ return { reason: 'malformed' };
883
+ }
884
+ if (!isObject(payload)) {
885
+ return { reason: 'malformed' };
886
+ }
887
+ return { payload };
888
+ }
889
+
890
+ /**
891
+ * `@exortek/challenge` — signed, single-use challenge tokens for
892
+ * multi-step auth flows.
893
+ *
894
+ * A challenge is a small, HMAC-signed envelope that carries context
895
+ * across a redirect or a follow-up request without a server-side
896
+ * session: who is being challenged (`userId`), how they proved
897
+ * themselves so far (`method`), what step of the flow they've cleared
898
+ * (`step`), and any bespoke metadata the caller needs on the other
899
+ * side. The token is stateless by default; single-use enforcement and
900
+ * revocation are opt-in via a store the caller supplies.
901
+ *
902
+ * See the README for the full API and worked examples.
903
+ */
904
+
905
+
906
+ const MIN_SECRET_BYTES = 32;
907
+
908
+ const KNOWN_METHODS = new Set([
909
+ 'totp',
910
+ 'hotp',
911
+ 'email_otp',
912
+ 'sms_otp',
913
+ 'backup_code',
914
+ 'passkey',
915
+ 'magic_link',
916
+ 'password',
917
+ 'webauthn',
918
+ 'oauth',
919
+ 'oidc',
920
+ ]);
921
+
922
+ /**
923
+ * @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
924
+ * | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc'
925
+ * | string} ChallengeMethod
926
+ */
927
+
928
+ /**
929
+ * @typedef {object} IncrStore
930
+ * @property {(key: string, ttlMs: number) => Promise<{ count: number }>} incr
931
+ * Atomic increment-with-expiry. First call returns `{ count: 1 }` and
932
+ * arms a TTL; subsequent calls before expiry return the incremented
933
+ * count. Used as compare-and-set for single-use enforcement.
934
+ */
935
+
936
+ /**
937
+ * @typedef {object} ChallengePayload
938
+ * @property {string} jti
939
+ * @property {number} iat
940
+ * @property {number} exp
941
+ * @property {string} [userId]
942
+ * @property {ChallengeMethod} [method]
943
+ * @property {string} [step]
944
+ * @property {string} [nextStep]
945
+ * @property {string} [ip] Only set when `ipBinding: true`.
946
+ * @property {string} [ua] Only set when `ua` supplied.
947
+ * @property {Record<string, unknown>} [meta]
948
+ */
949
+
950
+ /**
951
+ * @typedef {object} CreateChallengeOptions
952
+ * @property {string | Buffer | Uint8Array} secret
953
+ * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
954
+ * interpreted as UTF-8 — for a hex or base64 secret, decode to
955
+ * Buffer first.
956
+ * @property {string} [userId]
957
+ * @property {ChallengeMethod} [method]
958
+ * @property {string} [step]
959
+ * @property {string} [nextStep]
960
+ * @property {string} [ip]
961
+ * @property {string} [ua]
962
+ * @property {Record<string, unknown>} [metadata]
963
+ * @property {string | number} expiresIn Duration string (`'5m'`) or ms integer.
964
+ * @property {boolean} [singleUse=false]
965
+ * When true, the returned token can only be verified once — subsequent
966
+ * verifies with `consume: true` fail with `reason: 'replay'`. Requires
967
+ * `store` to be supplied.
968
+ * @property {IncrStore} [store]
969
+ * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
970
+ * `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
971
+ * @property {boolean} [ipBinding=false]
972
+ * When true, the caller-supplied `ip` is stamped into the payload and
973
+ * `verifyChallenge` will reject a request whose `ip` differs.
974
+ * @property {string} [prefix='chall_v1']
975
+ * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
976
+ * with this package. Callers can override to brand the token
977
+ * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
978
+ * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
979
+ * verify time or verification returns `reason: 'malformed'`.
980
+ * @property {number} [now] Override `Date.now()` for testing.
981
+ */
982
+
983
+ /**
984
+ * @typedef {object} VerifyChallengeOptions
985
+ * @property {string | Buffer | Uint8Array} secret
986
+ * @property {boolean} [consume=false]
987
+ * Enforce single-use. Requires `store` (typically the same one used
988
+ * at create time).
989
+ * @property {IncrStore} [store]
990
+ * @property {string} [expectedUserId]
991
+ * @property {ChallengeMethod} [expectedMethod]
992
+ * @property {string} [expectedStep]
993
+ * @property {string} [expectedNextStep]
994
+ * @property {string} [ip]
995
+ * The current request's IP. Required to verify a token that was
996
+ * created with `ipBinding: true`; ignored otherwise.
997
+ * @property {string} [prefix='chall_v1']
998
+ * Wire-format prefix. Must match the value passed to
999
+ * `createChallenge` — a token minted with a different prefix will
1000
+ * fail with `reason: 'malformed'`.
1001
+ * @property {number} [now] Override `Date.now()` for testing.
1002
+ */
1003
+
1004
+ /**
1005
+ * @typedef {'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid'
1006
+ * | 'user_mismatch' | 'method_mismatch' | 'step_mismatch'
1007
+ * | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay'
1008
+ * | 'store_unavailable'} VerifyFailureReason
1009
+ */
1010
+
1011
+ /**
1012
+ * @typedef {{ valid: true, payload: ChallengePayload }
1013
+ * | { valid: false, reason: VerifyFailureReason }} VerifyChallengeResult
1014
+ */
1015
+
1016
+ /**
1017
+ * Create a signed challenge token.
1018
+ *
1019
+ * const token = await createChallenge({
1020
+ * secret: process.env.CHALLENGE_SECRET,
1021
+ * userId: 'usr_123',
1022
+ * method: 'totp',
1023
+ * step: 'mfa_verified',
1024
+ * nextStep: 'login',
1025
+ * expiresIn: '5m',
1026
+ * singleUse: true,
1027
+ * store,
1028
+ * })
1029
+ *
1030
+ * @param {CreateChallengeOptions} options
1031
+ * @returns {Promise<string>}
1032
+ */
1033
+ async function createChallenge(options) {
1034
+ if (!isObject(options)) {
1035
+ throw invalidArgument('createChallenge.options must be an object');
1036
+ }
1037
+ const secret = _coerceSecret(options.secret, 'createChallenge.options.secret');
1038
+ let ttlMs;
1039
+ try {
1040
+ ttlMs = parseDuration(options.expiresIn);
1041
+ } catch (err) {
1042
+ throw invalidArgument(`createChallenge.options.expiresIn: ${err.message}`, { cause: err });
1043
+ }
1044
+ if (!isFiniteNumber(ttlMs) || ttlMs <= 0) {
1045
+ throw invalidArgument(`createChallenge.options.expiresIn must resolve to a positive duration; got ${ttlMs}ms`);
1046
+ }
1047
+ const prefix = isUndefined(options.prefix)
1048
+ ? DEFAULT_PREFIX
1049
+ : assertPrefix(options.prefix, 'createChallenge.options.prefix', invalidArgument);
1050
+ _assertStringOrUndef(options.userId, 'createChallenge.options.userId');
1051
+ _assertMethod(options.method, 'createChallenge.options.method');
1052
+ _assertStringOrUndef(options.step, 'createChallenge.options.step');
1053
+ _assertStringOrUndef(options.nextStep, 'createChallenge.options.nextStep');
1054
+ _assertStringOrUndef(options.ua, 'createChallenge.options.ua');
1055
+
1056
+ const singleUse = options.singleUse === true;
1057
+ const ipBinding = options.ipBinding === true;
1058
+
1059
+ if (singleUse && !_isStore(options.store)) {
1060
+ throw invalidArgument(
1061
+ 'createChallenge.options.store is required when singleUse: true — pass an object exposing incr(key, ttlMs) → { count }',
1062
+ );
1063
+ }
1064
+ if (ipBinding && !isString(options.ip)) {
1065
+ throw invalidArgument('createChallenge.options.ip must be a string when ipBinding: true');
1066
+ }
1067
+ if (!isUndefined(options.metadata) && !isObject(options.metadata)) {
1068
+ throw invalidArgument('createChallenge.options.metadata must be a plain object when provided');
1069
+ }
1070
+
1071
+ const now = _now(options.now);
1072
+ const jti = newJti();
1073
+
1074
+ /** @type {ChallengePayload} */
1075
+ const payload = {
1076
+ jti,
1077
+ iat: Math.floor(now / 1000),
1078
+ exp: Math.floor((now + ttlMs) / 1000),
1079
+ };
1080
+ if (options.userId) {
1081
+ payload.userId = options.userId;
1082
+ }
1083
+ if (options.method) {
1084
+ payload.method = options.method;
1085
+ }
1086
+ if (options.step) {
1087
+ payload.step = options.step;
1088
+ }
1089
+ if (options.nextStep) {
1090
+ payload.nextStep = options.nextStep;
1091
+ }
1092
+ if (ipBinding) {
1093
+ payload.ip = options.ip;
1094
+ }
1095
+ if (isString(options.ua)) {
1096
+ payload.ua = options.ua;
1097
+ }
1098
+ if (!isUndefined(options.metadata)) {
1099
+ payload.meta = options.metadata;
1100
+ }
1101
+ if (singleUse) {
1102
+ payload.su = 1;
1103
+ }
1104
+
1105
+ return sign(payload, secret, prefix);
1106
+ }
1107
+
1108
+ /**
1109
+ * Verify a challenge token. Returns `{ valid: true, payload }` on
1110
+ * success or `{ valid: false, reason }` on any expected failure. Only
1111
+ * throws on programmer errors (bad options, missing secret).
1112
+ *
1113
+ * const res = await verifyChallenge(token, {
1114
+ * secret: process.env.CHALLENGE_SECRET,
1115
+ * consume: true,
1116
+ * store,
1117
+ * expectedUserId: pendingUserId,
1118
+ * expectedMethod: 'totp',
1119
+ * ip: req.ip,
1120
+ * })
1121
+ * if (!res.valid) return reply.code(401).send({ error: res.reason })
1122
+ *
1123
+ * @param {string} token
1124
+ * @param {VerifyChallengeOptions} options
1125
+ * @returns {Promise<VerifyChallengeResult>}
1126
+ */
1127
+ async function verifyChallenge(token, options) {
1128
+ if (!isObject(options)) {
1129
+ throw invalidArgument('verifyChallenge.options must be an object');
1130
+ }
1131
+ const secret = _coerceSecret(options.secret, 'verifyChallenge.options.secret');
1132
+ const consume = options.consume === true;
1133
+ if (consume && !_isStore(options.store)) {
1134
+ throw invalidArgument(
1135
+ 'verifyChallenge.options.store is required when consume: true — pass the same store used at create time',
1136
+ );
1137
+ }
1138
+ const prefix = isUndefined(options.prefix)
1139
+ ? DEFAULT_PREFIX
1140
+ : assertPrefix(options.prefix, 'verifyChallenge.options.prefix', invalidArgument);
1141
+
1142
+ const parsed = decode(token, secret, prefix);
1143
+ if ('reason' in parsed) {
1144
+ return { valid: false, reason: parsed.reason };
1145
+ }
1146
+ const payload = /** @type {ChallengePayload & { su?: 1 }} */ (parsed.payload);
1147
+ const now = _now(options.now);
1148
+ const nowSec = Math.floor(now / 1000);
1149
+
1150
+ if (!isNumber(payload.exp) || payload.exp <= nowSec) {
1151
+ return { valid: false, reason: 'expired' };
1152
+ }
1153
+ if (isNumber(payload.iat) && payload.iat > nowSec + 60) {
1154
+ // Small clock-skew tolerance (60s) — reject only if clearly future-dated.
1155
+ return { valid: false, reason: 'not_yet_valid' };
1156
+ }
1157
+ if (!isUndefined(options.expectedUserId) && payload.userId !== options.expectedUserId) {
1158
+ return { valid: false, reason: 'user_mismatch' };
1159
+ }
1160
+ if (!isUndefined(options.expectedMethod) && payload.method !== options.expectedMethod) {
1161
+ return { valid: false, reason: 'method_mismatch' };
1162
+ }
1163
+ if (!isUndefined(options.expectedStep) && payload.step !== options.expectedStep) {
1164
+ return { valid: false, reason: 'step_mismatch' };
1165
+ }
1166
+ if (!isUndefined(options.expectedNextStep) && payload.nextStep !== options.expectedNextStep) {
1167
+ return { valid: false, reason: 'next_step_mismatch' };
1168
+ }
1169
+ if (!isUndefined(payload.ip)) {
1170
+ if (!isString(options.ip)) {
1171
+ return { valid: false, reason: 'ip_missing' };
1172
+ }
1173
+ if (options.ip !== payload.ip) {
1174
+ return { valid: false, reason: 'ip_mismatch' };
1175
+ }
1176
+ }
1177
+ if (consume && payload.su === 1) {
1178
+ const ttlMs = Math.max(1, payload.exp * 1000 - now);
1179
+ let count;
1180
+ try {
1181
+ // Namespace lives in the store's own keyPrefix (redisStore defaults
1182
+ // to 'chall:'). verifyChallenge passes the raw jti so a store shared
1183
+ // with other purposes can prefix once — no double 'chall:chall:'.
1184
+ const res = await options.store.incr(payload.jti, ttlMs);
1185
+ count = res?.count;
1186
+ } catch {
1187
+ return { valid: false, reason: 'store_unavailable' };
1188
+ }
1189
+ if (!isNumber(count) || count > 1) {
1190
+ return { valid: false, reason: 'replay' };
1191
+ }
1192
+ }
1193
+ // Strip internal fields so callers see only their own payload.
1194
+ const { su: _su, ...clean } = payload;
1195
+ return { valid: true, payload: clean };
1196
+ }
1197
+
1198
+ function _coerceSecret(input, name) {
1199
+ if (isString(input)) {
1200
+ const buf = Buffer.from(input, 'utf8');
1201
+ if (buf.length < MIN_SECRET_BYTES) {
1202
+ throw new ChallengeError(
1203
+ ErrorCode.INVALID_SECRET,
1204
+ `${name} must be at least ${MIN_SECRET_BYTES} bytes; got ${buf.length}. Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`,
1205
+ );
1206
+ }
1207
+ return buf;
1208
+ }
1209
+ if (isBytes(input)) {
1210
+ if (input.length < MIN_SECRET_BYTES) {
1211
+ throw new ChallengeError(
1212
+ ErrorCode.INVALID_SECRET,
1213
+ `${name} must be at least ${MIN_SECRET_BYTES} bytes; got ${input.length}`,
1214
+ );
1215
+ }
1216
+ return isBuffer(input) ? input : Buffer.from(input);
1217
+ }
1218
+ throw invalidArgument(`${name} must be a string, Buffer, or Uint8Array`);
1219
+ }
1220
+
1221
+ function _assertStringOrUndef(v, name) {
1222
+ if (!isUndefined(v) && !isString(v)) {
1223
+ throw invalidArgument(`${name} must be a string when provided`);
1224
+ }
1225
+ }
1226
+
1227
+ function _assertMethod(v, name) {
1228
+ if (isUndefined(v)) {
1229
+ return;
1230
+ }
1231
+ if (!isString(v)) {
1232
+ throw invalidArgument(`${name} must be a string when provided`);
1233
+ }
1234
+ if (!KNOWN_METHODS.has(v)) ;
1235
+ }
1236
+
1237
+ function _isStore(v) {
1238
+ return isObject(v) && isFunction(v.incr);
1239
+ }
1240
+
1241
+ function _now(override) {
1242
+ return isNumber(override) ? override : Date.now();
1243
+ }
1244
+
1245
+ export { ChallengeError, ErrorCode, createChallenge, verifyChallenge };