@otp-service/core 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +173 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 otp-service contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
type OtpChannel = "email" | "sms";
|
|
2
|
+
interface ChallengeRecord {
|
|
3
|
+
attemptsRemaining: number;
|
|
4
|
+
channel: OtpChannel;
|
|
5
|
+
challengeId: string;
|
|
6
|
+
createdAt: Date;
|
|
7
|
+
expiresAt: Date;
|
|
8
|
+
otpHash: string;
|
|
9
|
+
purpose: string;
|
|
10
|
+
recipient: string;
|
|
11
|
+
}
|
|
12
|
+
interface ChallengeStore {
|
|
13
|
+
create(record: ChallengeRecord): Promise<void>;
|
|
14
|
+
delete(challengeId: string): Promise<void>;
|
|
15
|
+
get(challengeId: string): Promise<ChallengeRecord | null>;
|
|
16
|
+
update(record: ChallengeRecord): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
interface DeliveryRequest {
|
|
19
|
+
challengeId: string;
|
|
20
|
+
channel: OtpChannel;
|
|
21
|
+
expiresAt: Date;
|
|
22
|
+
otp: string;
|
|
23
|
+
purpose: string;
|
|
24
|
+
recipient: string;
|
|
25
|
+
}
|
|
26
|
+
interface OtpDelivery {
|
|
27
|
+
sendChallenge(request: DeliveryRequest): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
type OtpDeliveryOutcome = "DEFINITIVE_FAILURE" | "OUTCOME_UNKNOWN";
|
|
30
|
+
declare class OtpDeliveryError extends Error {
|
|
31
|
+
readonly code: string;
|
|
32
|
+
readonly cause?: unknown;
|
|
33
|
+
readonly deliveryOutcome: OtpDeliveryOutcome;
|
|
34
|
+
readonly provider: string;
|
|
35
|
+
readonly retryable: boolean;
|
|
36
|
+
constructor(input: {
|
|
37
|
+
cause?: unknown;
|
|
38
|
+
code: string;
|
|
39
|
+
deliveryOutcome: OtpDeliveryOutcome;
|
|
40
|
+
message: string;
|
|
41
|
+
provider: string;
|
|
42
|
+
retryable: boolean;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
interface OtpSigner {
|
|
46
|
+
hash(otp: string): string;
|
|
47
|
+
verify(otp: string, otpHash: string): boolean;
|
|
48
|
+
}
|
|
49
|
+
interface OtpPolicy {
|
|
50
|
+
maxVerifyAttempts: number;
|
|
51
|
+
otpLength: number;
|
|
52
|
+
ttlSeconds: number;
|
|
53
|
+
}
|
|
54
|
+
interface CreateOtpServiceOptions {
|
|
55
|
+
challengeIdGenerator?: () => string;
|
|
56
|
+
clock?: () => Date;
|
|
57
|
+
delivery: OtpDelivery;
|
|
58
|
+
otpGenerator?: (length: number) => string;
|
|
59
|
+
policy: OtpPolicy;
|
|
60
|
+
signer: OtpSigner;
|
|
61
|
+
store: ChallengeStore;
|
|
62
|
+
}
|
|
63
|
+
interface GenerateChallengeInput {
|
|
64
|
+
channel: OtpChannel;
|
|
65
|
+
purpose: string;
|
|
66
|
+
recipient: string;
|
|
67
|
+
}
|
|
68
|
+
interface GenerateChallengeResult {
|
|
69
|
+
challengeId: string;
|
|
70
|
+
expiresAt: Date;
|
|
71
|
+
status: "CHALLENGE_CREATED";
|
|
72
|
+
}
|
|
73
|
+
interface VerifyChallengeInput {
|
|
74
|
+
challengeId: string;
|
|
75
|
+
otp: string;
|
|
76
|
+
}
|
|
77
|
+
type VerifyChallengeResult = {
|
|
78
|
+
challengeId: string;
|
|
79
|
+
status: "VERIFIED";
|
|
80
|
+
} | {
|
|
81
|
+
attemptsRemaining: number;
|
|
82
|
+
challengeId: string;
|
|
83
|
+
status: "INVALID";
|
|
84
|
+
} | {
|
|
85
|
+
challengeId: string;
|
|
86
|
+
status: "EXPIRED";
|
|
87
|
+
} | {
|
|
88
|
+
challengeId: string;
|
|
89
|
+
status: "ATTEMPTS_EXCEEDED";
|
|
90
|
+
};
|
|
91
|
+
interface OtpService {
|
|
92
|
+
generateChallenge(input: GenerateChallengeInput): Promise<GenerateChallengeResult>;
|
|
93
|
+
verifyChallenge(input: VerifyChallengeInput): Promise<VerifyChallengeResult>;
|
|
94
|
+
}
|
|
95
|
+
declare function createOtpService(options: CreateOtpServiceOptions): OtpService;
|
|
96
|
+
declare function hmacOtpSigner(input: {
|
|
97
|
+
secret: string;
|
|
98
|
+
}): OtpSigner;
|
|
99
|
+
|
|
100
|
+
export { type ChallengeRecord, type ChallengeStore, type CreateOtpServiceOptions, type DeliveryRequest, type GenerateChallengeInput, type GenerateChallengeResult, type OtpChannel, type OtpDelivery, OtpDeliveryError, type OtpDeliveryOutcome, type OtpPolicy, type OtpService, type OtpSigner, type VerifyChallengeInput, type VerifyChallengeResult, createOtpService, hmacOtpSigner };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { randomUUID, createHmac, timingSafeEqual, randomInt } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var OtpDeliveryError = class extends Error {
|
|
5
|
+
code;
|
|
6
|
+
cause;
|
|
7
|
+
deliveryOutcome;
|
|
8
|
+
provider;
|
|
9
|
+
retryable;
|
|
10
|
+
constructor(input) {
|
|
11
|
+
super(input.message);
|
|
12
|
+
this.name = "OtpDeliveryError";
|
|
13
|
+
this.code = input.code;
|
|
14
|
+
this.deliveryOutcome = input.deliveryOutcome;
|
|
15
|
+
this.provider = input.provider;
|
|
16
|
+
this.retryable = input.retryable;
|
|
17
|
+
this.cause = input.cause;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function createOtpService(options) {
|
|
21
|
+
const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
|
|
22
|
+
const challengeIdGenerator = options.challengeIdGenerator ?? randomUUID;
|
|
23
|
+
const otpGenerator = options.otpGenerator ?? defaultOtpGenerator;
|
|
24
|
+
const policy = validatePolicy(options.policy);
|
|
25
|
+
return {
|
|
26
|
+
async generateChallenge(input) {
|
|
27
|
+
validateGenerateChallengeInput(input);
|
|
28
|
+
const createdAt = clock();
|
|
29
|
+
const expiresAt = new Date(createdAt.getTime() + policy.ttlSeconds * 1e3);
|
|
30
|
+
const challengeId = challengeIdGenerator();
|
|
31
|
+
const otp = otpGenerator(policy.otpLength);
|
|
32
|
+
const record = {
|
|
33
|
+
attemptsRemaining: policy.maxVerifyAttempts,
|
|
34
|
+
challengeId,
|
|
35
|
+
channel: input.channel,
|
|
36
|
+
createdAt,
|
|
37
|
+
expiresAt,
|
|
38
|
+
otpHash: options.signer.hash(otp),
|
|
39
|
+
purpose: input.purpose,
|
|
40
|
+
recipient: input.recipient
|
|
41
|
+
};
|
|
42
|
+
await options.store.create(record);
|
|
43
|
+
try {
|
|
44
|
+
await options.delivery.sendChallenge({
|
|
45
|
+
challengeId,
|
|
46
|
+
channel: input.channel,
|
|
47
|
+
expiresAt,
|
|
48
|
+
otp,
|
|
49
|
+
purpose: input.purpose,
|
|
50
|
+
recipient: input.recipient
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof OtpDeliveryError && error.deliveryOutcome === "DEFINITIVE_FAILURE") {
|
|
54
|
+
await options.store.delete(challengeId);
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
challengeId,
|
|
60
|
+
expiresAt,
|
|
61
|
+
status: "CHALLENGE_CREATED"
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
async verifyChallenge(input) {
|
|
65
|
+
validateVerifyChallengeInput(input);
|
|
66
|
+
const record = await options.store.get(input.challengeId);
|
|
67
|
+
if (record === null) {
|
|
68
|
+
return {
|
|
69
|
+
challengeId: input.challengeId,
|
|
70
|
+
status: "EXPIRED"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (record.expiresAt.getTime() <= clock().getTime()) {
|
|
74
|
+
await options.store.delete(record.challengeId);
|
|
75
|
+
return {
|
|
76
|
+
challengeId: record.challengeId,
|
|
77
|
+
status: "EXPIRED"
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (record.attemptsRemaining <= 0) {
|
|
81
|
+
await options.store.delete(record.challengeId);
|
|
82
|
+
return {
|
|
83
|
+
challengeId: record.challengeId,
|
|
84
|
+
status: "ATTEMPTS_EXCEEDED"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (options.signer.verify(input.otp, record.otpHash)) {
|
|
88
|
+
await options.store.delete(record.challengeId);
|
|
89
|
+
return {
|
|
90
|
+
challengeId: record.challengeId,
|
|
91
|
+
status: "VERIFIED"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const attemptsRemaining = record.attemptsRemaining - 1;
|
|
95
|
+
const nextRecord = {
|
|
96
|
+
...record,
|
|
97
|
+
attemptsRemaining
|
|
98
|
+
};
|
|
99
|
+
if (attemptsRemaining <= 0) {
|
|
100
|
+
await options.store.update(nextRecord);
|
|
101
|
+
return {
|
|
102
|
+
challengeId: record.challengeId,
|
|
103
|
+
status: "ATTEMPTS_EXCEEDED"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
await options.store.update(nextRecord);
|
|
107
|
+
return {
|
|
108
|
+
attemptsRemaining,
|
|
109
|
+
challengeId: record.challengeId,
|
|
110
|
+
status: "INVALID"
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function hmacOtpSigner(input) {
|
|
116
|
+
if (input.secret.trim().length === 0) {
|
|
117
|
+
throw new Error("OTP signer secret must not be empty.");
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
hash(otp) {
|
|
121
|
+
return createHmac("sha256", input.secret).update(otp).digest("hex");
|
|
122
|
+
},
|
|
123
|
+
verify(otp, otpHash) {
|
|
124
|
+
const hashedOtp = createHmac("sha256", input.secret).update(otp).digest("hex");
|
|
125
|
+
const expected = Buffer.from(otpHash, "hex");
|
|
126
|
+
const actual = Buffer.from(hashedOtp, "hex");
|
|
127
|
+
if (expected.length !== actual.length) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return timingSafeEqual(actual, expected);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function defaultOtpGenerator(length) {
|
|
135
|
+
let otp = "";
|
|
136
|
+
for (let index = 0; index < length; index += 1) {
|
|
137
|
+
otp += randomInt(0, 10).toString();
|
|
138
|
+
}
|
|
139
|
+
return otp;
|
|
140
|
+
}
|
|
141
|
+
function validateGenerateChallengeInput(input) {
|
|
142
|
+
if (input.channel !== "sms" && input.channel !== "email") {
|
|
143
|
+
throw new Error("Challenge channel must be either sms or email.");
|
|
144
|
+
}
|
|
145
|
+
requireNonEmptyString(input.recipient, "Challenge recipient must not be empty.");
|
|
146
|
+
requireNonEmptyString(input.purpose, "Challenge purpose must not be empty.");
|
|
147
|
+
}
|
|
148
|
+
function validatePolicy(policy) {
|
|
149
|
+
if (!Number.isInteger(policy.maxVerifyAttempts) || policy.maxVerifyAttempts <= 0) {
|
|
150
|
+
throw new Error("OTP policy maxVerifyAttempts must be a positive integer.");
|
|
151
|
+
}
|
|
152
|
+
if (!Number.isInteger(policy.otpLength) || policy.otpLength < 4) {
|
|
153
|
+
throw new Error("OTP policy otpLength must be an integer greater than or equal to 4.");
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isInteger(policy.ttlSeconds) || policy.ttlSeconds <= 0) {
|
|
156
|
+
throw new Error("OTP policy ttlSeconds must be a positive integer.");
|
|
157
|
+
}
|
|
158
|
+
return policy;
|
|
159
|
+
}
|
|
160
|
+
function validateVerifyChallengeInput(input) {
|
|
161
|
+
requireNonEmptyString(input.challengeId, "Challenge ID must not be empty.");
|
|
162
|
+
requireNonEmptyString(input.otp, "OTP input must not be empty.");
|
|
163
|
+
}
|
|
164
|
+
function requireNonEmptyString(value, message) {
|
|
165
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
166
|
+
throw new Error(message);
|
|
167
|
+
}
|
|
168
|
+
return value.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { OtpDeliveryError, createOtpService, hmacOtpSigner };
|
|
172
|
+
//# sourceMappingURL=index.js.map
|
|
173
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { createHmac, randomInt, randomUUID, timingSafeEqual } from \"node:crypto\";\n\nexport type OtpChannel = \"email\" | \"sms\";\n\nexport interface ChallengeRecord {\n attemptsRemaining: number;\n channel: OtpChannel;\n challengeId: string;\n createdAt: Date;\n expiresAt: Date;\n otpHash: string;\n purpose: string;\n recipient: string;\n}\n\nexport interface ChallengeStore {\n create(record: ChallengeRecord): Promise<void>;\n delete(challengeId: string): Promise<void>;\n get(challengeId: string): Promise<ChallengeRecord | null>;\n update(record: ChallengeRecord): Promise<void>;\n}\n\nexport interface DeliveryRequest {\n challengeId: string;\n channel: OtpChannel;\n expiresAt: Date;\n otp: string;\n purpose: string;\n recipient: string;\n}\n\nexport interface OtpDelivery {\n sendChallenge(request: DeliveryRequest): Promise<void>;\n}\n\nexport type OtpDeliveryOutcome = \"DEFINITIVE_FAILURE\" | \"OUTCOME_UNKNOWN\";\n\nexport class OtpDeliveryError extends Error {\n readonly code: string;\n override readonly cause?: unknown;\n readonly deliveryOutcome: OtpDeliveryOutcome;\n readonly provider: string;\n readonly retryable: boolean;\n\n constructor(input: {\n cause?: unknown;\n code: string;\n deliveryOutcome: OtpDeliveryOutcome;\n message: string;\n provider: string;\n retryable: boolean;\n }) {\n super(input.message);\n this.name = \"OtpDeliveryError\";\n this.code = input.code;\n this.deliveryOutcome = input.deliveryOutcome;\n this.provider = input.provider;\n this.retryable = input.retryable;\n this.cause = input.cause;\n }\n}\n\nexport interface OtpSigner {\n hash(otp: string): string;\n verify(otp: string, otpHash: string): boolean;\n}\n\nexport interface OtpPolicy {\n maxVerifyAttempts: number;\n otpLength: number;\n ttlSeconds: number;\n}\n\nexport interface CreateOtpServiceOptions {\n challengeIdGenerator?: () => string;\n clock?: () => Date;\n delivery: OtpDelivery;\n otpGenerator?: (length: number) => string;\n policy: OtpPolicy;\n signer: OtpSigner;\n store: ChallengeStore;\n}\n\nexport interface GenerateChallengeInput {\n channel: OtpChannel;\n purpose: string;\n recipient: string;\n}\n\nexport interface GenerateChallengeResult {\n challengeId: string;\n expiresAt: Date;\n status: \"CHALLENGE_CREATED\";\n}\n\nexport interface VerifyChallengeInput {\n challengeId: string;\n otp: string;\n}\n\nexport type VerifyChallengeResult =\n | { challengeId: string; status: \"VERIFIED\" }\n | { attemptsRemaining: number; challengeId: string; status: \"INVALID\" }\n | { challengeId: string; status: \"EXPIRED\" }\n | { challengeId: string; status: \"ATTEMPTS_EXCEEDED\" };\n\nexport interface OtpService {\n generateChallenge(input: GenerateChallengeInput): Promise<GenerateChallengeResult>;\n verifyChallenge(input: VerifyChallengeInput): Promise<VerifyChallengeResult>;\n}\n\nexport function createOtpService(options: CreateOtpServiceOptions): OtpService {\n const clock = options.clock ?? (() => new Date());\n const challengeIdGenerator = options.challengeIdGenerator ?? randomUUID;\n const otpGenerator = options.otpGenerator ?? defaultOtpGenerator;\n const policy = validatePolicy(options.policy);\n\n return {\n async generateChallenge(input) {\n validateGenerateChallengeInput(input);\n\n const createdAt = clock();\n const expiresAt = new Date(createdAt.getTime() + policy.ttlSeconds * 1000);\n const challengeId = challengeIdGenerator();\n const otp = otpGenerator(policy.otpLength);\n const record: ChallengeRecord = {\n attemptsRemaining: policy.maxVerifyAttempts,\n challengeId,\n channel: input.channel,\n createdAt,\n expiresAt,\n otpHash: options.signer.hash(otp),\n purpose: input.purpose,\n recipient: input.recipient\n };\n\n await options.store.create(record);\n\n try {\n await options.delivery.sendChallenge({\n challengeId,\n channel: input.channel,\n expiresAt,\n otp,\n purpose: input.purpose,\n recipient: input.recipient\n });\n } catch (error) {\n if (error instanceof OtpDeliveryError && error.deliveryOutcome === \"DEFINITIVE_FAILURE\") {\n await options.store.delete(challengeId);\n }\n\n throw error;\n }\n\n return {\n challengeId,\n expiresAt,\n status: \"CHALLENGE_CREATED\"\n };\n },\n\n async verifyChallenge(input) {\n validateVerifyChallengeInput(input);\n\n const record = await options.store.get(input.challengeId);\n if (record === null) {\n return {\n challengeId: input.challengeId,\n status: \"EXPIRED\"\n };\n }\n\n if (record.expiresAt.getTime() <= clock().getTime()) {\n await options.store.delete(record.challengeId);\n return {\n challengeId: record.challengeId,\n status: \"EXPIRED\"\n };\n }\n\n if (record.attemptsRemaining <= 0) {\n await options.store.delete(record.challengeId);\n return {\n challengeId: record.challengeId,\n status: \"ATTEMPTS_EXCEEDED\"\n };\n }\n\n if (options.signer.verify(input.otp, record.otpHash)) {\n await options.store.delete(record.challengeId);\n return {\n challengeId: record.challengeId,\n status: \"VERIFIED\"\n };\n }\n\n const attemptsRemaining = record.attemptsRemaining - 1;\n const nextRecord: ChallengeRecord = {\n ...record,\n attemptsRemaining\n };\n\n if (attemptsRemaining <= 0) {\n await options.store.update(nextRecord);\n return {\n challengeId: record.challengeId,\n status: \"ATTEMPTS_EXCEEDED\"\n };\n }\n\n await options.store.update(nextRecord);\n return {\n attemptsRemaining,\n challengeId: record.challengeId,\n status: \"INVALID\"\n };\n }\n };\n}\n\nexport function hmacOtpSigner(input: { secret: string }): OtpSigner {\n if (input.secret.trim().length === 0) {\n throw new Error(\"OTP signer secret must not be empty.\");\n }\n\n return {\n hash(otp) {\n return createHmac(\"sha256\", input.secret).update(otp).digest(\"hex\");\n },\n verify(otp, otpHash) {\n const hashedOtp = createHmac(\"sha256\", input.secret).update(otp).digest(\"hex\");\n const expected = Buffer.from(otpHash, \"hex\");\n const actual = Buffer.from(hashedOtp, \"hex\");\n\n if (expected.length !== actual.length) {\n return false;\n }\n\n return timingSafeEqual(actual, expected);\n }\n };\n}\n\nfunction defaultOtpGenerator(length: number): string {\n let otp = \"\";\n\n for (let index = 0; index < length; index += 1) {\n otp += randomInt(0, 10).toString();\n }\n\n return otp;\n}\n\nfunction validateGenerateChallengeInput(input: GenerateChallengeInput): void {\n if (input.channel !== \"sms\" && input.channel !== \"email\") {\n throw new Error(\"Challenge channel must be either sms or email.\");\n }\n\n requireNonEmptyString(input.recipient, \"Challenge recipient must not be empty.\");\n requireNonEmptyString(input.purpose, \"Challenge purpose must not be empty.\");\n}\n\nfunction validatePolicy(policy: OtpPolicy): OtpPolicy {\n if (!Number.isInteger(policy.maxVerifyAttempts) || policy.maxVerifyAttempts <= 0) {\n throw new Error(\"OTP policy maxVerifyAttempts must be a positive integer.\");\n }\n\n if (!Number.isInteger(policy.otpLength) || policy.otpLength < 4) {\n throw new Error(\"OTP policy otpLength must be an integer greater than or equal to 4.\");\n }\n\n if (!Number.isInteger(policy.ttlSeconds) || policy.ttlSeconds <= 0) {\n throw new Error(\"OTP policy ttlSeconds must be a positive integer.\");\n }\n\n return policy;\n}\n\nfunction validateVerifyChallengeInput(input: VerifyChallengeInput): void {\n requireNonEmptyString(input.challengeId, \"Challenge ID must not be empty.\");\n requireNonEmptyString(input.otp, \"OTP input must not be empty.\");\n}\n\nfunction requireNonEmptyString(value: unknown, message: string): string {\n if (typeof value !== \"string\" || value.trim().length === 0) {\n throw new Error(message);\n }\n\n return value.trim();\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@otp-service/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Framework-agnostic OTP domain logic for Node.js services.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Suraj-H/otp-service-package-v2.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Suraj-H/otp-service-package-v2/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Suraj-H/otp-service-package-v2/tree/main/packages/core#readme",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsup --config tsup.config.ts",
|
|
35
|
+
"clean": "rm -rf dist",
|
|
36
|
+
"lint": "eslint src test",
|
|
37
|
+
"test": "vitest run --config vitest.config.ts",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
39
|
+
}
|
|
40
|
+
}
|