@agrentingai/paperclip-adapter 0.2.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.
@@ -0,0 +1,550 @@
1
+ import type {
2
+ AgrentingAdapterConfig,
3
+ AgrentingTask,
4
+ AgentInfo,
5
+ AgentProfile,
6
+ BalanceInfo,
7
+ HireAgentResult,
8
+ PaymentInfo,
9
+ ReassignTaskResult,
10
+ SendMessageOptions,
11
+ SendMessageResult,
12
+ TransactionInfo,
13
+ DiscoverAgentsOptions,
14
+ CreateTaskPaymentOptions,
15
+ Hiring,
16
+ TaskMessage,
17
+ HiringMessage,
18
+ Capability,
19
+ RetryHiringOptions,
20
+ } from "./types.js";
21
+
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+ const MAX_RETRIES = 3;
24
+
25
+ /**
26
+ * HTTP client for the Agrenting REST API.
27
+ * Wraps fetch with auth headers and base URL handling.
28
+ */
29
+ export class AgrentingClient {
30
+ private baseUrl: string;
31
+ private apiKey: string;
32
+
33
+ constructor(config: AgrentingAdapterConfig) {
34
+ this.baseUrl = config.agrentingUrl.replace(/\/+$/, "");
35
+ this.apiKey = config.apiKey;
36
+ }
37
+
38
+ private headers(): Record<string, string> {
39
+ return {
40
+ "Content-Type": "application/json",
41
+ "X-API-Key": this.apiKey,
42
+ };
43
+ }
44
+
45
+ private async request<T>(
46
+ method: string,
47
+ path: string,
48
+ body?: unknown
49
+ ): Promise<T> {
50
+ let lastError: Error | null = null;
51
+
52
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
53
+ const controller = new AbortController();
54
+ const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
55
+
56
+ try {
57
+ const response = await fetch(`${this.baseUrl}${path}`, {
58
+ method,
59
+ headers: this.headers(),
60
+ body: body ? JSON.stringify(body) : undefined,
61
+ signal: controller.signal,
62
+ });
63
+
64
+ if (!response.ok) {
65
+ const text = await response.text();
66
+ const shouldRetry = response.status === 429 || response.status >= 500;
67
+
68
+ if (shouldRetry && attempt < MAX_RETRIES) {
69
+ clearTimeout(timer);
70
+ // Respect Retry-After header on 429, otherwise use exponential backoff
71
+ const retryAfter = response.headers.get("Retry-After");
72
+ let delayMs = Math.min(1000 * 2 ** attempt, 30_000);
73
+ if (retryAfter) {
74
+ // Retry-After can be seconds (integer) or a date string (HTTP-date)
75
+ const seconds = parseInt(retryAfter, 10);
76
+ if (!Number.isNaN(seconds) && seconds > 0) {
77
+ delayMs = seconds * 1000;
78
+ } else {
79
+ // Try parsing as HTTP date
80
+ const dateMs = Date.parse(retryAfter);
81
+ if (!Number.isNaN(dateMs)) {
82
+ delayMs = Math.max(0, dateMs - Date.now());
83
+ }
84
+ }
85
+ }
86
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
87
+ continue;
88
+ }
89
+
90
+ throw new Error(
91
+ `Agrenting API ${response.status}: ${text.slice(0, 500)}`
92
+ );
93
+ }
94
+
95
+ const envelope = (await response.json()) as Record<string, unknown>;
96
+ if (Array.isArray(envelope.errors) && envelope.errors.length) {
97
+ throw new Error(`API errors: ${(envelope.errors as string[]).join(", ")}`);
98
+ }
99
+ return (envelope.data ?? envelope) as T;
100
+ } catch (err) {
101
+ lastError = err instanceof Error ? err : new Error(String(err));
102
+ if (attempt < MAX_RETRIES) {
103
+ clearTimeout(timer);
104
+ const delayMs = Math.min(1000 * 2 ** attempt, 30_000);
105
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
106
+ continue;
107
+ }
108
+ throw lastError;
109
+ } finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ // Should never reach here, but satisfy the compiler
115
+ throw lastError ?? new Error("Unexpected retry loop exit");
116
+ }
117
+
118
+ /** Submit a new task to Agrenting.
119
+ * When `maxPrice` is set, the task includes pricing info.
120
+ * Call `createTaskPayment` after this to actually lock escrow funds.
121
+ */
122
+ async createTask(params: {
123
+ providerAgentId: string;
124
+ capability: string;
125
+ input: string;
126
+ /** Max price in USD. If set, the task will have a price for escrow. */
127
+ maxPrice?: string;
128
+ /** Payment type: "crypto" | "escrow" | "nowpayments" */
129
+ paymentType?: string;
130
+ }): Promise<AgrentingTask> {
131
+ const body: Record<string, unknown> = {
132
+ provider_agent_id: params.providerAgentId,
133
+ capability: params.capability,
134
+ input: params.input,
135
+ };
136
+ if (params.maxPrice) {
137
+ body.max_price = params.maxPrice;
138
+ }
139
+ return this.request("POST", "/api/v1/tasks", body);
140
+ }
141
+
142
+ /** Create a payment for an existing task to lock escrow funds.
143
+ * This is the step that actually deducts funds from the client's balance
144
+ * and places them in escrow for the task.
145
+ */
146
+ async createTaskPayment(
147
+ taskId: string,
148
+ options: CreateTaskPaymentOptions = {}
149
+ ): Promise<PaymentInfo> {
150
+ const body: Record<string, unknown> = {
151
+ task_id: taskId,
152
+ };
153
+ if (options.cryptoCurrency) body.crypto_currency = options.cryptoCurrency;
154
+ if (options.paymentType) body.payment_type = options.paymentType;
155
+ return this.request("POST", `/api/v1/tasks/${taskId}/payments`, body);
156
+ }
157
+
158
+ /** Get payment info for a task */
159
+ async getTaskPayment(taskId: string): Promise<PaymentInfo> {
160
+ return this.request("GET", `/api/v1/tasks/${taskId}/payments`);
161
+ }
162
+
163
+ /** Get the status and result of a task */
164
+ async getTask(taskId: string): Promise<AgrentingTask> {
165
+ return this.request<AgrentingTask>("GET", `/api/v1/tasks/${taskId}`);
166
+ }
167
+
168
+ /** Get task timeline events (progress, attempts, status changes) */
169
+ async getTaskTimeline(taskId: string): Promise<{
170
+ events: Array<{
171
+ event_type: string;
172
+ timestamp: string;
173
+ progress_percent?: number;
174
+ progress_message?: string;
175
+ details?: Record<string, unknown>;
176
+ }>;
177
+ }> {
178
+ return this.request("GET", `/api/v1/tasks/${taskId}/timeline`);
179
+ }
180
+
181
+ /** Get attempt history for a task */
182
+ async getTaskAttempts(taskId: string): Promise<{
183
+ attempts: Array<{
184
+ id: string;
185
+ status: string;
186
+ created_at: string;
187
+ completed_at?: string;
188
+ error_reason?: string;
189
+ }>;
190
+ }> {
191
+ return this.request("GET", `/api/v1/tasks/${taskId}/attempts`);
192
+ }
193
+
194
+ /** Get current progress of a task */
195
+ async getTaskProgress(taskId: string): Promise<{
196
+ status: string;
197
+ progress_percent: number;
198
+ progress_message?: string;
199
+ updated_at: string;
200
+ }> {
201
+ const task = await this.getTask(taskId);
202
+ return {
203
+ status: task.status,
204
+ progress_percent: task.progress_percent ?? 0,
205
+ progress_message: task.progress_message,
206
+ updated_at: task.updated_at,
207
+ };
208
+ }
209
+
210
+ /** Register a webhook to receive task lifecycle events.
211
+ * Sends a flat request body matching the backend's expected shape.
212
+ */
213
+ async registerWebhook(params: {
214
+ callbackUrl: string;
215
+ eventTypes?: string[];
216
+ }): Promise<{
217
+ id: string;
218
+ callback_url: string;
219
+ event_types: string[];
220
+ secret_key: string;
221
+ status: string;
222
+ }> {
223
+ return this.request("POST", "/api/v1/webhooks", {
224
+ callback_url: params.callbackUrl,
225
+ event_types: params.eventTypes ?? [
226
+ "task.created",
227
+ "task.claimed",
228
+ "task.in_progress",
229
+ "task.completed",
230
+ "task.failed",
231
+ "task.cancelled",
232
+ ],
233
+ });
234
+ }
235
+
236
+ /** List registered webhooks */
237
+ async listWebhooks(): Promise<
238
+ Array<{
239
+ id: string;
240
+ callback_url: string;
241
+ event_types: string[];
242
+ status: string;
243
+ last_delivery_at?: string;
244
+ failure_count: number;
245
+ }>
246
+ > {
247
+ return this.request("GET", "/api/v1/webhooks");
248
+ }
249
+
250
+ /** Delete a registered webhook */
251
+ async deleteWebhook(webhookId: string): Promise<void> {
252
+ return this.request("DELETE", `/api/v1/webhooks/${webhookId}`);
253
+ }
254
+
255
+ /** Cancel a task by ID */
256
+ async cancelTask(taskId: string): Promise<AgrentingTask> {
257
+ return this.request("POST", `/api/v1/tasks/${taskId}/cancel`);
258
+ }
259
+
260
+ /** Discover marketplace agents available for hire.
261
+ * Filters by capability, price range, reputation, and availability.
262
+ */
263
+ async discoverAgents(
264
+ options: DiscoverAgentsOptions = {}
265
+ ): Promise<AgentInfo[]> {
266
+ const params = new URLSearchParams();
267
+ if (options.capability) params.set("capability", options.capability);
268
+ if (options.minPrice) params.set("min_price", options.minPrice.toFixed(2));
269
+ if (options.maxPrice) params.set("max_price", options.maxPrice.toFixed(2));
270
+ if (options.minReputation)
271
+ params.set("min_reputation", String(options.minReputation));
272
+ if (options.sortBy) params.set("sort_by", options.sortBy);
273
+ if (options.limit) params.set("limit", String(options.limit));
274
+
275
+ return this.request<AgentInfo[]>(
276
+ "GET",
277
+ `/api/v1/agents/discover?${params}`
278
+ );
279
+ }
280
+
281
+ /** Fetch the current platform balance including available, escrowed, and total. */
282
+ async getBalance(): Promise<BalanceInfo> {
283
+ return this.request("GET", "/api/v1/ledger/balance");
284
+ }
285
+
286
+ /** List recent transactions for the authenticated agent. */
287
+ async getTransactions(
288
+ options: { limit?: number; offset?: number; type?: string } = {}
289
+ ): Promise<TransactionInfo[]> {
290
+ const params = new URLSearchParams();
291
+ if (options.limit) params.set("limit", String(options.limit));
292
+ if (options.offset) params.set("offset", String(options.offset));
293
+ if (options.type) params.set("type", options.type);
294
+
295
+ return this.request<TransactionInfo[]>(
296
+ "GET",
297
+ `/api/v1/ledger/transactions?${params}`
298
+ );
299
+ }
300
+
301
+ /** Deposit funds into the Agrenting ledger. */
302
+ async deposit(params: {
303
+ amount: string;
304
+ currency?: string;
305
+ paymentMethod?: string;
306
+ }): Promise<{
307
+ transaction_id: string;
308
+ status: string;
309
+ deposit_address?: string;
310
+ payment_url?: string;
311
+ }> {
312
+ return this.request("POST", "/api/v1/ledger/deposit", {
313
+ amount: params.amount,
314
+ currency: params.currency ?? "USD",
315
+ payment_method: params.paymentMethod ?? "crypto",
316
+ });
317
+ }
318
+
319
+ /** Withdraw funds from the Agrenting ledger to an external wallet. */
320
+ async withdraw(params: {
321
+ amount: string;
322
+ currency?: string;
323
+ withdrawalAddressId?: string;
324
+ }): Promise<{
325
+ transaction_id: string;
326
+ status: string;
327
+ }> {
328
+ return this.request("POST", "/api/v1/ledger/withdraw", {
329
+ amount: params.amount,
330
+ currency: params.currency ?? "USD",
331
+ withdrawal_address_id: params.withdrawalAddressId,
332
+ });
333
+ }
334
+
335
+ /** Create a payment intent for off-platform payment processing. */
336
+ async createPaymentIntent(params: {
337
+ amount: string;
338
+ currency?: string;
339
+ paymentType?: string;
340
+ }): Promise<{
341
+ id: string;
342
+ status: string;
343
+ payment_url?: string;
344
+ address?: string;
345
+ }> {
346
+ return this.request("POST", "/api/v1/payments/create-intent", {
347
+ amount: params.amount,
348
+ currency: params.currency ?? "USD",
349
+ payment_type: params.paymentType ?? "crypto",
350
+ });
351
+ }
352
+
353
+ /** Validate connectivity and API key by fetching the account balance */
354
+ async testConnection(): Promise<{ ok: boolean; message: string }> {
355
+ try {
356
+ const data = await this.request<{
357
+ available?: string;
358
+ escrow?: string;
359
+ total?: string;
360
+ currency?: string;
361
+ }>("GET", "/api/v1/ledger/balance");
362
+ return {
363
+ ok: true,
364
+ message: `Connected. Balance: ${data.total ?? data.available ?? "N/A"} ${data.currency ?? "USD"} (Available: ${data.available ?? "N/A"})`,
365
+ };
366
+ } catch (err) {
367
+ return {
368
+ ok: false,
369
+ message: err instanceof Error ? err.message : "Unknown connection error",
370
+ };
371
+ }
372
+ }
373
+
374
+ /** Upload a document (e.g. instructions) to Agrenting.
375
+ * Uses the dedicated uploads endpoint which accepts base64-encoded content,
376
+ * separate from the deal-scoped `/api/v1/documents` endpoint.
377
+ */
378
+ async uploadDocument(params: {
379
+ name: string;
380
+ content: string;
381
+ contentType?: string;
382
+ documentType?: string;
383
+ taskId?: string;
384
+ }): Promise<{
385
+ id: string;
386
+ name: string;
387
+ file_url: string;
388
+ content_type: string;
389
+ file_hash: string;
390
+ document_type: string;
391
+ }> {
392
+ const contentBase64 = Buffer.from(params.content).toString("base64");
393
+ return this.request("POST", "/api/v1/uploads", {
394
+ name: params.name,
395
+ content: contentBase64,
396
+ content_type: params.contentType ?? "text/plain",
397
+ document_type: params.documentType ?? "instructions",
398
+ task_id: params.taskId,
399
+ });
400
+ }
401
+
402
+ /** Get the full profile of an agent by DID.
403
+ * Returns capabilities, pricing tiers, reviews, and availability.
404
+ */
405
+ async getAgentProfile(agentDid: string): Promise<AgentProfile> {
406
+ return this.request("GET", `/api/v1/agents/${encodeURIComponent(agentDid)}`);
407
+ }
408
+
409
+ /** Hire/bind an agent to your account.
410
+ * Returns adapter config so Paperclip can auto-provision the agent.
411
+ */
412
+ async hireAgent(
413
+ agentDid: string,
414
+ options: { pricingModel?: string } = {}
415
+ ): Promise<HireAgentResult> {
416
+ const body: Record<string, unknown> = {};
417
+ if (options.pricingModel) body.pricing_model = options.pricingModel;
418
+ return this.request(
419
+ "POST",
420
+ `/api/v1/agents/${encodeURIComponent(agentDid)}/hire`,
421
+ body
422
+ );
423
+ }
424
+
425
+ /** Send a message to a running task (mid-task instructions, feedback, or questions).
426
+ * Enables bidirectional communication between the Paperclip user and the remote agent.
427
+ */
428
+ async sendMessageToTask(
429
+ taskId: string,
430
+ options: SendMessageOptions
431
+ ): Promise<SendMessageResult> {
432
+ return this.request("POST", `/api/v1/tasks/${taskId}/messages`, {
433
+ message: options.message,
434
+ message_type: options.messageType ?? "instruction",
435
+ });
436
+ }
437
+
438
+ /** Get messages for a task.
439
+ * GET /api/v1/tasks/:id/messages
440
+ */
441
+ async getTaskMessages(taskId: string): Promise<TaskMessage[]> {
442
+ return this.request<TaskMessage[]>(
443
+ "GET",
444
+ `/api/v1/tasks/${taskId}/messages`
445
+ );
446
+ }
447
+
448
+ /** Reassign a failed or cancelled task to a different agent.
449
+ * If `newAgentDid` is omitted, the platform picks the best available agent.
450
+ */
451
+ async reassignTask(
452
+ taskId: string,
453
+ newAgentDid?: string
454
+ ): Promise<ReassignTaskResult> {
455
+ const body: Record<string, unknown> = {};
456
+ if (newAgentDid) {
457
+ body.new_provider_agent_did = newAgentDid;
458
+ }
459
+ return this.request<ReassignTaskResult>(
460
+ "POST",
461
+ `/api/v1/tasks/${taskId}/reassign`,
462
+ body
463
+ );
464
+ }
465
+
466
+ /** List all available capabilities with descriptions and usage stats.
467
+ * GET /api/v1/capabilities
468
+ */
469
+ async listCapabilities(): Promise<Capability[]> {
470
+ return this.request<Capability[]>("GET", "/api/v1/capabilities");
471
+ }
472
+
473
+ /** Send a message to a hiring for communication with the hired agent.
474
+ * POST /api/v1/hirings/:id/messages
475
+ */
476
+ async sendMessageToHiring(
477
+ hiringId: string,
478
+ content: string
479
+ ): Promise<HiringMessage> {
480
+ if (content.length > 5000) {
481
+ throw new Error("Message content exceeds 5000 character limit");
482
+ }
483
+ return this.request<HiringMessage>(
484
+ "POST",
485
+ `/api/v1/hirings/${hiringId}/messages`,
486
+ { content }
487
+ );
488
+ }
489
+
490
+ /** Get messages for a hiring.
491
+ * GET /api/v1/hirings/:id/messages
492
+ */
493
+ async getHiringMessages(hiringId: string): Promise<HiringMessage[]> {
494
+ return this.request<HiringMessage[]>(
495
+ "GET",
496
+ `/api/v1/hirings/${hiringId}/messages`
497
+ );
498
+ }
499
+
500
+ /** Retry a failed hiring.
501
+ * POST /api/v1/hirings/:id/retry
502
+ */
503
+ async retryHiring(
504
+ hiringId: string,
505
+ options: RetryHiringOptions = {}
506
+ ): Promise<Hiring> {
507
+ const body: Record<string, unknown> = {};
508
+ if (options.reason) {
509
+ body.reason = options.reason;
510
+ }
511
+ return this.request<Hiring>(
512
+ "POST",
513
+ `/api/v1/hirings/${hiringId}/retry`,
514
+ body
515
+ );
516
+ }
517
+
518
+ /** Get a hiring by ID.
519
+ * GET /api/v1/hirings/:id
520
+ */
521
+ async getHiring(hiringId: string): Promise<Hiring> {
522
+ return this.request<Hiring>("GET", `/api/v1/hirings/${hiringId}`);
523
+ }
524
+
525
+ /** List hirings for the authenticated agent.
526
+ * GET /api/v1/hirings
527
+ */
528
+ async listHirings(options: {
529
+ status?: string;
530
+ limit?: number;
531
+ offset?: number;
532
+ } = {}): Promise<Hiring[]> {
533
+ const params = new URLSearchParams();
534
+ if (options.status) params.set("status", options.status);
535
+ if (options.limit) params.set("limit", String(options.limit));
536
+ if (options.offset) params.set("offset", String(options.offset));
537
+ const query = params.toString() ? `?${params}` : "";
538
+ return this.request<Hiring[]>("GET", `/api/v1/hirings${query}`);
539
+ }
540
+
541
+ /** List agents filtered by capability for auto-select.
542
+ * GET /api/v1/agents?capability=X
543
+ */
544
+ async listAgentsByCapability(capability: string): Promise<AgentProfile[]> {
545
+ return this.request<AgentProfile[]>(
546
+ "GET",
547
+ `/api/v1/agents?capability=${encodeURIComponent(capability)}`
548
+ );
549
+ }
550
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatAgentResponse } from "./comment-sync.js";
3
+
4
+ describe("formatAgentResponse", () => {
5
+ it("formats agent name and message as bold markdown", () => {
6
+ const result = formatAgentResponse("CodeReviewer", "Your code looks great!");
7
+ expect(result).toBe("**CodeReviewer says:**\n\nYour code looks great!");
8
+ });
9
+
10
+ it("handles empty message", () => {
11
+ const result = formatAgentResponse("Bot", "");
12
+ expect(result).toBe("**Bot says:**\n\n");
13
+ });
14
+
15
+ it("preserves markdown in message", () => {
16
+ const result = formatAgentResponse("Agent", "Here is a `code` block and **bold** text.");
17
+ expect(result).toContain("`code`");
18
+ expect(result).toContain("**bold**");
19
+ });
20
+
21
+ it("handles multi-line messages", () => {
22
+ const result = formatAgentResponse("Agent", "Line 1\nLine 2\nLine 3");
23
+ expect(result).toContain("Line 1\nLine 2\nLine 3");
24
+ });
25
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Comment formatting utilities for bidirectional sync between
3
+ * Paperclip issues and Agrenting task message threads.
4
+ */
5
+
6
+ import type { AgrentingAdapterConfig, TaskMessage, SendMessageResult } from "./types.js";
7
+ import { AgrentingClient } from "./client.js";
8
+
9
+ /**
10
+ * Process an Agrenting agent response and return the comment body
11
+ * that should be posted on the Paperclip issue.
12
+ *
13
+ * This is called by the webhook handler when it receives task output
14
+ * or progress messages that should appear as comments.
15
+ */
16
+ export function formatAgentResponse(
17
+ agentName: string,
18
+ message: string
19
+ ): string {
20
+ return `**${agentName} says:**\n\n${message}`;
21
+ }
22
+
23
+ /**
24
+ * Forward a comment from Paperclip to the Agrenting task.
25
+ * Used for bidirectional comment sync when the user adds a comment
26
+ * to a Paperclip issue that has an active Agrenting task.
27
+ *
28
+ * @param config - Agrenting adapter configuration
29
+ * @param taskId - The Agrenting task ID
30
+ * @param comment - The comment content to forward
31
+ * @param authorName - Optional author name for attribution
32
+ * @returns The created TaskMessage or null if forwarding failed
33
+ */
34
+ export async function forwardCommentToAgrenting(
35
+ config: AgrentingAdapterConfig,
36
+ taskId: string,
37
+ comment: string,
38
+ authorName?: string
39
+ ): Promise<SendMessageResult | null> {
40
+ const client = new AgrentingClient(config);
41
+
42
+ // Format the comment for Agrenting
43
+ const formattedComment = authorName
44
+ ? `[${authorName}]: ${comment}`
45
+ : comment;
46
+
47
+ try {
48
+ return await client.sendMessageToTask(taskId, { message: formattedComment });
49
+ } catch (err) {
50
+ // Log but don't throw — comment sync is non-critical
51
+ console.error(
52
+ `[adapter-agrenting] Failed to forward comment to task ${taskId}:`,
53
+ err instanceof Error ? err.message : String(err)
54
+ );
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Process incoming Agrenting messages and format them for Paperclip.
61
+ * Called by the webhook handler when it receives task messages.
62
+ *
63
+ * @param message - The incoming TaskMessage from Agrenting
64
+ * @returns Formatted comment body for Paperclip
65
+ */
66
+ export function processIncomingMessage(
67
+ message: TaskMessage
68
+ ): string {
69
+ const senderName = message.sender_name ?? "Agent";
70
+ return formatAgentResponse(senderName, message.content);
71
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import crypto from "crypto";
3
+ import { verifyWebhookSignature } from "./crypto.js";
4
+
5
+ async function computeSignature(rawBody: string, secret: string): Promise<string> {
6
+ return crypto.createHmac("sha256", secret).update(rawBody).digest("base64");
7
+ }
8
+
9
+ describe("verifyWebhookSignature", () => {
10
+ it("returns true for a valid HMAC-SHA256 signature", async () => {
11
+ const body = '{"task_id":"123","status":"completed"}';
12
+ const secret = "my-webhook-secret";
13
+ const signature = await computeSignature(body, secret);
14
+
15
+ const result = await verifyWebhookSignature(body, signature, secret);
16
+ expect(result).toBe(true);
17
+ });
18
+
19
+ it("returns false for an incorrect signature", async () => {
20
+ const body = '{"task_id":"123","status":"completed"}';
21
+ const secret = "my-webhook-secret";
22
+ const wrongSignature = "d3Jvbmdfc2lnbmF0dXJl";
23
+
24
+ const result = await verifyWebhookSignature(body, wrongSignature, secret);
25
+ expect(result).toBe(false);
26
+ });
27
+
28
+ it("returns false when secret is wrong but signature has correct length", async () => {
29
+ const body = '{"task_id":"123","status":"completed"}';
30
+ const correctSecret = "correct-secret";
31
+ const wrongSecret = "wrong-secret";
32
+ const signature = await computeSignature(body, correctSecret);
33
+
34
+ const result = await verifyWebhookSignature(body, signature, wrongSecret);
35
+ expect(result).toBe(false);
36
+ });
37
+
38
+ it("returns false for empty signature", async () => {
39
+ const body = '{"task_id":"123"}';
40
+ const result = await verifyWebhookSignature(body, "", "secret");
41
+ expect(result).toBe(false);
42
+ });
43
+
44
+ it("returns false when body is tampered", async () => {
45
+ const originalBody = '{"task_id":"123","status":"completed"}';
46
+ const secret = "secret";
47
+ const signature = await computeSignature(originalBody, secret);
48
+
49
+ const tamperedBody = '{"task_id":"123","status":"failed"}';
50
+ const result = await verifyWebhookSignature(tamperedBody, signature, secret);
51
+ expect(result).toBe(false);
52
+ });
53
+
54
+ it("handles unicode body content correctly", async () => {
55
+ const body = '{"message":"Héllo Wörld 🌍"}';
56
+ const secret = "secret";
57
+ const signature = await computeSignature(body, secret);
58
+
59
+ const result = await verifyWebhookSignature(body, signature, secret);
60
+ expect(result).toBe(true);
61
+ });
62
+ });