@carts1024/velo-sdk 0.1.0-alpha.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.
@@ -0,0 +1,185 @@
1
+ import crypto from "crypto";
2
+ import assert from "node:assert/strict";
3
+ import test from "node:test";
4
+
5
+ import { Velo } from "./client.ts";
6
+ import { VeloWebhookSignatureVerificationError } from "./errors.ts";
7
+ import { verifyWebhookSignature } from "./webhooks.ts";
8
+
9
+ function generateTestSignatureHeader(payload: string, secret: string, timestamp: number): string {
10
+ const signaturePayload = `${timestamp}.${payload}`;
11
+ const hmac = crypto.createHmac("sha256", secret);
12
+ hmac.update(signaturePayload);
13
+ const hash = hmac.digest("hex");
14
+ return `t=${timestamp},v1=${hash}`;
15
+ }
16
+
17
+ test("verifyWebhookSignature parses valid signature and payload", async () => {
18
+ const secret = "whsec_testsecret12345";
19
+ const payload = JSON.stringify({
20
+ id: "evt_123",
21
+ type: "payment.succeeded",
22
+ test: true,
23
+ sentAt: new Date().toISOString(),
24
+ project: {
25
+ id: "proj_123",
26
+ registryProjectId: "reg_123",
27
+ name: "Test Project",
28
+ slug: "test-project",
29
+ },
30
+ paymentIntent: {
31
+ id: "pi_123",
32
+ amount: "10.00",
33
+ asset: "USDC",
34
+ receiverAddress: "GBA...",
35
+ merchantName: "Merchant Name",
36
+ description: "Test description",
37
+ status: "paid",
38
+ createdAt: new Date().toISOString(),
39
+ updatedAt: new Date().toISOString(),
40
+ },
41
+ });
42
+ const timestamp = Math.floor(Date.now() / 1000);
43
+ const signatureHeader = generateTestSignatureHeader(payload, secret, timestamp);
44
+
45
+ // Directly calling verifyWebhookSignature helper
46
+ const event = await verifyWebhookSignature({
47
+ payload,
48
+ signature: signatureHeader,
49
+ secret,
50
+ });
51
+
52
+ assert.equal(event.id, "evt_123");
53
+ assert.equal(event.type, "payment.succeeded");
54
+ assert.equal(event.test, true);
55
+ if (event.type === "payment.succeeded") {
56
+ assert.equal(event.paymentIntent.id, "pi_123");
57
+ assert.equal(event.paymentIntent.amount, "10.00");
58
+ } else {
59
+ assert.fail("Event type should be payment.succeeded");
60
+ }
61
+
62
+ // Testing Velo static verification
63
+ const staticEvent = await Velo.webhooks.verify({
64
+ payload,
65
+ signature: signatureHeader,
66
+ secret,
67
+ });
68
+ assert.equal(staticEvent.id, "evt_123");
69
+
70
+ // Testing Velo instance verification
71
+ const velo = new Velo({ apiKey: "test-key" });
72
+ const instanceEvent = await velo.webhooks.verify({
73
+ payload,
74
+ signature: signatureHeader,
75
+ secret,
76
+ });
77
+ assert.equal(instanceEvent.id, "evt_123");
78
+ });
79
+
80
+ test("verifyWebhookSignature rejects missing signature header", async () => {
81
+ const secret = "whsec_testsecret12345";
82
+ const payload = JSON.stringify({ type: "payment.succeeded" });
83
+
84
+ await assert.rejects(
85
+ () => verifyWebhookSignature({ payload, signature: null, secret }),
86
+ (err: unknown) => {
87
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
88
+ assert.equal(
89
+ (err as VeloWebhookSignatureVerificationError).message,
90
+ "Missing signature header",
91
+ );
92
+ return true;
93
+ },
94
+ );
95
+ });
96
+
97
+ test("verifyWebhookSignature rejects malformed headers", async () => {
98
+ const secret = "whsec_testsecret12345";
99
+ const payload = JSON.stringify({ type: "payment.succeeded" });
100
+
101
+ await assert.rejects(
102
+ () => verifyWebhookSignature({ payload, signature: "invalid-header", secret }),
103
+ (err: unknown) => {
104
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
105
+ assert.equal(
106
+ (err as VeloWebhookSignatureVerificationError).message,
107
+ "Invalid signature header format",
108
+ );
109
+ return true;
110
+ },
111
+ );
112
+
113
+ await assert.rejects(
114
+ () => verifyWebhookSignature({ payload, signature: "t=123", secret }),
115
+ (err: unknown) => {
116
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
117
+ assert.equal(
118
+ (err as VeloWebhookSignatureVerificationError).message,
119
+ "Invalid signature header format",
120
+ );
121
+ return true;
122
+ },
123
+ );
124
+ });
125
+
126
+ test("verifyWebhookSignature rejects expired timestamps", async () => {
127
+ const secret = "whsec_testsecret12345";
128
+ const payload = JSON.stringify({ type: "payment.succeeded" });
129
+ const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago (tolerance default is 5 mins)
130
+ const signatureHeader = generateTestSignatureHeader(payload, secret, timestamp);
131
+
132
+ await assert.rejects(
133
+ () => verifyWebhookSignature({ payload, signature: signatureHeader, secret }),
134
+ (err: unknown) => {
135
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
136
+ assert.match((err as VeloWebhookSignatureVerificationError).message, /timestamp expired/);
137
+ return true;
138
+ },
139
+ );
140
+ });
141
+
142
+ test("verifyWebhookSignature rejects future timestamps", async () => {
143
+ const secret = "whsec_testsecret12345";
144
+ const payload = JSON.stringify({ type: "payment.succeeded" });
145
+ const timestamp = Math.floor(Date.now() / 1000) + 600; // 10 minutes in the future
146
+ const signatureHeader = generateTestSignatureHeader(payload, secret, timestamp);
147
+
148
+ await assert.rejects(
149
+ () => verifyWebhookSignature({ payload, signature: signatureHeader, secret }),
150
+ (err: unknown) => {
151
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
152
+ assert.match((err as VeloWebhookSignatureVerificationError).message, /timestamp expired/);
153
+ return true;
154
+ },
155
+ );
156
+ });
157
+
158
+ test("verifyWebhookSignature rejects signature mismatch", async () => {
159
+ const secret = "whsec_testsecret12345";
160
+ const payload = JSON.stringify({ type: "payment.succeeded" });
161
+ const timestamp = Math.floor(Date.now() / 1000);
162
+ const signatureHeader = generateTestSignatureHeader(payload, secret, timestamp);
163
+
164
+ // Mismatched secret
165
+ await assert.rejects(
166
+ () =>
167
+ verifyWebhookSignature({ payload, signature: signatureHeader, secret: "whsec_wrongsecret" }),
168
+ (err: unknown) => {
169
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
170
+ assert.equal((err as VeloWebhookSignatureVerificationError).message, "Signature mismatch");
171
+ return true;
172
+ },
173
+ );
174
+
175
+ // Mismatched payload (tampering)
176
+ await assert.rejects(
177
+ () =>
178
+ verifyWebhookSignature({ payload: payload + "altered", signature: signatureHeader, secret }),
179
+ (err: unknown) => {
180
+ assert.equal(err instanceof VeloWebhookSignatureVerificationError, true);
181
+ assert.equal((err as VeloWebhookSignatureVerificationError).message, "Signature mismatch");
182
+ return true;
183
+ },
184
+ );
185
+ });
@@ -0,0 +1,97 @@
1
+ import type { WebhookEvent, VerifyWebhookParams } from "./types.ts";
2
+
3
+ import { VeloWebhookSignatureVerificationError } from "./errors.ts";
4
+
5
+ /**
6
+ * Verifies a Velo webhook signature using the Web Crypto API.
7
+ * This is fully compatible with both browser/Edge and Node.js environments.
8
+ * If signature verification fails, it throws a VeloWebhookSignatureVerificationError.
9
+ *
10
+ * @param params Object containing payload, signature, secret, and optional toleranceSeconds.
11
+ * @returns A promise resolving to the parsed and typed WebhookEvent.
12
+ * @throws {VeloWebhookSignatureVerificationError} If the verification fails.
13
+ */
14
+ export async function verifyWebhookSignature(params: VerifyWebhookParams): Promise<WebhookEvent> {
15
+ const { payload, signature: signatureHeader, secret, toleranceSeconds = 300 } = params;
16
+
17
+ if (!signatureHeader) {
18
+ throw new VeloWebhookSignatureVerificationError("Missing signature header");
19
+ }
20
+
21
+ if (!secret) {
22
+ throw new VeloWebhookSignatureVerificationError("Missing webhook signing secret");
23
+ }
24
+
25
+ const parts = signatureHeader.split(",");
26
+ let timestampStr: string | undefined;
27
+ let signature: string | undefined;
28
+
29
+ for (const part of parts) {
30
+ const equalIndex = part.indexOf("=");
31
+ if (equalIndex === -1) continue;
32
+ const key = part.slice(0, equalIndex).trim();
33
+ const val = part.slice(equalIndex + 1).trim();
34
+
35
+ if (key === "t") timestampStr = val;
36
+ if (key === "v1") signature = val;
37
+ }
38
+
39
+ if (!timestampStr || !signature) {
40
+ throw new VeloWebhookSignatureVerificationError("Invalid signature header format");
41
+ }
42
+
43
+ const timestamp = parseInt(timestampStr, 10);
44
+ if (isNaN(timestamp)) {
45
+ throw new VeloWebhookSignatureVerificationError("Invalid timestamp in header");
46
+ }
47
+
48
+ // Check clock drift to prevent replay attacks
49
+ const now = Math.floor(Date.now() / 1000);
50
+ if (Math.abs(now - timestamp) > toleranceSeconds) {
51
+ throw new VeloWebhookSignatureVerificationError(
52
+ "Signature timestamp expired or from the future",
53
+ );
54
+ }
55
+
56
+ const signaturePayload = `${timestamp}.${payload}`;
57
+
58
+ try {
59
+ const encoder = new TextEncoder();
60
+ const secretKeyData = encoder.encode(secret);
61
+ const signaturePayloadData = encoder.encode(signaturePayload);
62
+
63
+ // Import secret key for HMAC SHA-256
64
+ const cryptoKey = await globalThis.crypto.subtle.importKey(
65
+ "raw",
66
+ secretKeyData,
67
+ { name: "HMAC", hash: "SHA-256" },
68
+ false,
69
+ ["sign"],
70
+ );
71
+
72
+ // Compute expected signature
73
+ const signatureBuffer = await globalThis.crypto.subtle.sign(
74
+ "HMAC",
75
+ cryptoKey,
76
+ signaturePayloadData,
77
+ );
78
+
79
+ // Convert to hexadecimal string
80
+ const hashArray = Array.from(new Uint8Array(signatureBuffer));
81
+ const expectedSignature = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
82
+
83
+ if (signature !== expectedSignature) {
84
+ throw new VeloWebhookSignatureVerificationError("Signature mismatch");
85
+ }
86
+
87
+ const event = JSON.parse(payload) as WebhookEvent;
88
+
89
+ return event;
90
+ } catch (err: unknown) {
91
+ if (err instanceof VeloWebhookSignatureVerificationError) {
92
+ throw err;
93
+ }
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ throw new VeloWebhookSignatureVerificationError(`Verification failed: ${message}`);
96
+ }
97
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@repo/typescript-config/base.json",
3
+ "compilerOptions": {
4
+ "allowImportingTsExtensions": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "outDir": "dist"
8
+ },
9
+ "include": ["src"],
10
+ "exclude": ["node_modules", "dist"]
11
+ }