diff --git a/.changeset/dry-peas-ring.md b/.changeset/dry-peas-ring.md new file mode 100644 index 000000000..64a76b183 --- /dev/null +++ b/.changeset/dry-peas-ring.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🐛 fix update card webhook schema diff --git a/.changeset/fifty-friends-bet.md b/.changeset/fifty-friends-bet.md new file mode 100644 index 000000000..b3d74ac32 --- /dev/null +++ b/.changeset/fifty-friends-bet.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook logging for text response diff --git a/.changeset/long-moons-brake.md b/.changeset/long-moons-brake.md new file mode 100644 index 000000000..adba8b5e5 --- /dev/null +++ b/.changeset/long-moons-brake.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook retries diff --git a/.changeset/loud-shoes-visit.md b/.changeset/loud-shoes-visit.md new file mode 100644 index 000000000..2ab24c16d --- /dev/null +++ b/.changeset/loud-shoes-visit.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix user webhook routing diff --git a/.changeset/olive-onions-tan.md b/.changeset/olive-onions-tan.md new file mode 100644 index 000000000..ff26342e1 --- /dev/null +++ b/.changeset/olive-onions-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add webhook api diff --git a/.changeset/quick-ants-write.md b/.changeset/quick-ants-write.md new file mode 100644 index 000000000..48dc1dc7f --- /dev/null +++ b/.changeset/quick-ants-write.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add transaction receipt to webhook diff --git a/.changeset/shy-foxes-trade.md b/.changeset/shy-foxes-trade.md new file mode 100644 index 000000000..9a0a6491e --- /dev/null +++ b/.changeset/shy-foxes-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward exchange rate to webhooks diff --git a/.changeset/violet-plums-move.md b/.changeset/violet-plums-move.md new file mode 100644 index 000000000..678c94b8e --- /dev/null +++ b/.changeset/violet-plums-move.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward webhooks to source diff --git a/cspell.json b/cspell.json index 6877550ca..ffe95e098 100644 --- a/cspell.json +++ b/cspell.json @@ -77,6 +77,7 @@ "hdpi", "hexlify", "hideable", + "hmac", "hono", "IBMPlexMono-Medm", "IERC", diff --git a/docs/astro.config.ts b/docs/astro.config.ts index f2fc8ce97..d443655e7 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,10 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: [{ label: "Docs", items: ["index", "organization-authentication"] }, ...openAPISidebarGroups], + sidebar: [ + { label: "Docs", items: ["index", "organization-authentication", "webhooks"] }, + ...openAPISidebarGroups, + ], }), mermaid(), ], diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 68a3adce5..1e188dbfd 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -141,3 +141,81 @@ authClient.siwe console.error("nonce error", error); }); ``` + +## Creating a webhook with the authenticated header + + ```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; +const baseURL = "http://localhost:3000"; +const authClient = createAuthClient({ + baseURL, + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const webhooks = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + }); + console.log("webhooks", webhooks); + + // only if owner or admin roles for the organization + const newWebhook = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + method: "POST", + body: { + name: "foobar", + url: "https://test.com", + }, + }); + console.log("new webhook", newWebhook); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + + ``` diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md new file mode 100644 index 000000000..fd5e6b8cf --- /dev/null +++ b/docs/src/content/docs/webhooks.md @@ -0,0 +1,656 @@ +--- +title: Webhooks +sidebar: + label: Webhooks + order: 10 +--- + +Webhooks enable real-time event notifications, allowing you to integrate external systems with Exa. + +## Setting up webhooks + +A default endpoint can be configured and optionally an endpoint for each of the 5 event types: + +- Transaction created +- Transaction updated +- Transaction completed +- User updated +- Card updated + +## Webhook security and signing + +Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the Signature HTTP header of the request. + +You can verify webhook authenticity by computing the HMAC signature and comparing it to the `Signature` header included in the webhook request. + +Example: Verifying a webhook signature (Node.js) + +```typescript +import { createHmac } from "crypto"; + +const signature = createHmac("sha256", ) + .update() // JSON.stringify(payload) + .digest("hex"); +``` + +Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. + +## Retry policy and timeout + +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 2xx or times out. + +| Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | +| --- | --- | --- | --- | +| 0 | 500 | 0.5s | - | +| 1 | 1,000 | 1s | - | +| 2 | 2,000 | 2s | - | +| 3 | 4,000 | 4s | - | +| 4 | 8,000 | 8s | - | +| 5 | 16,000 | 16s | - | +| ..... | | | | +| 16 | 32,768,000 | 32768s | ~546.1min | +| 17 | 65,536,000 | 65536s | ~1092.3min | +| 18 | 131,072,000 | 131072s | ~2184.5min | +| 19 | 262,144,000 | 262144s | ~4369.1min | + +## Webhook flows + +There are 5 different types of flow that uses different events which details are in the `Event reference` section: + +- Purchase lifecycle with settlement +- Partial capture +- Over capture +- Force capture +- Refund + +### Purchase lifecycle with settlement + +This example demonstrates a complete transaction lifecycle through webhook notifications, showing how a transaction progresses from initial transaction created to final settlement with an amount adjustment. + +```mermaid +sequenceDiagram + participant Merchant + participant Exa + participant Blockchain + participant Uphold + + + Merchant->>Exa: auth request ($100) + activate Exa + + Exa->>Blockchain: simulate collect ($100) + activate Blockchain + Note over Blockchain: total collect simulation ($100) + Blockchain-->>Exa: simulation OK + deactivate Blockchain + Exa-->>Merchant: auth approved + + Exa->>Blockchain: collect ($100) + activate Blockchain + Note over Blockchain: total collect ($100) + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + + deactivate Exa + + Exa->>Uphold: transaction.created webhook ($100) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + Note over Merchant,Uphold: Time passes (usually same day) + + + Merchant->>Exa: reversal request (-20) + activate Exa + + Exa->>Blockchain: Refund ($20) + activate Blockchain + Note over Blockchain: Refund + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + Exa-->>Merchant: reversal approved + + Exa->>Uphold: webhook transaction.updated (-20) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + deactivate Exa + Note over Merchant,Uphold: Time passes (usually 3 business days) + + Exa->>Uphold: webhook transaction.completed (80) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + +``` + +#### Transaction Created + +Transaction authorized and created with timestamp, for $100.00 amount. + +```json +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T14:36:04.586Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Updated + +Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 +Note that this is a reversal, 1 of the 3 types of refunds. + +```json +{ + "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + "timestamp": "2025-08-12T20:08:37.707Z", + "resource": "transaction", + "action": "updated", + "receipt": { + "blockNumber": 98, + "transactionHash": "0x8c6ef90db7901c43018b3b079ac5ccf84e9c1eb2aaf0fd5f1f8b3e2b97d25fa3", + }, + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "authorizationUpdateAmount": -2000, + "status": "reversed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $80.00 with status "completed". + +```json +{ + "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", + "timestamp": "2025-08-12T20:23:20.662Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "status": "completed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +### Partial capture flow + +In a partial capture, the merchant settles for less than the authorized amount. After receiving the transaction completed webhook, the over authorized and captured funds are released to the user. This flow is common in restaurants, where the final charge may be lower than the original authorization after accounting for tips. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```json +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T16:37:08.862Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 108, + "transactionHash": "0x59be2972d1094e6abc14f595b71ed4e9e6ec4e2cd8d61e292f6debcba37e19b4", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. + +```json +{ + "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", + "timestamp": "2025-08-13T16:42:28.955Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 109, + "transactionHash": "0xd3b27341a97f4621865d896713a82be4099c5e0ad18782fb134fa33a77bba937", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 9000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 9000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5511", + "merchantName": "PartialCapture Example", + "authorizedAt": "2025-07-03T18:40:28.024Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Partial capture Example", + "enrichedMerchantCategory": "Business - Software" + } + } +} +``` + +### Over Capture + +In an over capture, the merchant settles for more than the originally authorized amount. This flow is typically used in scenarios that involve tips or additional surcharges, such as dining or hospitality. +Certain industries, like restaurants and bars, are allowed to settle for more than the authorized amount—typically up to 20%—to accommodate tips and similar charges. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```json +{ + "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", + "timestamp": "2025-08-13T16:53:21.455Z", + "resource": "transaction", + "action": "created", + "receipt": { + "blockNumber": 300, + "transactionHash": "0x7faf9d14fde333a946c27f9e173c2d640ef3b4fbafc7e75d2a8a4b8743efb001", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5812 - Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. + +```json +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T16:55:11.934Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 499, + "transactionHash": "0x2d3a8b61a94f5f36b0d64f3e6a7c5e1bb7eeba6004cd3f1dc7c02b265aec7b02", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 11000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Force Capture + +A force capture occurs when a merchant settles a transaction without prior authorization. These transactions bypass the authorization phase and proceed directly to settlement. This flow is typically used in offline scenarios, such as in-flight purchases where the merchant does not have internet access. + +#### Transaction completed + +```json +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T17:00:08.061Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "type": "spend", + "spend": { + "amount": 11000, + "currency": "usd", + "cardId": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Refund + +Refunds are treated as negative transactions and may or may not reference the original transaction completed. Unlike reversals, refunds can be initiated independently of the original transaction and may occur well after the initial settlement. + +#### Transaction created + +The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and +do sanity checks. + +```json +{ + "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + "timestamp": "2025-08-13T17:08:50.609Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5641 - Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. + +```json +{ + "id": "77474a56-51eb-4918-b09e-73cf20077b1b", + "timestamp": "2025-08-13T17:12:48.858Z", + "resource": "transaction", + "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "completed", + "enrichedMerchantName": "Test Refund", + "enrichedMerchantCategory": "Refunds - Insufficient Funds" + } + } +} +``` + +## Refunds + +There are 3 types of operations that return funds to the user: reversal, partial capture, and refund. + +### Reversal + +This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day. + +### Partial capture + +This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card. + +### Refund + +Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week. + +| Operation | Display | Time | +| --- | --- | --- | +| reversal | purchase details | same day | +| partial | purchase details | 2 or 3 business days | +| refunds | activity | weeks | + +## Event reference + +### Transaction created event + +The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. +This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. +The onchain receipt will be present only if a onchain transaction is necessary. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhookId and always the same when retry | 372d1a76-8a57-403e-a7f3-ac3231be144c | +| timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | +| resource | "transaction" | | transaction | +| action | "created" | | created | +| receipt?.blockNumber | number | onchain transaction block number | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | +| body.type | "spend" | | spend | +| body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | +| body.spend.currency | string | Always in usd | usd | +| body.spend.cardId | string | | 47c3c3b3-b197-4a97-ace3-901a6ad7cf61 | +| body.spend.localAmount | integer | Purchase amount in local currency | 100 | +| body.spend.localCurrency | string | The local currency | usd, eur, ars | +| body.spend.merchantCity? | string | The merchant city | "San Francisco" | +| body.spend.merchantCountry? | string | The merchant country | "US" | +| body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | +| body.spend.authorizedAmount | integer | The authorized amount | 100 | +| body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | +| body.spend.declinedReason? | string | Decline message | webhook declined | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | + +### Transaction updated event + +This webhook is sent whenever a transaction is updated. Note that the transaction may not have been created before this update. +Triggered for events such as incremental authorizations or reversals (a type of refund). + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | e972a2b0-a990-47af-b460-500ff75fbf65 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | +| resource | "transaction" | | transaction | +| action | "updated" | | updated | +| receipt.blockNumber | number | onchain transaction block number | 97 | +| receipt.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | amount in usd authorized | 2499 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | amount in local currency authorized | 2499 | +| body.spend.localCurrency | string | currency of the purchase | usd | +| body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | +| body.spend.merchantCountry? | string | country of the merchant | US | +| body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.merchantName | string | name of the merchant | UBER *TRIP | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | +| body.spend.authorizedAmount | number | amount authorized | 2499 | +| body.spend.authorizationUpdateAmount | number | amount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization | 726 | +| body.spend.status | "pending" \| "reversed" \| "declined" | current status of the transaction | pending | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | `https://storage.googleapis.com/heron-merchant-assets/icons/mrc_Syjxck7oqeRQxzHAjc9XrD.png` | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Uber | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Transport - Rides | + +### Transaction completed event + +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. The `receipt` exist only +if an onchain transaction is necessary. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 662eb701-f9ac-4baa-9f86-b341a730c6dc | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | +| resource | "transaction" | | transaction | +| action | "completed" | | completed | +| receipt?.blockNumber | number | onchain transaction block number. | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | +| body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | final settled amount in usd | 1041 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | final settled amount in local currency | 1270000 | +| body.spend.localCurrency | string | currency of the purchase | ars | +| body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | +| body.spend.merchantCountry? | string | country of the merchant | AR | +| body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | +| body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | +| body.spend.authorizedAmount | number | original authorized amount | 1035 | +| body.spend.status | "completed" | final status of the transaction | completed | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | `https://storage.googleapis.com/heron-merchant-assets/icons/mrc_BqxmeYFvJmCprexvXUDF7h.png` | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | + +### User updated + +This webhook is sent whenever a user's compliance status is updated. No response is required. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | bdc87700-bf6d-4d7d-ac29-3effb06e3000 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T19:16:56.709Z | +| resource | "user" | | user | +| action | "updated" | | updated | +| body.credentialId | string | credential id | 0xE18847D2f02cE2800C07c5b42e66c819eC78d35f | +| body.applicationReason | string | reason for application status | COMPROMISED_PERSONS, PEP | +| body.applicationStatus | "approved" \| "pending" \| "needsInformation" \| "needsVerification" \| "manualReview" \| "denied" \| "locked" \| "canceled" | current status of the application | pending | +| body.isActive | boolean | whether the user is active | true | + +### Card updated + +This webhook is currently triggered when a user adds their card to a digital wallet. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 31740000-bd68-40c8-a400-5a0131f58800 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:47:33.687Z | +| resource | "card" | | card | +| action | "updated" | | updated | +| body.id | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.last4 | string | last 4 digits of the card | 7392 | +| body.limit.amount | number | spending limit amount | 1000000 | +| body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | +| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" \| "INACTIVE" | current status of the card | ACTIVE | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | diff --git a/server/api/index.ts b/server/api/index.ts index 6f8d35507..f2540b44f 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -10,6 +10,7 @@ import kyc from "./kyc"; import passkey from "./passkey"; import pax from "./pax"; import ramp from "./ramp"; +import webhook from "./webhook"; import appOrigin from "../utils/appOrigin"; import auth from "../utils/auth"; @@ -28,6 +29,7 @@ const api = new Hono() .route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", pax) .route("/ramp", ramp) + .route("/webhook", webhook) .on(["POST", "GET"], "/auth/*", (c) => auth.handler(c.req.raw)); export default api; diff --git a/server/api/webhook.ts b/server/api/webhook.ts new file mode 100644 index 000000000..173b37f66 --- /dev/null +++ b/server/api/webhook.ts @@ -0,0 +1,302 @@ +import { captureException } from "@sentry/core"; +import { Mutex } from "async-mutex"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { randomBytes } from "node:crypto"; +import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union, url } from "valibot"; + +import database, { sources } from "../database"; +import authValidator from "../middleware/auth"; +import auth from "../utils/auth"; +import validatorHook from "../utils/validatorHook"; +import isValid from "../utils/webhook"; + +const BaseWebhook = object({ + url: string(), + transaction: optional( + object({ created: optional(string()), updated: optional(string()), completed: optional(string()) }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), +}); + +const Webhook = object({ ...BaseWebhook.entries, secret: string() }); + +const WebhookConfig = object({ type: picklist(["uphold"]), webhooks: record(string(), Webhook) }); + +const mutexes = new Map(); +function createMutex(organizationId: string) { + const mutex = new Mutex(); + mutexes.set(organizationId, mutex); + return mutex; +} + +export default new Hono() + .get( + "/", + authValidator(), + describeRoute({ + summary: "Get webhook information", + description: `Retrieve the organization's webhook information for an authenticated user the belongs to the organization. Only owner and admin roles can read the webhook information.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook information", + content: { "application/json": { schema: resolver(record(string(), BaseWebhook), { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const organizations = await auth.api.listOrganizations({ + headers: c.req.raw.headers, + }); + const organizationId = organizations[0]?.id; + if (!organizationId) return c.json({ code: "no organization" }, 403); + + const { success: canRead } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId, permissions: { webhook: ["read"] } }, + }); + if (!canRead) return c.json({ code: "no permission" }, 403); + + const source = await database.query.sources.findFirst({ + where: eq(sources.id, organizationId), + }); + if (!source) return c.json({}, 200); + const config = parse( + object({ ...WebhookConfig.entries, webhooks: record(string(), BaseWebhook) }), + source.config, + ); + return c.json(config.webhooks, 200); + }, + ) + .post( + "/", + authValidator(), + describeRoute({ + summary: "Creates or updates a webhook", + description: `it creates a new webhook if it doesn't exist or updates the existing webhook if it does. Only owner and admin roles can create or update a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook created or updated", + content: { "application/json": { schema: resolver(Webhook, { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 400: { + description: "Invalid webhook URL", + content: { + "application/json": { + schema: resolver( + object({ code: pipe(literal("invalid url"), metadata({ examples: ["https://example.com/webhook"] })) }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + vValidator( + "json", + object({ + name: string(), + url: pipe(string(), url()), + transaction: optional( + object({ + created: optional(pipe(string(), url())), + updated: optional(pipe(string(), url())), + completed: optional(pipe(string(), url())), + }), + ), + card: optional(object({ updated: optional(pipe(string(), url())) })), + user: optional(object({ updated: optional(pipe(string(), url())) })), + }), + validatorHook(), + ), + async (c) => { + const { name, ...payload } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + const { success: canCreate } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["create"] } }, + }); + if (!canCreate) return c.json({ code: "no permission" }, 403); + + try { + await Promise.all( + [ + payload.url, + payload.transaction?.created, + payload.transaction?.updated, + payload.transaction?.completed, + payload.card?.updated, + payload.user?.updated, + ] + .filter((u): u is string => u !== undefined) + .map((u) => isValid(u)), + ); + } catch (error) { + captureException(error, { level: "error" }); + return c.json({ code: "invalid url" as const }, 400); + } + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const webhook = { ...payload, secret: config.webhooks[name]?.secret ?? randomBytes(16).toString("hex") }; + await database + .update(sources) + .set({ config: { ...config, webhooks: { ...config.webhooks, [name]: webhook } } }) + .where(eq(sources.id, id)); + return c.json(webhook, 200); + } else { + const webhook = { ...payload, secret: randomBytes(16).toString("hex") }; + await database.insert(sources).values({ id, config: { type: "uphold", webhooks: { [name]: webhook } } }); + return c.json(webhook, 200); + } + }); + }, + ) + .delete( + "/", + authValidator(), + vValidator("json", object({ name: string() }), validatorHook()), + describeRoute({ + summary: "Deletes a webhook", + description: `it deletes the webhook with the given name. Only owner and admin roles can delete a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook deleted", + content: { + "application/json": { schema: resolver(object({ code: literal("ok") }), { errorMode: "ignore" }) }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { name } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + + const { success: canDelete } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["delete"] } }, + }); + if (!canDelete) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const { [name]: _, ...remainingWebhooks } = config.webhooks; + await database + .update(sources) + .set({ config: { ...config, webhooks: remainingWebhooks } }) + .where(eq(sources.id, id)); + } + return c.json({ code: "ok" }, 200); + }); + }, + ); diff --git a/server/database/schema.ts b/server/database/schema.ts index 0e03c9745..e546b2056 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -68,7 +68,15 @@ export const transactions = pgTable( ({ cardId }) => [index("transactions_card_id_index").on(cardId)], ); -export const credentialsRelations = relations(credentials, ({ many }) => ({ cards: many(cards) })); +export const sources = pgTable("sources", { + id: text("id").primaryKey(), + config: jsonb("config").notNull(), +}); + +export const credentialsRelations = relations(credentials, ({ many, one }) => ({ + cards: many(cards), + source: one(sources, { fields: [credentials.source], references: [sources.id] }), +})); export const cardsRelations = relations(cards, ({ many, one }) => ({ credential: one(credentials, { fields: [cards.credentialId], references: [credentials.id] }), diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 348e55731..c889d545b 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -13,6 +13,7 @@ import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; +import { createHmac } from "node:crypto"; import * as v from "valibot"; import { BaseError, @@ -28,9 +29,12 @@ import { padHex, RawContractError, toBytes, + withRetry, zeroHash, + type TransactionReceipt, } from "viem"; +import domain from "@exactly/common/domain"; import { auditorAbi, exaPluginAbi, @@ -66,6 +70,9 @@ import type { UnofficialStatusCode } from "hono/utils/http-status"; const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const debugWebhook = createDebug("exa:webhook"); +Object.assign(debugWebhook, { inspectOpts: { depth: undefined } }); + const BaseTransaction = v.object({ id: v.string(), type: v.literal("spend"), @@ -81,7 +88,7 @@ const BaseTransaction = v.object({ merchantCategory: v.nullish(v.string()), merchantCategoryCode: v.string(), merchantName: v.string(), - merchantId: v.optional(v.string()), + merchantId: v.nullish(v.string()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), authorizationMethod: v.optional(v.string()), @@ -100,6 +107,7 @@ const Transaction = v.variant("action", [ ...BaseTransaction.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.optional(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -114,7 +122,10 @@ const Transaction = v.variant("action", [ authorizationUpdateAmount: v.number(), authorizedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.picklist(["declined", "pending", "reversed"]), - declinedReason: v.optional(v.string()), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -143,6 +154,10 @@ const Transaction = v.variant("action", [ authorizedAt: v.pipe(v.string(), v.isoTimestamp()), postedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -203,7 +218,16 @@ const Payload = v.variant("resource", [ action: v.literal("updated"), body: v.object({ applicationReason: v.string(), - applicationStatus: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), firstName: v.string(), id: v.string(), isActive: v.boolean(), @@ -241,6 +265,9 @@ export default new Hono().post( where: eq(credentials.pandaId, pandaId), }); if (user) setUser({ id: user.account }); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); } return c.json({ code: "ok" }); } @@ -269,8 +296,8 @@ export default new Hono().post( type: payload.body.spend.amount < 0 ? "return" : "purchase", merchant: { mcc: payload.body.spend.merchantCategoryCode, - id: payload.body.spend.merchantId, name: payload.body.spend.merchantName, + ...(payload.body.spend.merchantId && { id: payload.body.spend.merchantId }), }, terminal: { type: payload.body.spend.authorizationMethod }, address: { countryCode: payload.body.spend.merchantCountry }, @@ -531,6 +558,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); sendPushNotification({ @@ -624,8 +655,8 @@ export default new Hono().post( where: eq(cards.id, payload.body.spend.cardId), with: { credential: { columns: { account: true, id: true, source: true } } }, }); - if (!card) return c.json({ code: "card not found" }, 404); + const account = v.parse(Address, card.credential.account); setUser({ id: account }); @@ -645,7 +676,7 @@ export default new Hono().post( feedback: { type: "authorization", status: "network_declined", - reason: payload.body.spend.declinedReason, + reason: payload.body.spend.declinedReason ?? "unknown", }, }).catch((error: unknown) => captureException(error, { level: "error" })); return c.json({ code: "ok" }); @@ -658,6 +689,10 @@ export default new Hono().post( feedback: { type: "authorization", status: "approved" }, }).catch((error: unknown) => captureException(error, { level: "error" })); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } if (payload.body.spend.status !== "pending" && payload.action !== "completed") return c.json({ code: "ok" }); @@ -694,6 +729,11 @@ export default new Hono().post( : { type: "settlement", status: "settled" }), }, }).catch((error: unknown) => captureException(error, { level: "error" })); + + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } try { @@ -744,6 +784,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); @@ -1168,3 +1212,292 @@ const TransactionPayload = v.object( { bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") }, "invalid transaction payload", ); + +async function publish(payload: v.InferOutput, receipt?: TransactionReceipt) { + if (payload.resource === "transaction" && payload.action === "requested") return; + if (receipt?.status === "reverted") return; + if (payload.resource === "dispute") return; + if (payload.resource === "card" && payload.action === "notification") return; + + async function sendWebhook(webhookPayload: v.InferOutput, url: string, secret: string) { + try { + const result = await withRetry( + async () => { + const response = await fetch(url, { + method: "POST", + redirect: "error", + headers: { + "Content-Type": "application/json", + Signature: createHmac("sha256", secret).update(JSON.stringify(webhookPayload)).digest("hex"), + }, + body: JSON.stringify(webhookPayload), + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) + throw new Error("WebhookFailed", { + cause: { + code: response.status, + response: await response.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), + payload: webhookPayload, + }, + }); + return response; + }, + { + delay: ({ count }) => Math.trunc(1 << count) * 500, + retryCount: domain === "base-sepolia.exactly.app" ? 3 : 20, + shouldRetry: ({ error }) => { + if (error instanceof Error) { + return error.message === "WebhookFailed" || error.name === "TimeoutError"; + } + return false; + }, + }, + ); + debugWebhook("%j", { + code: result.status, + response: await result.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), + payload: webhookPayload, + }); + } catch (error) { + if (error instanceof Error) { + if (error.message === "WebhookFailed") { + debugWebhook("%j", error.cause); + } else { + debugWebhook("%j", { error: error.message, payload: webhookPayload }); + } + } + throw error; + } + } + + const timestamp = new Date().toISOString(); + const user = await database.query.credentials.findFirst({ + columns: { id: true, source: true }, + with: { source: { columns: { config: true } } }, + where: eq( + credentials.pandaId, + (() => { + switch (payload.resource) { + case "card": + return payload.body.userId; + case "user": + return payload.body.id; + case "transaction": + return payload.body.spend.userId; + } + })(), + ), + }); + + if (!user?.source) return; + const config = v.parse(webhookConfig, user.source.config); + await Promise.allSettled( + Object.values(config.webhooks).map(async (webhook) => { + switch (payload.resource) { + case "user": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { ...payload.body, credentialId: user.id }, + }), + webhook.user?.[payload.action] ?? webhook.url, + webhook.secret, + ); + case "card": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { + ...payload.body, + status: { active: "ACTIVE", locked: "FROZEN", canceled: "DELETED", notActivated: "INACTIVE" }[ + payload.body.status + ], + }, + }), + webhook.card?.[payload.action] ?? webhook.url, + webhook.secret, + ); + case "transaction": + return sendWebhook( + v.parse(Webhook, { + ...payload, + ...(receipt && { receipt }), + timestamp, + ...(payload.action !== "updated" && + payload.body.spend.currency !== payload.body.spend.localCurrency && { + body: { + ...payload.body, + spend: { ...payload.body.spend, exchangeRate: payload.body.spend.exchangeRate }, + }, + }), + }), + webhook.transaction?.[payload.action] ?? webhook.url, + webhook.secret, + ); + } + }), + ).then((results) => { + for (const result of results) { + if (result.status === "rejected") captureException(result.reason, { level: "error" }); + } + }); +} + +const BaseWebhook = v.object({ + id: v.string(), + type: v.literal("spend"), + spend: v.object({ + amount: v.number(), + currency: v.literal("usd"), + cardId: v.string(), + localAmount: v.number(), + localCurrency: v.pipe(v.string(), v.length(3)), + merchantCity: v.nullish(v.pipe(v.string(), v.trim())), + merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategoryCode: v.string(), + merchantName: v.pipe(v.string(), v.trim()), + authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), + authorizedAmount: v.nullish(v.number()), + merchantId: v.nullish(v.string()), + }), +}); + +const Receipt = v.pipe( + v.object({ blockNumber: v.bigint(), transactionHash: v.string() }), + v.transform((r) => { + return { ...r, blockNumber: Number(r.blockNumber) }; + }), +); + +const Webhook = v.variant("resource", [ + v.variant("action", [ + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("created"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + status: v.picklist(["pending", "declined"]), + declinedReason: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("updated"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizationUpdateAmount: v.number(), + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.picklist(["declined", "pending", "reversed"]), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("completed"), + receipt: v.optional(Receipt), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), + }), + }), + }), + ]), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("card"), + action: v.literal("updated"), + body: v.object({ + id: v.string(), + last4: v.pipe(v.string(), v.length(4)), + limit: v.object({ + amount: v.number(), + frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), + }), + status: v.picklist(["ACTIVE", "FROZEN", "DELETED", "INACTIVE"]), + tokenWallets: v.nullish(v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))])), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("user"), + action: v.literal("updated"), + body: v.object({ + credentialId: v.string(), + applicationReason: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), + isActive: v.boolean(), + }), + }), +]); + +const webhookConfig = v.object({ + type: v.picklist(["uphold"]), + webhooks: v.record( + v.string(), + v.object({ + url: v.string(), + secret: v.string(), + transaction: v.optional( + v.object({ + created: v.optional(v.string()), + updated: v.optional(v.string()), + completed: v.optional(v.string()), + }), + ), + card: v.optional(v.object({ updated: v.optional(v.string()) })), + user: v.optional(v.object({ updated: v.optional(v.string()) })), + }), + ), +}); diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts new file mode 100644 index 000000000..2fd15b366 --- /dev/null +++ b/server/test/api/webhook.test.ts @@ -0,0 +1,304 @@ +import "../mocks/sentry"; + +import { eq } from "drizzle-orm"; +import { testClient } from "hono/testing"; +import { resolve4, resolve6 } from "node:dns/promises"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import chain from "@exactly/common/generated/chain"; + +import app from "../../api/webhook"; +import database, { sources } from "../../database"; +import auth from "../../utils/auth"; + +vi.mock("node:dns/promises", () => ({ + resolve4: vi.fn<() => Promise>(), + resolve6: vi.fn<() => Promise>(), +})); + +const appClient = testClient(app); + +const owner = mnemonicToAccount("test test test test test test test test test test test junk"); +const integratorAccount = mnemonicToAccount("test test test test test test test test test test test integrator"); +const memberAccount = mnemonicToAccount("test test test test test test test test test test test member"); + +describe("webhook", () => { + const integratorHeaders = new Headers(); + const memberHeaders = new Headers(); + + describe("authenticated", () => { + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + const ownerHeaders = new Headers(); + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + + const integratorNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: integratorAccount.address, chainId: chain.id }, + }); + const integratorMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: integratorNonceResult.nonce, + uri: `https://localhost`, + address: integratorAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const integratorResponse = await auth.api.verifySiweMessage({ + body: { + message: integratorMessage, + signature: await integratorAccount.signMessage({ message: integratorMessage }), + walletAddress: integratorAccount.address, + chainId: chain.id, + email: "integrator@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + integratorHeaders.set("cookie", `${integratorResponse.headers.get("set-cookie")}`); + const integrator = await auth.api.getSession({ headers: integratorHeaders }); + if (!integrator) throw new Error("integrator not found"); + + const memberNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: memberAccount.address, chainId: chain.id }, + }); + const memberMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: memberNonceResult.nonce, + uri: `https://localhost`, + address: memberAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const memberResponse = await auth.api.verifySiweMessage({ + body: { + message: memberMessage, + signature: await memberAccount.signMessage({ message: memberMessage }), + walletAddress: memberAccount.address, + chainId: chain.id, + email: "member@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + memberHeaders.set("cookie", `${memberResponse.headers.get("set-cookie")}`); + const member = await auth.api.getSession({ headers: memberHeaders }); + if (!member) throw new Error("member not found"); + + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { name: "External Organization", slug: "external-organization" }, + }); + + const integratorInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: integrator.user.email, role: "admin", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: integratorHeaders, body: { invitationId: integratorInvitation.id } }); + + const memberInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: member.user.email, role: "member", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: memberHeaders, body: { invitationId: memberInvitation.id } }); + }); + + beforeEach(() => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockResolvedValue([]); + }); + + afterEach(async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + await database.delete(sources).where(eq(sources.id, id)); + }); + + it("creates and gets a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const cookie = integratorHeaders.get("cookie") ?? ""; + + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + const getWebhook = await appClient.index.$get({}, { headers: { cookie } }); + + expect(getWebhook.status).toBe(200); + expect(response.status).toBe(200); + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { url: "https://test.com", secret: expect.any(String) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }, + }); + + await expect(getWebhook.json()).resolves.toStrictEqual({ + test: { + url: "https://test.com", + }, + }); + }); + + it("updates a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const update = await appClient.index.$post( + { + json: { + name: "test", + url: "https://test.updated.com", + transaction: { created: "https://test.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const createAnother = await appClient.index.$post( + { + json: { + name: "another", + url: "https://another.updated.com", + transaction: { created: "https://another.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { + url: "https://test.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://test.updated.com/created" }, + }, + another: { + url: "https://another.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://another.updated.com/created" }, + }, + }, + }); + + expect(create.status).toBe(200); + expect(update.status).toBe(200); + expect(createAnother.status).toBe(200); + }); + + it("deletes a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const remove = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ type: "uphold", webhooks: {} }); + expect(create.status).toBe(200); + expect(remove.status).toBe(200); + }); + + it("returns 200 when webhook is not found", async () => { + const getWebhook = await appClient.index.$get({}, { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }); + expect(getWebhook.status).toBe(200); + await expect(getWebhook.json()).resolves.toStrictEqual({}); + }); + + it("rejects url resolving to private ip", async () => { + vi.mocked(resolve4).mockResolvedValue(["10.0.0.1"]); + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid url" }); + }); + + it("rejects event-specific url resolving to private ip", async () => { + vi.mocked(resolve4).mockResolvedValueOnce(["93.184.216.34"]).mockResolvedValueOnce(["169.254.169.254"]); + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com", transaction: { created: "https://evil.internal" } } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid url" }); + }); + + describe("member", () => { + it("denies get webhook", async () => { + const response = await appClient.index.$get({}, { headers: { cookie: memberHeaders.get("cookie") ?? "" } }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + + it("denies create webhook", async () => { + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: memberHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + + it("denies delete webhook", async () => { + const response = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: memberHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + }); + }); +}); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9419097c2..40300ecfa 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,8 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { parse } from "valibot"; +import { createHmac, randomBytes } from "node:crypto"; +import { object, parse, string } from "valibot"; import { BaseError, createWalletClient, @@ -38,14 +39,13 @@ import chain, { exaPluginAbi, issuerCheckerAbi, marketAbi, - marketUSDCAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import ProposalType from "@exactly/common/ProposalType"; import { Address, type Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; -import database, { cards, credentials, transactions } from "../../database"; +import database, { cards, credentials, sources, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; @@ -1634,7 +1634,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("over capture debit", async () => { + it("over-captures debit", async () => { const hold = 25; const capture = 30; @@ -1686,7 +1686,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("partial capture debit", async () => { + it("partial-captures debit", async () => { const hold = 80; const capture = 40; const cardId = "partial-capture-debit"; @@ -1747,7 +1747,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture debit", async () => { + it("force-captures debit", async () => { const capture = 42; const cardId = "force-capture-debit"; @@ -1783,7 +1783,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture fraud", async () => { + it("force-captures fraud", async () => { const updateUser = vi.spyOn(panda, "updateUser").mockResolvedValue(userResponseTemplate); const currentFunds = await publicClient .readContract({ @@ -1962,7 +1962,12 @@ describe("concurrency", () => { Promise.all([ keeper.exaSend( { name: "mint", op: "tx.mint" }, - { address: inject("USDC"), abi: mockERC20Abi, functionName: "mint", args: [account2, 70_000_000n] }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [account2, 70_000_000n], + }, ), keeper.exaSend( { name: "create account", op: "exa.account" }, @@ -1973,12 +1978,25 @@ describe("concurrency", () => { args: [0n, [{ x: hexToBigInt(owner2.account.address), y: 0n }]], }, ), - ]).then(() => - keeper.exaSend( - { name: "poke", op: "exa.poke" }, - { address: account2, abi: exaPluginAbi, functionName: "poke", args: [marketUSDCAddress] }, - ), - ), + ]) + .then(() => + keeper.writeContract({ + address: account2, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), ]); }); @@ -2069,7 +2087,7 @@ describe("concurrency", () => { afterEach(() => vi.useRealTimers()); - it("mutex timeout", async () => { + it("times out when mutex is locked", async () => { const getMutex = vi.spyOn(panda, "getMutex"); const cardId = `${account2}-card`; const promises = Promise.all([ @@ -2122,6 +2140,429 @@ describe("concurrency", () => { }); }); +describe("webhooks", () => { + let webhookOwner: WalletClient, typeof chain, ReturnType>; + let webhookAccount: Address; + const secret = randomBytes(16).toString("hex"); + + beforeAll(async () => { + webhookOwner = createWalletClient({ + chain, + transport: http(), + account: privateKeyToAccount(generatePrivateKey()), + }); + webhookAccount = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(webhookOwner.account.address), + y: zeroHash, + }); + await Promise.all([ + database.insert(sources).values([ + { + id: "test", + config: { + type: "uphold", + webhooks: { sandbox: { url: "https://exa.test", secret } }, + }, + }, + ]), + database + .insert(credentials) + .values([ + { + id: webhookAccount, + publicKey: new Uint8Array(), + account: webhookAccount, + factory: zeroAddress, + source: "test", + pandaId: webhookAccount, + }, + ]) + .then(() => { + return database + .insert(cards) + .values([{ id: `${webhookAccount}-card`, credentialId: webhookAccount, lastFour: "1234", mode: 0 }]); + }), + + anvilClient.setBalance({ address: webhookOwner.account.address, value: 10n ** 24n }), + Promise.all([ + keeper.exaSend( + { name: "mint", op: "tx.mint" }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [webhookAccount, 50_000_000n], + }, + ), + keeper.exaSend( + { name: "create account", op: "exa.account" }, + { + address: inject("ExaAccountFactory"), + abi: exaAccountFactoryAbi, + functionName: "createAccount", + args: [0n, [{ x: hexToBigInt(webhookOwner.account.address), y: 0n }]], + }, + ), + ]) + .then(() => + keeper.writeContract({ + address: webhookAccount, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), + ]); + }); + + afterEach(() => vi.resetAllMocks()); + + it("forwards transaction created with exchangeRate", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: cardId, + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 100, + localAmount: 85, + localCurrency: "eur", + exchangeRate: 1.176_470_588_2, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.176_470_588_2 } }, + }); + }); + + it("forwards transaction created without exchangeRate when same currency", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "same-currency-tx", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); + }); + + it("forwards transaction updated without exchangeRate", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + + await appClient.index.$post({ + ...transactionUpdated, + json: { + ...transactionUpdated.json, + body: { + ...transactionUpdated.json.body, + id: "forward-transaction-updated", + spend: { + ...transactionUpdated.json.body.spend, + cardId, + userId: webhookAccount, + localCurrency: "eur", + localAmount: 6800, + authorizedAt: new Date().toISOString(), + status: "pending", + authorizationUpdateAmount: 98, + }, + }, + }, + }); + + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); + }); + + it("forwards transaction completed with exchangeRate", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const fetch = globalThis.fetch; + let publishCounter = 0; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publishCounter++; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + + await appClient.index.$post({ + ...transactionCompleted, + json: { + ...transactionCompleted.json, + body: { + ...transactionCompleted.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCompleted.json.body.spend, + cardId, + userId: webhookAccount, + postedAt: new Date().toISOString(), + status: "completed", + amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, + authorizedAmount: 99, + }, + }, + }, + }); + + await vi.waitUntil(() => publishCounter > 1, 60_000); + const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.178_571_428_6 } }, + }); + }); + + it("forwards card updated active", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards card updated canceled", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...cardCanceled, + json: { + ...cardCanceled.json, + body: { + ...cardCanceled.json.body, + userId: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards user updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text() { + return Promise.resolve("{}"); + }, + } as Response); + + await appClient.index.$post({ + ...userUpdated, + json: { + ...userUpdated.json, + body: { + ...userUpdated.json.body, + id: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("logs text on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith("%j", expect.objectContaining({ response: "OK" })); + }); + + it("logs json on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ status: 200, message: "OK" })), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith( + "%j", + expect.objectContaining({ response: { status: 200, message: "OK" } }), + ); + }); + + it("passes redirect error option to fetch", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { ...cardUpdated.json.body, userId: webhookAccount, tokenWallets: ["Apple"] }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + expect(options).toStrictEqual(expect.objectContaining({ redirect: "error" })); + }); +}); + const authorization = { header: { signature: "panda-signature" }, json: { @@ -2144,6 +2585,7 @@ const authorization = { merchantCity: "buenos aires", merchantCountry: "AR", merchantName: "99999", + merchantId: "550e8400-e29b-41d4-a716-446655440000", status: "pending", userEmail: "mail@mail.com", userFirstName: "David", @@ -2154,6 +2596,189 @@ const authorization = { }, } as const; +const cardUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + tokenWallets: ["Apple"], + }, + }, +} as const; + +const cardCanceled = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "canceled", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + }, + }, +} as const; + +const userUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + resource: "user", + action: "updated", + body: { + id: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", + firstName: "David", + lastName: "Mayer", + email: "mail@mail.com", + isActive: true, + isTermsOfServiceAccepted: true, + applicationStatus: "pending", + applicationExternalVerificationLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationCompletionLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationReason: "COMPROMISED_PERSONS, PEP", + }, + }, +} as const; + +const transactionCreated = { + header: { signature: "panda-signature" }, + json: { + id: "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "pending", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "rain@gmail.com", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "5641 - Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + merchantCategoryCode: "5641", + }, + }, + action: "created", + resource: "transaction", + }, +} as const; + +const transactionUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + body: { + id: "30dcf8c6-a1e5-48f1-9c40-ecffe8253d25", + type: "spend", + spend: { + amount: 8000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "reversed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "zjdnflol@gamil.com", + merchantId: "d0a30859-096d-57f4-bffd-fd745f44e048", + localAmount: 8000, + authorizedAt: "2025-06-25T15:24:11.337Z", + merchantCity: " ", + merchantName: "Test ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "jason", + merchantCountry: " ", + authorizedAmount: 8000, + merchantCategory: " - ", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test", + merchantCategoryCode: "", + enrichedMerchantCategory: "Education", + authorizationUpdateAmount: -2000, + }, + }, + action: "updated", + resource: "transaction", + }, +} as const; + +const transactionCompleted = { + header: { signature: "panda-signature" }, + json: { + id: "77474a56-51eb-4918-b09e-73cf20077b1b", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "completed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + postedAt: "2025-07-03T19:57:04.332Z", + userEmail: "rain@gmail.com", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test Refund", + merchantCategoryCode: "5641", + enrichedMerchantCategory: "Refunds - Insufficient Funds", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + }, + }, + action: "completed", + resource: "transaction", + }, +} as const; + const receipt = { status: "success", blockHash: zeroHash, @@ -2248,6 +2873,12 @@ const userResponseTemplate = { } as const; vi.mock("@sentry/node", { spy: true }); +const webhookLogger = vi.hoisted(() => vi.fn()); + +vi.mock("debug", () => { + const createDebug = vi.fn().mockReturnValueOnce(vi.fn()).mockReturnValueOnce(webhookLogger); + return { default: createDebug }; +}); afterEach(() => { vi.clearAllMocks(); diff --git a/server/test/utils/webhook.test.ts b/server/test/utils/webhook.test.ts new file mode 100644 index 000000000..b662c20a5 --- /dev/null +++ b/server/test/utils/webhook.test.ts @@ -0,0 +1,149 @@ +import { resolve4, resolve6 } from "node:dns/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import isValid from "../../utils/webhook"; + +vi.mock("node:dns/promises", () => ({ + resolve4: vi.fn<() => Promise>(), + resolve6: vi.fn<() => Promise>(), +})); + +describe("validateWebhookUrl", () => { + beforeEach(() => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockResolvedValue([]); + }); + + it("accepts valid https url with public ip", async () => { + await expect(isValid("https://example.com/webhook")).resolves.toBeUndefined(); + }); + + it("rejects http scheme", async () => { + await expect(isValid("http://example.com")).rejects.toThrow("url must use https"); + }); + + it("rejects ftp scheme", async () => { + await expect(isValid("ftp://example.com/file")).rejects.toThrow("url must use https"); + }); + + it("rejects malformed url", async () => { + await expect(isValid("not-a-url")).rejects.toThrow(); + }); + + it("rejects when dns does not resolve", async () => { + vi.mocked(resolve4).mockRejectedValue(new Error("ENOTFOUND")); + vi.mocked(resolve6).mockRejectedValue(new Error("ENOTFOUND")); + await expect(isValid("https://nonexistent.invalid")).rejects.toThrow("url does not resolve"); + }); + + it("rejects 127.0.0.0/8", async () => { + vi.mocked(resolve4).mockResolvedValue(["127.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 10.0.0.0/8", async () => { + vi.mocked(resolve4).mockResolvedValue(["10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 172.16.0.0/12", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.16.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts 172.15.255.255", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.15.255.255"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("accepts 172.32.0.0", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.32.0.0"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("rejects 192.168.0.0/16", async () => { + vi.mocked(resolve4).mockResolvedValue(["192.168.1.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 169.254.0.0/16", async () => { + vi.mocked(resolve4).mockResolvedValue(["169.254.169.254"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 0.0.0.0", async () => { + vi.mocked(resolve4).mockResolvedValue(["0.0.0.0"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects ::1", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fc00::/7", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fd12::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fe80::/10", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fe80::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fe90::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fe90::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fea0::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fea0::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects feb0::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["feb0::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts fec0::1 (outside fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fec0::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("rejects ::ffff-mapped private ipv4", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["::ffff:10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects when any address is private", async () => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34", "10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts public ipv6", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["2001:db8::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("resolves when only ipv6 succeeds", async () => { + vi.mocked(resolve4).mockRejectedValue(new Error("ENOTFOUND")); + vi.mocked(resolve6).mockResolvedValue(["2001:db8::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("resolves when only ipv4 succeeds", async () => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockRejectedValue(new Error("ENOTFOUND")); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); +}); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 2d0d8d940..ef68eea5e 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -16,6 +16,7 @@ import authSecret from "./authSecret"; import { authAdapter } from "../database/index"; const ac = createAccessControl({ ...defaultStatements, + webhook: ["create", "delete", "read"], }); export default betterAuth({ @@ -52,9 +53,11 @@ export default betterAuth({ ac, roles: { admin: ac.newRole({ + webhook: ["create", "delete", "read"], ...adminAc.statements, }), owner: ac.newRole({ + webhook: ["create", "delete", "read"], ...ownerAc.statements, }), member: ac.newRole({ diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 0b767c0d7..f894d3b65 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -59,6 +59,7 @@ export function extender(keeper: WalletClient MaybePromise) | string[]; level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; }, ) => withScope((scope) => @@ -147,6 +148,9 @@ export function extender(keeper: WalletClient + captureException(error, { level: "error" }), + ); const trace = await startSpan({ name: "trace transaction", op: "tx.trace" }, () => withRetry(() => traceClient.traceTransaction(hash), { delay: 1000, diff --git a/server/utils/webhook.ts b/server/utils/webhook.ts new file mode 100644 index 000000000..3e32dfdff --- /dev/null +++ b/server/utils/webhook.ts @@ -0,0 +1,29 @@ +import { resolve4, resolve6 } from "node:dns/promises"; + +export default async function isValid(raw: string) { + const { hostname, protocol } = new URL(raw); + if (protocol !== "https:") throw new Error("url must use https"); + const [v4, v6] = await Promise.all([resolve4(hostname).catch(() => []), resolve6(hostname).catch(() => [])]); + const addresses = [...v4, ...v6]; + if (addresses.length === 0) throw new Error("url does not resolve"); + + if ( + addresses + .map((ip) => (ip.startsWith("::ffff:") ? ip.slice(7).toLowerCase() : ip.toLowerCase())) + .some((lowerIp) => isPrivate(lowerIp)) + ) + throw new Error("url resolves to private address"); +} + +function isPrivate(ip: string) { + if (ip.includes(":")) return ip === "::1" || /^fe[89ab]/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd"); + const parts = ip.split(".").map(Number); + return ( + parts[0] === 127 || + parts[0] === 10 || + (parts[0] === 172 && parts[1] !== undefined && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + ip === "0.0.0.0" + ); +}