@otp-service/redis-store 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 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.
@@ -0,0 +1,20 @@
1
+ import { ChallengeStore } from '@otp-service/core';
2
+
3
+ interface RedisStoreClient {
4
+ del(key: string): Promise<number>;
5
+ get(key: string): Promise<string | null>;
6
+ set(key: string, value: string, options: {
7
+ expiration: {
8
+ type: "EX";
9
+ value: number;
10
+ };
11
+ }): Promise<unknown>;
12
+ }
13
+ interface CreateRedisChallengeStoreOptions {
14
+ client: RedisStoreClient;
15
+ clock?: () => Date;
16
+ keyPrefix?: string;
17
+ }
18
+ declare function createRedisChallengeStore(options: CreateRedisChallengeStoreOptions): ChallengeStore;
19
+
20
+ export { type CreateRedisChallengeStoreOptions, type RedisStoreClient, createRedisChallengeStore };
package/dist/index.js ADDED
@@ -0,0 +1,105 @@
1
+ // src/index.ts
2
+ function createRedisChallengeStore(options) {
3
+ const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
4
+ const keyPrefix = options.keyPrefix ?? "otp:challenge";
5
+ return {
6
+ async create(record) {
7
+ await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {
8
+ expiration: {
9
+ type: "EX",
10
+ value: ttlSecondsFrom(record.expiresAt, clock)
11
+ }
12
+ });
13
+ },
14
+ async delete(challengeId) {
15
+ await options.client.del(toRedisKey(keyPrefix, challengeId));
16
+ },
17
+ async get(challengeId) {
18
+ const value = await options.client.get(toRedisKey(keyPrefix, challengeId));
19
+ if (value === null) {
20
+ return null;
21
+ }
22
+ return deserializeRecord(value);
23
+ },
24
+ async update(record) {
25
+ const ttlSeconds = ttlSecondsFrom(record.expiresAt, clock);
26
+ if (ttlSeconds <= 0) {
27
+ await options.client.del(toRedisKey(keyPrefix, record.challengeId));
28
+ return;
29
+ }
30
+ await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {
31
+ expiration: {
32
+ type: "EX",
33
+ value: ttlSeconds
34
+ }
35
+ });
36
+ }
37
+ };
38
+ }
39
+ function deserializeRecord(value) {
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(value);
43
+ } catch {
44
+ throw new Error("Redis challenge record contains invalid JSON.");
45
+ }
46
+ assertSerializedChallengeRecord(parsed);
47
+ return {
48
+ attemptsRemaining: parsed.attemptsRemaining,
49
+ channel: parsed.channel,
50
+ challengeId: parsed.challengeId,
51
+ createdAt: new Date(parsed.createdAt),
52
+ expiresAt: new Date(parsed.expiresAt),
53
+ otpHash: parsed.otpHash,
54
+ purpose: parsed.purpose,
55
+ recipient: parsed.recipient
56
+ };
57
+ }
58
+ function assertSerializedChallengeRecord(value) {
59
+ if (typeof value !== "object" || value === null) {
60
+ throw new Error("Redis challenge record must be an object.");
61
+ }
62
+ const record = value;
63
+ if (!Number.isInteger(record.attemptsRemaining)) {
64
+ throw new Error("Redis challenge record attemptsRemaining must be an integer.");
65
+ }
66
+ if (record.channel !== "sms" && record.channel !== "email") {
67
+ throw new Error("Redis challenge record channel is invalid.");
68
+ }
69
+ const stringFields = ["challengeId", "createdAt", "expiresAt", "otpHash", "purpose", "recipient"];
70
+ for (const field of stringFields) {
71
+ const fieldValue = record[field];
72
+ if (typeof fieldValue !== "string" || fieldValue.trim().length === 0) {
73
+ throw new Error(`Redis challenge record ${field} must be a non-empty string.`);
74
+ }
75
+ }
76
+ if (Number.isNaN(new Date(record.createdAt).getTime())) {
77
+ throw new Error("Redis challenge record createdAt must be a valid ISO date string.");
78
+ }
79
+ if (Number.isNaN(new Date(record.expiresAt).getTime())) {
80
+ throw new Error("Redis challenge record expiresAt must be a valid ISO date string.");
81
+ }
82
+ }
83
+ function serializeRecord(record) {
84
+ return JSON.stringify({
85
+ attemptsRemaining: record.attemptsRemaining,
86
+ channel: record.channel,
87
+ challengeId: record.challengeId,
88
+ createdAt: record.createdAt.toISOString(),
89
+ expiresAt: record.expiresAt.toISOString(),
90
+ otpHash: record.otpHash,
91
+ purpose: record.purpose,
92
+ recipient: record.recipient
93
+ });
94
+ }
95
+ function toRedisKey(keyPrefix, challengeId) {
96
+ return `${keyPrefix}:${challengeId}`;
97
+ }
98
+ function ttlSecondsFrom(expiresAt, clock) {
99
+ const millisecondsRemaining = expiresAt.getTime() - clock().getTime();
100
+ return Math.max(0, Math.ceil(millisecondsRemaining / 1e3));
101
+ }
102
+
103
+ export { createRedisChallengeStore };
104
+ //# sourceMappingURL=index.js.map
105
+ //# 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 type { ChallengeRecord, ChallengeStore, OtpChannel } from \"@otp-service/core\";\n\nexport interface RedisStoreClient {\n del(key: string): Promise<number>;\n get(key: string): Promise<string | null>;\n set(key: string, value: string, options: { expiration: { type: \"EX\"; value: number } }): Promise<unknown>;\n}\n\nexport interface CreateRedisChallengeStoreOptions {\n client: RedisStoreClient;\n clock?: () => Date;\n keyPrefix?: string;\n}\n\ninterface SerializedChallengeRecord {\n attemptsRemaining: number;\n channel: OtpChannel;\n challengeId: string;\n createdAt: string;\n expiresAt: string;\n otpHash: string;\n purpose: string;\n recipient: string;\n}\n\nexport function createRedisChallengeStore(\n options: CreateRedisChallengeStoreOptions\n): ChallengeStore {\n const clock = options.clock ?? (() => new Date());\n const keyPrefix = options.keyPrefix ?? \"otp:challenge\";\n\n return {\n async create(record) {\n await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {\n expiration: {\n type: \"EX\",\n value: ttlSecondsFrom(record.expiresAt, clock)\n }\n });\n },\n\n async delete(challengeId) {\n await options.client.del(toRedisKey(keyPrefix, challengeId));\n },\n\n async get(challengeId) {\n const value = await options.client.get(toRedisKey(keyPrefix, challengeId));\n if (value === null) {\n return null;\n }\n\n return deserializeRecord(value);\n },\n\n async update(record) {\n const ttlSeconds = ttlSecondsFrom(record.expiresAt, clock);\n if (ttlSeconds <= 0) {\n await options.client.del(toRedisKey(keyPrefix, record.challengeId));\n return;\n }\n\n await options.client.set(toRedisKey(keyPrefix, record.challengeId), serializeRecord(record), {\n expiration: {\n type: \"EX\",\n value: ttlSeconds\n }\n });\n }\n };\n}\n\nfunction deserializeRecord(value: string): ChallengeRecord {\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(value);\n } catch {\n throw new Error(\"Redis challenge record contains invalid JSON.\");\n }\n\n assertSerializedChallengeRecord(parsed);\n\n return {\n attemptsRemaining: parsed.attemptsRemaining,\n channel: parsed.channel,\n challengeId: parsed.challengeId,\n createdAt: new Date(parsed.createdAt),\n expiresAt: new Date(parsed.expiresAt),\n otpHash: parsed.otpHash,\n purpose: parsed.purpose,\n recipient: parsed.recipient\n };\n}\n\nfunction assertSerializedChallengeRecord(value: unknown): asserts value is SerializedChallengeRecord {\n if (typeof value !== \"object\" || value === null) {\n throw new Error(\"Redis challenge record must be an object.\");\n }\n\n const record = value as Record<string, unknown>;\n\n if (!Number.isInteger(record.attemptsRemaining)) {\n throw new Error(\"Redis challenge record attemptsRemaining must be an integer.\");\n }\n\n if (record.channel !== \"sms\" && record.channel !== \"email\") {\n throw new Error(\"Redis challenge record channel is invalid.\");\n }\n\n const stringFields = [\"challengeId\", \"createdAt\", \"expiresAt\", \"otpHash\", \"purpose\", \"recipient\"] as const;\n\n for (const field of stringFields) {\n const fieldValue = record[field];\n if (typeof fieldValue !== \"string\" || fieldValue.trim().length === 0) {\n throw new Error(`Redis challenge record ${field} must be a non-empty string.`);\n }\n }\n\n if (Number.isNaN(new Date(record.createdAt as string).getTime())) {\n throw new Error(\"Redis challenge record createdAt must be a valid ISO date string.\");\n }\n\n if (Number.isNaN(new Date(record.expiresAt as string).getTime())) {\n throw new Error(\"Redis challenge record expiresAt must be a valid ISO date string.\");\n }\n}\n\nfunction serializeRecord(record: ChallengeRecord): string {\n return JSON.stringify({\n attemptsRemaining: record.attemptsRemaining,\n channel: record.channel,\n challengeId: record.challengeId,\n createdAt: record.createdAt.toISOString(),\n expiresAt: record.expiresAt.toISOString(),\n otpHash: record.otpHash,\n purpose: record.purpose,\n recipient: record.recipient\n } satisfies SerializedChallengeRecord);\n}\n\nfunction toRedisKey(keyPrefix: string, challengeId: string): string {\n return `${keyPrefix}:${challengeId}`;\n}\n\nfunction ttlSecondsFrom(expiresAt: Date, clock: () => Date): number {\n const millisecondsRemaining = expiresAt.getTime() - clock().getTime();\n return Math.max(0, Math.ceil(millisecondsRemaining / 1000));\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@otp-service/redis-store",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Redis-backed challenge state storage for OTP 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/redis-store#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
+ "dependencies": {
34
+ "@otp-service/core": "0.1.0"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup --config tsup.config.ts",
38
+ "clean": "rm -rf dist",
39
+ "lint": "eslint src test",
40
+ "test": "vitest run --config vitest.config.ts",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ }
43
+ }