tapimo 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/ApiKey.d.ts +65 -0
- package/dist/ApiKey.d.ts.map +1 -0
- package/dist/ApiKey.js +105 -0
- package/dist/ApiKey.js.map +1 -0
- package/dist/ApiKeySource.d.ts +166 -0
- package/dist/ApiKeySource.d.ts.map +1 -0
- package/dist/ApiKeySource.js +234 -0
- package/dist/ApiKeySource.js.map +1 -0
- package/dist/App.d.ts +19317 -0
- package/dist/App.d.ts.map +1 -0
- package/dist/App.js +612 -0
- package/dist/App.js.map +1 -0
- package/dist/Assets.d.ts +44 -0
- package/dist/Assets.d.ts.map +1 -0
- package/dist/Assets.js +23 -0
- package/dist/Assets.js.map +1 -0
- package/dist/Auth.d.ts +2 -0
- package/dist/Auth.d.ts.map +1 -0
- package/dist/Auth.js +2 -0
- package/dist/Auth.js.map +1 -0
- package/dist/Cli.d.ts +21 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +13 -0
- package/dist/Cli.js.map +1 -0
- package/dist/Client.d.ts +20 -0
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +24 -0
- package/dist/Client.js.map +1 -0
- package/dist/Metrics.d.ts +70 -0
- package/dist/Metrics.d.ts.map +1 -0
- package/dist/Metrics.js +91 -0
- package/dist/Metrics.js.map +1 -0
- package/dist/Scope.d.ts +35 -0
- package/dist/Scope.d.ts.map +1 -0
- package/dist/Scope.js +38 -0
- package/dist/Scope.js.map +1 -0
- package/dist/Webhooks.d.ts +79 -0
- package/dist/Webhooks.d.ts.map +1 -0
- package/dist/Webhooks.js +107 -0
- package/dist/Webhooks.js.map +1 -0
- package/dist/admin/Access.d.ts +41 -0
- package/dist/admin/Access.d.ts.map +1 -0
- package/dist/admin/Access.js +75 -0
- package/dist/admin/Access.js.map +1 -0
- package/dist/admin/App.d.ts +1221 -0
- package/dist/admin/App.d.ts.map +1 -0
- package/dist/admin/App.js +113 -0
- package/dist/admin/App.js.map +1 -0
- package/dist/admin/apps/api-keys.d.ts +209 -0
- package/dist/admin/apps/api-keys.d.ts.map +1 -0
- package/dist/admin/apps/api-keys.js +129 -0
- package/dist/admin/apps/api-keys.js.map +1 -0
- package/dist/admin/apps/scopes.d.ts +46 -0
- package/dist/admin/apps/scopes.d.ts.map +1 -0
- package/dist/admin/apps/scopes.js +41 -0
- package/dist/admin/apps/scopes.js.map +1 -0
- package/dist/admin/apps/verified-tokens.d.ts +1206 -0
- package/dist/admin/apps/verified-tokens.d.ts.map +1 -0
- package/dist/admin/apps/verified-tokens.js +485 -0
- package/dist/admin/apps/verified-tokens.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +3 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/ui.generated.d.ts +11 -0
- package/dist/admin/ui.generated.d.ts.map +1 -0
- package/dist/admin/ui.generated.js +43 -0
- package/dist/admin/ui.generated.js.map +1 -0
- package/dist/apps/activities.d.ts +18725 -0
- package/dist/apps/activities.d.ts.map +1 -0
- package/dist/apps/activities.js +3138 -0
- package/dist/apps/activities.js.map +1 -0
- package/dist/apps/addresses.d.ts +3662 -0
- package/dist/apps/addresses.d.ts.map +1 -0
- package/dist/apps/addresses.js +22 -0
- package/dist/apps/addresses.js.map +1 -0
- package/dist/apps/balances.d.ts +342 -0
- package/dist/apps/balances.d.ts.map +1 -0
- package/dist/apps/balances.js +393 -0
- package/dist/apps/balances.js.map +1 -0
- package/dist/apps/blocks.d.ts +931 -0
- package/dist/apps/blocks.d.ts.map +1 -0
- package/dist/apps/blocks.js +689 -0
- package/dist/apps/blocks.js.map +1 -0
- package/dist/apps/coingecko.d.ts +1053 -0
- package/dist/apps/coingecko.d.ts.map +1 -0
- package/dist/apps/coingecko.js +1015 -0
- package/dist/apps/coingecko.js.map +1 -0
- package/dist/apps/exchanges.d.ts +3000 -0
- package/dist/apps/exchanges.d.ts.map +1 -0
- package/dist/apps/exchanges.js +3570 -0
- package/dist/apps/exchanges.js.map +1 -0
- package/dist/apps/fee-amm.d.ts +640 -0
- package/dist/apps/fee-amm.d.ts.map +1 -0
- package/dist/apps/fee-amm.js +815 -0
- package/dist/apps/fee-amm.js.map +1 -0
- package/dist/apps/indexer.d.ts +116 -0
- package/dist/apps/indexer.d.ts.map +1 -0
- package/dist/apps/indexer.js +270 -0
- package/dist/apps/indexer.js.map +1 -0
- package/dist/apps/receipts.d.ts +1438 -0
- package/dist/apps/receipts.d.ts.map +1 -0
- package/dist/apps/receipts.js +874 -0
- package/dist/apps/receipts.js.map +1 -0
- package/dist/apps/rpc.d.ts +173 -0
- package/dist/apps/rpc.d.ts.map +1 -0
- package/dist/apps/rpc.js +191 -0
- package/dist/apps/rpc.js.map +1 -0
- package/dist/apps/tokenlist.d.ts +192 -0
- package/dist/apps/tokenlist.d.ts.map +1 -0
- package/dist/apps/tokenlist.js +153 -0
- package/dist/apps/tokenlist.js.map +1 -0
- package/dist/apps/tokens.d.ts +2246 -0
- package/dist/apps/tokens.d.ts.map +1 -0
- package/dist/apps/tokens.js +1789 -0
- package/dist/apps/tokens.js.map +1 -0
- package/dist/apps/transactions.d.ts +2327 -0
- package/dist/apps/transactions.d.ts.map +1 -0
- package/dist/apps/transactions.js +975 -0
- package/dist/apps/transactions.js.map +1 -0
- package/dist/apps/transfers.d.ts +589 -0
- package/dist/apps/transfers.d.ts.map +1 -0
- package/dist/apps/transfers.js +769 -0
- package/dist/apps/transfers.js.map +1 -0
- package/dist/apps/verified-tokens.d.ts +544 -0
- package/dist/apps/verified-tokens.d.ts.map +1 -0
- package/dist/apps/verified-tokens.js +234 -0
- package/dist/apps/verified-tokens.js.map +1 -0
- package/dist/apps/webhooks.d.ts +7684 -0
- package/dist/apps/webhooks.d.ts.map +1 -0
- package/dist/apps/webhooks.js +2112 -0
- package/dist/apps/webhooks.js.map +1 -0
- package/dist/cloudflare.d.ts +32 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +45 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/Auth.d.ts +298 -0
- package/dist/internal/Auth.d.ts.map +1 -0
- package/dist/internal/Auth.js +618 -0
- package/dist/internal/Auth.js.map +1 -0
- package/dist/internal/Cache.d.ts +74 -0
- package/dist/internal/Cache.d.ts.map +1 -0
- package/dist/internal/Cache.js +215 -0
- package/dist/internal/Cache.js.map +1 -0
- package/dist/internal/Cursor.d.ts +79 -0
- package/dist/internal/Cursor.d.ts.map +1 -0
- package/dist/internal/Cursor.js +116 -0
- package/dist/internal/Cursor.js.map +1 -0
- package/dist/internal/EdgeCache.d.ts +59 -0
- package/dist/internal/EdgeCache.d.ts.map +1 -0
- package/dist/internal/EdgeCache.js +174 -0
- package/dist/internal/EdgeCache.js.map +1 -0
- package/dist/internal/Log.d.ts +104 -0
- package/dist/internal/Log.d.ts.map +1 -0
- package/dist/internal/Log.js +118 -0
- package/dist/internal/Log.js.map +1 -0
- package/dist/internal/MetricSink.d.ts +65 -0
- package/dist/internal/MetricSink.d.ts.map +1 -0
- package/dist/internal/MetricSink.js +146 -0
- package/dist/internal/MetricSink.js.map +1 -0
- package/dist/internal/Mpp.d.ts +71 -0
- package/dist/internal/Mpp.d.ts.map +1 -0
- package/dist/internal/Mpp.js +105 -0
- package/dist/internal/Mpp.js.map +1 -0
- package/dist/internal/OpenApi.d.ts +313 -0
- package/dist/internal/OpenApi.d.ts.map +1 -0
- package/dist/internal/OpenApi.fonts.d.ts +2 -0
- package/dist/internal/OpenApi.fonts.d.ts.map +1 -0
- package/dist/internal/OpenApi.fonts.js +39 -0
- package/dist/internal/OpenApi.fonts.js.map +1 -0
- package/dist/internal/OpenApi.js +335 -0
- package/dist/internal/OpenApi.js.map +1 -0
- package/dist/internal/OpenApi.logo.d.ts +6 -0
- package/dist/internal/OpenApi.logo.d.ts.map +1 -0
- package/dist/internal/OpenApi.logo.js +19 -0
- package/dist/internal/OpenApi.logo.js.map +1 -0
- package/dist/internal/Path.d.ts +5 -0
- package/dist/internal/Path.d.ts.map +1 -0
- package/dist/internal/Path.js +16 -0
- package/dist/internal/Path.js.map +1 -0
- package/dist/internal/RateLimit.d.ts +60 -0
- package/dist/internal/RateLimit.d.ts.map +1 -0
- package/dist/internal/RateLimit.js +48 -0
- package/dist/internal/RateLimit.js.map +1 -0
- package/dist/internal/Response.d.ts +132 -0
- package/dist/internal/Response.d.ts.map +1 -0
- package/dist/internal/Response.js +72 -0
- package/dist/internal/Response.js.map +1 -0
- package/dist/internal/Schema.d.ts +257 -0
- package/dist/internal/Schema.d.ts.map +1 -0
- package/dist/internal/Schema.js +360 -0
- package/dist/internal/Schema.js.map +1 -0
- package/dist/internal/Store.d.ts +204 -0
- package/dist/internal/Store.d.ts.map +1 -0
- package/dist/internal/Store.js +291 -0
- package/dist/internal/Store.js.map +1 -0
- package/dist/internal/Tidx.d.ts +120 -0
- package/dist/internal/Tidx.d.ts.map +1 -0
- package/dist/internal/Tidx.js +156 -0
- package/dist/internal/Tidx.js.map +1 -0
- package/dist/internal/Timing.d.ts +22 -0
- package/dist/internal/Timing.d.ts.map +1 -0
- package/dist/internal/Timing.js +32 -0
- package/dist/internal/Timing.js.map +1 -0
- package/dist/internal/Ttl.d.ts +25 -0
- package/dist/internal/Ttl.d.ts.map +1 -0
- package/dist/internal/Ttl.js +49 -0
- package/dist/internal/Ttl.js.map +1 -0
- package/dist/internal/Value.d.ts +47 -0
- package/dist/internal/Value.d.ts.map +1 -0
- package/dist/internal/Value.js +122 -0
- package/dist/internal/Value.js.map +1 -0
- package/dist/internal/VerifiedTokens.d.ts +228 -0
- package/dist/internal/VerifiedTokens.d.ts.map +1 -0
- package/dist/internal/VerifiedTokens.js +450 -0
- package/dist/internal/VerifiedTokens.js.map +1 -0
- package/dist/internal/Viem.d.ts +2082 -0
- package/dist/internal/Viem.d.ts.map +1 -0
- package/dist/internal/Viem.js +63 -0
- package/dist/internal/Viem.js.map +1 -0
- package/dist/internal/WebhookDestination.d.ts +198 -0
- package/dist/internal/WebhookDestination.d.ts.map +1 -0
- package/dist/internal/WebhookDestination.js +170 -0
- package/dist/internal/WebhookDestination.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +433 -0
- package/dist/internal/Webhooks.d.ts.map +1 -0
- package/dist/internal/Webhooks.js +489 -0
- package/dist/internal/Webhooks.js.map +1 -0
- package/dist/internal/webhookDestinations/betterStack.d.ts +26 -0
- package/dist/internal/webhookDestinations/betterStack.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/betterStack.js +76 -0
- package/dist/internal/webhookDestinations/betterStack.js.map +1 -0
- package/dist/internal/webhookDestinations/slack.d.ts +59 -0
- package/dist/internal/webhookDestinations/slack.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/slack.js +422 -0
- package/dist/internal/webhookDestinations/slack.js.map +1 -0
- package/dist/internal/webhookDestinations/url.d.ts +17 -0
- package/dist/internal/webhookDestinations/url.d.ts.map +1 -0
- package/dist/internal/webhookDestinations/url.js +130 -0
- package/dist/internal/webhookDestinations/url.js.map +1 -0
- package/dist/openrpc.json +5302 -0
- package/package.json +46 -4
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000000.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
- package/seeds/31318/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
- package/seeds/31318/verified-tokens.json +30 -0
- package/seeds/4217/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
- package/seeds/4217/icons/0x20c000000000000000000000048c8f36df1c9a4a.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000000520792dcccccccc.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000000a6da882d075a4c3.svg +4 -0
- package/seeds/4217/icons/0x20c000000000000000000000111111111e910f0f.svg +30 -0
- package/seeds/4217/icons/0x20c00000000000000000000014f22ca97301eb73.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000001621e21f71cf12fb.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000002f52d5cc21a3207b.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000003158081efd85bfc2.svg +11 -0
- package/seeds/4217/icons/0x20c0000000000000000000003554d28269e0f3c2.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000383a23bacb546ab9.svg +1 -0
- package/seeds/4217/icons/0x20c00000000000000000000042109aef2f8b28e1.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000005c0bac7cef389a11.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000006fd9a167923ba194.png +0 -0
- package/seeds/4217/icons/0x20c0000000000000000000007f7ba549dd0251b9.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000008191667423f70e67.svg +5 -0
- package/seeds/4217/icons/0x20c0000000000000000000008ee4fcff88888888.svg +1 -0
- package/seeds/4217/icons/0x20c0000000000000000000009a4a4b17e0dc6651.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000ab02d39df30bd17e.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000ae247a1130450f09.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000aeed2ec36a54d0e5.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000b9537d11c60e8b50.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000bd95bfb69fbe6ce3.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000c412ec89d0c08be5.svg +1 -0
- package/seeds/4217/icons/0x20c000000000000000000000f047dd7018e50367.png +0 -0
- package/seeds/4217/verified-tokens.json +177 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000000.svg +25 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000001.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000002.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000000000000000000003.svg +1 -0
- package/seeds/42431/icons/0x20c0000000000000000000009e8d7eb59b783726.svg +1 -0
- package/seeds/42431/icons/0x20c000000000000000000000d72572838bbee59c.svg +1 -0
- package/seeds/42431/verified-tokens.json +44 -0
- package/seeds/openrpc/README.md +27 -0
- package/seeds/openrpc/execution-apis.json +8989 -0
- package/src/ApiKey.test.ts +91 -0
- package/src/ApiKey.ts +122 -0
- package/src/ApiKeySource.test-d.ts +11 -0
- package/src/ApiKeySource.test.ts +295 -0
- package/src/ApiKeySource.ts +303 -0
- package/src/App.test-d.ts +38 -0
- package/src/App.test.ts +65927 -0
- package/src/App.ts +855 -0
- package/src/Assets.ts +58 -0
- package/src/Auth.ts +1 -0
- package/src/Cli.test.ts +81 -0
- package/src/Cli.ts +38 -0
- package/src/Client.test-d.ts +1438 -0
- package/src/Client.test.ts +134 -0
- package/src/Client.ts +45 -0
- package/src/Metrics.ts +154 -0
- package/src/Scope.ts +50 -0
- package/src/Webhooks.test.ts +49 -0
- package/src/Webhooks.ts +172 -0
- package/src/admin/Access.test.ts +130 -0
- package/src/admin/Access.ts +87 -0
- package/src/admin/App.test.ts +225 -0
- package/src/admin/App.ts +216 -0
- package/src/admin/apps/api-keys.test.ts +156 -0
- package/src/admin/apps/api-keys.ts +157 -0
- package/src/admin/apps/scopes.test.ts +52 -0
- package/src/admin/apps/scopes.ts +47 -0
- package/src/admin/apps/verified-tokens.test.ts +415 -0
- package/src/admin/apps/verified-tokens.ts +566 -0
- package/src/admin/index.ts +2 -0
- package/src/admin/ui/plugin.ts +135 -0
- package/src/admin/ui/src/components/Checkbox.tsx +22 -0
- package/src/admin/ui/src/components/Dialog.tsx +32 -0
- package/src/admin/ui/src/components/MobileProfile.tsx +31 -0
- package/src/admin/ui/src/components/Page.tsx +53 -0
- package/src/admin/ui/src/components/TempoLogo.tsx +37 -0
- package/src/admin/ui/src/fonts/Pilat-Demi.woff2 +0 -0
- package/src/admin/ui/src/fonts/Pilat-Regular.woff2 +0 -0
- package/src/admin/ui/src/lib/api-keys.ts +63 -0
- package/src/admin/ui/src/lib/client.ts +15 -0
- package/src/admin/ui/src/lib/config.ts +57 -0
- package/src/admin/ui/src/lib/query-client.ts +26 -0
- package/src/admin/ui/src/lib/scopes.ts +25 -0
- package/src/admin/ui/src/lib/verified-tokens.ts +89 -0
- package/src/admin/ui/src/lib/wagmi.ts +18 -0
- package/src/admin/ui/src/routeTree.gen.ts +104 -0
- package/src/admin/ui/src/router.tsx +25 -0
- package/src/admin/ui/src/routes/__root.tsx +219 -0
- package/src/admin/ui/src/routes/api-keys.tsx +329 -0
- package/src/admin/ui/src/routes/index.tsx +7 -0
- package/src/admin/ui/src/routes/verified-tokens.tsx +331 -0
- package/src/admin/ui/src/styles.css +20 -0
- package/src/admin/ui/tsconfig.json +16 -0
- package/src/admin/ui/vite.config.ts +29 -0
- package/src/admin/ui.generated.ts +60 -0
- package/src/apps/activities.test.ts +1020 -0
- package/src/apps/activities.ts +3934 -0
- package/src/apps/addresses.ts +24 -0
- package/src/apps/balances.test.ts +274 -0
- package/src/apps/balances.ts +533 -0
- package/src/apps/blocks.test.ts +385 -0
- package/src/apps/blocks.ts +896 -0
- package/src/apps/coingecko.test.ts +218 -0
- package/src/apps/coingecko.ts +1383 -0
- package/src/apps/exchanges.test.ts +958 -0
- package/src/apps/exchanges.ts +4773 -0
- package/src/apps/fee-amm.test.ts +321 -0
- package/src/apps/fee-amm.ts +1121 -0
- package/src/apps/indexer.test.ts +153 -0
- package/src/apps/indexer.ts +330 -0
- package/src/apps/receipts.test.ts +285 -0
- package/src/apps/receipts.ts +1193 -0
- package/src/apps/rpc.test.ts +302 -0
- package/src/apps/rpc.ts +222 -0
- package/src/apps/tokenlist.test.ts +80 -0
- package/src/apps/tokenlist.ts +207 -0
- package/src/apps/tokens.test.ts +1062 -0
- package/src/apps/tokens.ts +2292 -0
- package/src/apps/transactions.test.ts +408 -0
- package/src/apps/transactions.ts +1363 -0
- package/src/apps/transfers.test.ts +371 -0
- package/src/apps/transfers.ts +1116 -0
- package/src/apps/verified-tokens.test.ts +114 -0
- package/src/apps/verified-tokens.ts +295 -0
- package/src/apps/webhooks.test.ts +1937 -0
- package/src/apps/webhooks.ts +2926 -0
- package/src/cloudflare.ts +55 -0
- package/src/index.ts +15 -0
- package/src/internal/Auth.test.ts +980 -0
- package/src/internal/Auth.ts +1012 -0
- package/src/internal/Cache.test.ts +240 -0
- package/src/internal/Cache.ts +262 -0
- package/src/internal/Cursor.test.ts +100 -0
- package/src/internal/Cursor.ts +149 -0
- package/src/internal/EdgeCache.test.ts +234 -0
- package/src/internal/EdgeCache.ts +224 -0
- package/src/internal/Errors.ts +14 -0
- package/src/internal/Log.test.ts +244 -0
- package/src/internal/Log.ts +216 -0
- package/src/internal/MetricSink.ts +220 -0
- package/src/internal/Mpp.test.ts +64 -0
- package/src/internal/Mpp.ts +141 -0
- package/src/internal/OpenApi.fonts.ts +38 -0
- package/src/internal/OpenApi.logo.ts +18 -0
- package/src/internal/OpenApi.ts +488 -0
- package/src/internal/Path.test.ts +14 -0
- package/src/internal/Path.ts +15 -0
- package/src/internal/RateLimit.test.ts +93 -0
- package/src/internal/RateLimit.ts +93 -0
- package/src/internal/Response.ts +131 -0
- package/src/internal/Schema.test.ts +34 -0
- package/src/internal/Schema.ts +523 -0
- package/src/internal/Store.test.ts +545 -0
- package/src/internal/Store.ts +526 -0
- package/src/internal/Tidx.local.test.ts +33 -0
- package/src/internal/Tidx.test.ts +294 -0
- package/src/internal/Tidx.ts +238 -0
- package/src/internal/Timing.test.ts +22 -0
- package/src/internal/Timing.ts +62 -0
- package/src/internal/Ttl.test.ts +18 -0
- package/src/internal/Ttl.ts +59 -0
- package/src/internal/Value.ts +114 -0
- package/src/internal/VerifiedTokens.test.ts +333 -0
- package/src/internal/VerifiedTokens.ts +645 -0
- package/src/internal/Viem.test.ts +115 -0
- package/src/internal/Viem.ts +165 -0
- package/src/internal/WebhookDestination.test.ts +648 -0
- package/src/internal/WebhookDestination.ts +331 -0
- package/src/internal/Webhooks.test.ts +668 -0
- package/src/internal/Webhooks.ts +940 -0
- package/src/internal/webhookDestinations/betterStack.ts +91 -0
- package/src/internal/webhookDestinations/slack.ts +496 -0
- package/src/internal/webhookDestinations/url.ts +128 -0
- package/src/openrpc.json +5302 -0
|
@@ -0,0 +1,1937 @@
|
|
|
1
|
+
import { Store, Webhooks as Core } from 'cadent'
|
|
2
|
+
|
|
3
|
+
import * as Tidx from '../internal/Tidx.js'
|
|
4
|
+
import * as Viem from '../internal/Viem.js'
|
|
5
|
+
import * as TestApp from '../../test/App.js'
|
|
6
|
+
import * as Mppx from '../../test/Mppx.js'
|
|
7
|
+
import * as Runtime from '../../test/runtime.js'
|
|
8
|
+
import * as Tempo from '../../test/Tempo.js'
|
|
9
|
+
import * as Webhooks from './webhooks.js'
|
|
10
|
+
|
|
11
|
+
/** API key with both webhook scopes, owned by `org_test`. */
|
|
12
|
+
const writer = {
|
|
13
|
+
id: 'key_writer',
|
|
14
|
+
orgId: 'org_test',
|
|
15
|
+
scopes: ['webhooks:read', 'webhooks:write'],
|
|
16
|
+
token: 'secret_writer',
|
|
17
|
+
} satisfies TestApp.source.Key
|
|
18
|
+
|
|
19
|
+
/** API key with read-only webhook scope, owned by `org_test`. */
|
|
20
|
+
const reader = {
|
|
21
|
+
id: 'key_reader',
|
|
22
|
+
orgId: 'org_test',
|
|
23
|
+
scopes: ['webhooks:read'],
|
|
24
|
+
token: 'secret_reader',
|
|
25
|
+
} satisfies TestApp.source.Key
|
|
26
|
+
|
|
27
|
+
/** API key in a different org, used to prove owner isolation. */
|
|
28
|
+
const other = {
|
|
29
|
+
id: 'key_other',
|
|
30
|
+
orgId: 'org_other',
|
|
31
|
+
scopes: ['webhooks:read', 'webhooks:write'],
|
|
32
|
+
token: 'secret_other',
|
|
33
|
+
} satisfies TestApp.source.Key
|
|
34
|
+
|
|
35
|
+
/** Creates a webhooks-enabled test client backed by an in-memory state store. */
|
|
36
|
+
function client(keys: readonly TestApp.source.Key[] = [writer, reader, other]) {
|
|
37
|
+
return TestApp.client({
|
|
38
|
+
auth: { source: TestApp.source({ keys }) },
|
|
39
|
+
webhook: { store: Store.memory() },
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** RequestInit carrying a bearer token for the given key. */
|
|
44
|
+
function as(key: { token: string }) {
|
|
45
|
+
return { headers: { authorization: `Bearer ${key.token}` } } as const
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal valid creation payload. */
|
|
49
|
+
const validBody = {
|
|
50
|
+
destination: { type: 'url', url: 'https://example.com/hook' },
|
|
51
|
+
eventType: 'token:transfer',
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
describe('feature gate', () => {
|
|
55
|
+
test('returns 404 when no state store is configured', async () => {
|
|
56
|
+
// Routes stay mounted for a stable schema, but the resource is disabled.
|
|
57
|
+
// `event-types` requires `data:read`, so seed a key that holds it to reach
|
|
58
|
+
// the feature gate rather than tripping the scope check first.
|
|
59
|
+
const dataReader = {
|
|
60
|
+
id: 'key_data_reader',
|
|
61
|
+
orgId: 'org_test',
|
|
62
|
+
scopes: ['data:read'],
|
|
63
|
+
token: 'secret_data_reader',
|
|
64
|
+
} satisfies TestApp.source.Key
|
|
65
|
+
const disabled = TestApp.client({ auth: { source: TestApp.source({ keys: [dataReader] }) } })
|
|
66
|
+
const response = await disabled.v1.webhooks['event-types'].$get({}, as(dataReader))
|
|
67
|
+
|
|
68
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
69
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
70
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"webhooks_not_enabled"`)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('access matrix', () => {
|
|
75
|
+
test('rejects anonymous reads with 401 even when MPP is enabled (API-key-only)', async () => {
|
|
76
|
+
// Webhooks opt out of the paid (MPP) lane for now, so an anonymous caller on
|
|
77
|
+
// a `public: false` route (`GET /webhooks`) is rejected outright rather than
|
|
78
|
+
// offered the paid path — even though MPP is enabled app-wide by default.
|
|
79
|
+
const response = await client().v1.webhooks.$get({ query: {} })
|
|
80
|
+
|
|
81
|
+
expect(response.status).toMatchInlineSnapshot(`401`)
|
|
82
|
+
expect(response.headers.has('www-authenticate')).toMatchInlineSnapshot(`false`)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('rejects an invalid key', async () => {
|
|
86
|
+
const response = await client().v1.webhooks.$get(
|
|
87
|
+
{ query: {} },
|
|
88
|
+
{ headers: { authorization: 'Bearer nope' } },
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(response.status).toMatchInlineSnapshot(`401`)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('rejects a key missing the read scope', async () => {
|
|
95
|
+
const noScope = {
|
|
96
|
+
id: 'key_noscope',
|
|
97
|
+
orgId: 'org_test',
|
|
98
|
+
scopes: ['data:read'],
|
|
99
|
+
token: 'secret_noscope',
|
|
100
|
+
} satisfies TestApp.source.Key
|
|
101
|
+
const response = await client([noScope]).v1.webhooks.$get({ query: {} }, as(noScope))
|
|
102
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
103
|
+
|
|
104
|
+
expect(response.status).toMatchInlineSnapshot(`403`)
|
|
105
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('rejects a read-only key on writes', async () => {
|
|
109
|
+
const response = await client().v1.webhooks.$post({ json: validBody }, as(reader))
|
|
110
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
111
|
+
|
|
112
|
+
expect(response.status).toMatchInlineSnapshot(`403`)
|
|
113
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('lists event types without authentication (public catalog)', async () => {
|
|
117
|
+
// `GET /webhooks/event-types` is intentionally public: anonymous callers are
|
|
118
|
+
// served within the public quota, no key or scope required.
|
|
119
|
+
const response = await client().v1.webhooks['event-types'].$get({})
|
|
120
|
+
const body = await TestApp.json(response, Webhooks.schema.getWebhookEventTypes.Response)
|
|
121
|
+
|
|
122
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
123
|
+
// The catalog is static public data and must be cacheable (`stable`),
|
|
124
|
+
// overriding the resource-wide `no-store` default for subscriptions —
|
|
125
|
+
// without it every request pays the full auth/rate-limit floor.
|
|
126
|
+
expect(response.headers.get('cache-control')).toMatchInlineSnapshot(
|
|
127
|
+
`"private, max-age=300, stale-while-revalidate=3600"`,
|
|
128
|
+
)
|
|
129
|
+
expect(body.data.map((entry) => entry.type)).toMatchInlineSnapshot(`
|
|
130
|
+
[
|
|
131
|
+
"block:created",
|
|
132
|
+
"log:emitted",
|
|
133
|
+
"token:transfer",
|
|
134
|
+
"transaction:included",
|
|
135
|
+
]
|
|
136
|
+
`)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('lists event types for a key with the data:read scope', async () => {
|
|
140
|
+
const dataReader = {
|
|
141
|
+
id: 'key_data_reader',
|
|
142
|
+
orgId: 'org_test',
|
|
143
|
+
scopes: ['data:read'],
|
|
144
|
+
token: 'secret_data_reader',
|
|
145
|
+
} satisfies TestApp.source.Key
|
|
146
|
+
const response = await client([dataReader]).v1.webhooks['event-types'].$get({}, as(dataReader))
|
|
147
|
+
|
|
148
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('rejects a key lacking the data:read scope', async () => {
|
|
152
|
+
const response = await client().v1.webhooks['event-types'].$get({}, as(reader))
|
|
153
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
154
|
+
|
|
155
|
+
expect(response.status).toMatchInlineSnapshot(`403`)
|
|
156
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"api_key_forbidden"`)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('CRUD lifecycle', () => {
|
|
161
|
+
test('creates, reads, lists, updates, and deletes a subscription', async () => {
|
|
162
|
+
const app = client()
|
|
163
|
+
|
|
164
|
+
// Create returns the one-time signing secret.
|
|
165
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
166
|
+
const createdBody = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
167
|
+
expect(created.status).toMatchInlineSnapshot(`200`)
|
|
168
|
+
expect(createdBody.id.startsWith('wh_')).toBe(true)
|
|
169
|
+
expect(createdBody.secret.startsWith('whsec_')).toBe(true)
|
|
170
|
+
expect(createdBody.status).toMatchInlineSnapshot(`"active"`)
|
|
171
|
+
expect(createdBody.destination.url).toMatchInlineSnapshot(`"https://example.com/hook"`)
|
|
172
|
+
// API-key-owned subscriptions never expire.
|
|
173
|
+
expect(createdBody.expiresAt).toMatchInlineSnapshot(`undefined`)
|
|
174
|
+
expect(createdBody).not.toHaveProperty('owner')
|
|
175
|
+
|
|
176
|
+
// Reads never expose the secret.
|
|
177
|
+
const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
|
|
178
|
+
{ param: { id: createdBody.id } },
|
|
179
|
+
as(writer),
|
|
180
|
+
)
|
|
181
|
+
const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
|
|
182
|
+
expect(read.status).toMatchInlineSnapshot(`200`)
|
|
183
|
+
expect(readBody.id).toBe(createdBody.id)
|
|
184
|
+
expect(readBody).not.toHaveProperty('secret')
|
|
185
|
+
expect(read.headers.get('cache-control')).toMatchInlineSnapshot(`"no-store"`)
|
|
186
|
+
|
|
187
|
+
// List shows the subscription.
|
|
188
|
+
const listed = await app.v1.webhooks.$get({ query: {} }, as(writer))
|
|
189
|
+
const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
|
|
190
|
+
expect(listed.status).toMatchInlineSnapshot(`200`)
|
|
191
|
+
expect(listedBody.data.map((entry) => entry.id)).toContain(createdBody.id)
|
|
192
|
+
|
|
193
|
+
// Patch pauses it.
|
|
194
|
+
const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
|
|
195
|
+
{ json: { status: 'paused' }, param: { id: createdBody.id } },
|
|
196
|
+
as(writer),
|
|
197
|
+
)
|
|
198
|
+
const patchedBody = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response)
|
|
199
|
+
expect(patched.status).toMatchInlineSnapshot(`200`)
|
|
200
|
+
expect(patchedBody.status).toMatchInlineSnapshot(`"paused"`)
|
|
201
|
+
|
|
202
|
+
// Delete removes it, after which reads 404.
|
|
203
|
+
const deleted = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete(
|
|
204
|
+
{ param: { id: createdBody.id } },
|
|
205
|
+
as(writer),
|
|
206
|
+
)
|
|
207
|
+
const deletedBody = await TestApp.json(deleted, Webhooks.schema.deleteWebhook.Response)
|
|
208
|
+
expect(deleted.status).toMatchInlineSnapshot(`200`)
|
|
209
|
+
expect(deletedBody.id).toBe(createdBody.id)
|
|
210
|
+
|
|
211
|
+
const missing = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
|
|
212
|
+
{ param: { id: createdBody.id } },
|
|
213
|
+
as(writer),
|
|
214
|
+
)
|
|
215
|
+
expect(missing.status).toMatchInlineSnapshot(`404`)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('embeds the exact subscription total via include=totalCount', async () => {
|
|
219
|
+
const app = client()
|
|
220
|
+
// Two subscriptions under the writer's owner.
|
|
221
|
+
await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
222
|
+
await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
223
|
+
|
|
224
|
+
const listed = await app.v1.webhooks.$get(
|
|
225
|
+
{ query: { include: 'totalCount', limit: '5' } },
|
|
226
|
+
as(writer),
|
|
227
|
+
)
|
|
228
|
+
const body = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
|
|
229
|
+
expect(listed.status).toBe(200)
|
|
230
|
+
// The count is the owner total, exact.
|
|
231
|
+
expect(body.data.length).toBe(2)
|
|
232
|
+
expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 2 })
|
|
233
|
+
|
|
234
|
+
// Without the include, `meta` is omitted.
|
|
235
|
+
const bare = await app.v1.webhooks.$get({ query: { limit: '5' } }, as(writer))
|
|
236
|
+
const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhooks.Response)
|
|
237
|
+
expect(bareBody.meta).toBeUndefined()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('a sibling key in the same org manages the org subscription', async () => {
|
|
241
|
+
const app = client()
|
|
242
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
243
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
244
|
+
|
|
245
|
+
// `reader` shares `org_test`, so it can read but not mutate (scope-gated).
|
|
246
|
+
const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(reader))
|
|
247
|
+
expect(read.status).toMatchInlineSnapshot(`200`)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('owner isolation', () => {
|
|
252
|
+
test('a different org cannot see or mutate another org subscription', async () => {
|
|
253
|
+
const app = client()
|
|
254
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
255
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
256
|
+
|
|
257
|
+
const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(other))
|
|
258
|
+
expect(read.status).toMatchInlineSnapshot(`404`)
|
|
259
|
+
|
|
260
|
+
const removed = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$delete(
|
|
261
|
+
{ param: { id } },
|
|
262
|
+
as(other),
|
|
263
|
+
)
|
|
264
|
+
expect(removed.status).toMatchInlineSnapshot(`404`)
|
|
265
|
+
|
|
266
|
+
const listed = await app.v1.webhooks.$get({ query: {} }, as(other))
|
|
267
|
+
const listedBody = await TestApp.json(listed, Webhooks.schema.listWebhooks.Response)
|
|
268
|
+
expect(listedBody.data).toMatchInlineSnapshot(`[]`)
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
describe('subscription limits', () => {
|
|
273
|
+
test('rejects creation past the configured per-owner cap with 403 limit_exceeded', async () => {
|
|
274
|
+
const app = TestApp.client({
|
|
275
|
+
auth: { source: TestApp.source({ keys: [writer, other] }) },
|
|
276
|
+
webhook: { maxPerOwner: 2, store: Store.memory() },
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// Fill the writer's quota.
|
|
280
|
+
for (let i = 0; i < 2; i++) {
|
|
281
|
+
const ok = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
282
|
+
expect(ok.status).toMatchInlineSnapshot(`200`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The third create is over the cap.
|
|
286
|
+
const over = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
287
|
+
expect(over.status).toMatchInlineSnapshot(`403`)
|
|
288
|
+
const body = (await over.json()) as { error?: { code?: string } }
|
|
289
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"limit_exceeded"`)
|
|
290
|
+
|
|
291
|
+
// The cap is per-owner: a different org is unaffected.
|
|
292
|
+
const otherOrg = await app.v1.webhooks.$post({ json: validBody }, as(other))
|
|
293
|
+
expect(otherOrg.status).toMatchInlineSnapshot(`200`)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
describe('delivery log', () => {
|
|
298
|
+
/** A delivery row with a lexically-sortable id, mirroring the core test helper. */
|
|
299
|
+
function row(subscriptionId: string, n: number): Core.Delivery {
|
|
300
|
+
return {
|
|
301
|
+
attempt: n,
|
|
302
|
+
createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(),
|
|
303
|
+
envelope: {
|
|
304
|
+
chainId: 4217,
|
|
305
|
+
createdAt: new Date(1_700_000_000_000 + n * 1_000).toISOString(),
|
|
306
|
+
data: { amount: String(n) },
|
|
307
|
+
id: `evt_${n}`,
|
|
308
|
+
subscriptionId,
|
|
309
|
+
type: 'token:transfer',
|
|
310
|
+
},
|
|
311
|
+
eventId: `evt_${n}`,
|
|
312
|
+
id: `whd_${String(1_700_000_000_000 + n * 1_000).padStart(15, '0')}_${'0'.repeat(16)}`,
|
|
313
|
+
requestUrl: 'https://example.com/hook',
|
|
314
|
+
status: 'succeeded',
|
|
315
|
+
subscriptionId,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Seeds a subscription owned by `writer` plus `count` delivery rows. */
|
|
320
|
+
async function seed(store: ReturnType<typeof Store.memory>, count: number) {
|
|
321
|
+
const app = TestApp.client({
|
|
322
|
+
auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
|
|
323
|
+
webhook: { store },
|
|
324
|
+
})
|
|
325
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
326
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
327
|
+
for (let n = 1; n <= count; n++) await Core.recordDelivery(store, row(id, n))
|
|
328
|
+
return { app, id }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
test('lists a subscription deliveries newest-first', async () => {
|
|
332
|
+
const store = Store.memory()
|
|
333
|
+
const { app, id } = await seed(store, 3)
|
|
334
|
+
|
|
335
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
336
|
+
{ param: { id }, query: {} },
|
|
337
|
+
as(writer),
|
|
338
|
+
)
|
|
339
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
340
|
+
const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
|
|
341
|
+
expect(body.data.map((d) => d.eventId)).toMatchInlineSnapshot(`
|
|
342
|
+
[
|
|
343
|
+
"evt_3",
|
|
344
|
+
"evt_2",
|
|
345
|
+
"evt_1",
|
|
346
|
+
]
|
|
347
|
+
`)
|
|
348
|
+
expect(body.nextCursor).toMatchInlineSnapshot(`null`)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('embeds the exact delivery total via include=totalCount', async () => {
|
|
352
|
+
const store = Store.memory()
|
|
353
|
+
const { app, id } = await seed(store, 8)
|
|
354
|
+
|
|
355
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
356
|
+
{ param: { id }, query: { include: 'totalCount', limit: '5' } },
|
|
357
|
+
as(writer),
|
|
358
|
+
)
|
|
359
|
+
const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
|
|
360
|
+
expect(response.status).toBe(200)
|
|
361
|
+
// The count is the full delivery total, independent of the page `limit`.
|
|
362
|
+
expect(body.data.length).toBe(5)
|
|
363
|
+
expect(body.meta).toEqual({ totalCountCapped: false, totalCount: 8 })
|
|
364
|
+
|
|
365
|
+
// Without the include, `meta` is omitted.
|
|
366
|
+
const bare = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
367
|
+
{ param: { id }, query: {} },
|
|
368
|
+
as(writer),
|
|
369
|
+
)
|
|
370
|
+
const bareBody = await TestApp.json(bare, Webhooks.schema.listWebhookDeliveries.Response)
|
|
371
|
+
expect(bareBody.meta).toBeUndefined()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test('paginates with cursor and limit', async () => {
|
|
375
|
+
const store = Store.memory()
|
|
376
|
+
const { app, id } = await seed(store, 6)
|
|
377
|
+
|
|
378
|
+
const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
379
|
+
{ param: { id }, query: { limit: '5' } },
|
|
380
|
+
as(writer),
|
|
381
|
+
)
|
|
382
|
+
const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response)
|
|
383
|
+
expect(firstBody.data.map((d) => d.eventId)).toEqual([
|
|
384
|
+
'evt_6',
|
|
385
|
+
'evt_5',
|
|
386
|
+
'evt_4',
|
|
387
|
+
'evt_3',
|
|
388
|
+
'evt_2',
|
|
389
|
+
])
|
|
390
|
+
|
|
391
|
+
const second = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
392
|
+
{ param: { id }, query: { cursor: firstBody.nextCursor!, limit: '5' } },
|
|
393
|
+
as(writer),
|
|
394
|
+
)
|
|
395
|
+
const secondBody = await TestApp.json(second, Webhooks.schema.listWebhookDeliveries.Response)
|
|
396
|
+
expect(secondBody.data.map((d) => d.eventId)).toEqual(['evt_1'])
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
test('serves a positional page via `page` (deterministic in-memory slice)', async () => {
|
|
400
|
+
const store = Store.memory()
|
|
401
|
+
const { app, id } = await seed(store, 6)
|
|
402
|
+
|
|
403
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
404
|
+
{ param: { id }, query: { limit: '5', page: '2' } },
|
|
405
|
+
as(writer),
|
|
406
|
+
)
|
|
407
|
+
const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response)
|
|
408
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
409
|
+
// Newest-first feed of evt_6..evt_1; page 2 at limit 5 holds the last row.
|
|
410
|
+
expect(body.data.map((d) => d.eventId)).toEqual(['evt_1'])
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('rejects `page` combined with `cursor`', async () => {
|
|
414
|
+
const store = Store.memory()
|
|
415
|
+
const { app, id } = await seed(store, 6)
|
|
416
|
+
|
|
417
|
+
const first = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
418
|
+
{ param: { id }, query: { limit: '5' } },
|
|
419
|
+
as(writer),
|
|
420
|
+
)
|
|
421
|
+
const firstBody = await TestApp.json(first, Webhooks.schema.listWebhookDeliveries.Response)
|
|
422
|
+
|
|
423
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
424
|
+
{ param: { id }, query: { cursor: firstBody.nextCursor!, page: '2' } },
|
|
425
|
+
as(writer),
|
|
426
|
+
)
|
|
427
|
+
expect(response.status).toMatchInlineSnapshot(`400`)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
test('a read-only key can list deliveries', async () => {
|
|
431
|
+
const store = Store.memory()
|
|
432
|
+
const { app, id } = await seed(store, 1)
|
|
433
|
+
|
|
434
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
435
|
+
{ param: { id }, query: {} },
|
|
436
|
+
as(reader),
|
|
437
|
+
)
|
|
438
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
test('a different org cannot read another org deliveries', async () => {
|
|
442
|
+
const store = Store.memory()
|
|
443
|
+
const { app, id } = await seed(store, 1)
|
|
444
|
+
|
|
445
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
446
|
+
{ param: { id }, query: {} },
|
|
447
|
+
as(other),
|
|
448
|
+
)
|
|
449
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
test('404s for an unknown subscription', async () => {
|
|
453
|
+
const app = client()
|
|
454
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
455
|
+
{ param: { id: 'wh_missing' }, query: {} },
|
|
456
|
+
as(writer),
|
|
457
|
+
)
|
|
458
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe('SSRF protection', () => {
|
|
463
|
+
test('rejects a non-https callback URL', async () => {
|
|
464
|
+
const response = await client().v1.webhooks.$post(
|
|
465
|
+
{ json: { ...validBody, destination: { type: 'url', url: 'http://example.com/hook' } } },
|
|
466
|
+
as(writer),
|
|
467
|
+
)
|
|
468
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
469
|
+
|
|
470
|
+
expect(response.status).toMatchInlineSnapshot(`400`)
|
|
471
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
test('rejects the cloud metadata host', async () => {
|
|
475
|
+
const response = await client().v1.webhooks.$post(
|
|
476
|
+
{
|
|
477
|
+
json: {
|
|
478
|
+
...validBody,
|
|
479
|
+
destination: { type: 'url', url: 'https://169.254.169.254/latest/meta-data' },
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
as(writer),
|
|
483
|
+
)
|
|
484
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
485
|
+
|
|
486
|
+
expect(response.status).toMatchInlineSnapshot(`400`)
|
|
487
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
|
|
488
|
+
})
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
describe('subscription context', () => {
|
|
492
|
+
test('round-trips context through create and read', async () => {
|
|
493
|
+
const app = TestApp.client({
|
|
494
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
495
|
+
webhook: { store: Store.memory() },
|
|
496
|
+
})
|
|
497
|
+
const context = {
|
|
498
|
+
description: 'Notify #ops when a large USDC transfer settles.',
|
|
499
|
+
metadata: { env: 'prod', team: 'payments' },
|
|
500
|
+
title: 'Prod USDC large transfers',
|
|
501
|
+
}
|
|
502
|
+
const created = await app.v1.webhooks.$post(
|
|
503
|
+
{
|
|
504
|
+
json: {
|
|
505
|
+
context,
|
|
506
|
+
destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
|
|
507
|
+
eventType: 'token:transfer',
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
as(writer),
|
|
511
|
+
)
|
|
512
|
+
expect(created.status).toBe(200)
|
|
513
|
+
const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
514
|
+
expect(body.context).toEqual(context)
|
|
515
|
+
|
|
516
|
+
const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get(
|
|
517
|
+
{ param: { id: body.id } },
|
|
518
|
+
as(writer),
|
|
519
|
+
)
|
|
520
|
+
const readBody = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
|
|
521
|
+
expect(readBody.context).toEqual(context)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
test('clears context with an explicit null patch', async () => {
|
|
525
|
+
const app = TestApp.client({
|
|
526
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
527
|
+
webhook: { store: Store.memory() },
|
|
528
|
+
})
|
|
529
|
+
const created = await app.v1.webhooks.$post(
|
|
530
|
+
{
|
|
531
|
+
json: {
|
|
532
|
+
context: { title: 'Temporary' },
|
|
533
|
+
destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
|
|
534
|
+
eventType: 'token:transfer',
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
as(writer),
|
|
538
|
+
)
|
|
539
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
540
|
+
|
|
541
|
+
const patched = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$patch(
|
|
542
|
+
{ json: { context: null }, param: { id } },
|
|
543
|
+
as(writer),
|
|
544
|
+
)
|
|
545
|
+
const body = await TestApp.json(patched, Webhooks.schema.updateWebhook.Response)
|
|
546
|
+
expect(body.context).toBeUndefined()
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
test('rejects metadata that exceeds the entry limit', async () => {
|
|
550
|
+
const app = TestApp.client({
|
|
551
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
552
|
+
webhook: { store: Store.memory() },
|
|
553
|
+
})
|
|
554
|
+
const metadata = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`key${i}`, 'value']))
|
|
555
|
+
const created = await app.v1.webhooks.$post(
|
|
556
|
+
{
|
|
557
|
+
json: {
|
|
558
|
+
context: { metadata },
|
|
559
|
+
destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/xxxx' },
|
|
560
|
+
eventType: 'token:transfer',
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
as(writer),
|
|
564
|
+
)
|
|
565
|
+
expect(created.status).toBe(400)
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
describe('slack destination', () => {
|
|
570
|
+
test('creates a subscription with a slack incoming-webhook URL', async () => {
|
|
571
|
+
const app = TestApp.client({
|
|
572
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
573
|
+
webhook: { store: Store.memory() },
|
|
574
|
+
})
|
|
575
|
+
const created = await app.v1.webhooks.$post(
|
|
576
|
+
{
|
|
577
|
+
json: {
|
|
578
|
+
destination: {
|
|
579
|
+
type: 'slack',
|
|
580
|
+
url: 'https://hooks.slack.com/services/T000/B000/xxxx',
|
|
581
|
+
},
|
|
582
|
+
eventType: 'token:transfer',
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
as(writer),
|
|
586
|
+
)
|
|
587
|
+
expect(created.status).toMatchInlineSnapshot(`200`)
|
|
588
|
+
const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
589
|
+
expect(body.destination).toMatchInlineSnapshot(`
|
|
590
|
+
{
|
|
591
|
+
"type": "slack",
|
|
592
|
+
"url": "https://hooks.slack.com/services/T000/B000/xxxx",
|
|
593
|
+
}
|
|
594
|
+
`)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('rejects a slack destination whose host is not hooks.slack.com', async () => {
|
|
598
|
+
const app = TestApp.client({
|
|
599
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
600
|
+
webhook: { store: Store.memory() },
|
|
601
|
+
})
|
|
602
|
+
const response = await app.v1.webhooks.$post(
|
|
603
|
+
{
|
|
604
|
+
json: {
|
|
605
|
+
destination: { type: 'slack', url: 'https://evil.example.com/hook' },
|
|
606
|
+
eventType: 'token:transfer',
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
as(writer),
|
|
610
|
+
)
|
|
611
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
612
|
+
|
|
613
|
+
expect(response.status).toMatchInlineSnapshot(`400`)
|
|
614
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
|
|
615
|
+
})
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
describe('betterstack destination', () => {
|
|
619
|
+
test('creates a subscription with a Better Stack source', async () => {
|
|
620
|
+
const app = TestApp.client({
|
|
621
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
622
|
+
webhook: { store: Store.memory() },
|
|
623
|
+
})
|
|
624
|
+
const created = await app.v1.webhooks.$post(
|
|
625
|
+
{
|
|
626
|
+
json: {
|
|
627
|
+
destination: {
|
|
628
|
+
token: 'src_token',
|
|
629
|
+
type: 'betterstack',
|
|
630
|
+
url: 'https://s1234567.eu-nbg-2.betterstackdata.com',
|
|
631
|
+
},
|
|
632
|
+
eventType: 'token:transfer',
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
as(writer),
|
|
636
|
+
)
|
|
637
|
+
expect(created.status).toMatchInlineSnapshot(`200`)
|
|
638
|
+
const body = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
639
|
+
expect(body.destination).toMatchInlineSnapshot(`
|
|
640
|
+
{
|
|
641
|
+
"token": "src_token",
|
|
642
|
+
"type": "betterstack",
|
|
643
|
+
"url": "https://s1234567.eu-nbg-2.betterstackdata.com",
|
|
644
|
+
}
|
|
645
|
+
`)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
test('rejects a Better Stack destination whose host is not betterstackdata.com', async () => {
|
|
649
|
+
const app = TestApp.client({
|
|
650
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
651
|
+
webhook: { store: Store.memory() },
|
|
652
|
+
})
|
|
653
|
+
const response = await app.v1.webhooks.$post(
|
|
654
|
+
{
|
|
655
|
+
json: {
|
|
656
|
+
destination: {
|
|
657
|
+
token: 'src_token',
|
|
658
|
+
type: 'betterstack',
|
|
659
|
+
url: 'https://evil.example.com/ingest',
|
|
660
|
+
},
|
|
661
|
+
eventType: 'token:transfer',
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
as(writer),
|
|
665
|
+
)
|
|
666
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
667
|
+
|
|
668
|
+
expect(response.status).toMatchInlineSnapshot(`400`)
|
|
669
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"url_invalid"`)
|
|
670
|
+
})
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
describe('ping', () => {
|
|
674
|
+
afterEach(() => {
|
|
675
|
+
vi.restoreAllMocks()
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
/** Seeds a webhooks-enabled client with a subscription owned by `writer`. */
|
|
679
|
+
async function seed() {
|
|
680
|
+
const app = TestApp.client({
|
|
681
|
+
auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
|
|
682
|
+
webhook: { store: Store.memory() },
|
|
683
|
+
})
|
|
684
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
685
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
686
|
+
return { app, id }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
test('delivers a synthetic ping and returns the result', async () => {
|
|
690
|
+
const { app, id } = await seed()
|
|
691
|
+
|
|
692
|
+
const calls: { signature: string | null }[] = []
|
|
693
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => {
|
|
694
|
+
calls.push({ signature: new Headers(init?.headers).get('tempo-signature') })
|
|
695
|
+
return new Response(null, { status: 200 })
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
|
|
699
|
+
{ param: { id } },
|
|
700
|
+
as(writer),
|
|
701
|
+
)
|
|
702
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
703
|
+
const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response)
|
|
704
|
+
expect(body.delivered).toMatchInlineSnapshot(`true`)
|
|
705
|
+
expect(body.responseStatus).toMatchInlineSnapshot(`200`)
|
|
706
|
+
expect(body.eventId).toMatch(/^evt_[0-9a-f]{64}$/)
|
|
707
|
+
|
|
708
|
+
// A single signed request reached the endpoint.
|
|
709
|
+
expect(calls).toHaveLength(1)
|
|
710
|
+
expect(calls[0]?.signature).toBeTruthy()
|
|
711
|
+
|
|
712
|
+
// The ping is observable in the delivery log under the same event id.
|
|
713
|
+
const deliveries = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
714
|
+
{ param: { id }, query: {} },
|
|
715
|
+
as(writer),
|
|
716
|
+
)
|
|
717
|
+
const log = await TestApp.json(deliveries, Webhooks.schema.listWebhookDeliveries.Response)
|
|
718
|
+
expect(log.data).toHaveLength(1)
|
|
719
|
+
expect(log.data[0]?.eventId).toBe(body.eventId)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('reports a failed delivery without disabling the subscription', async () => {
|
|
723
|
+
const { app, id } = await seed()
|
|
724
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 }))
|
|
725
|
+
|
|
726
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
|
|
727
|
+
{ param: { id } },
|
|
728
|
+
as(writer),
|
|
729
|
+
)
|
|
730
|
+
const body = await TestApp.json(response, Webhooks.schema.pingWebhook.Response)
|
|
731
|
+
expect(body.delivered).toMatchInlineSnapshot(`false`)
|
|
732
|
+
expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`)
|
|
733
|
+
|
|
734
|
+
// A failed test ping must leave a healthy subscription active.
|
|
735
|
+
const read = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].$get({ param: { id } }, as(writer))
|
|
736
|
+
const subscription = await TestApp.json(read, Webhooks.schema.getWebhook.Response)
|
|
737
|
+
expect(subscription.status).toMatchInlineSnapshot(`"active"`)
|
|
738
|
+
expect(subscription.failureCount).toMatchInlineSnapshot(`0`)
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test('a read-only key cannot ping', async () => {
|
|
742
|
+
const { app, id } = await seed()
|
|
743
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
|
|
744
|
+
{ param: { id } },
|
|
745
|
+
as(reader),
|
|
746
|
+
)
|
|
747
|
+
expect(response.status).toMatchInlineSnapshot(`403`)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
test('a different org cannot ping another org subscription', async () => {
|
|
751
|
+
const { app, id } = await seed()
|
|
752
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
|
|
753
|
+
{ param: { id } },
|
|
754
|
+
as(other),
|
|
755
|
+
)
|
|
756
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
test('404s for an unknown subscription', async () => {
|
|
760
|
+
const app = client()
|
|
761
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].ping.$post(
|
|
762
|
+
{ param: { id: 'wh_missing' } },
|
|
763
|
+
as(writer),
|
|
764
|
+
)
|
|
765
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
766
|
+
})
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
describe('retry delivery', () => {
|
|
770
|
+
afterEach(() => {
|
|
771
|
+
vi.restoreAllMocks()
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
const deliveryId = 'whd_001700000000000_0000000000000000'
|
|
775
|
+
|
|
776
|
+
/** Seeds a webhooks client with a subscription plus one logged delivery to replay. */
|
|
777
|
+
async function seed() {
|
|
778
|
+
const store = Store.memory()
|
|
779
|
+
const app = TestApp.client({
|
|
780
|
+
auth: { source: TestApp.source({ keys: [writer, reader, other] }) },
|
|
781
|
+
webhook: { store },
|
|
782
|
+
})
|
|
783
|
+
const created = await app.v1.webhooks.$post({ json: validBody }, as(writer))
|
|
784
|
+
const { id } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
785
|
+
await Core.recordDelivery(store, {
|
|
786
|
+
attempt: 1,
|
|
787
|
+
createdAt: new Date(1_700_000_000_000).toISOString(),
|
|
788
|
+
envelope: {
|
|
789
|
+
chainId: 4217,
|
|
790
|
+
createdAt: new Date(1_700_000_000_000).toISOString(),
|
|
791
|
+
data: { amount: '1' },
|
|
792
|
+
id: 'evt_seed',
|
|
793
|
+
subscriptionId: id,
|
|
794
|
+
type: 'token:transfer',
|
|
795
|
+
},
|
|
796
|
+
eventId: 'evt_seed',
|
|
797
|
+
id: deliveryId,
|
|
798
|
+
requestUrl: 'https://example.com/hook',
|
|
799
|
+
status: 'failed',
|
|
800
|
+
subscriptionId: id,
|
|
801
|
+
})
|
|
802
|
+
return { app, id, store }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
test('replays the stored envelope and records a new attempt', async () => {
|
|
806
|
+
const { app, id, store } = await seed()
|
|
807
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
|
|
808
|
+
|
|
809
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
810
|
+
':deliveryId'
|
|
811
|
+
].retry.$post({ param: { deliveryId, id } }, as(writer))
|
|
812
|
+
expect(response.status).toMatchInlineSnapshot(`200`)
|
|
813
|
+
const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response)
|
|
814
|
+
expect(body.delivered).toMatchInlineSnapshot(`true`)
|
|
815
|
+
expect(body.eventId).toMatchInlineSnapshot(`"evt_seed"`)
|
|
816
|
+
|
|
817
|
+
// A new attempt was appended alongside the seeded row.
|
|
818
|
+
expect(await Core.listDeliveries(store, id)).toHaveLength(2)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('a failed replay reports the error', async () => {
|
|
822
|
+
const { app, id } = await seed()
|
|
823
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 500 }))
|
|
824
|
+
|
|
825
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
826
|
+
':deliveryId'
|
|
827
|
+
].retry.$post({ param: { deliveryId, id } }, as(writer))
|
|
828
|
+
const body = await TestApp.json(response, Webhooks.schema.retryWebhookDelivery.Response)
|
|
829
|
+
expect(body.delivered).toMatchInlineSnapshot(`false`)
|
|
830
|
+
expect(body.error).toMatchInlineSnapshot(`"non-2xx response (500)"`)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test('a read-only key cannot retry', async () => {
|
|
834
|
+
const { app, id } = await seed()
|
|
835
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
836
|
+
':deliveryId'
|
|
837
|
+
].retry.$post({ param: { deliveryId, id } }, as(reader))
|
|
838
|
+
expect(response.status).toMatchInlineSnapshot(`403`)
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
test('a different org cannot retry another org delivery', async () => {
|
|
842
|
+
const { app, id } = await seed()
|
|
843
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
844
|
+
':deliveryId'
|
|
845
|
+
].retry.$post({ param: { deliveryId, id } }, as(other))
|
|
846
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
test('404s with delivery_not_found for an unknown delivery', async () => {
|
|
850
|
+
const { app, id } = await seed()
|
|
851
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
852
|
+
':deliveryId'
|
|
853
|
+
].retry.$post({ param: { deliveryId: 'whd_missing', id } }, as(writer))
|
|
854
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
855
|
+
const body = (await response.json()) as { error?: { code?: string } }
|
|
856
|
+
expect(body.error?.code).toMatchInlineSnapshot(`"delivery_not_found"`)
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
test('404s for an unknown subscription', async () => {
|
|
860
|
+
const app = client()
|
|
861
|
+
const response = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries[
|
|
862
|
+
':deliveryId'
|
|
863
|
+
].retry.$post({ param: { deliveryId: 'whd_x', id: 'wh_missing' } }, as(writer))
|
|
864
|
+
expect(response.status).toMatchInlineSnapshot(`404`)
|
|
865
|
+
})
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
describe('OpenAPI visibility', () => {
|
|
869
|
+
test('hides webhook routes when the feature is disabled', async () => {
|
|
870
|
+
const app = TestApp.create({ auth: { source: TestApp.source({ keys: [writer] }) } })
|
|
871
|
+
const body = (await (await app.request('/openapi.json')).json()) as { paths: object }
|
|
872
|
+
|
|
873
|
+
// OpenAPI paths include App.create's mounted `/v1` prefix; the webhook
|
|
874
|
+
// sub-app's local `/webhooks` paths are not emitted prefix-free.
|
|
875
|
+
expect(Object.keys(body.paths).some((path) => path.startsWith('/v1/webhooks'))).toBe(false)
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
test('documents webhook routes when the feature is enabled', async () => {
|
|
879
|
+
const app = TestApp.create({
|
|
880
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
881
|
+
webhook: { store: Store.memory() },
|
|
882
|
+
})
|
|
883
|
+
const body = (await (await app.request('/openapi.json')).json()) as { paths: object }
|
|
884
|
+
const paths = Object.keys(body.paths)
|
|
885
|
+
.filter((path) => path.startsWith('/v1/webhooks'))
|
|
886
|
+
.sort()
|
|
887
|
+
|
|
888
|
+
expect(paths).toMatchInlineSnapshot(`
|
|
889
|
+
[
|
|
890
|
+
"/v1/webhooks",
|
|
891
|
+
"/v1/webhooks/event-types",
|
|
892
|
+
"/v1/webhooks/{id}",
|
|
893
|
+
"/v1/webhooks/{id}/deliveries",
|
|
894
|
+
"/v1/webhooks/{id}/deliveries/{deliveryId}/retry",
|
|
895
|
+
"/v1/webhooks/{id}/ping",
|
|
896
|
+
]
|
|
897
|
+
`)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test('omits the outbound `webhooks` section when the feature is disabled', async () => {
|
|
901
|
+
const app = TestApp.create({ auth: { source: TestApp.source({ keys: [writer] }) } })
|
|
902
|
+
const body = (await (await app.request('/openapi.json')).json()) as { webhooks?: object }
|
|
903
|
+
|
|
904
|
+
expect(body.webhooks).toMatchInlineSnapshot(`undefined`)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
test('documents the outbound delivery envelope under OAS `webhooks` when enabled', async () => {
|
|
908
|
+
const app = TestApp.create({
|
|
909
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
910
|
+
webhook: { store: Store.memory() },
|
|
911
|
+
})
|
|
912
|
+
const body = (await (await app.request('/openapi.json')).json()) as {
|
|
913
|
+
webhooks?: {
|
|
914
|
+
event?: {
|
|
915
|
+
post?: {
|
|
916
|
+
parameters?: { name: string }[]
|
|
917
|
+
requestBody?: {
|
|
918
|
+
content?: { 'application/json'?: { schema?: { oneOf?: { title?: string }[] } } }
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
expect(body.webhooks?.event?.post?.parameters?.map((p) => p.name)).toMatchInlineSnapshot(`
|
|
926
|
+
[
|
|
927
|
+
"tempo-signature",
|
|
928
|
+
"tempo-event-id",
|
|
929
|
+
"tempo-event-type",
|
|
930
|
+
]
|
|
931
|
+
`)
|
|
932
|
+
// Each envelope variant carries a `title` so Scalar renders a labelled
|
|
933
|
+
// variant selector rather than collapsing the union to its first member.
|
|
934
|
+
const variants =
|
|
935
|
+
body.webhooks?.event?.post?.requestBody?.content?.['application/json']?.schema?.oneOf
|
|
936
|
+
expect(variants?.map((v) => v.title)).toMatchInlineSnapshot(`
|
|
937
|
+
[
|
|
938
|
+
"Transfer event",
|
|
939
|
+
"Included transaction event",
|
|
940
|
+
"Log event",
|
|
941
|
+
"Block event",
|
|
942
|
+
"Ping (test delivery)",
|
|
943
|
+
]
|
|
944
|
+
`)
|
|
945
|
+
})
|
|
946
|
+
|
|
947
|
+
test('documents per-event-type filters on the create body', async () => {
|
|
948
|
+
const app = TestApp.create({
|
|
949
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
950
|
+
webhook: { store: Store.memory() },
|
|
951
|
+
})
|
|
952
|
+
const body = (await (await app.request('/openapi.json')).json()) as {
|
|
953
|
+
paths: {
|
|
954
|
+
'/v1/webhooks': {
|
|
955
|
+
post: {
|
|
956
|
+
requestBody: {
|
|
957
|
+
content: {
|
|
958
|
+
'application/json': {
|
|
959
|
+
schema: {
|
|
960
|
+
oneOf?: {
|
|
961
|
+
title?: string
|
|
962
|
+
properties?: { filters?: { properties?: Record<string, unknown> } }
|
|
963
|
+
}[]
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const variants =
|
|
973
|
+
body.paths['/v1/webhooks'].post.requestBody.content['application/json'].schema.oneOf
|
|
974
|
+
|
|
975
|
+
// Each event type renders as a titled variant exposing only its own filters.
|
|
976
|
+
expect(
|
|
977
|
+
variants?.map((v) => ({
|
|
978
|
+
filters: Object.keys(v.properties?.filters?.properties ?? {}),
|
|
979
|
+
title: v.title,
|
|
980
|
+
})),
|
|
981
|
+
).toMatchInlineSnapshot(`
|
|
982
|
+
[
|
|
983
|
+
{
|
|
984
|
+
"filters": [
|
|
985
|
+
"address",
|
|
986
|
+
"recipient",
|
|
987
|
+
"sender",
|
|
988
|
+
"token",
|
|
989
|
+
],
|
|
990
|
+
"title": "Transfer subscription",
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
"filters": [
|
|
994
|
+
"hash",
|
|
995
|
+
"address",
|
|
996
|
+
"from",
|
|
997
|
+
"to",
|
|
998
|
+
"includeCalls",
|
|
999
|
+
"includeReceipt",
|
|
1000
|
+
"value",
|
|
1001
|
+
"input",
|
|
1002
|
+
"calls",
|
|
1003
|
+
"callCount",
|
|
1004
|
+
"txType",
|
|
1005
|
+
"feeToken",
|
|
1006
|
+
"feePayer",
|
|
1007
|
+
"gasLimit",
|
|
1008
|
+
"gasUsed",
|
|
1009
|
+
"maxFeePerGas",
|
|
1010
|
+
"maxPriorityFeePerGas",
|
|
1011
|
+
"nonce",
|
|
1012
|
+
"nonceKey",
|
|
1013
|
+
"validBefore",
|
|
1014
|
+
"validAfter",
|
|
1015
|
+
"blockNumber",
|
|
1016
|
+
"timestamp",
|
|
1017
|
+
"status",
|
|
1018
|
+
],
|
|
1019
|
+
"title": "Included-transaction subscription",
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
"filters": [
|
|
1023
|
+
"address",
|
|
1024
|
+
"signature",
|
|
1025
|
+
"topic0",
|
|
1026
|
+
"topic1",
|
|
1027
|
+
"topic2",
|
|
1028
|
+
"topic3",
|
|
1029
|
+
"args",
|
|
1030
|
+
"blockNumber",
|
|
1031
|
+
],
|
|
1032
|
+
"title": "Log subscription",
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
"filters": [
|
|
1036
|
+
"number",
|
|
1037
|
+
"miner",
|
|
1038
|
+
"proposer",
|
|
1039
|
+
"gasUsed",
|
|
1040
|
+
"gasLimit",
|
|
1041
|
+
"timestamp",
|
|
1042
|
+
],
|
|
1043
|
+
"title": "Block subscription",
|
|
1044
|
+
},
|
|
1045
|
+
]
|
|
1046
|
+
`)
|
|
1047
|
+
})
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
describe('envelope schema', () => {
|
|
1051
|
+
/** A `transfer` subscription used to build representative envelopes. */
|
|
1052
|
+
async function transferSubscription() {
|
|
1053
|
+
const store = Store.memory()
|
|
1054
|
+
return Core.buildEnvelope({
|
|
1055
|
+
blockNumber: 100,
|
|
1056
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
1057
|
+
data: {
|
|
1058
|
+
address: '0x20c0000000000000000000008f5425160ebe5525',
|
|
1059
|
+
amount: '1000',
|
|
1060
|
+
blockNumber: 100,
|
|
1061
|
+
recipient: '0x2222222222222222222222222222222222222222',
|
|
1062
|
+
sender: '0x1111111111111111111111111111111111111111',
|
|
1063
|
+
timestamp: '2026-01-01T00:00:00.000Z',
|
|
1064
|
+
transactionHash: '0x33333333333333333333333333333333333333333333333333333333333333aa',
|
|
1065
|
+
},
|
|
1066
|
+
logIndex: 0,
|
|
1067
|
+
subscription: await Core.createSubscription(store, {
|
|
1068
|
+
chainId: 42431,
|
|
1069
|
+
eventType: 'token:transfer',
|
|
1070
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1071
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1072
|
+
}),
|
|
1073
|
+
})
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
test('round-trips a delivered transfer envelope', async () => {
|
|
1077
|
+
const parsed = Webhooks.schema.Envelope.safeParse(await transferSubscription())
|
|
1078
|
+
|
|
1079
|
+
expect(parsed.success).toMatchInlineSnapshot(`true`)
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
test('round-trips a ping envelope', async () => {
|
|
1083
|
+
const store = Store.memory()
|
|
1084
|
+
const subscription = await Core.createSubscription(store, {
|
|
1085
|
+
chainId: 42431,
|
|
1086
|
+
eventType: 'token:transfer',
|
|
1087
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1088
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1089
|
+
})
|
|
1090
|
+
const parsed = Webhooks.schema.Envelope.safeParse(Core.buildPingEnvelope(subscription))
|
|
1091
|
+
|
|
1092
|
+
expect(parsed.success).toMatchInlineSnapshot(`true`)
|
|
1093
|
+
expect(parsed.success && parsed.data.type).toMatchInlineSnapshot(`"ping"`)
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
test('rejects an unknown envelope type', () => {
|
|
1097
|
+
const parsed = Webhooks.schema.Envelope.safeParse({
|
|
1098
|
+
chainId: 42431,
|
|
1099
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
1100
|
+
data: {},
|
|
1101
|
+
id: 'evt_x',
|
|
1102
|
+
subscriptionId: 'wh_x',
|
|
1103
|
+
type: 'unknown',
|
|
1104
|
+
})
|
|
1105
|
+
|
|
1106
|
+
expect(parsed.success).toMatchInlineSnapshot(`false`)
|
|
1107
|
+
})
|
|
1108
|
+
})
|
|
1109
|
+
|
|
1110
|
+
describe('MPP access (disabled for now — API-key-only)', () => {
|
|
1111
|
+
test('rejects an MPP payer with 401, offering no payment challenge', async () => {
|
|
1112
|
+
const app = TestApp.create({
|
|
1113
|
+
auth: { source: TestApp.source({ keys: [] }) },
|
|
1114
|
+
mpp: {
|
|
1115
|
+
charge: {
|
|
1116
|
+
chainId: Tempo.chain.id,
|
|
1117
|
+
currency: Tempo.currency,
|
|
1118
|
+
decimals: 6,
|
|
1119
|
+
getClient: () => Tempo.client,
|
|
1120
|
+
recipient: Tempo.accounts[2].address,
|
|
1121
|
+
},
|
|
1122
|
+
secretKey: 'secret_test_key_0123456789abcdef',
|
|
1123
|
+
},
|
|
1124
|
+
webhook: { store: Store.memory() },
|
|
1125
|
+
})
|
|
1126
|
+
|
|
1127
|
+
// The paid lane is disabled on webhook routes, so no `402` challenge is
|
|
1128
|
+
// offered: the MPP client's request is rejected outright (`401`) and never
|
|
1129
|
+
// gets a chance to pay, so no payment receipt is issued.
|
|
1130
|
+
const response = await Mppx.createClient(app).fetch('http://tempo-api.test/v1/webhooks', {
|
|
1131
|
+
body: JSON.stringify(validBody),
|
|
1132
|
+
headers: { 'content-type': 'application/json' },
|
|
1133
|
+
method: 'POST',
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
expect(response.status).toMatchInlineSnapshot(`401`)
|
|
1137
|
+
expect(response.headers.has('payment-receipt')).toMatchInlineSnapshot(`false`)
|
|
1138
|
+
})
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
// The poll tests pin a FIXED immutable historical window on testnet chain 42431
|
|
1142
|
+
// (real TIDX + `rpc.testnet.tempo.xyz`). Under `localnet`, the test RPC client is
|
|
1143
|
+
// redirected to the disposable local chain, which lacks that history, so the
|
|
1144
|
+
// whole block is testnet-only.
|
|
1145
|
+
describe.skipIf(Runtime.get().mode === 'localnet')('poll', () => {
|
|
1146
|
+
/** Decodes the opaque `(block, index)` keyset cursor the poller stores. */
|
|
1147
|
+
function decodeCursor(token: string | null): [number, number] | null {
|
|
1148
|
+
if (!token) return null
|
|
1149
|
+
const json = atob(token.replaceAll('-', '+').replaceAll('_', '/'))
|
|
1150
|
+
return JSON.parse(json) as [number, number]
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Encodes a `(block, index)` keyset cursor in the poller's opaque format. */
|
|
1154
|
+
function encodeCursor(values: [number, number]): string {
|
|
1155
|
+
return btoa(JSON.stringify(values)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** End-of-block index sentinel (matches the poller's own cursor sentinel). */
|
|
1159
|
+
const endOfBlockIndex = 2_147_483_647
|
|
1160
|
+
|
|
1161
|
+
// The poller scans real TIDX, so the tests pin a FIXED historical block window
|
|
1162
|
+
// on chain 42431. Chain history is immutable, so a narrow `sender` filter over
|
|
1163
|
+
// this window yields a deterministic, snapshot-stable row set while exercising
|
|
1164
|
+
// the real keyset/`toBlock` SQL and decode path end to end — no fabricated rows.
|
|
1165
|
+
const realTidx = Tidx.getClient({
|
|
1166
|
+
chainId: 42431,
|
|
1167
|
+
tidx: { baseUrl: 'https://tidx.tempo.xyz', basicAuth: process.env.VITE_TIDX_BASIC_AUTH },
|
|
1168
|
+
})
|
|
1169
|
+
// The monitoring scanners (`log:emitted`/`block:created`/`transaction:included`)
|
|
1170
|
+
// read the same immutable historical window over real RPC (chain 42431 →
|
|
1171
|
+
// `rpc.testnet.tempo.xyz`), so their results are snapshot-stable too.
|
|
1172
|
+
const client = Viem.getClient({ chainId: 42431, rpc: TestApp.rpc })
|
|
1173
|
+
// Confirmed scan window `[windowFrom, windowTo]`: anchor the cursor just below
|
|
1174
|
+
// `windowFrom`, and pass `headBlockNumber = windowTo + confirmations` so the
|
|
1175
|
+
// poller's `safeBlock` lands exactly on `windowTo`.
|
|
1176
|
+
const windowFrom = 1_000_000
|
|
1177
|
+
const windowTo = 1_000_002
|
|
1178
|
+
const windowCursor = encodeCursor([windowFrom - 1, endOfBlockIndex])
|
|
1179
|
+
const windowHead = windowTo + 2
|
|
1180
|
+
// A sender whose only transfers in `[windowFrom, windowTo]` are exactly two
|
|
1181
|
+
// rows, both in block 1,000,002 (`log_idx` 0 then 1): a TIP-20 transfer
|
|
1182
|
+
// followed by its fee payment. Filtering on it makes the scan deterministic.
|
|
1183
|
+
const windowSender = '0xe7687128b0a808c2831ff94d4f7b2fb35c65af38'
|
|
1184
|
+
|
|
1185
|
+
async function createTransferSubscription(store: ReturnType<typeof Store.memory>) {
|
|
1186
|
+
return Core.createSubscription(store, {
|
|
1187
|
+
chainId: 42431,
|
|
1188
|
+
eventType: 'token:transfer',
|
|
1189
|
+
filters: { sender: windowSender },
|
|
1190
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1191
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1192
|
+
})
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
test('dispatches a signed envelope per row and advances the cursor', async () => {
|
|
1196
|
+
const store = Store.memory()
|
|
1197
|
+
const subscription = await createTransferSubscription(store)
|
|
1198
|
+
// Anchor the subscription just below the pinned window so the poller scans
|
|
1199
|
+
// it this tick (a brand-new subscription anchors at head and skips its first
|
|
1200
|
+
// tick).
|
|
1201
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1202
|
+
|
|
1203
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1204
|
+
const result = await Webhooks.poll({
|
|
1205
|
+
chainId: 42431,
|
|
1206
|
+
dispatch: async (input) => {
|
|
1207
|
+
dispatched.push(input)
|
|
1208
|
+
return { ok: true }
|
|
1209
|
+
},
|
|
1210
|
+
eventTypes: ['token:transfer'],
|
|
1211
|
+
headBlockNumber: windowHead,
|
|
1212
|
+
client,
|
|
1213
|
+
store,
|
|
1214
|
+
tidx: realTidx,
|
|
1215
|
+
})
|
|
1216
|
+
|
|
1217
|
+
expect(result).toMatchInlineSnapshot(`
|
|
1218
|
+
{
|
|
1219
|
+
"chainId": 42431,
|
|
1220
|
+
"dispatched": 2,
|
|
1221
|
+
"failed": 0,
|
|
1222
|
+
"safeBlock": 1000002,
|
|
1223
|
+
"scanned": 2,
|
|
1224
|
+
"subscriptions": 1,
|
|
1225
|
+
}
|
|
1226
|
+
`)
|
|
1227
|
+
expect(dispatched).toHaveLength(2)
|
|
1228
|
+
// The envelope carries the read-endpoint transfer row verbatim — here the
|
|
1229
|
+
// first of the two pinned-window transfers from `windowSender`.
|
|
1230
|
+
expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
|
|
1231
|
+
{
|
|
1232
|
+
"address": "0x20c00000000000000000000071de0cd31ab0d105",
|
|
1233
|
+
"amount": "10000",
|
|
1234
|
+
"blockNumber": 1000002,
|
|
1235
|
+
"logIndex": 0,
|
|
1236
|
+
"recipient": "0x9e39034aae71fb89f66061a2602eb6efec271754",
|
|
1237
|
+
"sender": "0xe7687128b0a808c2831ff94d4f7b2fb35c65af38",
|
|
1238
|
+
"timestamp": "2026-01-14T18:38:03.000Z",
|
|
1239
|
+
"token": {
|
|
1240
|
+
"decimals": 6,
|
|
1241
|
+
"symbol": "QKSUSD",
|
|
1242
|
+
},
|
|
1243
|
+
"transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
|
|
1244
|
+
}
|
|
1245
|
+
`)
|
|
1246
|
+
|
|
1247
|
+
// Cursor advanced to the last dispatched row `(1000002, 1)`.
|
|
1248
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1249
|
+
[
|
|
1250
|
+
1000002,
|
|
1251
|
+
1,
|
|
1252
|
+
]
|
|
1253
|
+
`)
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
test('pauses the cursor at the last success when dispatch fails', async () => {
|
|
1257
|
+
const store = Store.memory()
|
|
1258
|
+
const subscription = await createTransferSubscription(store)
|
|
1259
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1260
|
+
|
|
1261
|
+
let calls = 0
|
|
1262
|
+
const result = await Webhooks.poll({
|
|
1263
|
+
chainId: 42431,
|
|
1264
|
+
// Succeed on the first row, fail on the second.
|
|
1265
|
+
dispatch: async () => (++calls === 1 ? { ok: true } : { error: 'boom', ok: false }),
|
|
1266
|
+
eventTypes: ['token:transfer'],
|
|
1267
|
+
headBlockNumber: windowHead,
|
|
1268
|
+
client,
|
|
1269
|
+
store,
|
|
1270
|
+
tidx: realTidx,
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
expect(result.dispatched).toMatchInlineSnapshot(`1`)
|
|
1274
|
+
expect(result.failed).toMatchInlineSnapshot(`1`)
|
|
1275
|
+
|
|
1276
|
+
// Cursor stops at the first (successful) row `(1000002, 0)`, so the failed
|
|
1277
|
+
// one re-runs on the next tick (at-least-once).
|
|
1278
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1279
|
+
[
|
|
1280
|
+
1000002,
|
|
1281
|
+
0,
|
|
1282
|
+
]
|
|
1283
|
+
`)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
test('does not advance the cursor when the first row fails', async () => {
|
|
1287
|
+
const store = Store.memory()
|
|
1288
|
+
const subscription = await createTransferSubscription(store)
|
|
1289
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1290
|
+
|
|
1291
|
+
const result = await Webhooks.poll({
|
|
1292
|
+
chainId: 42431,
|
|
1293
|
+
dispatch: async () => ({ error: 'unreachable', ok: false }),
|
|
1294
|
+
eventTypes: ['token:transfer'],
|
|
1295
|
+
headBlockNumber: windowHead,
|
|
1296
|
+
client,
|
|
1297
|
+
store,
|
|
1298
|
+
tidx: realTidx,
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
expect(result.dispatched).toMatchInlineSnapshot(`0`)
|
|
1302
|
+
expect(result.failed).toMatchInlineSnapshot(`1`)
|
|
1303
|
+
// Nothing accepted → cursor untouched at the window anchor, so the row
|
|
1304
|
+
// retries next tick.
|
|
1305
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1306
|
+
[
|
|
1307
|
+
999999,
|
|
1308
|
+
2147483647,
|
|
1309
|
+
]
|
|
1310
|
+
`)
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
test('anchors a first-seen subscription at the confirmed head without backfilling', async () => {
|
|
1314
|
+
const store = Store.memory()
|
|
1315
|
+
const subscription = await createTransferSubscription(store)
|
|
1316
|
+
|
|
1317
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1318
|
+
const result = await Webhooks.poll({
|
|
1319
|
+
chainId: 42431,
|
|
1320
|
+
dispatch: async (input) => {
|
|
1321
|
+
dispatched.push(input)
|
|
1322
|
+
return { ok: true }
|
|
1323
|
+
},
|
|
1324
|
+
eventTypes: ['token:transfer'],
|
|
1325
|
+
headBlockNumber: windowHead,
|
|
1326
|
+
client,
|
|
1327
|
+
store,
|
|
1328
|
+
// Rows exist in history, but a brand-new subscription must not replay them.
|
|
1329
|
+
tidx: realTidx,
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
// Nothing scanned or dispatched on the first tick; the cursor is anchored at
|
|
1333
|
+
// the head of the confirmed window (`windowHead - confirmations` = 1000002).
|
|
1334
|
+
expect(result.scanned).toMatchInlineSnapshot(`0`)
|
|
1335
|
+
expect(dispatched).toHaveLength(0)
|
|
1336
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1337
|
+
[
|
|
1338
|
+
1000002,
|
|
1339
|
+
2147483647,
|
|
1340
|
+
]
|
|
1341
|
+
`)
|
|
1342
|
+
})
|
|
1343
|
+
|
|
1344
|
+
test('advances to the end of the confirmed window on an empty scan', async () => {
|
|
1345
|
+
const store = Store.memory()
|
|
1346
|
+
const subscription = await createTransferSubscription(store)
|
|
1347
|
+
// Anchor just past the window's only matching block (1000002) and confirm up
|
|
1348
|
+
// to block 1000004 — a range `windowSender` has no transfers in, so the scan
|
|
1349
|
+
// is genuinely empty.
|
|
1350
|
+
await Core.setCursor(store, subscription.id, encodeCursor([windowTo, endOfBlockIndex]))
|
|
1351
|
+
|
|
1352
|
+
const result = await Webhooks.poll({
|
|
1353
|
+
chainId: 42431,
|
|
1354
|
+
dispatch: async () => ({ ok: true }),
|
|
1355
|
+
eventTypes: ['token:transfer'],
|
|
1356
|
+
headBlockNumber: windowTo + 4,
|
|
1357
|
+
client,
|
|
1358
|
+
store,
|
|
1359
|
+
tidx: realTidx,
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
expect(result.scanned).toMatchInlineSnapshot(`0`)
|
|
1363
|
+
|
|
1364
|
+
// No rows, clean scan: cursor jumps past the end of the confirmed window
|
|
1365
|
+
// (`headBlockNumber - confirmations` = block 1000004) so it is not rescanned.
|
|
1366
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1367
|
+
[
|
|
1368
|
+
1000004,
|
|
1369
|
+
2147483647,
|
|
1370
|
+
]
|
|
1371
|
+
`)
|
|
1372
|
+
})
|
|
1373
|
+
|
|
1374
|
+
// The two pinned-window rows are `Transfer(from, to, value)` logs. Their raw
|
|
1375
|
+
// topic0 (the `Transfer` selector) and the `from` address padded to a bytes32
|
|
1376
|
+
// topic let the raw-log path be filtered as deterministically as the decoded
|
|
1377
|
+
// path.
|
|
1378
|
+
const transferSignature =
|
|
1379
|
+
'event Transfer(address indexed from, address indexed to, uint256 value)'
|
|
1380
|
+
const transferSelector = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
|
|
1381
|
+
const windowSenderTopic = `0x${'0'.repeat(24)}${windowSender.slice(2)}`
|
|
1382
|
+
|
|
1383
|
+
test('log:emitted decodes args from a signature-matched event view', async () => {
|
|
1384
|
+
const store = Store.memory()
|
|
1385
|
+
const subscription = await Core.createSubscription(store, {
|
|
1386
|
+
chainId: 42431,
|
|
1387
|
+
eventType: 'log:emitted',
|
|
1388
|
+
filters: { signature: transferSignature, args: { from: windowSender } },
|
|
1389
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1390
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1391
|
+
})
|
|
1392
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1393
|
+
|
|
1394
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1395
|
+
const result = await Webhooks.poll({
|
|
1396
|
+
chainId: 42431,
|
|
1397
|
+
dispatch: async (input) => {
|
|
1398
|
+
dispatched.push(input)
|
|
1399
|
+
return { ok: true }
|
|
1400
|
+
},
|
|
1401
|
+
eventTypes: ['log:emitted'],
|
|
1402
|
+
headBlockNumber: windowHead,
|
|
1403
|
+
client,
|
|
1404
|
+
store,
|
|
1405
|
+
tidx: realTidx,
|
|
1406
|
+
})
|
|
1407
|
+
|
|
1408
|
+
expect(result.scanned).toMatchInlineSnapshot(`2`)
|
|
1409
|
+
expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
|
|
1410
|
+
{
|
|
1411
|
+
"address": "0x20c00000000000000000000071de0cd31ab0d105",
|
|
1412
|
+
"args": {
|
|
1413
|
+
"from": "0xE7687128b0A808C2831ff94d4F7B2fb35C65aF38",
|
|
1414
|
+
"to": "0x9E39034aAE71fb89f66061a2602eb6eFeC271754",
|
|
1415
|
+
"value": "10000",
|
|
1416
|
+
},
|
|
1417
|
+
"blockNumber": 1000002,
|
|
1418
|
+
"data": "0x0000000000000000000000000000000000000000000000000000000000002710",
|
|
1419
|
+
"event": {
|
|
1420
|
+
"name": "Transfer",
|
|
1421
|
+
"signature": "event Transfer(address indexed from, address indexed to, uint256 value)",
|
|
1422
|
+
"topic0": "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
|
|
1423
|
+
},
|
|
1424
|
+
"logIndex": 0,
|
|
1425
|
+
"timestamp": "2026-01-14T18:38:03.000Z",
|
|
1426
|
+
"topics": [
|
|
1427
|
+
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
|
|
1428
|
+
"0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38",
|
|
1429
|
+
"0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754",
|
|
1430
|
+
],
|
|
1431
|
+
"transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
|
|
1432
|
+
"transactionIndex": 0,
|
|
1433
|
+
}
|
|
1434
|
+
`)
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
test('log:emitted scans raw logs by topic when no signature is given', async () => {
|
|
1438
|
+
const store = Store.memory()
|
|
1439
|
+
const subscription = await Core.createSubscription(store, {
|
|
1440
|
+
chainId: 42431,
|
|
1441
|
+
eventType: 'log:emitted',
|
|
1442
|
+
filters: { topic0: transferSelector, topic1: windowSenderTopic },
|
|
1443
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1444
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1445
|
+
})
|
|
1446
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1447
|
+
|
|
1448
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1449
|
+
const result = await Webhooks.poll({
|
|
1450
|
+
chainId: 42431,
|
|
1451
|
+
dispatch: async (input) => {
|
|
1452
|
+
dispatched.push(input)
|
|
1453
|
+
return { ok: true }
|
|
1454
|
+
},
|
|
1455
|
+
eventTypes: ['log:emitted'],
|
|
1456
|
+
headBlockNumber: windowHead,
|
|
1457
|
+
client,
|
|
1458
|
+
store,
|
|
1459
|
+
tidx: realTidx,
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
expect(result.scanned).toMatchInlineSnapshot(`2`)
|
|
1463
|
+
expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
|
|
1464
|
+
{
|
|
1465
|
+
"address": "0x20c00000000000000000000071de0cd31ab0d105",
|
|
1466
|
+
"blockNumber": 1000002,
|
|
1467
|
+
"data": "0x0000000000000000000000000000000000000000000000000000000000002710",
|
|
1468
|
+
"logIndex": 0,
|
|
1469
|
+
"timestamp": "2026-01-14T18:38:03.000Z",
|
|
1470
|
+
"topics": [
|
|
1471
|
+
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
|
|
1472
|
+
"0x000000000000000000000000e7687128b0a808c2831ff94d4f7b2fb35c65af38",
|
|
1473
|
+
"0x0000000000000000000000009e39034aae71fb89f66061a2602eb6efec271754",
|
|
1474
|
+
],
|
|
1475
|
+
"transactionHash": "0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3",
|
|
1476
|
+
"transactionIndex": 0,
|
|
1477
|
+
}
|
|
1478
|
+
`)
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
test('block:created scans new blocks and resolves transaction counts', async () => {
|
|
1482
|
+
const store = Store.memory()
|
|
1483
|
+
const subscription = await Core.createSubscription(store, {
|
|
1484
|
+
chainId: 42431,
|
|
1485
|
+
eventType: 'block:created',
|
|
1486
|
+
// Pin to a single block so the heartbeat feed is deterministic.
|
|
1487
|
+
filters: { number: { eq: `0x${windowTo.toString(16)}` } },
|
|
1488
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1489
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1490
|
+
})
|
|
1491
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1492
|
+
|
|
1493
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1494
|
+
const result = await Webhooks.poll({
|
|
1495
|
+
chainId: 42431,
|
|
1496
|
+
dispatch: async (input) => {
|
|
1497
|
+
dispatched.push(input)
|
|
1498
|
+
return { ok: true }
|
|
1499
|
+
},
|
|
1500
|
+
eventTypes: ['block:created'],
|
|
1501
|
+
headBlockNumber: windowHead,
|
|
1502
|
+
client,
|
|
1503
|
+
store,
|
|
1504
|
+
tidx: realTidx,
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
expect(result.scanned).toMatchInlineSnapshot(`1`)
|
|
1508
|
+
expect(dispatched[0]?.envelope.data).toMatchInlineSnapshot(`
|
|
1509
|
+
{
|
|
1510
|
+
"gasLimit": 500000000,
|
|
1511
|
+
"gasUsed": 4340281,
|
|
1512
|
+
"hash": "0x3fe7d9e595f3d8215ec840c6ef55ac0bead58933e96fc2ead0f2aaf79715f45d",
|
|
1513
|
+
"miner": "0x0000000000000000000000000000000000000000",
|
|
1514
|
+
"number": 1000002,
|
|
1515
|
+
"parentHash": "0xa406dff9c51cc2abbef3a811f0499e99a568bf781019f129ab45dca1df0e43e5",
|
|
1516
|
+
"timestamp": "2026-01-14T18:38:03.000Z",
|
|
1517
|
+
"transactionCount": 52,
|
|
1518
|
+
}
|
|
1519
|
+
`)
|
|
1520
|
+
// The window scanner advances to the end of the fully-scanned block so the
|
|
1521
|
+
// next tick resumes at the following block.
|
|
1522
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toMatchInlineSnapshot(`
|
|
1523
|
+
[
|
|
1524
|
+
1000002,
|
|
1525
|
+
2147483647,
|
|
1526
|
+
]
|
|
1527
|
+
`)
|
|
1528
|
+
})
|
|
1529
|
+
|
|
1530
|
+
test('transaction:included filters on txs columns and inner calls', async () => {
|
|
1531
|
+
const store = Store.memory()
|
|
1532
|
+
const subscription = await Core.createSubscription(store, {
|
|
1533
|
+
chainId: 42431,
|
|
1534
|
+
eventType: 'transaction:included',
|
|
1535
|
+
// `from` + an `input` selector (ERC20 `transfer` = 0xa9059cbb) pins the
|
|
1536
|
+
// scan to the deterministic window tx.
|
|
1537
|
+
filters: { from: windowSender, input: { selector: '0xa9059cbb' } },
|
|
1538
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1539
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1540
|
+
})
|
|
1541
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1542
|
+
|
|
1543
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1544
|
+
const result = await Webhooks.poll({
|
|
1545
|
+
chainId: 42431,
|
|
1546
|
+
dispatch: async (input) => {
|
|
1547
|
+
dispatched.push(input)
|
|
1548
|
+
return { ok: true }
|
|
1549
|
+
},
|
|
1550
|
+
eventTypes: ['transaction:included'],
|
|
1551
|
+
headBlockNumber: windowHead,
|
|
1552
|
+
client,
|
|
1553
|
+
store,
|
|
1554
|
+
tidx: realTidx,
|
|
1555
|
+
})
|
|
1556
|
+
|
|
1557
|
+
expect(result.scanned).toMatchInlineSnapshot(`1`)
|
|
1558
|
+
const tx = dispatched[0]?.envelope.data as { hash: string; sender: string } | undefined
|
|
1559
|
+
expect(tx?.hash).toMatchInlineSnapshot(
|
|
1560
|
+
`"0x3d24a706cc2f6f4c96620bef1f61ddb23040ff77c22c8db42918c7c424bbf9d3"`,
|
|
1561
|
+
)
|
|
1562
|
+
expect(tx?.sender).toBe(windowSender)
|
|
1563
|
+
})
|
|
1564
|
+
|
|
1565
|
+
test('transaction:included routes a status filter through receipts', async () => {
|
|
1566
|
+
const store = Store.memory()
|
|
1567
|
+
const subscription = await Core.createSubscription(store, {
|
|
1568
|
+
chainId: 42431,
|
|
1569
|
+
eventType: 'transaction:included',
|
|
1570
|
+
filters: { from: windowSender, status: 'success' },
|
|
1571
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1572
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1573
|
+
})
|
|
1574
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1575
|
+
|
|
1576
|
+
const result = await Webhooks.poll({
|
|
1577
|
+
chainId: 42431,
|
|
1578
|
+
dispatch: async () => ({ ok: true }),
|
|
1579
|
+
eventTypes: ['transaction:included'],
|
|
1580
|
+
headBlockNumber: windowHead,
|
|
1581
|
+
client,
|
|
1582
|
+
store,
|
|
1583
|
+
tidx: realTidx,
|
|
1584
|
+
})
|
|
1585
|
+
|
|
1586
|
+
// The window tx succeeded, so the status=success subquery keeps it.
|
|
1587
|
+
expect(result.scanned).toMatchInlineSnapshot(`1`)
|
|
1588
|
+
})
|
|
1589
|
+
|
|
1590
|
+
test('transaction:included embeds the receipt when includeReceipt is set', async () => {
|
|
1591
|
+
const store = Store.memory()
|
|
1592
|
+
const subscription = await Core.createSubscription(store, {
|
|
1593
|
+
chainId: 42431,
|
|
1594
|
+
eventType: 'transaction:included',
|
|
1595
|
+
// No receipt-touching filter — `includeReceipt` alone must trigger the
|
|
1596
|
+
// fetch and embed it in the delivered payload.
|
|
1597
|
+
filters: { from: windowSender, input: { selector: '0xa9059cbb' }, includeReceipt: true },
|
|
1598
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
1599
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
1600
|
+
})
|
|
1601
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1602
|
+
|
|
1603
|
+
const dispatched: Webhooks.poll.Dispatchable[] = []
|
|
1604
|
+
const result = await Webhooks.poll({
|
|
1605
|
+
chainId: 42431,
|
|
1606
|
+
dispatch: async (input) => {
|
|
1607
|
+
dispatched.push(input)
|
|
1608
|
+
return { ok: true }
|
|
1609
|
+
},
|
|
1610
|
+
eventTypes: ['transaction:included'],
|
|
1611
|
+
headBlockNumber: windowHead,
|
|
1612
|
+
client,
|
|
1613
|
+
store,
|
|
1614
|
+
tidx: realTidx,
|
|
1615
|
+
})
|
|
1616
|
+
|
|
1617
|
+
expect(result.scanned).toMatchInlineSnapshot(`1`)
|
|
1618
|
+
const data = dispatched[0]?.envelope.data as
|
|
1619
|
+
| { hash: string; meta?: { receipt?: { status: string; transactionHash: string } } }
|
|
1620
|
+
| undefined
|
|
1621
|
+
// The receipt is embedded under `meta.receipt`, matching the read endpoint's
|
|
1622
|
+
// `include=receipt` shape, and refers to the same transaction.
|
|
1623
|
+
expect(data?.meta?.receipt?.status).toMatchInlineSnapshot(`"success"`)
|
|
1624
|
+
expect(data?.meta?.receipt?.transactionHash).toBe(data?.hash)
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
// End-to-end through the *default* transport: `poll` with no injected
|
|
1628
|
+
// `dispatch` falls back to `deliverAndRecord`, which signs and POSTs via
|
|
1629
|
+
// `globalThis.fetch` and records the outcome. These stub the global fetch so a
|
|
1630
|
+
// seeded TIDX row reaches a captured local sink with no network.
|
|
1631
|
+
describe('default transport (deliverAndRecord)', () => {
|
|
1632
|
+
afterEach(() => {
|
|
1633
|
+
vi.restoreAllMocks()
|
|
1634
|
+
})
|
|
1635
|
+
|
|
1636
|
+
test('delivers a verifiable signed envelope and records success', async () => {
|
|
1637
|
+
const store = Store.memory()
|
|
1638
|
+
const subscription = await createTransferSubscription(store)
|
|
1639
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1640
|
+
|
|
1641
|
+
// Real TIDX queries also go through `globalThis.fetch`, so the mock passes
|
|
1642
|
+
// non-webhook requests through to the real network and only captures the
|
|
1643
|
+
// delivery POST to the subscription URL.
|
|
1644
|
+
const realFetch = globalThis.fetch.bind(globalThis)
|
|
1645
|
+
const calls: { body: string; headers: Headers; url: string }[] = []
|
|
1646
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
1647
|
+
const url =
|
|
1648
|
+
typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
|
|
1649
|
+
if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
|
|
1650
|
+
calls.push({
|
|
1651
|
+
body: typeof init?.body === 'string' ? init.body : '',
|
|
1652
|
+
headers: new Headers(init?.headers),
|
|
1653
|
+
url,
|
|
1654
|
+
})
|
|
1655
|
+
return new Response(null, { status: 200 })
|
|
1656
|
+
})
|
|
1657
|
+
|
|
1658
|
+
const result = await Webhooks.poll({
|
|
1659
|
+
// One row per tick keeps the delivery assertions focused on a single
|
|
1660
|
+
// signed envelope.
|
|
1661
|
+
batchSize: 1,
|
|
1662
|
+
chainId: 42431,
|
|
1663
|
+
eventTypes: ['token:transfer'],
|
|
1664
|
+
headBlockNumber: windowHead,
|
|
1665
|
+
client,
|
|
1666
|
+
store,
|
|
1667
|
+
tidx: realTidx,
|
|
1668
|
+
})
|
|
1669
|
+
|
|
1670
|
+
expect(result.dispatched).toBe(1)
|
|
1671
|
+
expect(calls).toHaveLength(1)
|
|
1672
|
+
expect(calls[0]?.url).toBe(subscription.destination.url)
|
|
1673
|
+
|
|
1674
|
+
// The captured body verifies against the subscription's signing secret.
|
|
1675
|
+
expect(
|
|
1676
|
+
Core.verify({
|
|
1677
|
+
body: calls[0]?.body ?? '',
|
|
1678
|
+
secret: subscription.secret,
|
|
1679
|
+
signature: calls[0]?.headers.get('tempo-signature') ?? '',
|
|
1680
|
+
}),
|
|
1681
|
+
).toBe(true)
|
|
1682
|
+
|
|
1683
|
+
// The delivered envelope carries the matched transfer row and its type.
|
|
1684
|
+
const envelope = JSON.parse(calls[0]?.body ?? '{}') as Core.Envelope
|
|
1685
|
+
expect(envelope.type).toBe('token:transfer')
|
|
1686
|
+
expect(calls[0]?.headers.get('tempo-event-type')).toBe('token:transfer')
|
|
1687
|
+
expect((envelope.data as { blockNumber: number }).blockNumber).toBe(windowTo)
|
|
1688
|
+
|
|
1689
|
+
// Success resets the failure counter, stamps lastDeliveryAt, stays active.
|
|
1690
|
+
const after = await Core.getSubscription(store, subscription.owner, subscription.id)
|
|
1691
|
+
expect(after?.status).toBe('active')
|
|
1692
|
+
expect(after?.failureCount).toBe(0)
|
|
1693
|
+
expect(after?.lastDeliveryAt).toBeDefined()
|
|
1694
|
+
})
|
|
1695
|
+
|
|
1696
|
+
test('pauses on a failed delivery and re-delivers on the next tick', async () => {
|
|
1697
|
+
const store = Store.memory()
|
|
1698
|
+
const subscription = await createTransferSubscription(store)
|
|
1699
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1700
|
+
|
|
1701
|
+
const realFetch = globalThis.fetch.bind(globalThis)
|
|
1702
|
+
let attempts = 0
|
|
1703
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
1704
|
+
const url =
|
|
1705
|
+
typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
|
|
1706
|
+
if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
|
|
1707
|
+
attempts++
|
|
1708
|
+
return new Response(null, { status: attempts === 1 ? 500 : 200 })
|
|
1709
|
+
})
|
|
1710
|
+
|
|
1711
|
+
const tick = () =>
|
|
1712
|
+
Webhooks.poll({
|
|
1713
|
+
batchSize: 1,
|
|
1714
|
+
chainId: 42431,
|
|
1715
|
+
eventTypes: ['token:transfer'],
|
|
1716
|
+
headBlockNumber: windowHead,
|
|
1717
|
+
client,
|
|
1718
|
+
store,
|
|
1719
|
+
tidx: realTidx,
|
|
1720
|
+
})
|
|
1721
|
+
|
|
1722
|
+
const first = await tick()
|
|
1723
|
+
expect(first.failed).toBe(1)
|
|
1724
|
+
expect(first.dispatched).toBe(0)
|
|
1725
|
+
// Cursor paused at the prior position so the row re-delivers next tick.
|
|
1726
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toEqual([
|
|
1727
|
+
windowFrom - 1,
|
|
1728
|
+
endOfBlockIndex,
|
|
1729
|
+
])
|
|
1730
|
+
|
|
1731
|
+
const second = await tick()
|
|
1732
|
+
expect(second.dispatched).toBe(1)
|
|
1733
|
+
expect(attempts).toBe(2)
|
|
1734
|
+
// Now advanced past the redelivered row (the first row in block 1000002).
|
|
1735
|
+
expect(decodeCursor(await Core.getCursor(store, subscription.id))).toEqual([windowTo, 0])
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
test('auto-disables a subscription after sustained failures', async () => {
|
|
1739
|
+
const store = Store.memory()
|
|
1740
|
+
const subscription = await createTransferSubscription(store)
|
|
1741
|
+
await Core.setCursor(store, subscription.id, windowCursor)
|
|
1742
|
+
|
|
1743
|
+
// Real TIDX queries pass through; only the webhook delivery fails (500).
|
|
1744
|
+
const realFetch = globalThis.fetch.bind(globalThis)
|
|
1745
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
|
1746
|
+
const url =
|
|
1747
|
+
typeof input === 'string' ? input : input instanceof Request ? input.url : input.href
|
|
1748
|
+
if (!url.startsWith(subscription.destination.url)) return realFetch(input, init)
|
|
1749
|
+
return new Response(null, { status: 500 })
|
|
1750
|
+
})
|
|
1751
|
+
|
|
1752
|
+
const tick = () =>
|
|
1753
|
+
Webhooks.poll({
|
|
1754
|
+
batchSize: 1,
|
|
1755
|
+
chainId: 42431,
|
|
1756
|
+
eventTypes: ['token:transfer'],
|
|
1757
|
+
headBlockNumber: windowHead,
|
|
1758
|
+
client,
|
|
1759
|
+
store,
|
|
1760
|
+
tidx: realTidx,
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
// Each tick delivers once and fails; after `maxFailures` ticks the
|
|
1764
|
+
// subscription auto-disables and drops out of the poller's active index.
|
|
1765
|
+
for (let i = 0; i < Core.maxFailures; i++) await tick()
|
|
1766
|
+
|
|
1767
|
+
const disabled = await Core.getSubscription(store, subscription.owner, subscription.id)
|
|
1768
|
+
expect(disabled?.status).toBe('disabled')
|
|
1769
|
+
expect(disabled?.failureCount).toBe(Core.maxFailures)
|
|
1770
|
+
|
|
1771
|
+
// A subsequent tick visits no active subscriptions.
|
|
1772
|
+
const after = await tick()
|
|
1773
|
+
expect(after.subscriptions).toBe(0)
|
|
1774
|
+
})
|
|
1775
|
+
})
|
|
1776
|
+
|
|
1777
|
+
// Full loop through the public HTTP surface: create over the route, let the
|
|
1778
|
+
// poller detect a seeded TIDX row and deliver via the default transport, then
|
|
1779
|
+
// read the recorded outcome back through the deliveries route. Unlike the
|
|
1780
|
+
// `poll` tests above (which seed subscriptions via the core helper), this
|
|
1781
|
+
// proves the route-issued secret and id line up with what is delivered/logged.
|
|
1782
|
+
describe('end-to-end (routes + poller)', () => {
|
|
1783
|
+
test('create → poll → signed delivery → deliveries route records success', async () => {
|
|
1784
|
+
const store = Store.memory()
|
|
1785
|
+
const app = TestApp.client({
|
|
1786
|
+
auth: { source: TestApp.source({ keys: [writer] }) },
|
|
1787
|
+
webhook: { store },
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
// 1. Create through the route; capture the one-time signing secret + id.
|
|
1791
|
+
// The `windowSender` filter pins the scan to the deterministic window rows.
|
|
1792
|
+
const created = await app.v1.webhooks.$post(
|
|
1793
|
+
{ json: { ...validBody, chainId: 42431, filters: { sender: windowSender } } },
|
|
1794
|
+
as(writer),
|
|
1795
|
+
)
|
|
1796
|
+
expect(created.status).toBe(200)
|
|
1797
|
+
const { id, secret } = await TestApp.json(created, Webhooks.schema.createWebhook.Response)
|
|
1798
|
+
|
|
1799
|
+
// Anchor the cursor just below the window so the poller scans this tick (a
|
|
1800
|
+
// brand-new subscription anchors at the confirmed head and skips its first
|
|
1801
|
+
// tick).
|
|
1802
|
+
await Core.setCursor(store, id, windowCursor)
|
|
1803
|
+
|
|
1804
|
+
// 2. A local delivery sink that captures the request and acks 200, injected
|
|
1805
|
+
// via `deliverAndRecord`'s `fetch` option (the SSRF guard blocks loopback,
|
|
1806
|
+
// so a real local server can't be a subscriber URL) — no global mock.
|
|
1807
|
+
const calls: { body: string; headers: Headers; url: string }[] = []
|
|
1808
|
+
const fetch = (async (input, init) => {
|
|
1809
|
+
calls.push({
|
|
1810
|
+
body: typeof init?.body === 'string' ? init.body : '',
|
|
1811
|
+
headers: new Headers(init?.headers),
|
|
1812
|
+
url: typeof input === 'string' ? input : '',
|
|
1813
|
+
})
|
|
1814
|
+
return new Response(null, { status: 200 })
|
|
1815
|
+
}) satisfies typeof globalThis.fetch
|
|
1816
|
+
|
|
1817
|
+
// 3. Poll a real TIDX transfer row; dispatch through the real
|
|
1818
|
+
// sign+deliver+record core with the injected transport. `batchSize: 1`
|
|
1819
|
+
// delivers exactly one envelope from the window.
|
|
1820
|
+
const result = await Webhooks.poll({
|
|
1821
|
+
batchSize: 1,
|
|
1822
|
+
chainId: 42431,
|
|
1823
|
+
dispatch: ({ envelope, subscription }) =>
|
|
1824
|
+
Core.deliverAndRecord(store, subscription, envelope, { fetch }),
|
|
1825
|
+
eventTypes: ['token:transfer'],
|
|
1826
|
+
headBlockNumber: windowHead,
|
|
1827
|
+
client,
|
|
1828
|
+
store,
|
|
1829
|
+
tidx: realTidx,
|
|
1830
|
+
})
|
|
1831
|
+
expect(result.dispatched).toBe(1)
|
|
1832
|
+
|
|
1833
|
+
// The subscriber received exactly one POST to its URL, signed verifiably
|
|
1834
|
+
// with the secret the route returned.
|
|
1835
|
+
expect(calls).toHaveLength(1)
|
|
1836
|
+
expect(calls[0]?.url).toBe(validBody.destination.url)
|
|
1837
|
+
expect(
|
|
1838
|
+
Core.verify({
|
|
1839
|
+
body: calls[0]?.body ?? '',
|
|
1840
|
+
secret,
|
|
1841
|
+
signature: calls[0]?.headers.get('tempo-signature') ?? '',
|
|
1842
|
+
}),
|
|
1843
|
+
).toBe(true)
|
|
1844
|
+
const envelope = JSON.parse(calls[0]?.body ?? '{}') as Core.Envelope
|
|
1845
|
+
expect(envelope.type).toBe('token:transfer')
|
|
1846
|
+
|
|
1847
|
+
// 4. The deliveries route shows the recorded success for this event.
|
|
1848
|
+
const deliveries = await app.v1.webhooks[':id{wh_[A-Za-z0-9_-]+}'].deliveries.$get(
|
|
1849
|
+
{ param: { id }, query: {} },
|
|
1850
|
+
as(writer),
|
|
1851
|
+
)
|
|
1852
|
+
const log = await TestApp.json(deliveries, Webhooks.schema.listWebhookDeliveries.Response)
|
|
1853
|
+
expect(log.data).toHaveLength(1)
|
|
1854
|
+
expect(log.data[0]?.status).toBe('succeeded')
|
|
1855
|
+
expect(log.data[0]?.eventId).toBe(envelope.id)
|
|
1856
|
+
})
|
|
1857
|
+
})
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
describe('matchesOperator', () => {
|
|
1861
|
+
const addr = '0x20c0000000000000000000008f5425160ebe5525'
|
|
1862
|
+
const addr2 = '0x1111111111111111111111111111111111111111'
|
|
1863
|
+
|
|
1864
|
+
test('bare value matches case-insensitively (string kind)', () => {
|
|
1865
|
+
expect(Webhooks.matchesOperator(addr.toUpperCase(), addr)).toBe(true)
|
|
1866
|
+
expect(Webhooks.matchesOperator(addr2, addr)).toBe(false)
|
|
1867
|
+
})
|
|
1868
|
+
|
|
1869
|
+
test('a missing value never matches (fail-closed)', () => {
|
|
1870
|
+
expect(Webhooks.matchesOperator(undefined, addr)).toBe(false)
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
test('{ eq } matches equality', () => {
|
|
1874
|
+
expect(Webhooks.matchesOperator(addr, { eq: addr })).toBe(true)
|
|
1875
|
+
})
|
|
1876
|
+
|
|
1877
|
+
test('{ in } matches membership', () => {
|
|
1878
|
+
expect(Webhooks.matchesOperator(addr, { in: [addr2, addr] })).toBe(true)
|
|
1879
|
+
expect(Webhooks.matchesOperator(addr, { in: [addr2] })).toBe(false)
|
|
1880
|
+
})
|
|
1881
|
+
|
|
1882
|
+
test('{ not } matches inequality', () => {
|
|
1883
|
+
expect(Webhooks.matchesOperator(addr, { not: addr2 })).toBe(true)
|
|
1884
|
+
expect(Webhooks.matchesOperator(addr, { not: addr })).toBe(false)
|
|
1885
|
+
})
|
|
1886
|
+
|
|
1887
|
+
test('compare bare value matches numeric equality', () => {
|
|
1888
|
+
expect(Webhooks.matchesOperator('0x1a', '0x1a', 'number')).toBe(true)
|
|
1889
|
+
expect(Webhooks.matchesOperator('0x1b', '0x1a', 'number')).toBe(false)
|
|
1890
|
+
})
|
|
1891
|
+
|
|
1892
|
+
test('compare range AND-combines bounds', () => {
|
|
1893
|
+
const op = { gte: '0x1', lte: '0x100' }
|
|
1894
|
+
expect(Webhooks.matchesOperator('0x80', op, 'number')).toBe(true)
|
|
1895
|
+
expect(Webhooks.matchesOperator('0x200', op, 'number')).toBe(false)
|
|
1896
|
+
})
|
|
1897
|
+
|
|
1898
|
+
test('bytes selector matches the first four bytes', () => {
|
|
1899
|
+
expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { selector: '0xa9059cbb' })).toBe(true)
|
|
1900
|
+
expect(Webhooks.matchesOperator('0x12345678deadbeef', { selector: '0xa9059cbb' })).toBe(false)
|
|
1901
|
+
})
|
|
1902
|
+
|
|
1903
|
+
test('bytes startsWith matches a hex prefix', () => {
|
|
1904
|
+
expect(Webhooks.matchesOperator('0xa9059cbbdeadbeef', { startsWith: '0xa9059cbb' })).toBe(true)
|
|
1905
|
+
expect(Webhooks.matchesOperator('0xdead', { startsWith: '0xa9059cbb' })).toBe(false)
|
|
1906
|
+
})
|
|
1907
|
+
})
|
|
1908
|
+
|
|
1909
|
+
describe('parseFilters', () => {
|
|
1910
|
+
test('returns validated filters for a known event type', () => {
|
|
1911
|
+
expect(
|
|
1912
|
+
Webhooks.parseFilters('token:transfer', {
|
|
1913
|
+
sender: '0x20c0000000000000000000008f5425160ebe5525',
|
|
1914
|
+
}),
|
|
1915
|
+
).toMatchInlineSnapshot(`
|
|
1916
|
+
{
|
|
1917
|
+
"sender": "0x20c0000000000000000000008f5425160ebe5525",
|
|
1918
|
+
}
|
|
1919
|
+
`)
|
|
1920
|
+
})
|
|
1921
|
+
|
|
1922
|
+
test('treats undefined filters as the empty (match-all) set', () => {
|
|
1923
|
+
expect(Webhooks.parseFilters('token:transfer', undefined)).toMatchInlineSnapshot(`{}`)
|
|
1924
|
+
})
|
|
1925
|
+
|
|
1926
|
+
test('fails closed on an invalid filter shape', () => {
|
|
1927
|
+
let error: unknown
|
|
1928
|
+
try {
|
|
1929
|
+
Webhooks.parseFilters('token:transfer', { sender: 'not-an-address' })
|
|
1930
|
+
} catch (cause) {
|
|
1931
|
+
error = cause
|
|
1932
|
+
}
|
|
1933
|
+
expect(error).toBeInstanceOf(Core.InvalidFilterError)
|
|
1934
|
+
expect((error as Core.InvalidFilterError).eventType).toBe('token:transfer')
|
|
1935
|
+
expect((error as Core.InvalidFilterError).details.length).toBeGreaterThan(0)
|
|
1936
|
+
})
|
|
1937
|
+
})
|