Skip to content

Commit 11da7cb

Browse files
authored
feat(webhooks): declare triggerable-event catalogs for author-time trigger validation (#130)
* feat(webhooks): declare triggerable-event catalogs for author-time trigger validation * test(webhooks): assert catalog coverage dynamically across all wired event providers
1 parent 7822a38 commit 11da7cb

4 files changed

Lines changed: 275 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tangle-network/agent-integrations",
3-
"version": "0.47.0",
3+
"version": "0.48.0",
44
"description": "Vendor-neutral integration contracts and runtime helpers for sandbox and agent apps.",
55
"homepage": "https://github.com/tangle-network/agent-integrations#readme",
66
"repository": {

src/webhooks/providers.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ export const stripeWebhookProvider: WebhookProvider = {
4545
},
4646
]
4747
},
48+
eventCatalog: {
49+
// Stripe emits the raw event `type` (`charge.succeeded`) — no namespace, and
50+
// an OPEN, versioned vocabulary (~250 types), so these are authoring hints,
51+
// not a gate.
52+
namespace: null,
53+
closed: false,
54+
events: [
55+
{ id: 'checkout.session.completed' },
56+
{ id: 'checkout.session.expired' },
57+
{ id: 'customer.subscription.created' },
58+
{ id: 'customer.subscription.updated' },
59+
{ id: 'customer.subscription.deleted' },
60+
{ id: 'invoice.paid' },
61+
{ id: 'invoice.payment_failed' },
62+
{ id: 'payment_intent.succeeded' },
63+
{ id: 'payment_intent.payment_failed' },
64+
{ id: 'charge.succeeded' },
65+
{ id: 'charge.refunded' },
66+
],
67+
},
4868
}
4969

5070
/** Slack Events API provider. Handles the `url_verification` handshake
@@ -81,6 +101,26 @@ export const slackWebhookProvider: WebhookProvider = {
81101
headers: normalizeHeaders(headers),
82102
}]
83103
},
104+
eventCatalog: {
105+
// Slack namespaces every event `slack.<events-api-type>`, so a prefix-less
106+
// event is provably dead. The Events API vocabulary is open (Slack adds
107+
// types), so `events` are hints, not a gate. `slack.url_verification` (the
108+
// config handshake) is intentionally excluded — it carries no workflow
109+
// meaning and is answered before dispatch.
110+
namespace: 'slack.',
111+
closed: false,
112+
events: [
113+
{ id: 'slack.message' },
114+
{ id: 'slack.app_mention' },
115+
{ id: 'slack.reaction_added' },
116+
{ id: 'slack.reaction_removed' },
117+
{ id: 'slack.channel_created' },
118+
{ id: 'slack.member_joined_channel' },
119+
{ id: 'slack.team_join' },
120+
{ id: 'slack.app_home_opened' },
121+
{ id: 'slack.file_shared' },
122+
],
123+
},
84124
}
85125

86126
/** DocuSeal webhook provider. Signature header `X-Docuseal-Signature`. */
@@ -107,6 +147,23 @@ export const docusealWebhookProvider: WebhookProvider = {
107147
headers: normalizeHeaders(headers),
108148
}]
109149
},
150+
eventCatalog: {
151+
// DocuSeal namespaces every event `docuseal.<event_type>`. The documented
152+
// set is small but not derivable from `parse`, so it stays open (namespace
153+
// is the authoritative gate) with the documented types as hints.
154+
namespace: 'docuseal.',
155+
closed: false,
156+
events: [
157+
{ id: 'docuseal.form.viewed' },
158+
{ id: 'docuseal.form.started' },
159+
{ id: 'docuseal.form.completed' },
160+
{ id: 'docuseal.form.declined' },
161+
{ id: 'docuseal.submission.created' },
162+
{ id: 'docuseal.submission.completed' },
163+
{ id: 'docuseal.submission.expired' },
164+
{ id: 'docuseal.submission.archived' },
165+
],
166+
},
110167
}
111168

112169
/** Gmail push provider. Cloud Pub/Sub posts a JWT-signed envelope; the
@@ -246,6 +303,15 @@ export const telegramWebhookProvider: WebhookProvider = {
246303
headers: normalizeHeaders(headers),
247304
}]
248305
},
306+
eventCatalog: {
307+
// Derived from the SAME TELEGRAM_UPDATE_KEYS `parse` matches on, so the
308+
// catalog is exhaustive and cannot drift — hence `closed`. (`telegram.unknown`,
309+
// parse's fallback for an update with no recognized key, is intentionally
310+
// excluded: it is not a triggerable event.)
311+
namespace: 'telegram.',
312+
closed: true,
313+
events: TELEGRAM_UPDATE_KEYS.map((k) => ({ id: `telegram.${k}` })),
314+
},
249315
}
250316

251317
/** Dropbox Sign (HelloSign) webhook provider. There is NO signature header:
@@ -299,6 +365,24 @@ export const hellosignWebhookProvider: WebhookProvider = {
299365
headers: normalizeHeaders(headers),
300366
}]
301367
},
368+
eventCatalog: {
369+
// Dropbox Sign namespaces every event `hellosign.<event_type>`. Documented
370+
// set as hints; namespace is the authoritative gate. `callback_test` (the
371+
// app-config ping) is intentionally excluded — it is not a workflow event.
372+
namespace: 'hellosign.',
373+
closed: false,
374+
events: [
375+
{ id: 'hellosign.signature_request_sent' },
376+
{ id: 'hellosign.signature_request_viewed' },
377+
{ id: 'hellosign.signature_request_signed' },
378+
{ id: 'hellosign.signature_request_all_signed' },
379+
{ id: 'hellosign.signature_request_declined' },
380+
{ id: 'hellosign.signature_request_reassigned' },
381+
{ id: 'hellosign.signature_request_remind' },
382+
{ id: 'hellosign.signature_request_canceled' },
383+
{ id: 'hellosign.signature_request_expired' },
384+
],
385+
},
302386
}
303387

304388
/** Keys Clay HTTP-API-action payloads commonly carry that can anchor

src/webhooks/router.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,37 @@ export type SignatureVerification =
6262
| { valid: true }
6363
| { valid: false; reason: string }
6464

65+
/** One declared triggerable event, in the runtime `eventType` namespace a
66+
* provider's `parse` emits. */
67+
export interface TriggerEventDescriptor {
68+
/** The `eventType` a workflow `provider_event` trigger matches on
69+
* (`slack.app_mention`, `charge.succeeded`). */
70+
id: string
71+
/** Short human label for authoring surfaces; optional. */
72+
title?: string
73+
}
74+
75+
/** The set of events a provider can deliver, for author-time validation of
76+
* workflow `provider_event` triggers. Co-located with the provider's `parse`
77+
* (the source of the runtime namespace) so the two cannot drift — see the
78+
* per-provider drift tests. Consumers validate an authored event with two
79+
* checks: it must carry `namespace` (a prefix-less event on a namespaced
80+
* provider can never be delivered), and — only when `closed` — be one of
81+
* `events` (an exhaustive set makes an unlisted event an authoring error). */
82+
export interface TriggerEventCatalog {
83+
/** Required prefix every `parse`-emitted `eventType` carries (`slack.`,
84+
* `telegram.`), or `null` when the provider emits raw event names (stripe). */
85+
namespace: string | null
86+
/** Declared events. Exhaustive iff `closed`; otherwise authoring hints for an
87+
* open, provider-owned vocabulary. */
88+
events: readonly TriggerEventDescriptor[]
89+
/** True when `events` is the COMPLETE deliverable set, so an event outside it
90+
* is rejected. False when the provider owns an open-ended vocabulary it keeps
91+
* adding to (Stripe's ~250 types, Slack's Events API) and only `namespace`
92+
* can be authoritatively enforced. */
93+
closed: boolean
94+
}
95+
6596
/** Per-provider plug-in. Stateless — the router calls `verifySignature`
6697
* then `parse` on every request. The provider's HTTP-shape concerns
6798
* (e.g., raw body required) are documented per provider. */
@@ -91,6 +122,10 @@ export interface WebhookProvider {
91122
* provider declare the body/headers it needs. The frozen status contract is
92123
* unchanged — only the 200 body/headers are customized. */
93124
successResponse?: { body: string; headers?: Record<string, string> }
125+
/** Declared triggerable-event catalog, for author-time validation of workflow
126+
* `provider_event` triggers. Optional: a provider without one is left
127+
* unvalidated by consumers (the trigger still installs). */
128+
eventCatalog?: TriggerEventCatalog
94129
}
95130

96131
export interface WebhookIdempotencyStore {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Trigger-event catalog drift guard. The catalog a consumer validates workflow
3+
* `provider_event` triggers against MUST agree with what `parse()` actually
4+
* emits — otherwise a real event gets rejected, or a dead one accepted. These
5+
* tests pin the two together: catalog shape invariants, the namespace holding
6+
* against live parse output, and the `closed` (Telegram) catalog being exactly
7+
* the set `parse` can produce.
8+
*/
9+
10+
import { describe, expect, it } from 'vitest'
11+
import * as webhooks from '../src/webhooks/index'
12+
import {
13+
docusealWebhookProvider,
14+
hellosignWebhookProvider,
15+
slackWebhookProvider,
16+
stripeWebhookProvider,
17+
telegramWebhookProvider,
18+
type WebhookProvider,
19+
} from '../src/webhooks/index'
20+
21+
/** A value from the webhooks barrel that is a WebhookProvider. */
22+
function isWebhookProvider(v: unknown): v is WebhookProvider {
23+
return (
24+
typeof v === 'object' &&
25+
v !== null &&
26+
typeof (v as { id?: unknown }).id === 'string' &&
27+
typeof (v as { verifySignature?: unknown }).verifySignature === 'function' &&
28+
typeof (v as { parse?: unknown }).parse === 'function'
29+
)
30+
}
31+
32+
// EVERY exported webhook provider, discovered dynamically so a new provider is
33+
// covered by the invariants below without editing this test.
34+
const ALL_PROVIDERS: WebhookProvider[] = Object.values(webhooks).filter(isWebhookProvider)
35+
36+
// Providers the platform wires as workflow `provider_event` sources. These MUST
37+
// declare a catalog — without one their triggers can never be author-time
38+
// validated. Kept explicit so dropping a catalog from a wired provider fails
39+
// here loudly, while a non-event provider (gmail/gdrive push, generic HMAC) may
40+
// legitimately have none.
41+
const EVENT_SOURCE_PROVIDER_IDS = ['stripe', 'slack', 'docuseal', 'telegram', 'hellosign']
42+
43+
describe('event catalog — shape invariants', () => {
44+
// Assert every provider that DECLARES a catalog declares a well-formed one —
45+
// catalog is optional, so a provider without one is skipped, not failed.
46+
for (const provider of ALL_PROVIDERS) {
47+
const catalog = provider.eventCatalog
48+
if (!catalog) continue
49+
it(`${provider.id}: declares a well-formed catalog`, () => {
50+
expect(catalog.events.length).toBeGreaterThan(0)
51+
// No duplicate ids.
52+
const ids = catalog.events.map((e) => e.id)
53+
expect(new Set(ids).size).toBe(ids.length)
54+
// Every declared event carries the namespace (when the provider has one).
55+
if (catalog.namespace !== null) {
56+
for (const id of ids) {
57+
expect(id.startsWith(catalog.namespace)).toBe(true)
58+
}
59+
}
60+
})
61+
}
62+
63+
// Coverage guard: a provider the platform routes workflow events through must
64+
// carry a catalog, or its triggers silently skip validation.
65+
for (const id of EVENT_SOURCE_PROVIDER_IDS) {
66+
it(`${id}: is a wired event source and declares a catalog`, () => {
67+
const provider = ALL_PROVIDERS.find((p) => p.id === id)
68+
expect(provider, `no exported webhook provider with id "${id}"`).toBeDefined()
69+
expect(
70+
provider?.eventCatalog,
71+
`wired event-source provider "${id}" must declare an eventCatalog`,
72+
).toBeDefined()
73+
})
74+
}
75+
})
76+
77+
describe('event catalog — namespace holds against parse()', () => {
78+
async function eventTypeOf(
79+
provider: WebhookProvider,
80+
rawBody: string,
81+
): Promise<string> {
82+
const [env] = await provider.parse({ rawBody, headers: {} })
83+
return env.eventType
84+
}
85+
86+
it('slack: parsed event carries the declared namespace', async () => {
87+
const eventType = await eventTypeOf(
88+
slackWebhookProvider,
89+
JSON.stringify({ type: 'event_callback', event: { type: 'app_mention' } }),
90+
)
91+
expect(eventType).toBe('slack.app_mention')
92+
expect(eventType.startsWith(slackWebhookProvider.eventCatalog!.namespace!)).toBe(true)
93+
})
94+
95+
it('docuseal: parsed event carries the declared namespace', async () => {
96+
const eventType = await eventTypeOf(
97+
docusealWebhookProvider,
98+
JSON.stringify({ event_type: 'form.completed' }),
99+
)
100+
expect(eventType).toBe('docuseal.form.completed')
101+
expect(eventType.startsWith(docusealWebhookProvider.eventCatalog!.namespace!)).toBe(true)
102+
})
103+
104+
it('hellosign: parsed event carries the declared namespace', async () => {
105+
const eventType = await eventTypeOf(
106+
hellosignWebhookProvider,
107+
JSON.stringify({ event: { event_type: 'signature_request_signed' } }),
108+
)
109+
expect(eventType).toBe('hellosign.signature_request_signed')
110+
expect(eventType.startsWith(hellosignWebhookProvider.eventCatalog!.namespace!)).toBe(true)
111+
})
112+
113+
it('stripe: raw event type, no namespace to enforce', async () => {
114+
const eventType = await eventTypeOf(
115+
stripeWebhookProvider,
116+
JSON.stringify({ id: 'evt_1', type: 'charge.succeeded' }),
117+
)
118+
expect(eventType).toBe('charge.succeeded')
119+
expect(stripeWebhookProvider.eventCatalog!.namespace).toBeNull()
120+
})
121+
})
122+
123+
describe('telegram — closed catalog is exactly what parse can emit', () => {
124+
const catalog = telegramWebhookProvider.eventCatalog!
125+
const catalogIds = new Set(catalog.events.map((e) => e.id))
126+
127+
it('is closed and namespaced', () => {
128+
expect(catalog.closed).toBe(true)
129+
expect(catalog.namespace).toBe('telegram.')
130+
})
131+
132+
it('every update kind parse recognizes is a catalog member', async () => {
133+
// Drive parse with one update per declared kind; each must round-trip to a
134+
// catalog id. This is the drift guard: parse derives the kind from the same
135+
// TELEGRAM_UPDATE_KEYS the catalog is built from, so any divergence fails here.
136+
for (const { id } of catalog.events) {
137+
const kind = id.slice('telegram.'.length)
138+
const [env] = await telegramWebhookProvider.parse({
139+
rawBody: JSON.stringify({ update_id: 1, [kind]: {} }),
140+
headers: {},
141+
})
142+
expect(env.eventType).toBe(id)
143+
expect(catalogIds.has(env.eventType)).toBe(true)
144+
}
145+
})
146+
147+
it('an unrecognized update falls back to telegram.unknown, which is NOT triggerable', async () => {
148+
const [env] = await telegramWebhookProvider.parse({
149+
rawBody: JSON.stringify({ update_id: 1, not_a_real_update: {} }),
150+
headers: {},
151+
})
152+
expect(env.eventType).toBe('telegram.unknown')
153+
expect(catalogIds.has('telegram.unknown')).toBe(false)
154+
})
155+
})

0 commit comments

Comments
 (0)