import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as App from '../../../App.js' import * as Memberships from '../../../db/tables/memberships.js' import * as Cursor from '../../../internal/Cursor.js' import * as core_Webhooks from '../../../internal/Webhooks.js' import * as TestApp from '../../../../test/App.js' import { management } from '../App.js' import * as Orgs from './orgs.js' import * as Webhooks from './webhooks.js' const origin = 'http://localhost' const webhook = { applicationEventTypes: ['funding:deposit.updated', 'funding:transfer.updated'], supportedChainIds: [4217, 42431], } satisfies App.Webhook function json(method: 'PATCH' | 'POST', cookie: string, body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json', cookie }, method, } } function createApp(options: createApp.Options = {}) { return App.create({ auth: { session: { wallet: { origin } } }, db: options.db ?? TestApp.database(), kv: { store: TestApp.kvStore() }, }).route('/', management({ webhook: options.webhook ?? webhook })) } declare namespace createApp { type Options = { db?: App.create.Options['db'] | undefined webhook?: App.Webhook | false | undefined } } async function session(app: TestApp.signIn.App) { const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) return cookie! } async function organization(app: ReturnType, cookie: string) { const response = await app.request('/v1/orgs', json('POST', cookie, { name: 'Acme' })) return TestApp.json(response, Orgs.schema.Organization) } describe('webhooks', () => { test('publishes generator-ready OpenAPI contracts', async () => { const spec = await (await createApp().request('/openapi.json')).json() const collection = spec.paths['/v1/orgs/{orgId}/webhooks'] const deliveries = spec.paths['/v1/orgs/{orgId}/webhooks/{id}/deliveries'].get const delivery = spec.paths['/v1/orgs/{orgId}/webhooks/{id}/deliveries/{deliveryId}'].get const detail = spec.paths['/v1/orgs/{orgId}/webhooks/{id}'] const retry = spec.paths['/v1/orgs/{orgId}/webhooks/{id}/deliveries/{deliveryId}/retry'].post expect({ components: [ 'CreateOrganizationWebhookBetterstackResponse', 'CreateOrganizationWebhookRequest', 'CreateOrganizationWebhookResponse', 'CreateOrganizationWebhookSlackResponse', 'CreateOrganizationWebhookUrlResponse', 'DeleteOrganizationWebhookResponse', 'OrganizationWebhook', 'OrganizationWebhookDelivery', 'OrganizationWebhookDeliveryList', 'OrganizationWebhookDeliverySummary', 'OrganizationWebhookList', 'RetryOrganizationWebhookDeliveryResponse', 'UpdateOrganizationWebhookRequest', ].filter((name) => spec.components.schemas[name]), create: { errors: { 400: collection.post.responses[400].content['application/json'].schema, 403: collection.post.responses[403].content['application/json'].schema, 404: collection.post.responses[404].content['application/json'].schema, }, operationId: collection.post.operationId, request: collection.post.requestBody.content['application/json'].schema, response: collection.post.responses[200].content['application/json'].schema, }, delete: { errors: { 400: detail.delete.responses[400].content['application/json'].schema, 403: detail.delete.responses[403].content['application/json'].schema, 404: detail.delete.responses[404].content['application/json'].schema, }, operationId: detail.delete.operationId, response: detail.delete.responses[200].content['application/json'].schema, }, getDelivery: { errors: { 400: delivery.responses[400].content['application/json'].schema, 404: delivery.responses[404].content['application/json'].schema, }, operationId: delivery.operationId, response: delivery.responses[200].content['application/json'].schema, }, list: { errors: { 400: collection.get.responses[400].content['application/json'].schema, 404: collection.get.responses[404].content['application/json'].schema, }, operationId: collection.get.operationId, response: collection.get.responses[200].content['application/json'].schema, }, listDeliveries: { errors: { 400: deliveries.responses[400].content['application/json'].schema, 404: deliveries.responses[404].content['application/json'].schema, }, operationId: deliveries.operationId, response: deliveries.responses[200].content['application/json'].schema, }, retry: { errors: { 400: retry.responses[400].content['application/json'].schema, 403: retry.responses[403].content['application/json'].schema, 404: retry.responses[404].content['application/json'].schema, }, operationId: retry.operationId, response: retry.responses[200].content['application/json'].schema, }, timestamps: { createdAt: spec.components.schemas.OrganizationWebhook.properties.createdAt.format, deliveryCreatedAt: spec.components.schemas.OrganizationWebhookDeliverySummary.properties.createdAt.format, updatedAt: spec.components.schemas.OrganizationWebhook.properties.updatedAt.format, }, update: { errors: { 400: detail.patch.responses[400].content['application/json'].schema, 403: detail.patch.responses[403].content['application/json'].schema, 404: detail.patch.responses[404].content['application/json'].schema, }, operationId: detail.patch.operationId, request: detail.patch.requestBody.content['application/json'].schema, response: detail.patch.responses[200].content['application/json'].schema, }, }).toMatchInlineSnapshot(` { "components": [ "CreateOrganizationWebhookBetterstackResponse", "CreateOrganizationWebhookRequest", "CreateOrganizationWebhookResponse", "CreateOrganizationWebhookSlackResponse", "CreateOrganizationWebhookUrlResponse", "DeleteOrganizationWebhookResponse", "OrganizationWebhook", "OrganizationWebhookDelivery", "OrganizationWebhookDeliveryList", "OrganizationWebhookDeliverySummary", "OrganizationWebhookList", "RetryOrganizationWebhookDeliveryResponse", "UpdateOrganizationWebhookRequest", ], "create": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrChainIdUnsupportedOrEventTypeUnsupportedOrFiltersInvalidOrParamInvalidOrUrlInvalidError", }, "403": { "$ref": "#/components/schemas/ApiKeyForbiddenOrApiKeyIpForbiddenOrForbiddenOrLimitExceededError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "createOrgWebhook", "request": { "$ref": "#/components/schemas/CreateOrganizationWebhookRequest", }, "response": { "$ref": "#/components/schemas/CreateOrganizationWebhookResponse", }, }, "delete": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundOrWebhookNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "deleteOrgWebhook", "response": { "$ref": "#/components/schemas/DeleteOrganizationWebhookResponse", }, }, "getDelivery": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "404": { "$ref": "#/components/schemas/DeliveryNotFoundOrOrganizationNotFoundOrWebhookNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "getOrgWebhookDelivery", "response": { "$ref": "#/components/schemas/OrganizationWebhookDelivery", }, }, "list": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "listOrgWebhooks", "response": { "$ref": "#/components/schemas/OrganizationWebhookList", }, }, "listDeliveries": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidOrQueryInvalidError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundOrWebhookNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "listOrgWebhookDeliveries", "response": { "$ref": "#/components/schemas/OrganizationWebhookDeliveryList", }, }, "retry": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/DeliveryNotFoundOrOrganizationNotFoundOrWebhookNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "retryOrgWebhookDelivery", "response": { "$ref": "#/components/schemas/RetryOrganizationWebhookDeliveryResponse", }, }, "timestamps": { "createdAt": "date-time", "deliveryCreatedAt": "date-time", "updatedAt": "date-time", }, "update": { "errors": { "400": { "$ref": "#/components/schemas/ApiKeyMalformedOrBodyInvalidOrParamInvalidError", }, "403": { "$ref": "#/components/schemas/ForbiddenError", }, "404": { "$ref": "#/components/schemas/OrganizationNotFoundOrWebhookNotFoundOrWebhooksNotEnabledError", }, }, "operationId": "updateOrgWebhook", "request": { "$ref": "#/components/schemas/UpdateOrganizationWebhookRequest", }, "response": { "$ref": "#/components/schemas/OrganizationWebhook", }, }, } `) }) test('creates funding transfer subscriptions without reading the RPC head', async () => { const db = TestApp.database() const app = createApp({ db }) const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', cookie, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/funding' }, environment: 'production', eventType: 'funding:transfer.updated', filters: { status: 'completed' }, }), ) const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response) const stored = await core_Webhooks.getSubscription( db, { orgId: org.id, type: 'api_key' }, created.id, ) const listedResponse = await app.request(`/v1/orgs/${org.id}/webhooks`, { headers: { cookie }, }) const listed = await TestApp.json(listedResponse, Webhooks.schema.ListResponse) expect(response.status).toBe(200) expect(created.environment).toBe('production') expect(listed.data[0]?.environment).toBe('production') expect(stored?.environment).toBe('production') expect(await core_Webhooks.getCursor(db, created.id)).toBeNull() }) test('preserves a production funding environment on testnet', async () => { const db = TestApp.database() const app = createApp({ db }) const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', cookie, { chainId: 42431, destination: { type: 'url', url: 'https://example.com/funding-testnet' }, environment: 'production', eventType: 'funding:deposit.updated', }), ) const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response) const stored = await core_Webhooks.getSubscription( db, { orgId: org.id, type: 'api_key' }, created.id, ) expect(response.status).toBe(200) expect(created.environment).toBe('production') expect(stored?.environment).toBe('production') }) test('requires an explicit environment for funding deposit subscriptions', async () => { const app = createApp() const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', cookie, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/funding' }, eventType: 'funding:deposit.updated', }), ) expect(response.status).toBe(400) }) test('creates, lists, pauses, and deletes an organization webhook', async () => { const db = TestApp.database() const app = createApp({ db }) const cookie = await session(app) const org = await organization(app, cookie) const collection = `/v1/orgs/${org.id}/webhooks` const createdResponse = await app.request( collection, json('POST', cookie, { chainId: 4217, context: { title: 'Payments' }, destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', filters: {}, }), ) expect(createdResponse.status).toBe(200) const created = await TestApp.json(createdResponse, Webhooks.schema.createWebhook.Response) expect(created).toHaveProperty('secret', expect.stringMatching(/^whsec_/)) expect(Cursor.decode((await core_Webhooks.getCursor(db, created.id))!, ['int'])).toEqual([ expect.any(Number), ]) const listedResponse = await app.request(collection, { headers: { cookie } }) expect(listedResponse.status).toBe(200) const listed = await TestApp.json(listedResponse, Webhooks.schema.ListResponse) expect(listed.data).toMatchObject([ { context: { title: 'Payments' }, id: created.id, status: 'active', }, ]) const item = `${collection}/${created.id}` const pausedResponse = await app.request(item, json('PATCH', cookie, { status: 'paused' })) expect(pausedResponse.status).toBe(200) expect((await TestApp.json(pausedResponse, Webhooks.schema.Subscription)).status).toBe('paused') const deletedResponse = await app.request(item, { headers: { cookie }, method: 'DELETE', }) expect(deletedResponse.status).toBe(200) expect(await TestApp.json(deletedResponse, Webhooks.schema.DeleteResponse)).toEqual({ id: created.id, }) const empty = await TestApp.json( await app.request(collection, { headers: { cookie } }), Webhooks.schema.ListResponse, ) expect(empty.data).toEqual([]) }) test('lists paginated delivery history with destination credentials redacted', async () => { const db = TestApp.database() const app = createApp({ db }) const cookie = await session(app) const org = await organization(app, cookie) const subscription = await core_Webhooks.createSubscription(db, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/hooks/capability?token=secret#fragment', }, eventType: 'token:transfer', owner: { orgId: org.id, type: 'api_key' }, }) for (let attempt = 1; attempt <= 6; attempt++) { const createdAt = new Date(1_700_000_000_000 + attempt * 1_000).toISOString() await core_Webhooks.recordDelivery(db, { attempt, createdAt, envelope: { chainId: 4217, createdAt, data: { address: '0x20c0000000000000000000008f5425160ebe5525', amount: String(attempt), blockNumber: attempt, recipient: '0x9e39034aae71fb89f66061a2602eb6efec271754', sender: '0xe7687128b0a808c2831ff94d4f7b2fb35c65af38', timestamp: createdAt, transactionHash: `0x${attempt.toString(16).padStart(64, '0')}`, }, id: `evt_${attempt}`, subscriptionId: subscription.id, type: 'token:transfer', }, eventId: `evt_${attempt}`, id: `whd_${String(1_700_000_000_000 + attempt * 1_000).padStart(15, '0')}_${'0'.repeat(16)}`, requestUrl: subscription.destination.url, responseMs: attempt, responseStatus: 200, status: 'succeeded', subscriptionId: subscription.id, }) } const path = `/v1/orgs/${org.id}/webhooks/${subscription.id}/deliveries?limit=5` const response = await app.request(path, { headers: { cookie } }) expect(response.status).toBe(200) const body = await TestApp.json(response, Webhooks.schema.listWebhookDeliveries.Response) expect(body.data.map((delivery) => delivery.eventId)).toEqual([ 'evt_6', 'evt_5', 'evt_4', 'evt_3', 'evt_2', ]) expect(body.nextCursor).toBeTruthy() expect(body.data[0]?.requestUrl).toBe('https://example.com/…') expect(JSON.stringify(body)).not.toContain('capability') expect(JSON.stringify(body)).not.toContain('token=secret') expect(JSON.stringify(body)).not.toContain('amount') const detailResponse = await app.request(`${path.split('?')[0]}/${body.data[0]!.id}`, { headers: { cookie }, }) expect(detailResponse.status).toBe(200) const detail = await TestApp.json(detailResponse, Webhooks.schema.getWebhookDelivery.Response) expect(detail.envelope).toMatchObject({ data: { amount: '6' }, id: 'evt_6', subscriptionId: subscription.id, type: 'token:transfer', }) expect(detail.requestUrl).toBe('https://example.com/…') expect(JSON.stringify(detail)).not.toContain('capability') expect(JSON.stringify(detail)).not.toContain('token=secret') expect(JSON.stringify(detail)).not.toContain(subscription.secret) }) test('returns delivery_not_found when loading or replaying an unknown delivery', async () => { const db = TestApp.database() const app = createApp({ db }) const cookie = await session(app) const org = await organization(app, cookie) const subscription = await core_Webhooks.createSubscription(db, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', owner: { orgId: org.id, type: 'api_key' }, }) const detailResponse = await app.request( `/v1/orgs/${org.id}/webhooks/${subscription.id}/deliveries/whd_missing`, { headers: { cookie } }, ) expect(detailResponse.status).toBe(404) expect(await detailResponse.json()).toMatchObject({ error: { code: 'delivery_not_found' }, }) const response = await app.request( `/v1/orgs/${org.id}/webhooks/${subscription.id}/deliveries/whd_missing/retry`, { headers: { cookie }, method: 'POST' }, ) expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ error: { code: 'delivery_not_found' } }) }) test('hides webhooks behind foreign organizations', async () => { const app = createApp() const cookie = await session(app) const org = await organization(app, cookie) const stranger = await session(app) const response = await app.request(`/v1/orgs/${org.id}/webhooks`, { headers: { cookie: stranger }, }) expect(response.status).toBe(404) }) test('rejects chains the host does not poll', async () => { const app = createApp() const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', cookie, { chainId: 1, destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', filters: {}, }), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'chain_id_unsupported' }, }) }) test('requires an explicit chain id', async () => { const app = createApp() const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', cookie, { destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', filters: {}, }), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid' } }) }) test('redacts managed destination credentials and only returns secrets for URLs', async () => { const app = createApp() const cookie = await session(app) const org = await organization(app, cookie) const collection = `/v1/orgs/${org.id}/webhooks` const url = await TestApp.json( await app.request( collection, json('POST', cookie, { chainId: 4217, destination: { type: 'url', url: 'https://example.com/hooks/capability?token=secret#fragment', }, eventType: 'token:transfer', filters: {}, }), ), Webhooks.schema.createWebhook.Response, ) expect(url).toMatchObject({ destination: { type: 'url', url: 'https://example.com/hooks/capability?token=secret#fragment', }, secret: expect.stringMatching(/^whsec_/), }) const listed = await TestApp.json( await app.request(collection, { headers: { cookie } }), Webhooks.schema.ListResponse, ) expect(listed.data.find(({ id }) => id === url.id)?.destination).toEqual({ type: 'url', url: 'https://example.com/…', }) expect(JSON.stringify(listed)).not.toContain('capability') expect(JSON.stringify(listed)).not.toContain('token=secret') const slack = await TestApp.json( await app.request( collection, json('POST', cookie, { chainId: 4217, destination: { type: 'slack', url: 'https://hooks.slack.com/services/T000/B000/XXXXXXXX', }, eventType: 'token:transfer', filters: {}, }), ), Webhooks.schema.createWebhook.Response, ) expect(slack).toMatchObject({ destination: { type: 'slack', url: 'https://hooks.slack.com/…' }, }) expect('secret' in slack).toBe(false) const betterstack = await TestApp.json( await app.request( collection, json('POST', cookie, { chainId: 4217, destination: { token: 'source-token', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }, eventType: 'token:transfer', filters: {}, }), ), Webhooks.schema.createWebhook.Response, ) expect(betterstack).toMatchObject({ destination: { token: '[redacted]', type: 'betterstack', url: 'https://s1234567.eu-nbg-2.betterstackdata.com', }, }) expect('secret' in betterstack).toBe(false) }) test('uses the shared per-owner limit', async () => { const app = createApp({ webhook: { maxPerOwner: 1, supportedChainIds: webhook.supportedChainIds }, }) const cookie = await session(app) const org = await organization(app, cookie) const collection = `/v1/orgs/${org.id}/webhooks` const body = { chainId: 4217, destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', filters: {}, } expect((await app.request(collection, json('POST', cookie, body))).status).toBe(200) const response = await app.request(collection, json('POST', cookie, body)) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'limit_exceeded' } }) }) test('lists every webhook when the creation limit is lowered', async () => { const db = TestApp.database() const app = createApp({ db, webhook: { maxPerOwner: 1, supportedChainIds: webhook.supportedChainIds }, }) const cookie = await session(app) const org = await organization(app, cookie) const input = { chainId: 4217, destination: { type: 'url', url: 'https://example.com/webhooks' } as const, eventType: 'token:transfer' as const, owner: { orgId: org.id, type: 'api_key' as const }, } await core_Webhooks.createSubscription(db, input) await core_Webhooks.createSubscription(db, input) const response = await app.request(`/v1/orgs/${org.id}/webhooks`, { headers: { cookie }, }) expect(response.status).toBe(200) expect((await TestApp.json(response, Webhooks.schema.ListResponse)).data).toHaveLength(2) }) test('keeps management disabled unless the capability is supplied', async () => { const app = createApp({ webhook: false }) const cookie = await session(app) const org = await organization(app, cookie) const response = await app.request(`/v1/orgs/${org.id}/webhooks`, { headers: { cookie }, }) expect(response.status).toBe(404) expect(await response.json()).toMatchObject({ error: { code: 'webhooks_not_enabled' } }) const spec = (await (await app.request('/openapi.json')).json()) as { paths: Record } expect(spec.paths[`/v1/orgs/{orgId}/webhooks`]).toBeUndefined() }) test('requires an admin role for mutations', async () => { const db = TestApp.database() const app = createApp({ db }) const ownerCookie = await session(app) const org = await organization(app, ownerCookie) const memberAccount = privateKeyToAccount(generatePrivateKey()) const { cookie: memberCookie } = await TestApp.signIn(app, memberAccount) const me = (await ( await app.request('/v1/me', { headers: { cookie: memberCookie! } }) ).json()) as { id: string } await Memberships.create(db, { orgId: org.id, role: 'member', userId: me.id }) const response = await app.request( `/v1/orgs/${org.id}/webhooks`, json('POST', memberCookie!, { destination: { type: 'url', url: 'https://example.com/webhooks' }, eventType: 'token:transfer', filters: {}, }), ) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'forbidden' } }) }) })