@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.
@@ -0,0 +1,710 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Non-throwing type predicates. Companion to `asserts.js`.
5
+ *
6
+ * ### When to reach for which
7
+ *
8
+ * - Writing a **throw-guard** at a function boundary — a bad shape
9
+ * should fail with the caller's typed error class? Use
10
+ * `asserts` + `defineGuards` (`assertObject`, `assertString`, …).
11
+ * The `assert*` family bundles the check with the correctly-bound
12
+ * throw and preserves the "one error class per package" contract.
13
+ *
14
+ * - Writing a **dispatch / branch / fallback** where the wrong shape
15
+ * is meant to be handled, not thrown? Use these predicates.
16
+ * Typical shapes:
17
+ *
18
+ * if (isObject(config.store)) { ... } else { ... }
19
+ *
20
+ * const isManager = isObject(v) && isFunction(v.issue);
21
+ *
22
+ * The two APIs deliberately do not overlap in intent: `assert*` throws,
23
+ * `is*` never does. Bypassing an `assert*` with an `if (!is*(x)) throw
24
+ * new PkgError(...)` pattern skips the package's guards binding and
25
+ * duplicates work the assert family already handles — don't.
26
+ */
27
+
28
+
29
+ /**
30
+ * `true` when `value` is a function. Class constructors, arrow
31
+ * functions, and generator functions all pass — `typeof` on any
32
+ * callable is `'function'`.
33
+ *
34
+ * @param {unknown} value
35
+ * @returns {boolean}
36
+ */
37
+ function isFunction(value) {
38
+ return typeof value === 'function';
39
+ }
40
+
41
+ /**
42
+ * `true` when `value` is `undefined`.
43
+ *
44
+ * @param {unknown} value
45
+ * @returns {boolean}
46
+ */
47
+ function isUndefined(value) {
48
+ return typeof value === 'undefined';
49
+ }
50
+
51
+ /**
52
+ * `true` for a safe integer — a finite `number` inside `[MIN_SAFE_INTEGER,
53
+ * MAX_SAFE_INTEGER]` with no fractional part. Mirrors `Number.isSafeInteger`.
54
+ *
55
+ * @param {unknown} value
56
+ * @returns {boolean}
57
+ */
58
+ function isInteger(value) {
59
+ return Number.isSafeInteger(value);
60
+ }
61
+
62
+ /**
63
+ * Imperative single-argument guard helpers — the everyday
64
+ * `assertPositiveInt(x, 'name')` shape used at API boundaries.
65
+ * Companion to the compound schema builder in
66
+ * `@exortek/shared/validate`: **schema** for whole options objects,
67
+ * **asserts** for one-liner argument guards at the call site.
68
+ *
69
+ * The consumer surface is {@link defineGuards} (or the raw
70
+ * {@link bindAsserts} for full control). Each package binds the assert
71
+ * set to its own typed error class once (in `internal/guards.js`), so
72
+ * every argument failure throws that package's class —
73
+ * `err instanceof CryptoError` holds and users see at a glance which
74
+ * package raised the error. The bound `parse` bridges
75
+ * `@exortek/shared/validate` schemas to the same error class.
76
+ *
77
+ * **Path naming convention** (used as the `name` argument):
78
+ *
79
+ * `<publicFunction>[.options|.config][.<field>]`
80
+ *
81
+ * - `assertString(name, 'createUser.name')` — top-level arg
82
+ * - `assertPositiveInt(n, 'scrypt.options.r')` — nested option
83
+ * - `assertBytesOrString(pwd, 'pepper.wrap.password')` — method arg
84
+ *
85
+ * Keep the path short: the emitted error is `"<name> must be <desc>"`,
86
+ * so a stack-line grep already tells the caller which function fired.
87
+ * Reserve `hint` for actionable follow-ups ("pass the bytes returned by
88
+ * encryptSymmetric()"), not for restating the field.
89
+ */
90
+
91
+ /**
92
+ * @typedef {{ cause?: unknown }} WrapExtra
93
+ * Extra options forwarded to the wrap function, so `invalidArgument`
94
+ * sites that catch an underlying error can preserve the `cause` chain
95
+ * without falling back to a raw `new PackageError(...)`.
96
+ *
97
+ * @typedef {(message: string, extra?: WrapExtra) => Error} WrapFn
98
+ * Constructs (never throws) the binding package's error, e.g.
99
+ * `(m, { cause } = {}) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m, { cause })`.
100
+ *
101
+ * @typedef {{ hint?: string }} AssertOptions
102
+ * `hint` is appended to the message after an em-dash — use it for
103
+ * actionable guidance ("pass the exact bytes returned by …").
104
+ *
105
+ * @typedef {{ safeParse: (input: unknown, path?: string) => { ok: true, value: unknown } | { ok: false, errors: string[] } }} ParseableSchema
106
+ */
107
+
108
+ /**
109
+ * Build the failure message. `description` completes the sentence
110
+ * `"<name> must be <description>"`; `hint` (optional) follows an
111
+ * em-dash.
112
+ *
113
+ * @param {string} name
114
+ * @param {string} description
115
+ * @param {string} [hint]
116
+ * @returns {string}
117
+ */
118
+ function message(name, description, hint) {
119
+ return `${name} must be ${description}${hint ? ` — ${hint}` : ''}`;
120
+ }
121
+
122
+ const ENCODINGS = new Set(['hex', 'base64', 'base64url']);
123
+
124
+ /**
125
+ * Bind the assert set to a package's error class. Call once per
126
+ * package (conventionally in `internal/guards.js`) and import the
127
+ * bound object everywhere:
128
+ *
129
+ * import { bindAsserts } from '@exortek/shared/asserts';
130
+ * import { CryptoError, ErrorCode } from '../errors.js';
131
+ * export const g = bindAsserts((m) => new CryptoError(ErrorCode.INVALID_ARGUMENT, m));
132
+ *
133
+ * // call sites:
134
+ * g.assertPositiveInt(options.iterations, 'options.iterations');
135
+ * const cfg = g.parse(OptionsSchema, options, 'options');
136
+ *
137
+ * @param {WrapFn} wrap
138
+ */
139
+ function bindAsserts(wrap) {
140
+ /**
141
+ * @param {string} name
142
+ * @param {string} description
143
+ * @param {AssertOptions} [opts]
144
+ * @returns {never}
145
+ */
146
+ function fail(name, description, opts) {
147
+ throw wrap(message(name, description, opts?.hint));
148
+ }
149
+
150
+ /**
151
+ * Assert that `value` is a plain object (not `null`, not an array,
152
+ * not a primitive).
153
+ * @param {unknown} value
154
+ * @param {string} name
155
+ * @param {AssertOptions} [opts]
156
+ */
157
+ function assertObject(value, name, opts) {
158
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
159
+ fail(name, 'an object', opts);
160
+ }
161
+ }
162
+
163
+ return {
164
+ /**
165
+ * Construct (not throw) the bound error with a free-form message —
166
+ * for `throw g.invalidArgument('…')` sites that don't fit the
167
+ * `X must be Y` shape (canonicalisation errors, cross-field
168
+ * constraint failures). Pass `{ cause }` when the argument-shape
169
+ * failure was triggered by an underlying throw whose chain matters
170
+ * (e.g. `JSON.stringify` on a bigint).
171
+ *
172
+ * @param {string} msg
173
+ * @param {WrapExtra} [extra]
174
+ * @returns {Error}
175
+ */
176
+ invalidArgument(msg, extra) {
177
+ return wrap(msg, extra);
178
+ },
179
+
180
+ /**
181
+ * Validate `input` against a `@exortek/shared/validate` schema;
182
+ * failures throw the bound error carrying every collected message.
183
+ * The bridge that keeps schema validation on the package's own
184
+ * error surface.
185
+ *
186
+ * @param {ParseableSchema} schema
187
+ * @param {unknown} input
188
+ * @param {string} [path='options']
189
+ * @returns {unknown} the parsed (possibly defaulted) value
190
+ */
191
+ parse(schema, input, path = 'options') {
192
+ const r = schema.safeParse(input, path);
193
+ if (!r.ok) {
194
+ throw wrap(r.errors.join('; '));
195
+ }
196
+ return r.value;
197
+ },
198
+
199
+ /**
200
+ * Assert that `value` is a non-negative safe integer (`0, 1, 2, …`).
201
+ * @param {unknown} value
202
+ * @param {string} name Argument name to include in the error message.
203
+ * @param {AssertOptions} [opts]
204
+ */
205
+ assertNonNegativeInt(value, name, opts) {
206
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0) {
207
+ fail(name, 'a non-negative safe integer', opts);
208
+ }
209
+ },
210
+
211
+ /**
212
+ * Assert that `value` is a strictly positive safe integer (`1, 2, 3, …`).
213
+ * @param {unknown} value
214
+ * @param {string} name
215
+ * @param {AssertOptions} [opts]
216
+ */
217
+ assertPositiveInt(value, name, opts) {
218
+ if (!Number.isSafeInteger(value) || /** @type {number} */ (value) <= 0) {
219
+ fail(name, 'a positive integer', opts);
220
+ }
221
+ },
222
+
223
+ /**
224
+ * Assert that `value` fits in a 48-bit unsigned integer (`0 … 2^48 − 1`).
225
+ * Used for Unix millisecond timestamps in UUID v7 / ULID.
226
+ * @param {unknown} value
227
+ * @param {string} name
228
+ * @param {AssertOptions} [opts]
229
+ */
230
+ assertUint48(value, name, opts) {
231
+ if (
232
+ !Number.isSafeInteger(value) ||
233
+ /** @type {number} */ (value) < 0 ||
234
+ /** @type {number} */ (value) > 0xffffffffffff
235
+ ) {
236
+ fail(name, 'a non-negative safe integer ≤ 2^48 − 1 (Unix ms since epoch)', opts);
237
+ }
238
+ },
239
+
240
+ /**
241
+ * Assert that `value` is a string (may be empty).
242
+ * @param {unknown} value
243
+ * @param {string} name
244
+ * @param {AssertOptions} [opts]
245
+ */
246
+ assertString(value, name, opts) {
247
+ if (typeof value !== 'string') {
248
+ fail(name, 'a string', opts);
249
+ }
250
+ },
251
+
252
+ /**
253
+ * Assert that `value` is a non-empty string.
254
+ * @param {unknown} value
255
+ * @param {string} name
256
+ * @param {AssertOptions} [opts]
257
+ */
258
+ assertNonEmptyString(value, name, opts) {
259
+ if (typeof value !== 'string' || value.length === 0) {
260
+ fail(name, 'a non-empty string', opts);
261
+ }
262
+ },
263
+
264
+ /**
265
+ * Assert that `value` is a boolean.
266
+ * @param {unknown} value
267
+ * @param {string} name
268
+ * @param {AssertOptions} [opts]
269
+ */
270
+ assertBoolean(value, name, opts) {
271
+ if (typeof value !== 'boolean') {
272
+ fail(name, 'a boolean', opts);
273
+ }
274
+ },
275
+
276
+ /**
277
+ * Assert that `value` is a function.
278
+ * @param {unknown} value
279
+ * @param {string} name
280
+ * @param {AssertOptions} [opts]
281
+ */
282
+ assertFunction(value, name, opts) {
283
+ if (typeof value !== 'function') {
284
+ fail(name, 'a function', opts);
285
+ }
286
+ },
287
+
288
+ assertObject,
289
+
290
+ /**
291
+ * Assert that `value` is either `undefined` or a plain object —
292
+ * the "optional options object" pattern.
293
+ * @param {unknown} value
294
+ * @param {string} name
295
+ * @param {AssertOptions} [opts]
296
+ */
297
+ assertOptionalObject(value, name, opts) {
298
+ if (value === undefined) {
299
+ return;
300
+ }
301
+ assertObject(value, name, opts);
302
+ },
303
+
304
+ /**
305
+ * Assert that `value` is a byte buffer (`Buffer` or `Uint8Array`) —
306
+ * strings are NOT accepted. For already-encoded material where a
307
+ * string would be ambiguous (ciphertext, signatures, raw key bytes).
308
+ * @param {unknown} value
309
+ * @param {string} name
310
+ * @param {AssertOptions} [opts]
311
+ */
312
+ assertBytes(value, name, opts) {
313
+ if (!Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
314
+ fail(name, 'a Buffer or Uint8Array', opts);
315
+ }
316
+ },
317
+
318
+ /**
319
+ * Assert that `value` is either a string or a byte buffer (`Buffer`
320
+ * or `Uint8Array`).
321
+ * @param {unknown} value
322
+ * @param {string} name
323
+ * @param {AssertOptions} [opts]
324
+ */
325
+ assertBytesOrString(value, name, opts) {
326
+ if (typeof value !== 'string' && !Buffer.isBuffer(value) && !(value instanceof Uint8Array)) {
327
+ fail(name, 'a string or Buffer', opts);
328
+ }
329
+ },
330
+
331
+ /**
332
+ * Assert that `encoding` is one of the accepted output/input
333
+ * encodings. Pass `allowBuffer: false` where a `Buffer` output makes
334
+ * no sense (verifying a string signature, decoding a token payload).
335
+ *
336
+ * @param {unknown} encoding
337
+ * @param {string} name
338
+ * @param {AssertOptions & { allowBuffer?: boolean }} [opts]
339
+ */
340
+ assertEncoding(encoding, name, opts) {
341
+ const allowBuffer = opts?.allowBuffer !== false;
342
+ const valid = (typeof encoding === 'string' && ENCODINGS.has(encoding)) || (allowBuffer && encoding === 'buffer');
343
+ if (!valid) {
344
+ const list = allowBuffer ? "'hex', 'base64', 'base64url', or 'buffer'" : "'hex', 'base64', or 'base64url'";
345
+ fail(name, list, opts);
346
+ }
347
+ },
348
+ };
349
+ }
350
+
351
+ /**
352
+ * One-line sugar over {@link bindAsserts} — the overwhelmingly common
353
+ * pattern where a package binds every argument-shape failure to its
354
+ * own error class + `INVALID_ARGUMENT` code. Emits the standard
355
+ * `new ErrorClass(code, message, { cause })` shape, so the underlying
356
+ * `BaseError` `{ cause, status, details }` options object is respected.
357
+ *
358
+ * // packages/<pkg>/src/internal/guards.js
359
+ * import { defineGuards } from '@exortek/shared/asserts';
360
+ * import { PasswordError, ErrorCode } from '../errors.js';
361
+ *
362
+ * export const { assertString, invalidArgument, parse } =
363
+ * defineGuards(PasswordError, ErrorCode.INVALID_ARGUMENT);
364
+ *
365
+ * Reach for the raw {@link bindAsserts} when you need a different
366
+ * mapping (an alt code, a decorated message) — it stays available.
367
+ *
368
+ * @param {new (code: string, message: string, options?: { cause?: unknown }) => Error} ErrorClass
369
+ * @param {string} code
370
+ */
371
+ function defineGuards(ErrorClass, code) {
372
+ return bindAsserts((message, extra) => new ErrorClass(code, message, extra));
373
+ }
374
+
375
+ /**
376
+ * Shared base error class — the single error structure behind every
377
+ * `@exortek/*` package's `errors.js`.
378
+ *
379
+ * Every package keeps its own class identity with a one-liner subclass;
380
+ * codes stay per-package frozen maps, status mapping is declared as a
381
+ * static field:
382
+ *
383
+ * import { BaseError } from '@exortek/shared/errors';
384
+ *
385
+ * export const ErrorCode = Object.freeze({
386
+ * INVALID_ARGUMENT: 'INVALID_ARGUMENT',
387
+ * INVALID_TOKEN: 'INVALID_TOKEN',
388
+ * });
389
+ *
390
+ * export class JwtError extends BaseError {
391
+ * static statuses = { INVALID_ARGUMENT: 400, INVALID_TOKEN: 401 };
392
+ * static defaultStatus = 500;
393
+ * }
394
+ *
395
+ * Instances carry a stable machine-readable `code` (branch on this,
396
+ * never on the message), an optional HTTP `status`, an optional
397
+ * `details` object, and the standard `cause` chain.
398
+ */
399
+ class BaseError extends Error {
400
+ /**
401
+ * Optional `code → HTTP status` map declared on the subclass. When
402
+ * absent the instance carries no `status` at all — for HTTP-agnostic
403
+ * packages like `@exortek/crypto`.
404
+ *
405
+ * @type {Record<string, number> | undefined}
406
+ */
407
+ static statuses = undefined;
408
+
409
+ /**
410
+ * Fallback status for codes missing from `statuses`.
411
+ *
412
+ * @type {number}
413
+ */
414
+ static defaultStatus = 500;
415
+
416
+ /**
417
+ * @param {string} code Stable machine-readable code; branch on this.
418
+ * @param {string} message Human-readable diagnostic. Free-form; may
419
+ * change across versions.
420
+ * @param {{ cause?: unknown, status?: number, details?: Record<string, unknown> }} [options]
421
+ */
422
+ constructor(code, message, options = {}) {
423
+ super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
424
+ this.name = new.target.name;
425
+ /** @type {string} */
426
+ this.code = code;
427
+ const statuses = /** @type {typeof BaseError} */ (new.target).statuses;
428
+ if (options.status !== undefined) {
429
+ /** @type {number | undefined} */
430
+ this.status = options.status;
431
+ } else if (statuses !== undefined) {
432
+ // Object.hasOwn guards against inherited-property lookups for
433
+ // exotic code values ('toString', 'constructor', …).
434
+ this.status = Object.hasOwn(statuses, code)
435
+ ? statuses[code]
436
+ : /** @type {typeof BaseError} */ (new.target).defaultStatus;
437
+ }
438
+ if (options.details) {
439
+ /** @type {Record<string, unknown> | undefined} */
440
+ this.details = options.details;
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Stable machine-readable codes for every programmer-error failure that
447
+ * `@exortek/challenge` can raise. Branch on `code`, never on the
448
+ * message. Note: expected verify failures (bad signature, expired,
449
+ * mismatched claim) are NOT thrown — `verifyChallenge` returns
450
+ * `{ valid: false, reason }` for those so a wrong or stale token is a
451
+ * normal auth outcome, not an exception. Errors below fire only when
452
+ * the caller configured something wrong.
453
+ */
454
+
455
+ const ErrorCode = Object.freeze({
456
+ INVALID_ARGUMENT: 'INVALID_ARGUMENT',
457
+ INVALID_SECRET: 'INVALID_SECRET',
458
+ });
459
+
460
+ /**
461
+ * Every recoverable failure raised by this package. Carries a stable
462
+ * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
463
+ * status a middleware layer would use when translating the error.
464
+ */
465
+ class ChallengeError extends BaseError {
466
+ static statuses = {
467
+ [ErrorCode.INVALID_ARGUMENT]: 400,
468
+ [ErrorCode.INVALID_SECRET]: 400,
469
+ };
470
+ static defaultStatus = 500;
471
+ }
472
+
473
+ /**
474
+ * Argument guards bound to `ChallengeError` — the package-wide binding
475
+ * of `@exortek/shared/asserts`. Import guards from here, never from the
476
+ * shared module directly: every argument-shape failure must throw
477
+ * `ChallengeError` with `ErrorCode.INVALID_ARGUMENT` so callers get one
478
+ * error class for the whole package.
479
+ */
480
+
481
+
482
+ const { invalidArgument} = defineGuards(
483
+ ChallengeError,
484
+ ErrorCode.INVALID_ARGUMENT,
485
+ );
486
+
487
+ /**
488
+ * In-process memory store for challenge single-use enforcement.
489
+ *
490
+ * Not cluster-safe: every worker has its own state, so a token accepted
491
+ * on one worker could still be replayed on another. Use for dev,
492
+ * single-node deploys, sticky-session behind an LB, and tests. For
493
+ * multi-worker production, use {@link redisStore} or pass any object
494
+ * exposing `incr(key, ttlMs) → { count }` (e.g.
495
+ * `@exortek/security`'s rate-limit stores).
496
+ *
497
+ * Eviction: true LRU (least-recently-used). Every `incr` on an existing
498
+ * key re-inserts it so it becomes the newest entry;
499
+ * `map.keys().next().value` is then the least-recently-touched key and
500
+ * is dropped when `maxKeys` is exceeded. This matters for the
501
+ * replay-guard tombstone: a repeated verify attempt refreshes the
502
+ * `count > 1` entry so it stays authoritative until its TTL expires.
503
+ * FIFO would let an idle tombstone age out prematurely and a third
504
+ * replay attempt could see a fresh counter.
505
+ *
506
+ * Textbook pattern, popularised in the JS community by projects like
507
+ * `toad-cache` (MIT); no code copied, only the same ES2015
508
+ * iteration-order guarantee.
509
+ *
510
+ * Expired entries are pruned lazily on read, plus a `setInterval` sweep
511
+ * (unref'd so it never blocks process exit). The `maxKeys` cap
512
+ * protects against unbounded growth if the caller forgets to clean up.
513
+ */
514
+
515
+
516
+ /**
517
+ * @typedef {object} MemoryStoreOptions
518
+ * @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
519
+ * @property {number} [sweepMs=60000] Interval for the background TTL sweep.
520
+ */
521
+
522
+ /**
523
+ * @param {MemoryStoreOptions} [options]
524
+ * @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
525
+ */
526
+ function memoryStore(options = {}) {
527
+ const maxKeys = options.maxKeys ?? 10_000;
528
+ const sweepMs = options.sweepMs ?? 60_000;
529
+
530
+ if (!isInteger(maxKeys) || maxKeys < 1) {
531
+ throw invalidArgument(`memoryStore.options.maxKeys must be a positive integer; got ${maxKeys}`);
532
+ }
533
+ if (!isInteger(sweepMs) || sweepMs < 1000) {
534
+ throw invalidArgument(`memoryStore.options.sweepMs must be an integer >= 1000; got ${sweepMs}`);
535
+ }
536
+
537
+ /** @type {Map<string, { count: number, expiresAt: number }>} */
538
+ const map = new Map();
539
+ let timer = null;
540
+
541
+ function peekFresh(key) {
542
+ const entry = map.get(key);
543
+ if (!entry) {
544
+ return null;
545
+ }
546
+ if (entry.expiresAt <= Date.now()) {
547
+ map.delete(key);
548
+ return null;
549
+ }
550
+ return entry;
551
+ }
552
+
553
+ function sweep() {
554
+ const t = Date.now();
555
+ for (const [key, entry] of map) {
556
+ if (entry.expiresAt <= t) {
557
+ map.delete(key);
558
+ }
559
+ }
560
+ }
561
+
562
+ function scheduleSweeper() {
563
+ if (timer) {
564
+ return;
565
+ }
566
+ timer = setInterval(sweep, sweepMs);
567
+ if (isFunction(timer.unref)) {
568
+ timer.unref();
569
+ }
570
+ }
571
+
572
+ function evictIfFull() {
573
+ if (map.size < maxKeys) {
574
+ return;
575
+ }
576
+ const oldest = map.keys().next().value;
577
+ if (!isUndefined(oldest)) {
578
+ map.delete(oldest);
579
+ }
580
+ }
581
+
582
+ return {
583
+ async incr(key, ttlMs) {
584
+ scheduleSweeper();
585
+ const existing = peekFresh(key);
586
+ if (existing) {
587
+ existing.count += 1;
588
+ // Refresh LRU position on activity — the replay-guard tombstone
589
+ // must stay warm until its TTL fires, or a third replay attempt
590
+ // could see the entry evicted and get a fresh count.
591
+ map.delete(key);
592
+ map.set(key, existing);
593
+ return { count: existing.count, expiresAt: existing.expiresAt };
594
+ }
595
+ evictIfFull();
596
+ const expiresAt = Date.now() + ttlMs;
597
+ map.set(key, { count: 1, expiresAt });
598
+ return { count: 1, expiresAt };
599
+ },
600
+
601
+ _size: () => map.size,
602
+ _stop: () => {
603
+ if (timer) {
604
+ clearInterval(timer);
605
+ timer = null;
606
+ }
607
+ },
608
+ };
609
+ }
610
+
611
+ /**
612
+ * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
613
+ * package that ships a Redis store (jwt blacklist, session store,
614
+ * security rate-limit) does the same "not null + required methods
615
+ * are functions" check up front so a missing dependency surfaces as
616
+ * a clean typed error instead of `TypeError: client.get is not a
617
+ * function` deep in a store operation.
618
+ *
619
+ * Callers pass their own `wrap` callback that throws their typed
620
+ * error class with the message — this file has no opinion on the
621
+ * error surface.
622
+ */
623
+
624
+ /**
625
+ * @param {unknown} client
626
+ * @param {readonly string[]} methods Method names that must be functions on the client.
627
+ * @param {(message: string) => never} wrap
628
+ * Called with a diagnostic message when the check fails; must throw.
629
+ * The caller's typed error class is emitted from inside this callback.
630
+ * @returns {void}
631
+ */
632
+ function assertRedisClient(client, methods, wrap) {
633
+ if (!client || typeof client !== 'object') {
634
+ wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
635
+ }
636
+ const c = /** @type {Record<string, unknown>} */ (client);
637
+ for (const method of methods) {
638
+ if (typeof c[method] !== 'function') {
639
+ wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
640
+ }
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Redis-backed store for challenge single-use enforcement.
646
+ *
647
+ * Cluster-safe: state lives in Redis, so a challenge accepted on one
648
+ * worker cannot be replayed on another. Works with any client exposing
649
+ * `eval(script, numkeys, ...args)` — verified against `ioredis`,
650
+ * `node-redis` v4+, and `@upstash/redis` (HTTP client, runs on
651
+ * Cloudflare Workers / Vercel Edge / Deno Deploy).
652
+ *
653
+ * Atomicity: `incr` runs a single Lua script that INCR's the key and
654
+ * PEXPIRE's it only when the key is fresh (count === 1). The TTL
655
+ * anchors to the first increment — exactly what single-use enforcement
656
+ * needs (the tombstone lives as long as the token could still verify,
657
+ * then rolls off).
658
+ */
659
+
660
+
661
+ const INCR_SCRIPT = `
662
+ local key = KEYS[1]
663
+ local ttl = tonumber(ARGV[1])
664
+ local count = redis.call('INCR', key)
665
+ local pttl
666
+ if count == 1 then
667
+ redis.call('PEXPIRE', key, ttl)
668
+ pttl = ttl
669
+ else
670
+ pttl = redis.call('PTTL', key)
671
+ if pttl < 0 then
672
+ redis.call('PEXPIRE', key, ttl)
673
+ pttl = ttl
674
+ end
675
+ end
676
+ return { count, pttl }
677
+ `.trim();
678
+
679
+ /**
680
+ * @typedef {object} RedisStoreOptions
681
+ * @property {string} [keyPrefix='chall:'] Prepended to every key.
682
+ */
683
+
684
+ /**
685
+ * @param {any} client
686
+ * @param {RedisStoreOptions} [options]
687
+ * @returns {import('./index.js').IncrStore}
688
+ */
689
+ function redisStore(client, options = {}) {
690
+ assertRedisClient(client, ['eval'], msg => {
691
+ throw invalidArgument(`redisStore.client: ${msg}`);
692
+ });
693
+ const keyPrefix = options.keyPrefix ?? 'chall:';
694
+ const k = key => `${keyPrefix}${key}`;
695
+
696
+ return {
697
+ async incr(key, ttlMs) {
698
+ const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
699
+ // ioredis returns [count, pttl] as numbers; node-redis v4 does too;
700
+ // Upstash HTTP driver returns strings. Coerce defensively.
701
+ const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
702
+ const count = Number(arr[0]);
703
+ const pttl = Number(arr[1]);
704
+ return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
705
+ },
706
+ };
707
+ }
708
+
709
+ exports.memoryStore = memoryStore;
710
+ exports.redisStore = redisStore;