Skip to content

Commit a4b21cc

Browse files
authored
feat(webhooks): standalone Telegram + HelloSign inbound webhook providers (#104)
* feat(webhooks): add standalone Telegram + HelloSign inbound providers Both providers had verifier logic at the connector-adapter level but no standalone WebhookProvider, so the inbound webhook router (and consumers that drive it — e.g. a per-connection inbound route) could not use them. Add them to webhooks/providers.ts (auto-exported from /webhooks): - telegramWebhookProvider: Telegram authenticates webhooks by echoing the secret_token set at setWebhook time in the X-Telegram-Bot-Api-Secret-Token header (the body is NOT signed), so we constant-time compare it to the per-connection secret. update_id is the idempotency anchor; the first present update key names the event (telegram.message, telegram.callback_query, ...). New capability — the connector only had a setWebhook mutation + a TODO. - hellosignWebhookProvider: Dropbox Sign has no signature header; the body carries event.event_hash = HMAC_SHA256(apiKey, event_time + event_type), so `secret` is the account API key. Handles JSON, form-urlencoded `json=`, and multipart `name="json"` bodies (mirrors the connector adapter's extraction). Tests cover verify pass/fail/missing and parse for both (incl. HelloSign's form-encoded body). Full suite (4077) + typecheck + build green. Additive exports only: 0.39.0 -> 0.40.0. * fix(webhooks): harden telegram and hellosign inbound providers - HelloSign: key providerEventId on a sha256 digest of the parsed body rather than event_hash. event_hash = HMAC(apiKey, event_time + event_type), so two distinct same-type events in the same second collide and the second would be dropped by delivery dedup. The body digest is stable across redeliveries of one event and distinct across events that differ in content. - Telegram: extend TELEGRAM_UPDATE_KEYS to the full Bot API Update set (business_connection, edited_business_message, deleted_business_messages, message_reaction, message_reaction_count, purchased_paid_media, chat_boost, removed_chat_boost) so those deliveries classify by type instead of telegram.unknown. - Tests: multipart name="json" verify+parse, same-second collision + redelivery stability, and a message_reaction regression. typecheck clean; full suite green (4080). * fix(webhooks): decode form-urlencoded hellosign bodies with URLSearchParams The `json=` fallback used decodeURIComponent, which does not convert `+` to a space — but application/x-www-form-urlencoded encodes spaces as `+`. Space-bearing fields (e.g. signature_request.title) were corrupted, and JSON with structural whitespace could fail to parse. Switch to URLSearchParams.get('json'), which decodes `+` and percent-escapes correctly. Applied to both the new webhook provider and the HelloSign connector adapter it mirrors. typecheck clean; full suite green (4081). * fix(webhooks): make hellosign provider production-correct (ACK body + canonical event id) - Router: add an optional WebhookProvider.successResponse so a provider can declare the 200 ACK body/headers it needs; WebhookRouter.handle() returns it on a verified delivery instead of the default { received, total } JSON. The frozen status contract is unchanged. - HelloSign: declare the literal 'Hello API Event Received' ACK (text/plain) Dropbox Sign requires — without it a verified delivery is treated as failed and retried indefinitely. - HelloSign: prefer the canonical event.event_id for providerEventId, falling back to the body digest only when it is absent (parity with the connector adapter; the digest still avoids the event_hash same-second collision). - Tests: event_id preference, the ACK field, and a router-level assertion that a verified HelloSign delivery returns 200 with body 'Hello API Event Received'. typecheck clean; full suite green (4084). * fix(webhooks): linear multipart json extraction, removing quadratic backtracking The HelloSign multipart `name="json"` fallback used a regex with two sequential lazy scans (`name="json"[^]*?\r?\n\r?\n([\s\S]*?)\r?\n--`). On a body with many `\r\n\r\n` separators and no closing boundary it backtracks quadratically — measured ~26s for a 400KB body — so a crafted POST to the public webhook ingress could stall the event loop. Replace it with indexOf + fixed-shape separator probes (no `*`/`+` quantifiers), which extract the same part in a single linear pass (~1.5ms at 1MB) with identical semantics. Applied to both the webhook provider and the connector adapter that share the pattern. typecheck clean; full suite green (4085). * docs(webhooks): list telegram and hellosign providers in the router table The package now exports telegramWebhookProvider and hellosignWebhookProvider; add them to the WebhookRouter row of the API reference.
1 parent 648c430 commit a4b21cc

7 files changed

Lines changed: 446 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ OAuth credentials.
176176
| `validateProviderPassthroughRequest` | Policy-gated provider-native HTTP escape hatch validation. |
177177
| `buildIntegrationToolCatalog` | Converts connector actions into agent/tool definitions. |
178178
| `discoverWorkspaceCapabilities` | Per-workspace capability discovery: scope-gated, MCP-shape tool descriptors for agent runtime binding. |
179-
| `WebhookRouter` (+ `stripeWebhookProvider`, `docusealWebhookProvider`, `slackWebhookProvider`, `gmailWebhookProvider`, `gdriveWebhookProvider`, `genericHmacWebhookProvider`) | Inbound webhook router: verify signature, parse, idempotency dedup, async deliver. |
179+
| `WebhookRouter` (+ `stripeWebhookProvider`, `docusealWebhookProvider`, `slackWebhookProvider`, `gmailWebhookProvider`, `gdriveWebhookProvider`, `telegramWebhookProvider`, `hellosignWebhookProvider`, `genericHmacWebhookProvider`) | Inbound webhook router: verify signature, parse, idempotency dedup, async deliver. |
180180
| `searchIntegrationTools` | Intent search over normalized integration tools. |
181181
| `buildDefaultIntegrationRegistry` | Composes setup specs and vendored catalog metadata into one deduplicated connector registry. |
182182
| `composeIntegrationRegistry` | Merges arbitrary catalog sources with explicit aliases, precedence, support tiers, and conflict diagnostics. |

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.39.0",
3+
"version": "0.40.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/connectors/adapters/hellosign.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -648,26 +648,33 @@ function parseInboundBody(rawBody: string): HelloSignInboundBody | null {
648648
} catch {
649649
// fall through to form-data extraction
650650
}
651-
// crude multipart extraction: look for a `json=` value (form-urlencoded
652-
// fallback some Dropbox Sign apps still emit)
653-
const match = /(?:^|&)json=([^&]+)/.exec(rawBody)
654-
if (match) {
651+
// form-urlencoded `json=` fallback some Dropbox Sign apps still emit.
652+
// URLSearchParams decodes `+` as a space (decodeURIComponent leaves it
653+
// literal) and resolves percent-escapes, so space-bearing fields survive.
654+
const formJson = new URLSearchParams(rawBody).get('json')
655+
if (formJson) {
655656
try {
656-
return JSON.parse(decodeURIComponent(match[1])) as HelloSignInboundBody
657+
return JSON.parse(formJson) as HelloSignInboundBody
657658
} catch {
658659
return null
659660
}
660661
}
661-
// multipart/form-data with a `name="json"` part
662-
const partMatch = /name="json"[^]*?\r?\n\r?\n([\s\S]*?)\r?\n--/m.exec(rawBody)
663-
if (partMatch) {
664-
try {
665-
return JSON.parse(partMatch[1]) as HelloSignInboundBody
666-
} catch {
667-
return null
668-
}
662+
// multipart/form-data with a `name="json"` part. Extract linearly (indexOf +
663+
// fixed-shape separator probes, no unbounded lazy regex) so a hostile body
664+
// can't drive quadratic backtracking on this public ingress.
665+
const nameIdx = rawBody.indexOf('name="json"')
666+
if (nameIdx === -1) return null
667+
const afterName = rawBody.slice(nameIdx)
668+
const sep = /\r?\n\r?\n/.exec(afterName)
669+
if (!sep) return null
670+
const partBody = afterName.slice(sep.index + sep[0].length)
671+
const end = /\r?\n--/.exec(partBody)
672+
if (!end) return null
673+
try {
674+
return JSON.parse(partBody.slice(0, end.index)) as HelloSignInboundBody
675+
} catch {
676+
return null
669677
}
670-
return null
671678
}
672679

673680
async function ensureFreshAccessToken(

src/webhooks/providers.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
verifyStripeSignature,
1919
} from '../connectors/webhooks.js'
2020
import type { WebhookEnvelope, WebhookHeaders, WebhookProvider, SignatureVerification } from './router.js'
21-
import { createHmac, timingSafeEqual } from 'node:crypto'
21+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
2222

2323
/** Stripe webhook provider. Signature header `Stripe-Signature`. */
2424
export const stripeWebhookProvider: WebhookProvider = {
@@ -191,6 +191,172 @@ export const gdriveWebhookProvider: WebhookProvider = {
191191
},
192192
}
193193

194+
/** The Telegram update keys, in resolution order — the first present key names
195+
* the update kind (`telegram.message`, `telegram.callback_query`, …). */
196+
const TELEGRAM_UPDATE_KEYS = [
197+
'message',
198+
'edited_message',
199+
'channel_post',
200+
'edited_channel_post',
201+
'business_connection',
202+
'business_message',
203+
'edited_business_message',
204+
'deleted_business_messages',
205+
'message_reaction',
206+
'message_reaction_count',
207+
'inline_query',
208+
'chosen_inline_result',
209+
'callback_query',
210+
'shipping_query',
211+
'pre_checkout_query',
212+
'purchased_paid_media',
213+
'poll',
214+
'poll_answer',
215+
'my_chat_member',
216+
'chat_member',
217+
'chat_join_request',
218+
'chat_boost',
219+
'removed_chat_boost',
220+
] as const
221+
222+
/** Telegram Bot API webhook provider. Telegram does NOT sign the body — it
223+
* authenticates by echoing the `secret_token` set at `setWebhook` time in the
224+
* `X-Telegram-Bot-Api-Secret-Token` header. We compare it constant-time to the
225+
* per-connection secret. `update_id` is the idempotency anchor. */
226+
export const telegramWebhookProvider: WebhookProvider = {
227+
id: 'telegram',
228+
verifySignature({ headers, secret }): SignatureVerification {
229+
const token = firstHeader(headers, 'x-telegram-bot-api-secret-token')
230+
if (!token) return { valid: false, reason: 'missing_telegram_secret_token' }
231+
const a = Buffer.from(token, 'utf-8')
232+
const b = Buffer.from(secret, 'utf-8')
233+
if (a.length !== b.length) return { valid: false, reason: 'invalid_secret_token' }
234+
return timingSafeEqual(a, b) ? { valid: true } : { valid: false, reason: 'invalid_secret_token' }
235+
},
236+
parse({ rawBody, headers, now }): WebhookEnvelope[] {
237+
const evt = safeJson(rawBody) as ({ update_id?: number } & Record<string, unknown>) | null
238+
if (!evt || typeof evt !== 'object') return []
239+
const kind = TELEGRAM_UPDATE_KEYS.find((k) => k in evt) ?? 'unknown'
240+
return [{
241+
provider: 'telegram',
242+
eventType: `telegram.${kind}`,
243+
providerEventId: typeof evt.update_id === 'number' ? String(evt.update_id) : undefined,
244+
receivedAt: now ?? Date.now(),
245+
payload: evt,
246+
headers: normalizeHeaders(headers),
247+
}]
248+
},
249+
}
250+
251+
/** Dropbox Sign (HelloSign) webhook provider. There is NO signature header:
252+
* the body carries `event.event_hash = HMAC_SHA256(apiKey, event_time +
253+
* event_type)` (hex), so the `secret` here is the account's API key (the
254+
* caller resolves it from the connection credential). The body is JSON, or a
255+
* `json=`/multipart `name="json"` form field on older apps. */
256+
export const hellosignWebhookProvider: WebhookProvider = {
257+
id: 'hellosign',
258+
// Dropbox Sign acknowledges a delivery only when the response body is exactly
259+
// 'Hello API Event Received'; any other body makes it retry the event.
260+
successResponse: { body: 'Hello API Event Received', headers: { 'content-type': 'text/plain' } },
261+
verifySignature({ rawBody, secret }): SignatureVerification {
262+
const body = parseHelloSignBody(rawBody)
263+
const event = body?.event
264+
if (
265+
!event ||
266+
typeof event.event_time !== 'string' ||
267+
typeof event.event_type !== 'string' ||
268+
typeof event.event_hash !== 'string'
269+
) {
270+
return { valid: false, reason: 'missing_event_fields' }
271+
}
272+
const expected = createHmac('sha256', secret)
273+
.update(`${event.event_time}${event.event_type}`)
274+
.digest('hex')
275+
const a = Buffer.from(event.event_hash.toLowerCase(), 'utf-8')
276+
const b = Buffer.from(expected.toLowerCase(), 'utf-8')
277+
if (a.length !== b.length) return { valid: false, reason: 'invalid_signature' }
278+
return timingSafeEqual(a, b) ? { valid: true } : { valid: false, reason: 'invalid_signature' }
279+
},
280+
parse({ rawBody, headers, now }): WebhookEnvelope[] {
281+
const body = parseHelloSignBody(rawBody)
282+
const event = body?.event
283+
if (!event) return []
284+
return [{
285+
provider: 'hellosign',
286+
eventType: `hellosign.${event.event_type ?? 'unknown'}`,
287+
// Prefer Dropbox Sign's canonical `event.event_id` (stable across
288+
// redeliveries, correlates with the provider). Fall back to a digest of
289+
// the parsed body when it's absent: `event_hash` = HMAC(apiKey,
290+
// event_time + event_type), so two distinct same-type events in the same
291+
// second share it — the body digest stays distinct across differing
292+
// events and identical across redeliveries of one event.
293+
providerEventId:
294+
typeof event.event_id === 'string' && event.event_id.length > 0
295+
? event.event_id
296+
: createHash('sha256').update(JSON.stringify(body)).digest('hex'),
297+
receivedAt: now ?? Date.now(),
298+
payload: body,
299+
headers: normalizeHeaders(headers),
300+
}]
301+
},
302+
}
303+
304+
interface HelloSignInboundBody {
305+
event?: {
306+
event_time?: string
307+
event_type?: string
308+
event_hash?: string
309+
event_id?: string
310+
}
311+
}
312+
313+
/** Dropbox Sign posts JSON, a form-urlencoded `json=` field, or a
314+
* multipart `name="json"` part depending on app vintage. Mirrors the
315+
* connector adapter's body extraction. */
316+
function parseHelloSignBody(rawBody: string): HelloSignInboundBody | null {
317+
try {
318+
const json = JSON.parse(rawBody) as HelloSignInboundBody
319+
if (json && typeof json === 'object') return json
320+
} catch {
321+
// fall through to form-data extraction
322+
}
323+
// application/x-www-form-urlencoded `json=` field. URLSearchParams decodes
324+
// `+` as a space (decodeURIComponent leaves it literal) and resolves
325+
// percent-escapes, so space-bearing string fields aren't corrupted.
326+
const formJson = new URLSearchParams(rawBody).get('json')
327+
if (formJson) {
328+
try {
329+
return JSON.parse(formJson) as HelloSignInboundBody
330+
} catch {
331+
return null
332+
}
333+
}
334+
// multipart/form-data with a `name="json"` part. Extract linearly (indexOf +
335+
// fixed-shape separator probes, no unbounded lazy regex) so a hostile body
336+
// can't drive quadratic backtracking on this public ingress.
337+
return extractMultipartJsonField(rawBody)
338+
}
339+
340+
/** Pull the `json` part out of a multipart/form-data body without an
341+
* unbounded backtracking regex. Returns null if the part isn't present or
342+
* isn't valid JSON. The separator probes (`\r?\n\r?\n`, `\r?\n--`) are
343+
* fixed-shape (no `*`/`+`), so each runs in a single linear scan. */
344+
function extractMultipartJsonField(rawBody: string): HelloSignInboundBody | null {
345+
const nameIdx = rawBody.indexOf('name="json"')
346+
if (nameIdx === -1) return null
347+
const afterName = rawBody.slice(nameIdx)
348+
const sep = /\r?\n\r?\n/.exec(afterName)
349+
if (!sep) return null
350+
const partBody = afterName.slice(sep.index + sep[0].length)
351+
const end = /\r?\n--/.exec(partBody)
352+
if (!end) return null
353+
try {
354+
return JSON.parse(partBody.slice(0, end.index)) as HelloSignInboundBody
355+
} catch {
356+
return null
357+
}
358+
}
359+
194360
/** Generic HMAC provider — for the long-tail webhook source where the
195361
* caller has standardised on a single sha256-of-body scheme. Header
196362
* `X-Signature` by default; override at provider-build time if needed. */

src/webhooks/router.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ export interface WebhookProvider {
8484
headers: WebhookHeaders
8585
now?: number
8686
}): WebhookEnvelope[] | Promise<WebhookEnvelope[]>
87+
/** Optional provider-specific 200 ACK the router returns on a verified
88+
* delivery, in place of the default `{ received, total }` JSON. Dropbox Sign
89+
* acknowledges a delivery only when the body is exactly
90+
* 'Hello API Event Received' and retries otherwise; this lets such a
91+
* provider declare the body/headers it needs. The frozen status contract is
92+
* unchanged — only the 200 body/headers are customized. */
93+
successResponse?: { body: string; headers?: Record<string, string> }
8794
}
8895

8996
export interface WebhookIdempotencyStore {
@@ -196,6 +203,13 @@ export class WebhookRouter {
196203
void this.deliverEach(accepted)
197204
})
198205

206+
if (provider.successResponse) {
207+
return {
208+
status: 200,
209+
body: provider.successResponse.body,
210+
headers: provider.successResponse.headers,
211+
}
212+
}
199213
return { status: 200, body: { received: accepted.length, total: events.length } }
200214
}
201215

0 commit comments

Comments
 (0)