redis-otp-manager 0.4.0 → 0.5.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/README.md CHANGED
@@ -10,6 +10,8 @@ This package currently includes:
10
10
  - Keyed HMAC support with app secret configuration
11
11
  - Secret rotation support for verification
12
12
  - Legacy SHA-256 verification compatibility for migrations
13
+ - Hook-based observability events
14
+ - Structured metadata support for audit and logging context
13
15
  - Redis-compatible storage adapter
14
16
  - In-memory adapter for tests
15
17
  - Intent-aware key strategy
@@ -33,12 +35,6 @@ For NestJS apps, also install the Nest peer dependencies used by your app:
33
35
  npm install @nestjs/common @nestjs/core reflect-metadata rxjs
34
36
  ```
35
37
 
36
- ## Module Support
37
-
38
- This package supports both:
39
- - ESM imports
40
- - CommonJS/Nest `ts-node/register` style resolution
41
-
42
38
  ## Quality Checks
43
39
 
44
40
  ```bash
@@ -68,6 +64,59 @@ const otp = new OTPManager({
68
64
  });
69
65
  ```
70
66
 
67
+ ## Observability Hooks
68
+
69
+ `v0.5.0` adds hook-based observability without changing the core OTP flow.
70
+
71
+ ```ts
72
+ const otp = new OTPManager({
73
+ store: new RedisAdapter(redisClient),
74
+ ttl: 300,
75
+ maxAttempts: 3,
76
+ hooks: {
77
+ onGenerated: async (event) => logger.info("otp_generated", event),
78
+ onVerified: async (event) => logger.info("otp_verified", event),
79
+ onFailed: async (event) => logger.warn("otp_failed", event),
80
+ onLocked: async (event) => logger.warn("otp_locked", event),
81
+ onRateLimited: async (event) => logger.warn("otp_rate_limited", event),
82
+ onCooldownBlocked: async (event) => logger.warn("otp_cooldown_blocked", event),
83
+ onHookError: async (error, context) => logger.error("otp_hook_error", { error, context }),
84
+ },
85
+ });
86
+ ```
87
+
88
+ Supported lifecycle hooks:
89
+ - `onGenerated`
90
+ - `onVerified`
91
+ - `onFailed`
92
+ - `onLocked`
93
+ - `onRateLimited`
94
+ - `onCooldownBlocked`
95
+ - `onHookError`
96
+
97
+ Default behavior:
98
+ - hooks are optional
99
+ - hook execution is non-blocking by default
100
+ - hook failures do not break OTP generation or verification unless `throwOnError: true`
101
+
102
+ ## Structured Metadata
103
+
104
+ You can pass request-scoped metadata into both `generate()` and `verify()`.
105
+ This metadata is forwarded to hooks but is not stored in Redis.
106
+
107
+ ```ts
108
+ await otp.generate({
109
+ type: "email",
110
+ identifier: "user@example.com",
111
+ intent: "login",
112
+ metadata: {
113
+ requestId: "req_123",
114
+ userId: "user_42",
115
+ ip: "203.0.113.10",
116
+ },
117
+ });
118
+ ```
119
+
71
120
  ## Cryptographic Hardening
72
121
 
73
122
  When `hashing.secret` is configured, new OTPs are stored using keyed HMAC instead of plain SHA-256.
@@ -91,12 +140,6 @@ Recommended migration strategy:
91
140
  - optionally add `previousSecrets` during secret rotation
92
141
  - after old in-flight OTPs naturally expire, you can disable legacy verification if desired
93
142
 
94
- Secure secret management guidance:
95
- - store secrets in environment variables or your secret manager
96
- - never hardcode secrets in source control
97
- - rotate secrets deliberately and keep the previous secret only as long as needed
98
- - use different secrets across environments
99
-
100
143
  ## Production Security Notes
101
144
 
102
145
  When you use `RedisAdapter`, the package takes the Redis-specific atomic path for:
@@ -107,37 +150,6 @@ That prevents the most important race condition from earlier versions where two
107
150
 
108
151
  Simpler adapters like `MemoryAdapter` intentionally stay on the non-atomic fallback path to keep tests and local development lightweight.
109
152
 
110
- ## NestJS
111
-
112
- ```ts
113
- import { Module } from "@nestjs/common";
114
- import { createClient } from "redis";
115
- import { OTPManager, RedisAdapter } from "redis-otp-manager";
116
- import { OTPModule, InjectOTPManager } from "redis-otp-manager/nest";
117
-
118
- const redisClient = createClient({ url: process.env.REDIS_URL });
119
-
120
- @Module({
121
- imports: [
122
- OTPModule.forRoot({
123
- store: new RedisAdapter(redisClient),
124
- ttl: 300,
125
- maxAttempts: 5,
126
- resendCooldown: 45,
127
- rateLimit: {
128
- window: 60,
129
- max: 3,
130
- },
131
- hashing: {
132
- secret: process.env.OTP_HMAC_SECRET,
133
- },
134
- isGlobal: true,
135
- }),
136
- ],
137
- })
138
- export class AppModule {}
139
- ```
140
-
141
153
  ## API
142
154
 
143
155
  ### `new OTPManager(options)`
@@ -164,6 +176,16 @@ type OTPManagerOptions = {
164
176
  previousSecrets?: string[];
165
177
  allowLegacyVerify?: boolean;
166
178
  };
179
+ hooks?: {
180
+ onGenerated?: (event) => void | Promise<void>;
181
+ onVerified?: (event) => void | Promise<void>;
182
+ onFailed?: (event) => void | Promise<void>;
183
+ onLocked?: (event) => void | Promise<void>;
184
+ onRateLimited?: (event) => void | Promise<void>;
185
+ onCooldownBlocked?: (event) => void | Promise<void>;
186
+ onHookError?: (error, context) => void | Promise<void>;
187
+ throwOnError?: boolean;
188
+ };
167
189
  };
168
190
  ```
169
191
 
@@ -185,33 +207,17 @@ type OTPManagerOptions = {
185
207
  - prefer `RedisAdapter` in production to get the atomic security path
186
208
  - keep Redis private and behind authenticated network access
187
209
 
188
- ## Key Design
189
-
190
- ```txt
191
- otp:{intent}:{type}:{identifier}
192
- attempts:{intent}:{type}:{identifier}
193
- rate:{type}:{identifier}
194
- cooldown:{intent}:{type}:{identifier}
195
- ```
196
-
197
210
  ## Release Automation
198
211
 
199
212
  Publishing on every `main` merge is not recommended for npm packages because npm versions are immutable. The safer setup is:
200
213
  - merge to `main` runs CI only
201
- - publish happens when you push a version tag like `v0.4.0`
214
+ - publish happens when you push a version tag like `v0.5.0`
202
215
 
203
216
  Required GitHub secrets:
204
217
  - `NPM_TOKEN`
205
218
 
206
- Tag-based publish:
207
-
208
- ```bash
209
- git tag v0.4.0
210
- git push origin v0.4.0
211
- ```
212
-
213
219
  ## Next Roadmap
214
220
 
215
- - hooks/events
216
- - analytics and observability
221
+ - analytics and observability integrations
217
222
  - delivery helper integrations
223
+ - more advanced audit persistence adapters
@@ -21,6 +21,7 @@ class OTPManager {
21
21
  async generate(input) {
22
22
  validatePayload(input);
23
23
  const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
24
+ const eventContext = this.buildEventContext(input, normalizedInput.identifier);
24
25
  const otpKey = (0, key_builder_js_1.buildOtpKey)(normalizedInput);
25
26
  const attemptsKey = (0, key_builder_js_1.buildAttemptsKey)(normalizedInput);
26
27
  const rateLimitKey = (0, key_builder_js_1.buildRateLimitKey)(normalizedInput);
@@ -40,38 +41,54 @@ class OTPManager {
40
41
  resendCooldown: this.options.resendCooldown,
41
42
  });
42
43
  if (atomicResult === "cooldown") {
44
+ await this.emitCooldownBlocked(eventContext);
43
45
  throw new otp_errors_js_1.OTPResendCooldownError();
44
46
  }
45
47
  if (atomicResult === "rate_limit") {
48
+ await this.emitRateLimited(eventContext);
46
49
  throw new otp_errors_js_1.OTPRateLimitExceededError();
47
50
  }
48
51
  if (atomicResult === "ok") {
49
- return {
52
+ const result = {
50
53
  expiresIn: this.options.ttl,
51
54
  otp: this.options.devMode ? otp : undefined,
52
55
  };
56
+ await this.emitGenerated(eventContext);
57
+ return result;
53
58
  }
54
59
  }
55
60
  if (this.options.resendCooldown) {
56
61
  const cooldownActive = await this.options.store.get(cooldownKey);
57
62
  if (cooldownActive) {
63
+ await this.emitCooldownBlocked(eventContext);
58
64
  throw new otp_errors_js_1.OTPResendCooldownError();
59
65
  }
60
66
  }
61
- await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
67
+ try {
68
+ await (0, rate_limiter_js_1.assertWithinRateLimit)(this.options.store, rateLimitKey, this.options.rateLimit);
69
+ }
70
+ catch (error) {
71
+ if (error instanceof otp_errors_js_1.OTPRateLimitExceededError) {
72
+ await this.emitRateLimited(eventContext);
73
+ }
74
+ throw error;
75
+ }
62
76
  await this.options.store.set(otpKey, storedHash, this.options.ttl);
63
77
  await this.options.store.del(attemptsKey);
64
78
  if (this.options.resendCooldown) {
65
79
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
66
80
  }
67
- return {
81
+ const result = {
68
82
  expiresIn: this.options.ttl,
69
83
  otp: this.options.devMode ? otp : undefined,
70
84
  };
85
+ await this.emitGenerated(eventContext);
86
+ return result;
71
87
  }
72
88
  async verify(input) {
73
89
  validatePayload(input);
74
90
  const normalizedInput = (0, identifier_normalizer_js_1.normalizePayloadIdentifier)(input, this.options.identifierNormalization);
91
+ const eventContext = this.buildEventContext(input, normalizedInput.identifier);
75
92
  if (!input.otp || !input.otp.trim()) {
76
93
  throw new TypeError("OTP must be a non-empty string.");
77
94
  }
@@ -87,20 +104,25 @@ class OTPManager {
87
104
  maxAttempts: this.options.maxAttempts,
88
105
  });
89
106
  if (atomicResult === "verified") {
107
+ await this.emitVerified(eventContext);
90
108
  return true;
91
109
  }
92
110
  if (atomicResult === "expired") {
111
+ await this.emitFailed(eventContext, "expired");
93
112
  throw new otp_errors_js_1.OTPExpiredError();
94
113
  }
95
114
  if (atomicResult === "max_attempts") {
115
+ await this.emitLocked(eventContext);
96
116
  throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
97
117
  }
98
118
  if (atomicResult === "invalid") {
119
+ await this.emitFailed(eventContext, "invalid");
99
120
  throw new otp_errors_js_1.OTPInvalidError();
100
121
  }
101
122
  }
102
123
  const storedHash = await this.options.store.get(otpKey);
103
124
  if (!storedHash) {
125
+ await this.emitFailed(eventContext, "expired");
104
126
  throw new otp_errors_js_1.OTPExpiredError();
105
127
  }
106
128
  const isValid = (0, otp_hash_js_1.verifyOtpHash)(input.otp, normalizedInput, storedHash, this.options.hashing);
@@ -109,14 +131,117 @@ class OTPManager {
109
131
  if (attempts >= this.options.maxAttempts) {
110
132
  await this.options.store.del(otpKey);
111
133
  await this.options.store.del(attemptsKey);
134
+ await this.emitLocked(eventContext);
112
135
  throw new otp_errors_js_1.OTPMaxAttemptsExceededError();
113
136
  }
137
+ await this.emitFailed(eventContext, "invalid", attempts);
114
138
  throw new otp_errors_js_1.OTPInvalidError();
115
139
  }
116
140
  await this.options.store.del(otpKey);
117
141
  await this.options.store.del(attemptsKey);
142
+ await this.emitVerified(eventContext);
118
143
  return true;
119
144
  }
145
+ buildEventContext(input, normalizedIdentifier) {
146
+ return {
147
+ type: input.type,
148
+ identifier: input.identifier,
149
+ normalizedIdentifier,
150
+ intent: input.intent ?? "default",
151
+ timestamp: new Date().toISOString(),
152
+ metadata: input.metadata,
153
+ };
154
+ }
155
+ async emitGenerated(context) {
156
+ await this.dispatchHook("generated", {
157
+ ...context,
158
+ expiresIn: this.options.ttl,
159
+ devMode: this.options.devMode,
160
+ });
161
+ }
162
+ async emitVerified(context) {
163
+ await this.dispatchHook("verified", context);
164
+ }
165
+ async emitFailed(context, reason, attemptsUsed) {
166
+ await this.dispatchHook("failed", {
167
+ ...context,
168
+ reason,
169
+ attemptsUsed,
170
+ attemptsRemaining: attemptsUsed !== undefined ? Math.max(this.options.maxAttempts - attemptsUsed, 0) : undefined,
171
+ });
172
+ }
173
+ async emitLocked(context) {
174
+ await this.dispatchHook("locked", {
175
+ ...context,
176
+ maxAttempts: this.options.maxAttempts,
177
+ });
178
+ }
179
+ async emitRateLimited(context) {
180
+ if (!this.options.rateLimit) {
181
+ return;
182
+ }
183
+ await this.dispatchHook("rate_limited", {
184
+ ...context,
185
+ window: this.options.rateLimit.window,
186
+ max: this.options.rateLimit.max,
187
+ });
188
+ }
189
+ async emitCooldownBlocked(context) {
190
+ if (!this.options.resendCooldown) {
191
+ return;
192
+ }
193
+ await this.dispatchHook("cooldown_blocked", {
194
+ ...context,
195
+ resendCooldown: this.options.resendCooldown,
196
+ });
197
+ }
198
+ async dispatchHook(eventName, payload) {
199
+ const hook = this.getHook(eventName);
200
+ if (!hook) {
201
+ return;
202
+ }
203
+ const execute = async () => {
204
+ try {
205
+ await hook(payload);
206
+ }
207
+ catch (error) {
208
+ if (this.options.hooks?.onHookError) {
209
+ await this.options.hooks.onHookError(error, {
210
+ event: eventName,
211
+ payload,
212
+ });
213
+ }
214
+ if (this.options.hooks?.throwOnError) {
215
+ throw error;
216
+ }
217
+ }
218
+ };
219
+ if (this.options.hooks?.throwOnError) {
220
+ await execute();
221
+ return;
222
+ }
223
+ queueMicrotask(() => {
224
+ void execute();
225
+ });
226
+ }
227
+ getHook(eventName) {
228
+ switch (eventName) {
229
+ case "generated":
230
+ return this.options.hooks?.onGenerated;
231
+ case "verified":
232
+ return this.options.hooks?.onVerified;
233
+ case "failed":
234
+ return this.options.hooks?.onFailed;
235
+ case "locked":
236
+ return this.options.hooks?.onLocked;
237
+ case "rate_limited":
238
+ return this.options.hooks?.onRateLimited;
239
+ case "cooldown_blocked":
240
+ return this.options.hooks?.onCooldownBlocked;
241
+ default:
242
+ return undefined;
243
+ }
244
+ }
120
245
  }
121
246
  exports.OTPManager = OTPManager;
122
247
  function validateManagerOptions(options) {
@@ -170,6 +295,24 @@ function validateManagerOptions(options) {
170
295
  throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
171
296
  }
172
297
  }
298
+ if (options.hooks) {
299
+ const hookEntries = [
300
+ options.hooks.onGenerated,
301
+ options.hooks.onVerified,
302
+ options.hooks.onFailed,
303
+ options.hooks.onLocked,
304
+ options.hooks.onRateLimited,
305
+ options.hooks.onCooldownBlocked,
306
+ options.hooks.onHookError,
307
+ ];
308
+ if (hookEntries.some((hook) => hook !== undefined && typeof hook !== "function")) {
309
+ throw new TypeError("All hooks must be functions when provided.");
310
+ }
311
+ if (options.hooks.throwOnError !== undefined &&
312
+ typeof options.hooks.throwOnError !== "boolean") {
313
+ throw new TypeError("hooks.throwOnError must be a boolean.");
314
+ }
315
+ }
173
316
  }
174
317
  function validatePayload(input) {
175
318
  if (!input.type || !input.type.trim()) {
@@ -4,4 +4,13 @@ export declare class OTPManager {
4
4
  constructor(options: OTPManagerOptions);
5
5
  generate(input: GenerateOTPInput): Promise<GenerateOTPResult>;
6
6
  verify(input: VerifyOTPInput): Promise<true>;
7
+ private buildEventContext;
8
+ private emitGenerated;
9
+ private emitVerified;
10
+ private emitFailed;
11
+ private emitLocked;
12
+ private emitRateLimited;
13
+ private emitCooldownBlocked;
14
+ private dispatchHook;
15
+ private getHook;
7
16
  }
@@ -18,6 +18,7 @@ export class OTPManager {
18
18
  async generate(input) {
19
19
  validatePayload(input);
20
20
  const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
21
+ const eventContext = this.buildEventContext(input, normalizedInput.identifier);
21
22
  const otpKey = buildOtpKey(normalizedInput);
22
23
  const attemptsKey = buildAttemptsKey(normalizedInput);
23
24
  const rateLimitKey = buildRateLimitKey(normalizedInput);
@@ -37,38 +38,54 @@ export class OTPManager {
37
38
  resendCooldown: this.options.resendCooldown,
38
39
  });
39
40
  if (atomicResult === "cooldown") {
41
+ await this.emitCooldownBlocked(eventContext);
40
42
  throw new OTPResendCooldownError();
41
43
  }
42
44
  if (atomicResult === "rate_limit") {
45
+ await this.emitRateLimited(eventContext);
43
46
  throw new OTPRateLimitExceededError();
44
47
  }
45
48
  if (atomicResult === "ok") {
46
- return {
49
+ const result = {
47
50
  expiresIn: this.options.ttl,
48
51
  otp: this.options.devMode ? otp : undefined,
49
52
  };
53
+ await this.emitGenerated(eventContext);
54
+ return result;
50
55
  }
51
56
  }
52
57
  if (this.options.resendCooldown) {
53
58
  const cooldownActive = await this.options.store.get(cooldownKey);
54
59
  if (cooldownActive) {
60
+ await this.emitCooldownBlocked(eventContext);
55
61
  throw new OTPResendCooldownError();
56
62
  }
57
63
  }
58
- await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
64
+ try {
65
+ await assertWithinRateLimit(this.options.store, rateLimitKey, this.options.rateLimit);
66
+ }
67
+ catch (error) {
68
+ if (error instanceof OTPRateLimitExceededError) {
69
+ await this.emitRateLimited(eventContext);
70
+ }
71
+ throw error;
72
+ }
59
73
  await this.options.store.set(otpKey, storedHash, this.options.ttl);
60
74
  await this.options.store.del(attemptsKey);
61
75
  if (this.options.resendCooldown) {
62
76
  await this.options.store.set(cooldownKey, "1", this.options.resendCooldown);
63
77
  }
64
- return {
78
+ const result = {
65
79
  expiresIn: this.options.ttl,
66
80
  otp: this.options.devMode ? otp : undefined,
67
81
  };
82
+ await this.emitGenerated(eventContext);
83
+ return result;
68
84
  }
69
85
  async verify(input) {
70
86
  validatePayload(input);
71
87
  const normalizedInput = normalizePayloadIdentifier(input, this.options.identifierNormalization);
88
+ const eventContext = this.buildEventContext(input, normalizedInput.identifier);
72
89
  if (!input.otp || !input.otp.trim()) {
73
90
  throw new TypeError("OTP must be a non-empty string.");
74
91
  }
@@ -84,20 +101,25 @@ export class OTPManager {
84
101
  maxAttempts: this.options.maxAttempts,
85
102
  });
86
103
  if (atomicResult === "verified") {
104
+ await this.emitVerified(eventContext);
87
105
  return true;
88
106
  }
89
107
  if (atomicResult === "expired") {
108
+ await this.emitFailed(eventContext, "expired");
90
109
  throw new OTPExpiredError();
91
110
  }
92
111
  if (atomicResult === "max_attempts") {
112
+ await this.emitLocked(eventContext);
93
113
  throw new OTPMaxAttemptsExceededError();
94
114
  }
95
115
  if (atomicResult === "invalid") {
116
+ await this.emitFailed(eventContext, "invalid");
96
117
  throw new OTPInvalidError();
97
118
  }
98
119
  }
99
120
  const storedHash = await this.options.store.get(otpKey);
100
121
  if (!storedHash) {
122
+ await this.emitFailed(eventContext, "expired");
101
123
  throw new OTPExpiredError();
102
124
  }
103
125
  const isValid = verifyOtpHash(input.otp, normalizedInput, storedHash, this.options.hashing);
@@ -106,14 +128,117 @@ export class OTPManager {
106
128
  if (attempts >= this.options.maxAttempts) {
107
129
  await this.options.store.del(otpKey);
108
130
  await this.options.store.del(attemptsKey);
131
+ await this.emitLocked(eventContext);
109
132
  throw new OTPMaxAttemptsExceededError();
110
133
  }
134
+ await this.emitFailed(eventContext, "invalid", attempts);
111
135
  throw new OTPInvalidError();
112
136
  }
113
137
  await this.options.store.del(otpKey);
114
138
  await this.options.store.del(attemptsKey);
139
+ await this.emitVerified(eventContext);
115
140
  return true;
116
141
  }
142
+ buildEventContext(input, normalizedIdentifier) {
143
+ return {
144
+ type: input.type,
145
+ identifier: input.identifier,
146
+ normalizedIdentifier,
147
+ intent: input.intent ?? "default",
148
+ timestamp: new Date().toISOString(),
149
+ metadata: input.metadata,
150
+ };
151
+ }
152
+ async emitGenerated(context) {
153
+ await this.dispatchHook("generated", {
154
+ ...context,
155
+ expiresIn: this.options.ttl,
156
+ devMode: this.options.devMode,
157
+ });
158
+ }
159
+ async emitVerified(context) {
160
+ await this.dispatchHook("verified", context);
161
+ }
162
+ async emitFailed(context, reason, attemptsUsed) {
163
+ await this.dispatchHook("failed", {
164
+ ...context,
165
+ reason,
166
+ attemptsUsed,
167
+ attemptsRemaining: attemptsUsed !== undefined ? Math.max(this.options.maxAttempts - attemptsUsed, 0) : undefined,
168
+ });
169
+ }
170
+ async emitLocked(context) {
171
+ await this.dispatchHook("locked", {
172
+ ...context,
173
+ maxAttempts: this.options.maxAttempts,
174
+ });
175
+ }
176
+ async emitRateLimited(context) {
177
+ if (!this.options.rateLimit) {
178
+ return;
179
+ }
180
+ await this.dispatchHook("rate_limited", {
181
+ ...context,
182
+ window: this.options.rateLimit.window,
183
+ max: this.options.rateLimit.max,
184
+ });
185
+ }
186
+ async emitCooldownBlocked(context) {
187
+ if (!this.options.resendCooldown) {
188
+ return;
189
+ }
190
+ await this.dispatchHook("cooldown_blocked", {
191
+ ...context,
192
+ resendCooldown: this.options.resendCooldown,
193
+ });
194
+ }
195
+ async dispatchHook(eventName, payload) {
196
+ const hook = this.getHook(eventName);
197
+ if (!hook) {
198
+ return;
199
+ }
200
+ const execute = async () => {
201
+ try {
202
+ await hook(payload);
203
+ }
204
+ catch (error) {
205
+ if (this.options.hooks?.onHookError) {
206
+ await this.options.hooks.onHookError(error, {
207
+ event: eventName,
208
+ payload,
209
+ });
210
+ }
211
+ if (this.options.hooks?.throwOnError) {
212
+ throw error;
213
+ }
214
+ }
215
+ };
216
+ if (this.options.hooks?.throwOnError) {
217
+ await execute();
218
+ return;
219
+ }
220
+ queueMicrotask(() => {
221
+ void execute();
222
+ });
223
+ }
224
+ getHook(eventName) {
225
+ switch (eventName) {
226
+ case "generated":
227
+ return this.options.hooks?.onGenerated;
228
+ case "verified":
229
+ return this.options.hooks?.onVerified;
230
+ case "failed":
231
+ return this.options.hooks?.onFailed;
232
+ case "locked":
233
+ return this.options.hooks?.onLocked;
234
+ case "rate_limited":
235
+ return this.options.hooks?.onRateLimited;
236
+ case "cooldown_blocked":
237
+ return this.options.hooks?.onCooldownBlocked;
238
+ default:
239
+ return undefined;
240
+ }
241
+ }
117
242
  }
118
243
  function validateManagerOptions(options) {
119
244
  if (!Number.isInteger(options.ttl) || options.ttl <= 0) {
@@ -166,6 +291,24 @@ function validateManagerOptions(options) {
166
291
  throw new TypeError("hashing.allowLegacyVerify must be a boolean.");
167
292
  }
168
293
  }
294
+ if (options.hooks) {
295
+ const hookEntries = [
296
+ options.hooks.onGenerated,
297
+ options.hooks.onVerified,
298
+ options.hooks.onFailed,
299
+ options.hooks.onLocked,
300
+ options.hooks.onRateLimited,
301
+ options.hooks.onCooldownBlocked,
302
+ options.hooks.onHookError,
303
+ ];
304
+ if (hookEntries.some((hook) => hook !== undefined && typeof hook !== "function")) {
305
+ throw new TypeError("All hooks must be functions when provided.");
306
+ }
307
+ if (options.hooks.throwOnError !== undefined &&
308
+ typeof options.hooks.throwOnError !== "boolean") {
309
+ throw new TypeError("hooks.throwOnError must be a boolean.");
310
+ }
311
+ }
169
312
  }
170
313
  function validatePayload(input) {
171
314
  if (!input.type || !input.type.trim()) {
@@ -1,8 +1,10 @@
1
1
  export type OTPChannel = "email" | "sms" | "token" | (string & {});
2
+ export type OTPMetadata = Record<string, unknown>;
2
3
  export interface OTPPayload {
3
4
  type: OTPChannel;
4
5
  identifier: string;
5
6
  intent?: string;
7
+ metadata?: OTPMetadata;
6
8
  }
7
9
  export interface GenerateOTPInput extends OTPPayload {
8
10
  }
@@ -23,6 +25,49 @@ export interface OTPHashingOptions {
23
25
  previousSecrets?: string[];
24
26
  allowLegacyVerify?: boolean;
25
27
  }
28
+ export interface OTPEventContext {
29
+ type: OTPChannel;
30
+ identifier: string;
31
+ normalizedIdentifier: string;
32
+ intent: string;
33
+ timestamp: string;
34
+ metadata?: OTPMetadata;
35
+ }
36
+ export interface OTPGeneratedEvent extends OTPEventContext {
37
+ expiresIn: number;
38
+ devMode: boolean;
39
+ }
40
+ export interface OTPVerifiedEvent extends OTPEventContext {
41
+ }
42
+ export interface OTPFailedEvent extends OTPEventContext {
43
+ reason: "invalid" | "expired";
44
+ attemptsUsed?: number;
45
+ attemptsRemaining?: number;
46
+ }
47
+ export interface OTPLockedEvent extends OTPEventContext {
48
+ maxAttempts: number;
49
+ }
50
+ export interface OTPRateLimitedEvent extends OTPEventContext {
51
+ window: number;
52
+ max: number;
53
+ }
54
+ export interface OTPCooldownBlockedEvent extends OTPEventContext {
55
+ resendCooldown: number;
56
+ }
57
+ export interface OTPHookErrorContext {
58
+ event: "generated" | "verified" | "failed" | "locked" | "rate_limited" | "cooldown_blocked";
59
+ payload: OTPGeneratedEvent | OTPVerifiedEvent | OTPFailedEvent | OTPLockedEvent | OTPRateLimitedEvent | OTPCooldownBlockedEvent;
60
+ }
61
+ export interface OTPHooks {
62
+ onGenerated?: (event: OTPGeneratedEvent) => void | Promise<void>;
63
+ onVerified?: (event: OTPVerifiedEvent) => void | Promise<void>;
64
+ onFailed?: (event: OTPFailedEvent) => void | Promise<void>;
65
+ onLocked?: (event: OTPLockedEvent) => void | Promise<void>;
66
+ onRateLimited?: (event: OTPRateLimitedEvent) => void | Promise<void>;
67
+ onCooldownBlocked?: (event: OTPCooldownBlockedEvent) => void | Promise<void>;
68
+ onHookError?: (error: unknown, context: OTPHookErrorContext) => void | Promise<void>;
69
+ throwOnError?: boolean;
70
+ }
26
71
  export interface OTPManagerOptions {
27
72
  store: StoreAdapter;
28
73
  ttl: number;
@@ -33,6 +78,7 @@ export interface OTPManagerOptions {
33
78
  resendCooldown?: number;
34
79
  identifierNormalization?: IdentifierNormalizationConfig;
35
80
  hashing?: OTPHashingOptions;
81
+ hooks?: OTPHooks;
36
82
  }
37
83
  export interface GenerateOTPResult {
38
84
  expiresIn: number;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { OTPManager } from "./core/otp.service.js";
2
2
  export { RedisAdapter } from "./adapters/redis.adapter.js";
3
3
  export { MemoryAdapter } from "./adapters/memory.adapter.js";
4
- export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPHashingOptions, OTPManagerOptions, OTPPayload, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
4
+ export type { GenerateOTPInput, GenerateOTPResult, IdentifierNormalizationConfig, OTPChannel, OTPCooldownBlockedEvent, OTPEventContext, OTPFailedEvent, OTPGeneratedEvent, OTPHashingOptions, OTPHookErrorContext, OTPHooks, OTPLockedEvent, OTPManagerOptions, OTPMetadata, OTPPayload, OTPRateLimitedEvent, OTPVerifiedEvent, RateLimitConfig, StoreAdapter, VerifyOTPInput, } from "./core/otp.types.js";
5
5
  export { OTPError, OTPExpiredError, OTPInvalidError, OTPMaxAttemptsExceededError, OTPRateLimitExceededError, OTPResendCooldownError, } from "./errors/otp.errors.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redis-otp-manager",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Lightweight, Redis-backed OTP manager for Node.js apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -91,4 +91,3 @@
91
91
  "typescript": "^5.8.0"
92
92
  }
93
93
  }
94
-