@exortek/challenge 1.0.0 → 1.0.1

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/CHANGELOG.md CHANGED
@@ -1,31 +1,34 @@
1
1
  # @exortek/challenge
2
2
 
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 31223e4: Consolidate duplicated store internals into @exortek/shared utilities (redis-helpers, incr-store,
8
+ record-store). No public API changes.
9
+
10
+ apikey: fix Redis store race condition where a concurrent update() could silently un-revoke a key — revocations now
11
+ use a tombstone key that update() never touches.
12
+
13
+ apikey: fix memory store put() storing by reference instead of copying — now consistent with getById()'s copy-on-read
14
+ contract.
15
+
3
16
  ## 1.0.0
4
17
 
5
18
  ### Initial release
6
19
 
7
- - **`createChallenge(options)`** — issue an HMAC-signed challenge token
8
- carrying `userId` / `method` / `step` / `nextStep` / `metadata` across
9
- a multi-step auth flow. Optional `ipBinding` stamps the origin IP into
10
- the payload; optional `singleUse` marks the token for one-shot
11
- consumption via a caller-supplied store.
12
- - **`verifyChallenge(token, options)`** HMAC-verify, expiry-check,
13
- and optionally match `expectedUserId` / `expectedMethod` /
14
- `expectedStep` / `expectedNextStep`, plus IP match when the token was
15
- IP-bound. Returns `{ valid: true, payload }` on success or
16
- `{ valid: false, reason }` on any expected failure; never throws on
17
- bad tokens, only on programmer errors.
18
- - **Stores** ships `memoryStore()` (single-node / dev, LRU + TTL
19
- sweep) and `redisStore(client)` (cluster-safe, single Lua round-trip
20
- per verify) under the `@exortek/challenge/stores` subpath. Any object
21
- exposing `incr(key, ttlMs) { count }` also works e.g.
22
- `@exortek/security`'s rate-limit stores.
23
- - **Token format:** `<prefix>.<base64url(payload)>.<base64url(hmac)>`
24
- — deliberately not a JWT so the two token families cannot be confused
25
- at a call site. Prefix defaults to `chall_v1`; callers can override
26
- via `options.prefix` (e.g. `'server_challenge'`, `'myapp_v1'`) to
27
- brand the wire format for their service. Must match
28
- `/^[A-Za-z0-9_-]{1,32}$/`, and the same prefix must be used at create
29
- and verify time.
30
- - **Errors:** stable `ErrorCode.INVALID_ARGUMENT` /
31
- `ErrorCode.INVALID_SECRET` codes on the `ChallengeError` class.
20
+ - **`createChallenge(options)`** — issue an HMAC-signed challenge token carrying `userId` / `method` / `step` /
21
+ `nextStep` / `metadata` across a multi-step auth flow. Optional `ipBinding` stamps the origin IP into the payload;
22
+ optional `singleUse` marks the token for one-shot consumption via a caller-supplied store.
23
+ - **`verifyChallenge(token, options)`** HMAC-verify, expiry-check, and optionally match `expectedUserId` /
24
+ `expectedMethod` / `expectedStep` / `expectedNextStep`, plus IP match when the token was IP-bound. Returns
25
+ `{ valid: true, payload }` on success or `{ valid: false, reason }` on any expected failure; never throws on bad
26
+ tokens, only on programmer errors.
27
+ - **Stores** — ships `memoryStore()` (single-node / dev, LRU + TTL sweep) and `redisStore(client)` (cluster-safe, single
28
+ Lua round-trip per verify) under the `@exortek/challenge/stores` subpath. Any object exposing
29
+ `incr(key, ttlMs) { count }` also works e.g. `@exortek/security`'s rate-limit stores.
30
+ - **Token format:** `<prefix>.<base64url(payload)>.<base64url(hmac)>` deliberately not a JWT so the two token families
31
+ cannot be confused at a call site. Prefix defaults to `chall_v1`; callers can override via `options.prefix` (e.g.
32
+ `'server_challenge'`, `'myapp_v1'`) to brand the wire format for their service. Must match `/^[A-Za-z0-9_-]{1,32}$/`,
33
+ and the same prefix must be used at create and verify time.
34
+ - **Errors:** stable `ErrorCode.INVALID_ARGUMENT` / `ErrorCode.INVALID_SECRET` codes on the `ChallengeError` class.
package/dist/errors.d.ts CHANGED
@@ -1,14 +1,4 @@
1
- /**
2
- * Stable machine-readable codes for every programmer-error failure that
3
- * `@exortek/challenge` can raise. Branch on `code`, never on the
4
- * message. Note: expected verify failures (bad signature, expired,
5
- * mismatched claim) are NOT thrown — `verifyChallenge` returns
6
- * `{ valid: false, reason }` for those so a wrong or stale token is a
7
- * normal auth outcome, not an exception. Errors below fire only when
8
- * the caller configured something wrong.
9
- */
10
- import { BaseError } from '@exortek/shared/errors';
11
- export declare const ErrorCode: Readonly<{
1
+ export const ErrorCode: Readonly<{
12
2
  INVALID_ARGUMENT: "INVALID_ARGUMENT";
13
3
  INVALID_SECRET: "INVALID_SECRET";
14
4
  }>;
@@ -17,10 +7,10 @@ export declare const ErrorCode: Readonly<{
17
7
  * `code` (from {@link ErrorCode}) and a `status` — the HTTP response
18
8
  * status a middleware layer would use when translating the error.
19
9
  */
20
- export declare class ChallengeError extends BaseError {
10
+ export class ChallengeError extends BaseError {
21
11
  static statuses: {
22
12
  INVALID_ARGUMENT: number;
23
13
  INVALID_SECRET: number;
24
14
  };
25
- static defaultStatus: number;
26
15
  }
16
+ import { BaseError } from '@exortek/shared/errors';
package/dist/index.d.ts CHANGED
@@ -1,130 +1,3 @@
1
- /**
2
- * `@exortek/challenge` — signed, single-use challenge tokens for
3
- * multi-step auth flows.
4
- *
5
- * A challenge is a small, HMAC-signed envelope that carries context
6
- * across a redirect or a follow-up request without a server-side
7
- * session: who is being challenged (`userId`), how they proved
8
- * themselves so far (`method`), what step of the flow they've cleared
9
- * (`step`), and any bespoke metadata the caller needs on the other
10
- * side. The token is stateless by default; single-use enforcement and
11
- * revocation are opt-in via a store the caller supplies.
12
- *
13
- * See the README for the full API and worked examples.
14
- */
15
- export { ChallengeError, ErrorCode } from './errors.js';
16
- export type ChallengeMethod = 'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code' | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc' | string;
17
- export type IncrStore = {
18
- /**
19
- * Atomic increment-with-expiry. First call returns `{ count: 1 }` and
20
- * arms a TTL; subsequent calls before expiry return the incremented
21
- * count. Used as compare-and-set for single-use enforcement.
22
- */
23
- incr: (key: string, ttlMs: number) => Promise<{
24
- count: number;
25
- }>;
26
- };
27
- export type ChallengePayload = {
28
- jti: string;
29
- iat: number;
30
- exp: number;
31
- userId?: string;
32
- method?: ChallengeMethod;
33
- step?: string;
34
- nextStep?: string;
35
- /**
36
- * Only set when `ipBinding: true`.
37
- */
38
- ip?: string;
39
- /**
40
- * Only set when `ua` supplied.
41
- */
42
- ua?: string;
43
- meta?: Record<string, unknown>;
44
- };
45
- export type CreateChallengeOptions = {
46
- /**
47
- * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
48
- * interpreted as UTF-8 — for a hex or base64 secret, decode to
49
- * Buffer first.
50
- */
51
- secret: string | Buffer | Uint8Array;
52
- userId?: string;
53
- method?: ChallengeMethod;
54
- step?: string;
55
- nextStep?: string;
56
- ip?: string;
57
- ua?: string;
58
- metadata?: Record<string, unknown>;
59
- /**
60
- * Duration string (`'5m'`) or ms integer.
61
- */
62
- expiresIn: string | number;
63
- /**
64
- * When true, the returned token can only be verified once — subsequent
65
- * verifies with `consume: true` fail with `reason: 'replay'`. Requires
66
- * `store` to be supplied.
67
- */
68
- singleUse?: boolean;
69
- /**
70
- * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
71
- * `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
72
- */
73
- store?: IncrStore;
74
- /**
75
- * When true, the caller-supplied `ip` is stamped into the payload and
76
- * `verifyChallenge` will reject a request whose `ip` differs.
77
- */
78
- ipBinding?: boolean;
79
- /**
80
- * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
81
- * with this package. Callers can override to brand the token
82
- * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
83
- * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
84
- * verify time or verification returns `reason: 'malformed'`.
85
- */
86
- prefix?: string;
87
- /**
88
- * Override `Date.now()` for testing.
89
- */
90
- now?: number;
91
- };
92
- export type VerifyChallengeOptions = {
93
- secret: string | Buffer | Uint8Array;
94
- /**
95
- * Enforce single-use. Requires `store` (typically the same one used
96
- * at create time).
97
- */
98
- consume?: boolean;
99
- store?: IncrStore;
100
- expectedUserId?: string;
101
- expectedMethod?: ChallengeMethod;
102
- expectedStep?: string;
103
- expectedNextStep?: string;
104
- /**
105
- * The current request's IP. Required to verify a token that was
106
- * created with `ipBinding: true`; ignored otherwise.
107
- */
108
- ip?: string;
109
- /**
110
- * Wire-format prefix. Must match the value passed to
111
- * `createChallenge` — a token minted with a different prefix will
112
- * fail with `reason: 'malformed'`.
113
- */
114
- prefix?: string;
115
- /**
116
- * Override `Date.now()` for testing.
117
- */
118
- now?: number;
119
- };
120
- export type VerifyFailureReason = 'malformed' | 'bad_signature' | 'expired' | 'not_yet_valid' | 'user_mismatch' | 'method_mismatch' | 'step_mismatch' | 'next_step_mismatch' | 'ip_mismatch' | 'ip_missing' | 'replay' | 'store_unavailable';
121
- export type VerifyChallengeResult = {
122
- valid: true;
123
- payload: ChallengePayload;
124
- } | {
125
- valid: false;
126
- reason: VerifyFailureReason;
127
- };
128
1
  /**
129
2
  * @typedef {'totp' | 'hotp' | 'email_otp' | 'sms_otp' | 'backup_code'
130
3
  * | 'passkey' | 'magic_link' | 'password' | 'webauthn' | 'oauth' | 'oidc'
@@ -229,7 +102,7 @@ export type VerifyChallengeResult = {
229
102
  * @param {CreateChallengeOptions} options
230
103
  * @returns {Promise<string>}
231
104
  */
232
- export declare function createChallenge(options: CreateChallengeOptions): Promise<string>;
105
+ export function createChallenge(options: CreateChallengeOptions): Promise<string>;
233
106
  /**
234
107
  * Verify a challenge token. Returns `{ valid: true, payload }` on
235
108
  * success or `{ valid: false, reason }` on any expected failure. Only
@@ -249,4 +122,117 @@ export declare function createChallenge(options: CreateChallengeOptions): Promis
249
122
  * @param {VerifyChallengeOptions} options
250
123
  * @returns {Promise<VerifyChallengeResult>}
251
124
  */
252
- export declare function verifyChallenge(token: string, options: VerifyChallengeOptions): Promise<VerifyChallengeResult>;
125
+ export function verifyChallenge(token: string, options: VerifyChallengeOptions): Promise<VerifyChallengeResult>;
126
+ export type ChallengeMethod = "totp" | "hotp" | "email_otp" | "sms_otp" | "backup_code" | "passkey" | "magic_link" | "password" | "webauthn" | "oauth" | "oidc" | string;
127
+ export type IncrStore = {
128
+ /**
129
+ * Atomic increment-with-expiry. First call returns `{ count: 1 }` and
130
+ * arms a TTL; subsequent calls before expiry return the incremented
131
+ * count. Used as compare-and-set for single-use enforcement.
132
+ */
133
+ incr: (key: string, ttlMs: number) => Promise<{
134
+ count: number;
135
+ }>;
136
+ };
137
+ export type ChallengePayload = {
138
+ jti: string;
139
+ iat: number;
140
+ exp: number;
141
+ userId?: string | undefined;
142
+ method?: string | undefined;
143
+ step?: string | undefined;
144
+ nextStep?: string | undefined;
145
+ /**
146
+ * Only set when `ipBinding: true`.
147
+ */
148
+ ip?: string | undefined;
149
+ /**
150
+ * Only set when `ua` supplied.
151
+ */
152
+ ua?: string | undefined;
153
+ meta?: Record<string, unknown> | undefined;
154
+ };
155
+ export type CreateChallengeOptions = {
156
+ /**
157
+ * HMAC-SHA256 secret. **Must be at least 32 raw bytes.** A string is
158
+ * interpreted as UTF-8 — for a hex or base64 secret, decode to
159
+ * Buffer first.
160
+ */
161
+ secret: string | Buffer | Uint8Array;
162
+ userId?: string | undefined;
163
+ method?: string | undefined;
164
+ step?: string | undefined;
165
+ nextStep?: string | undefined;
166
+ ip?: string | undefined;
167
+ ua?: string | undefined;
168
+ metadata?: Record<string, unknown> | undefined;
169
+ /**
170
+ * Duration string (`'5m'`) or ms integer.
171
+ */
172
+ expiresIn: string | number;
173
+ /**
174
+ * When true, the returned token can only be verified once — subsequent
175
+ * verifies with `consume: true` fail with `reason: 'replay'`. Requires
176
+ * `store` to be supplied.
177
+ */
178
+ singleUse?: boolean | undefined;
179
+ /**
180
+ * Any object exposing `incr(key, ttlMs) → { count }`. Compatible with
181
+ * `@exortek/security`'s rate-limit stores; also easy to wrap Redis.
182
+ */
183
+ store?: IncrStore | undefined;
184
+ /**
185
+ * When true, the caller-supplied `ip` is stamped into the payload and
186
+ * `verifyChallenge` will reject a request whose `ip` differs.
187
+ */
188
+ ipBinding?: boolean | undefined;
189
+ /**
190
+ * Wire-format prefix. Defaults to `'chall_v1'` — the value shipped
191
+ * with this package. Callers can override to brand the token
192
+ * family (e.g. `'server_challenge'`, `'myapp_v1'`); must match
193
+ * `/^[A-Za-z0-9_-]{1,32}$/`. The same prefix must be passed at
194
+ * verify time or verification returns `reason: 'malformed'`.
195
+ */
196
+ prefix?: string | undefined;
197
+ /**
198
+ * Override `Date.now()` for testing.
199
+ */
200
+ now?: number | undefined;
201
+ };
202
+ export type VerifyChallengeOptions = {
203
+ secret: string | Buffer | Uint8Array;
204
+ /**
205
+ * Enforce single-use. Requires `store` (typically the same one used
206
+ * at create time).
207
+ */
208
+ consume?: boolean | undefined;
209
+ store?: IncrStore | undefined;
210
+ expectedUserId?: string | undefined;
211
+ expectedMethod?: string | undefined;
212
+ expectedStep?: string | undefined;
213
+ expectedNextStep?: string | undefined;
214
+ /**
215
+ * The current request's IP. Required to verify a token that was
216
+ * created with `ipBinding: true`; ignored otherwise.
217
+ */
218
+ ip?: string | undefined;
219
+ /**
220
+ * Wire-format prefix. Must match the value passed to
221
+ * `createChallenge` — a token minted with a different prefix will
222
+ * fail with `reason: 'malformed'`.
223
+ */
224
+ prefix?: string | undefined;
225
+ /**
226
+ * Override `Date.now()` for testing.
227
+ */
228
+ now?: number | undefined;
229
+ };
230
+ export type VerifyFailureReason = "malformed" | "bad_signature" | "expired" | "not_yet_valid" | "user_mismatch" | "method_mismatch" | "step_mismatch" | "next_step_mismatch" | "ip_mismatch" | "ip_missing" | "replay" | "store_unavailable";
231
+ export type VerifyChallengeResult = {
232
+ valid: true;
233
+ payload: ChallengePayload;
234
+ } | {
235
+ valid: false;
236
+ reason: VerifyFailureReason;
237
+ };
238
+ export { ChallengeError, ErrorCode } from "./errors.js";
@@ -1,8 +1,5 @@
1
- /**
2
- * Argument guards bound to `ChallengeError` the package-wide binding
3
- * of `@exortek/shared/asserts`. Import guards from here, never from the
4
- * shared module directly: every argument-shape failure must throw
5
- * `ChallengeError` with `ErrorCode.INVALID_ARGUMENT` so callers get one
6
- * error class for the whole package.
7
- */
8
- export declare const invalidArgument: (msg: string, extra?: import("@exortek/shared/asserts").WrapExtra) => Error, parse: (schema: import("@exortek/shared/asserts").ParseableSchema, input: unknown, path?: string) => unknown, assertObject: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void, assertNonEmptyString: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void, assertBytes: (value: unknown, name: string, opts?: import("@exortek/shared/asserts").AssertOptions) => void;
1
+ export const invalidArgument: (msg: string, extra?: WrapExtra) => Error;
2
+ export const parse: (schema: ParseableSchema, input: unknown, path?: string) => unknown;
3
+ export const assertObject: (value: unknown, name: string, opts?: AssertOptions) => void;
4
+ export const assertNonEmptyString: (value: unknown, name: string, opts?: AssertOptions) => void;
5
+ export const assertBytes: (value: unknown, name: string, opts?: AssertOptions) => void;
@@ -1,11 +1,5 @@
1
- /**
2
- * Store implementations shipped with `@exortek/challenge`. Any object
3
- * exposing the {@link IncrStore} shape works — these are provided as
4
- * a zero-config default (memory) and a cluster-safe default (Redis).
5
- * `@exortek/security`'s rate-limit stores are wire-compatible.
6
- */
7
- export { memoryStore } from './memory.js';
8
- export { redisStore } from './redis.js';
1
+ export { memoryStore } from "./memory.js";
2
+ export { redisStore } from "./redis.js";
9
3
  export type IncrStore = {
10
4
  /**
11
5
  * Atomic increment-with-expiry. First call for a fresh key returns
@@ -18,11 +12,3 @@ export type IncrStore = {
18
12
  expiresAt?: number;
19
13
  }>;
20
14
  };
21
- /**
22
- * @typedef {object} IncrStore
23
- * @property {(key: string, ttlMs: number) => Promise<{ count: number, expiresAt?: number }>} incr
24
- * Atomic increment-with-expiry. First call for a fresh key returns
25
- * `{ count: 1 }` and arms a TTL of `ttlMs`; subsequent calls before
26
- * expiry return the incremented count. `verifyChallenge` reads
27
- * `count > 1` as "already consumed".
28
- */
@@ -1,40 +1,3 @@
1
- /**
2
- * In-process memory store for challenge single-use enforcement.
3
- *
4
- * Not cluster-safe: every worker has its own state, so a token accepted
5
- * on one worker could still be replayed on another. Use for dev,
6
- * single-node deploys, sticky-session behind an LB, and tests. For
7
- * multi-worker production, use {@link redisStore} or pass any object
8
- * exposing `incr(key, ttlMs) → { count }` (e.g.
9
- * `@exortek/security`'s rate-limit stores).
10
- *
11
- * Eviction: true LRU (least-recently-used). Every `incr` on an existing
12
- * key re-inserts it so it becomes the newest entry;
13
- * `map.keys().next().value` is then the least-recently-touched key and
14
- * is dropped when `maxKeys` is exceeded. This matters for the
15
- * replay-guard tombstone: a repeated verify attempt refreshes the
16
- * `count > 1` entry so it stays authoritative until its TTL expires.
17
- * FIFO would let an idle tombstone age out prematurely and a third
18
- * replay attempt could see a fresh counter.
19
- *
20
- * Textbook pattern, popularised in the JS community by projects like
21
- * `toad-cache` (MIT); no code copied, only the same ES2015
22
- * iteration-order guarantee.
23
- *
24
- * Expired entries are pruned lazily on read, plus a `setInterval` sweep
25
- * (unref'd so it never blocks process exit). The `maxKeys` cap
26
- * protects against unbounded growth if the caller forgets to clean up.
27
- */
28
- export type MemoryStoreOptions = {
29
- /**
30
- * Hard cap; oldest entry dropped when exceeded.
31
- */
32
- maxKeys?: number;
33
- /**
34
- * Interval for the background TTL sweep.
35
- */
36
- sweepMs?: number;
37
- };
38
1
  /**
39
2
  * @typedef {object} MemoryStoreOptions
40
3
  * @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
@@ -44,7 +7,17 @@ export type MemoryStoreOptions = {
44
7
  * @param {MemoryStoreOptions} [options]
45
8
  * @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
46
9
  */
47
- export declare function memoryStore(options?: MemoryStoreOptions): import('./index.js').IncrStore & {
10
+ export function memoryStore(options?: MemoryStoreOptions): import("./index.js").IncrStore & {
48
11
  _size: () => number;
49
12
  _stop: () => void;
50
13
  };
14
+ export type MemoryStoreOptions = {
15
+ /**
16
+ * Hard cap; oldest entry dropped when exceeded.
17
+ */
18
+ maxKeys?: number | undefined;
19
+ /**
20
+ * Interval for the background TTL sweep.
21
+ */
22
+ sweepMs?: number | undefined;
23
+ };
@@ -1,24 +1,3 @@
1
- /**
2
- * Redis-backed store for challenge single-use enforcement.
3
- *
4
- * Cluster-safe: state lives in Redis, so a challenge accepted on one
5
- * worker cannot be replayed on another. Works with any client exposing
6
- * `eval(script, numkeys, ...args)` — verified against `ioredis`,
7
- * `node-redis` v4+, and `@upstash/redis` (HTTP client, runs on
8
- * Cloudflare Workers / Vercel Edge / Deno Deploy).
9
- *
10
- * Atomicity: `incr` runs a single Lua script that INCR's the key and
11
- * PEXPIRE's it only when the key is fresh (count === 1). The TTL
12
- * anchors to the first increment — exactly what single-use enforcement
13
- * needs (the tombstone lives as long as the token could still verify,
14
- * then rolls off).
15
- */
16
- export type RedisStoreOptions = {
17
- /**
18
- * Prepended to every key.
19
- */
20
- keyPrefix?: string;
21
- };
22
1
  /**
23
2
  * @typedef {object} RedisStoreOptions
24
3
  * @property {string} [keyPrefix='chall:'] Prepended to every key.
@@ -28,4 +7,10 @@ export type RedisStoreOptions = {
28
7
  * @param {RedisStoreOptions} [options]
29
8
  * @returns {import('./index.js').IncrStore}
30
9
  */
31
- export declare function redisStore(client: any, options?: RedisStoreOptions): import('./index.js').IncrStore;
10
+ export function redisStore(client: any, options?: RedisStoreOptions): import("./index.js").IncrStore;
11
+ export type RedisStoreOptions = {
12
+ /**
13
+ * Prepended to every key.
14
+ */
15
+ keyPrefix?: string | undefined;
16
+ };
package/dist/stores.cjs CHANGED
@@ -59,6 +59,194 @@ function isInteger(value) {
59
59
  return Number.isSafeInteger(value);
60
60
  }
61
61
 
62
+ /**
63
+ * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
64
+ * package that ships a Redis store (jwt blacklist, session store,
65
+ * security rate-limit) does the same "not null + required methods
66
+ * are functions" check up front so a missing dependency surfaces as
67
+ * a clean typed error instead of `TypeError: client.get is not a
68
+ * function` deep in a store operation.
69
+ *
70
+ * Callers pass their own `wrap` callback that throws their typed
71
+ * error class with the message — this file has no opinion on the
72
+ * error surface.
73
+ */
74
+
75
+ /**
76
+ * @param {unknown} client
77
+ * @param {readonly string[]} methods Method names that must be functions on the client.
78
+ * @param {(message: string) => never} wrap
79
+ * Called with a diagnostic message when the check fails; must throw.
80
+ * The caller's typed error class is emitted from inside this callback.
81
+ * @returns {void}
82
+ */
83
+ function assertRedisClient(client, methods, wrap) {
84
+ if (!client || typeof client !== 'object') {
85
+ wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
86
+ }
87
+ const c = /** @type {Record<string, unknown>} */ (client);
88
+ for (const method of methods) {
89
+ if (typeof c[method] !== 'function') {
90
+ wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Shared IncrStore — atomic TTL counter for replay guards and rate
97
+ * limiting. Memory and Redis factories.
98
+ *
99
+ * Interface: `incr(key, ttlMs) → { count, expiresAt }`.
100
+ * First call arms a TTL and returns `{ count: 1 }`; subsequent calls
101
+ * before expiry increment the counter.
102
+ *
103
+ * Consumers: `@exortek/challenge`, `@exortek/magic-link` (rate limiter).
104
+ */
105
+
106
+
107
+ // Memory
108
+
109
+ /**
110
+ * @typedef {object} MemoryIncrStoreOptions
111
+ * @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
112
+ * @property {number} [sweepMs=60000] Interval for the background TTL sweep.
113
+ */
114
+
115
+ /**
116
+ * @param {MemoryIncrStoreOptions} [options]
117
+ * @param {(msg: string) => never} [wrap] Error factory for validation.
118
+ * @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }>, _size: () => number, _stop: () => void }}
119
+ */
120
+ function createMemoryIncrStore(options = {}, wrap) {
121
+ const fail = wrap ?? (msg => { throw new TypeError(msg); });
122
+ const maxKeys = options.maxKeys ?? 10_000;
123
+ const sweepMs = options.sweepMs ?? 60_000;
124
+
125
+ if (!isInteger(maxKeys) || maxKeys < 1) {
126
+ fail(`memoryIncrStore.maxKeys must be a positive integer; got ${maxKeys}`);
127
+ }
128
+ if (!isInteger(sweepMs) || sweepMs < 1000) {
129
+ fail(`memoryIncrStore.sweepMs must be an integer >= 1000; got ${sweepMs}`);
130
+ }
131
+
132
+ /** @type {Map<string, { count: number, expiresAt: number }>} */
133
+ const map = new Map();
134
+ let timer = null;
135
+
136
+ function peekFresh(key) {
137
+ const entry = map.get(key);
138
+ if (!entry) {
139
+ return null;
140
+ }
141
+ if (entry.expiresAt <= Date.now()) {
142
+ map.delete(key);
143
+ return null;
144
+ }
145
+ return entry;
146
+ }
147
+
148
+ function sweep() {
149
+ const t = Date.now();
150
+ for (const [key, entry] of map) {
151
+ if (entry.expiresAt <= t) {
152
+ map.delete(key);
153
+ }
154
+ }
155
+ }
156
+
157
+ function scheduleSweeper() {
158
+ if (timer) {
159
+ return;
160
+ }
161
+ timer = setInterval(sweep, sweepMs);
162
+ if (isFunction(timer.unref)) {
163
+ timer.unref();
164
+ }
165
+ }
166
+
167
+ function evictIfFull() {
168
+ if (map.size < maxKeys) {
169
+ return;
170
+ }
171
+ const oldest = map.keys().next().value;
172
+ if (!isUndefined(oldest)) {
173
+ map.delete(oldest);
174
+ }
175
+ }
176
+
177
+ return {
178
+ async incr(key, ttlMs) {
179
+ scheduleSweeper();
180
+ const existing = peekFresh(key);
181
+ if (existing) {
182
+ existing.count += 1;
183
+ map.delete(key);
184
+ map.set(key, existing);
185
+ return { count: existing.count, expiresAt: existing.expiresAt };
186
+ }
187
+ evictIfFull();
188
+ const expiresAt = Date.now() + ttlMs;
189
+ map.set(key, { count: 1, expiresAt });
190
+ return { count: 1, expiresAt };
191
+ },
192
+
193
+ _size: () => map.size,
194
+ _stop: () => {
195
+ if (timer) {
196
+ clearInterval(timer);
197
+ timer = null;
198
+ }
199
+ },
200
+ };
201
+ }
202
+
203
+ // Redis
204
+
205
+ const INCR_SCRIPT = `
206
+ local key = KEYS[1]
207
+ local ttl = tonumber(ARGV[1])
208
+ local count = redis.call('INCR', key)
209
+ local pttl
210
+ if count == 1 then
211
+ redis.call('PEXPIRE', key, ttl)
212
+ pttl = ttl
213
+ else
214
+ pttl = redis.call('PTTL', key)
215
+ if pttl < 0 then
216
+ redis.call('PEXPIRE', key, ttl)
217
+ pttl = ttl
218
+ end
219
+ end
220
+ return { count, pttl }
221
+ `.trim();
222
+
223
+ /**
224
+ * @typedef {object} RedisIncrStoreOptions
225
+ * @property {string} [keyPrefix=''] Prepended to every key.
226
+ */
227
+
228
+ /**
229
+ * @param {object} client
230
+ * @param {RedisIncrStoreOptions} [options]
231
+ * @param {(msg: string) => never} [wrap] Error factory for assertRedisClient.
232
+ * @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }> }}
233
+ */
234
+ function createRedisIncrStore(client, options = {}, wrap) {
235
+ assertRedisClient(client, ['eval'], wrap ?? (msg => { throw new TypeError(msg); }));
236
+ const keyPrefix = options.keyPrefix ?? '';
237
+ const k = key => `${keyPrefix}${key}`;
238
+
239
+ return {
240
+ async incr(key, ttlMs) {
241
+ const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
242
+ const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
243
+ const count = Number(arr[0]);
244
+ const pttl = Number(arr[1]);
245
+ return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
246
+ },
247
+ };
248
+ }
249
+
62
250
  /**
63
251
  * Imperative single-argument guard helpers — the everyday
64
252
  * `assertPositiveInt(x, 'name')` shape used at API boundaries.
@@ -524,121 +712,9 @@ const { invalidArgument} = defineGuards(
524
712
  * @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
525
713
  */
526
714
  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
- }
715
+ return createMemoryIncrStore(options, msg => {
716
+ throw invalidArgument(msg);
717
+ });
642
718
  }
643
719
 
644
720
  /**
@@ -658,24 +734,6 @@ function assertRedisClient(client, methods, wrap) {
658
734
  */
659
735
 
660
736
 
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
737
  /**
680
738
  * @typedef {object} RedisStoreOptions
681
739
  * @property {string} [keyPrefix='chall:'] Prepended to every key.
@@ -687,23 +745,9 @@ return { count, pttl }
687
745
  * @returns {import('./index.js').IncrStore}
688
746
  */
689
747
  function redisStore(client, options = {}) {
690
- assertRedisClient(client, ['eval'], msg => {
748
+ return createRedisIncrStore(client, { keyPrefix: options.keyPrefix ?? 'chall:' }, msg => {
691
749
  throw invalidArgument(`redisStore.client: ${msg}`);
692
750
  });
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
751
  }
708
752
 
709
753
  exports.memoryStore = memoryStore;
package/dist/stores.mjs CHANGED
@@ -57,6 +57,194 @@ function isInteger(value) {
57
57
  return Number.isSafeInteger(value);
58
58
  }
59
59
 
60
+ /**
61
+ * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
62
+ * package that ships a Redis store (jwt blacklist, session store,
63
+ * security rate-limit) does the same "not null + required methods
64
+ * are functions" check up front so a missing dependency surfaces as
65
+ * a clean typed error instead of `TypeError: client.get is not a
66
+ * function` deep in a store operation.
67
+ *
68
+ * Callers pass their own `wrap` callback that throws their typed
69
+ * error class with the message — this file has no opinion on the
70
+ * error surface.
71
+ */
72
+
73
+ /**
74
+ * @param {unknown} client
75
+ * @param {readonly string[]} methods Method names that must be functions on the client.
76
+ * @param {(message: string) => never} wrap
77
+ * Called with a diagnostic message when the check fails; must throw.
78
+ * The caller's typed error class is emitted from inside this callback.
79
+ * @returns {void}
80
+ */
81
+ function assertRedisClient(client, methods, wrap) {
82
+ if (!client || typeof client !== 'object') {
83
+ wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
84
+ }
85
+ const c = /** @type {Record<string, unknown>} */ (client);
86
+ for (const method of methods) {
87
+ if (typeof c[method] !== 'function') {
88
+ wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Shared IncrStore — atomic TTL counter for replay guards and rate
95
+ * limiting. Memory and Redis factories.
96
+ *
97
+ * Interface: `incr(key, ttlMs) → { count, expiresAt }`.
98
+ * First call arms a TTL and returns `{ count: 1 }`; subsequent calls
99
+ * before expiry increment the counter.
100
+ *
101
+ * Consumers: `@exortek/challenge`, `@exortek/magic-link` (rate limiter).
102
+ */
103
+
104
+
105
+ // Memory
106
+
107
+ /**
108
+ * @typedef {object} MemoryIncrStoreOptions
109
+ * @property {number} [maxKeys=10000] Hard cap; oldest entry dropped when exceeded.
110
+ * @property {number} [sweepMs=60000] Interval for the background TTL sweep.
111
+ */
112
+
113
+ /**
114
+ * @param {MemoryIncrStoreOptions} [options]
115
+ * @param {(msg: string) => never} [wrap] Error factory for validation.
116
+ * @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }>, _size: () => number, _stop: () => void }}
117
+ */
118
+ function createMemoryIncrStore(options = {}, wrap) {
119
+ const fail = wrap ?? (msg => { throw new TypeError(msg); });
120
+ const maxKeys = options.maxKeys ?? 10_000;
121
+ const sweepMs = options.sweepMs ?? 60_000;
122
+
123
+ if (!isInteger(maxKeys) || maxKeys < 1) {
124
+ fail(`memoryIncrStore.maxKeys must be a positive integer; got ${maxKeys}`);
125
+ }
126
+ if (!isInteger(sweepMs) || sweepMs < 1000) {
127
+ fail(`memoryIncrStore.sweepMs must be an integer >= 1000; got ${sweepMs}`);
128
+ }
129
+
130
+ /** @type {Map<string, { count: number, expiresAt: number }>} */
131
+ const map = new Map();
132
+ let timer = null;
133
+
134
+ function peekFresh(key) {
135
+ const entry = map.get(key);
136
+ if (!entry) {
137
+ return null;
138
+ }
139
+ if (entry.expiresAt <= Date.now()) {
140
+ map.delete(key);
141
+ return null;
142
+ }
143
+ return entry;
144
+ }
145
+
146
+ function sweep() {
147
+ const t = Date.now();
148
+ for (const [key, entry] of map) {
149
+ if (entry.expiresAt <= t) {
150
+ map.delete(key);
151
+ }
152
+ }
153
+ }
154
+
155
+ function scheduleSweeper() {
156
+ if (timer) {
157
+ return;
158
+ }
159
+ timer = setInterval(sweep, sweepMs);
160
+ if (isFunction(timer.unref)) {
161
+ timer.unref();
162
+ }
163
+ }
164
+
165
+ function evictIfFull() {
166
+ if (map.size < maxKeys) {
167
+ return;
168
+ }
169
+ const oldest = map.keys().next().value;
170
+ if (!isUndefined(oldest)) {
171
+ map.delete(oldest);
172
+ }
173
+ }
174
+
175
+ return {
176
+ async incr(key, ttlMs) {
177
+ scheduleSweeper();
178
+ const existing = peekFresh(key);
179
+ if (existing) {
180
+ existing.count += 1;
181
+ map.delete(key);
182
+ map.set(key, existing);
183
+ return { count: existing.count, expiresAt: existing.expiresAt };
184
+ }
185
+ evictIfFull();
186
+ const expiresAt = Date.now() + ttlMs;
187
+ map.set(key, { count: 1, expiresAt });
188
+ return { count: 1, expiresAt };
189
+ },
190
+
191
+ _size: () => map.size,
192
+ _stop: () => {
193
+ if (timer) {
194
+ clearInterval(timer);
195
+ timer = null;
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ // Redis
202
+
203
+ const INCR_SCRIPT = `
204
+ local key = KEYS[1]
205
+ local ttl = tonumber(ARGV[1])
206
+ local count = redis.call('INCR', key)
207
+ local pttl
208
+ if count == 1 then
209
+ redis.call('PEXPIRE', key, ttl)
210
+ pttl = ttl
211
+ else
212
+ pttl = redis.call('PTTL', key)
213
+ if pttl < 0 then
214
+ redis.call('PEXPIRE', key, ttl)
215
+ pttl = ttl
216
+ end
217
+ end
218
+ return { count, pttl }
219
+ `.trim();
220
+
221
+ /**
222
+ * @typedef {object} RedisIncrStoreOptions
223
+ * @property {string} [keyPrefix=''] Prepended to every key.
224
+ */
225
+
226
+ /**
227
+ * @param {object} client
228
+ * @param {RedisIncrStoreOptions} [options]
229
+ * @param {(msg: string) => never} [wrap] Error factory for assertRedisClient.
230
+ * @returns {{ incr: (key: string, ttlMs: number) => Promise<{ count: number, expiresAt: number }> }}
231
+ */
232
+ function createRedisIncrStore(client, options = {}, wrap) {
233
+ assertRedisClient(client, ['eval'], wrap ?? (msg => { throw new TypeError(msg); }));
234
+ const keyPrefix = options.keyPrefix ?? '';
235
+ const k = key => `${keyPrefix}${key}`;
236
+
237
+ return {
238
+ async incr(key, ttlMs) {
239
+ const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
240
+ const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
241
+ const count = Number(arr[0]);
242
+ const pttl = Number(arr[1]);
243
+ return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
244
+ },
245
+ };
246
+ }
247
+
60
248
  /**
61
249
  * Imperative single-argument guard helpers — the everyday
62
250
  * `assertPositiveInt(x, 'name')` shape used at API boundaries.
@@ -522,121 +710,9 @@ const { invalidArgument} = defineGuards(
522
710
  * @returns {import('./index.js').IncrStore & { _size: () => number, _stop: () => void }}
523
711
  */
524
712
  function memoryStore(options = {}) {
525
- const maxKeys = options.maxKeys ?? 10_000;
526
- const sweepMs = options.sweepMs ?? 60_000;
527
-
528
- if (!isInteger(maxKeys) || maxKeys < 1) {
529
- throw invalidArgument(`memoryStore.options.maxKeys must be a positive integer; got ${maxKeys}`);
530
- }
531
- if (!isInteger(sweepMs) || sweepMs < 1000) {
532
- throw invalidArgument(`memoryStore.options.sweepMs must be an integer >= 1000; got ${sweepMs}`);
533
- }
534
-
535
- /** @type {Map<string, { count: number, expiresAt: number }>} */
536
- const map = new Map();
537
- let timer = null;
538
-
539
- function peekFresh(key) {
540
- const entry = map.get(key);
541
- if (!entry) {
542
- return null;
543
- }
544
- if (entry.expiresAt <= Date.now()) {
545
- map.delete(key);
546
- return null;
547
- }
548
- return entry;
549
- }
550
-
551
- function sweep() {
552
- const t = Date.now();
553
- for (const [key, entry] of map) {
554
- if (entry.expiresAt <= t) {
555
- map.delete(key);
556
- }
557
- }
558
- }
559
-
560
- function scheduleSweeper() {
561
- if (timer) {
562
- return;
563
- }
564
- timer = setInterval(sweep, sweepMs);
565
- if (isFunction(timer.unref)) {
566
- timer.unref();
567
- }
568
- }
569
-
570
- function evictIfFull() {
571
- if (map.size < maxKeys) {
572
- return;
573
- }
574
- const oldest = map.keys().next().value;
575
- if (!isUndefined(oldest)) {
576
- map.delete(oldest);
577
- }
578
- }
579
-
580
- return {
581
- async incr(key, ttlMs) {
582
- scheduleSweeper();
583
- const existing = peekFresh(key);
584
- if (existing) {
585
- existing.count += 1;
586
- // Refresh LRU position on activity — the replay-guard tombstone
587
- // must stay warm until its TTL fires, or a third replay attempt
588
- // could see the entry evicted and get a fresh count.
589
- map.delete(key);
590
- map.set(key, existing);
591
- return { count: existing.count, expiresAt: existing.expiresAt };
592
- }
593
- evictIfFull();
594
- const expiresAt = Date.now() + ttlMs;
595
- map.set(key, { count: 1, expiresAt });
596
- return { count: 1, expiresAt };
597
- },
598
-
599
- _size: () => map.size,
600
- _stop: () => {
601
- if (timer) {
602
- clearInterval(timer);
603
- timer = null;
604
- }
605
- },
606
- };
607
- }
608
-
609
- /**
610
- * Duck-type guard for a Redis-compatible client. Every `@exortek/*`
611
- * package that ships a Redis store (jwt blacklist, session store,
612
- * security rate-limit) does the same "not null + required methods
613
- * are functions" check up front so a missing dependency surfaces as
614
- * a clean typed error instead of `TypeError: client.get is not a
615
- * function` deep in a store operation.
616
- *
617
- * Callers pass their own `wrap` callback that throws their typed
618
- * error class with the message — this file has no opinion on the
619
- * error surface.
620
- */
621
-
622
- /**
623
- * @param {unknown} client
624
- * @param {readonly string[]} methods Method names that must be functions on the client.
625
- * @param {(message: string) => never} wrap
626
- * Called with a diagnostic message when the check fails; must throw.
627
- * The caller's typed error class is emitted from inside this callback.
628
- * @returns {void}
629
- */
630
- function assertRedisClient(client, methods, wrap) {
631
- if (!client || typeof client !== 'object') {
632
- wrap('client is required — pass a Redis-compatible instance (ioredis / node-redis / @upstash/redis)');
633
- }
634
- const c = /** @type {Record<string, unknown>} */ (client);
635
- for (const method of methods) {
636
- if (typeof c[method] !== 'function') {
637
- wrap(`client is missing '${method}()' — is this a Redis-compatible client?`);
638
- }
639
- }
713
+ return createMemoryIncrStore(options, msg => {
714
+ throw invalidArgument(msg);
715
+ });
640
716
  }
641
717
 
642
718
  /**
@@ -656,24 +732,6 @@ function assertRedisClient(client, methods, wrap) {
656
732
  */
657
733
 
658
734
 
659
- const INCR_SCRIPT = `
660
- local key = KEYS[1]
661
- local ttl = tonumber(ARGV[1])
662
- local count = redis.call('INCR', key)
663
- local pttl
664
- if count == 1 then
665
- redis.call('PEXPIRE', key, ttl)
666
- pttl = ttl
667
- else
668
- pttl = redis.call('PTTL', key)
669
- if pttl < 0 then
670
- redis.call('PEXPIRE', key, ttl)
671
- pttl = ttl
672
- end
673
- end
674
- return { count, pttl }
675
- `.trim();
676
-
677
735
  /**
678
736
  * @typedef {object} RedisStoreOptions
679
737
  * @property {string} [keyPrefix='chall:'] Prepended to every key.
@@ -685,23 +743,9 @@ return { count, pttl }
685
743
  * @returns {import('./index.js').IncrStore}
686
744
  */
687
745
  function redisStore(client, options = {}) {
688
- assertRedisClient(client, ['eval'], msg => {
746
+ return createRedisIncrStore(client, { keyPrefix: options.keyPrefix ?? 'chall:' }, msg => {
689
747
  throw invalidArgument(`redisStore.client: ${msg}`);
690
748
  });
691
- const keyPrefix = options.keyPrefix ?? 'chall:';
692
- const k = key => `${keyPrefix}${key}`;
693
-
694
- return {
695
- async incr(key, ttlMs) {
696
- const raw = await client.eval(INCR_SCRIPT, 1, k(key), String(Math.max(1, Math.ceil(ttlMs))));
697
- // ioredis returns [count, pttl] as numbers; node-redis v4 does too;
698
- // Upstash HTTP driver returns strings. Coerce defensively.
699
- const arr = Array.isArray(raw) ? raw : [raw, ttlMs];
700
- const count = Number(arr[0]);
701
- const pttl = Number(arr[1]);
702
- return { count, expiresAt: Date.now() + (Number.isFinite(pttl) && pttl > 0 ? pttl : ttlMs) };
703
- },
704
- };
705
749
  }
706
750
 
707
751
  export { memoryStore, redisStore };
package/dist/token.d.ts CHANGED
@@ -1,23 +1,3 @@
1
- /**
2
- * Token codec for `@exortek/challenge`.
3
- *
4
- * Format:
5
- *
6
- * <prefix>.<base64url(JSON payload)>.<base64url(HMAC-SHA256 tag)>
7
- *
8
- * The default prefix is {@link DEFAULT_PREFIX} (`chall_v1`) — deliberately
9
- * unlike a JWT so the two token families cannot be confused at a call
10
- * site. Callers may override it (e.g. `'server_challenge'`,
11
- * `'myapp_v1'`) to brand the wire format for a specific service; the
12
- * same prefix must be used at create and verify time, or verification
13
- * returns `reason: 'malformed'`.
14
- *
15
- * HMAC covers `<prefix>.<b64u payload>` — the same string the caller
16
- * received minus the trailing tag. Any change to prefix or payload
17
- * invalidates the signature; a token minted with one prefix cannot be
18
- * accepted under another even by the same secret.
19
- */
20
- export declare const DEFAULT_PREFIX = "chall_v1";
21
1
  /**
22
2
  * Assert a prefix is well-shaped. Throws via the caller's `invalidArg`
23
3
  * function so `createChallenge` / `verifyChallenge` can raise
@@ -29,7 +9,7 @@ export declare const DEFAULT_PREFIX = "chall_v1";
29
9
  * @param {(msg: string) => Error} invalidArg
30
10
  * @returns {string}
31
11
  */
32
- export declare function assertPrefix(prefix: string, name: string, invalidArg: (msg: string) => Error): string;
12
+ export function assertPrefix(prefix: string, name: string, invalidArg: (msg: string) => Error): string;
33
13
  /**
34
14
  * Random ID for the token's `jti` claim — 128 bits of entropy, encoded
35
15
  * as 22 base64url characters. Used as the store key for single-use
@@ -37,7 +17,7 @@ export declare function assertPrefix(prefix: string, name: string, invalidArg: (
37
17
  *
38
18
  * @returns {string}
39
19
  */
40
- export declare function newJti(): string;
20
+ export function newJti(): string;
41
21
  /**
42
22
  * Sign a payload with `secret` and return the compact token string.
43
23
  *
@@ -46,7 +26,7 @@ export declare function newJti(): string;
46
26
  * @param {string} [prefix] Wire prefix; defaults to {@link DEFAULT_PREFIX}.
47
27
  * @returns {string}
48
28
  */
49
- export declare function sign(payload: object, secret: Buffer, prefix?: string): string;
29
+ export function sign(payload: object, secret: Buffer, prefix?: string): string;
50
30
  /**
51
31
  * Parse + HMAC-verify a token. Returns the decoded payload on success,
52
32
  * or a reason string on any failure. Never throws on user-input
@@ -57,8 +37,9 @@ export declare function sign(payload: object, secret: Buffer, prefix?: string):
57
37
  * @param {string} [prefix] Expected prefix; defaults to {@link DEFAULT_PREFIX}.
58
38
  * @returns {{ payload: object } | { reason: 'malformed' | 'bad_signature' }}
59
39
  */
60
- export declare function decode(token: string, secret: Buffer, prefix?: string): {
40
+ export function decode(token: string, secret: Buffer, prefix?: string): {
61
41
  payload: object;
62
42
  } | {
63
- reason: 'malformed' | 'bad_signature';
43
+ reason: "malformed" | "bad_signature";
64
44
  };
45
+ export const DEFAULT_PREFIX: "chall_v1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exortek/challenge",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Signed, single-use challenge tokens for multi-step auth flows on Node.js — carries userId / method / step / metadata across a redirect or a second request without a server-side session; HMAC-signed, expiring, optional IP-binding, single-use enforcement via any @exortek/security store.",
5
5
  "type": "module",
6
6
  "sideEffects": false,