zooid 0.13.0 → 0.14.1

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.
@@ -0,0 +1,104 @@
1
+ import { createHmac } from 'node:crypto'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { Hono } from 'hono'
4
+ import { loadZooidConfig } from '@zooid/core'
5
+ import { eventNameFor, mountWebhookRoutes } from './webhook-routes.js'
6
+
7
+ const secret = 'route-secret'
8
+ const body = JSON.stringify({ action: 'opened' })
9
+ const signature = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
10
+ const yaml = `
11
+ runtime: local
12
+ transports:
13
+ matrix:
14
+ type: matrix
15
+ homeserver: http://localhost:8448
16
+ as_token: t
17
+ hs_token: h
18
+ user_namespace: '@.*:example.org'
19
+ agents:
20
+ product:
21
+ acp: { preset: opencode }
22
+ matrix: { rooms: ['#product:example.org'] }
23
+ triggers:
24
+ triage:
25
+ webhook:
26
+ provider: github
27
+ secret: ${secret}
28
+ as: '@hook:example.org'
29
+ room: '!product:example.org'
30
+ mention: product
31
+ text: Triage.
32
+ `
33
+
34
+ function makeApp() {
35
+ const sent: Array<Record<string, unknown>> = []
36
+ const app = new Hono()
37
+ mountWebhookRoutes(app, {
38
+ triggers: loadZooidConfig(yaml).triggers,
39
+ agentUserIds: { product: '@product:example.org' },
40
+ resolveRoom: async (room) => room,
41
+ ensureBot: async () => {},
42
+ sendMessage: async (message: Record<string, unknown>) => {
43
+ sent.push(message)
44
+ return { event_id: '$event' }
45
+ },
46
+ })
47
+ return { app, sent }
48
+ }
49
+
50
+ function post(app: Hono, path: string) {
51
+ return app.request(path, {
52
+ method: 'POST',
53
+ body,
54
+ headers: {
55
+ 'x-hub-signature-256': signature,
56
+ 'x-github-event': 'issues',
57
+ 'x-github-delivery': crypto.randomUUID(),
58
+ },
59
+ })
60
+ }
61
+
62
+ describe('webhook route contract', () => {
63
+ it('accepts a signed delivery at /_zooid/webhooks/:name', async () => {
64
+ const { app, sent } = makeApp()
65
+ expect((await post(app, '/_zooid/webhooks/triage')).status).toBe(202)
66
+ await vi.waitFor(() => expect(sent).toHaveLength(1))
67
+ })
68
+
69
+ it('does not expose the retired /webhook/:name route', async () => {
70
+ const { app, sent } = makeApp()
71
+ expect((await post(app, '/webhook/triage')).status).toBe(404)
72
+ expect(sent).toHaveLength(0)
73
+ })
74
+ })
75
+
76
+ describe('event name binding', () => {
77
+ const headers = { 'x-github-event': 'pull_request' }
78
+
79
+ it('reads GitHub from the header, not the payload', () => {
80
+ expect(eventNameFor('github', headers, { type: 'ignored' })).toBe('pull_request')
81
+ })
82
+
83
+ it('reads stripe and standard-webhooks from body.type', () => {
84
+ expect(eventNameFor('stripe', {}, { type: 'invoice.payment_failed' })).toBe(
85
+ 'invoice.payment_failed',
86
+ )
87
+ expect(eventNameFor('standard', {}, { type: 'user.created' })).toBe('user.created')
88
+ })
89
+
90
+ it('reads slack from body.event.type', () => {
91
+ expect(eventNameFor('slack', {}, { event: { type: 'app_mention' } })).toBe('app_mention')
92
+ })
93
+
94
+ it('leaves custom unbound — only the operator knows their service shape', () => {
95
+ expect(eventNameFor('custom', headers, { type: 'anything' })).toBeUndefined()
96
+ })
97
+
98
+ it('yields undefined rather than throwing on a missing or wrong-typed path', () => {
99
+ expect(eventNameFor('stripe', {}, undefined)).toBeUndefined()
100
+ expect(eventNameFor('stripe', {}, { type: 42 })).toBeUndefined()
101
+ expect(eventNameFor('slack', {}, { event: null })).toBeUndefined()
102
+ expect(eventNameFor('github', {}, {})).toBeUndefined()
103
+ })
104
+ })
@@ -0,0 +1,237 @@
1
+ import type { Context, Hono } from 'hono'
2
+ import type { MatchContext, TriggerConfig, WebhookTriggerConfig } from '@zooid/core'
3
+ import { evaluateMatch, renderTemplate } from '@zooid/core'
4
+ import { fireTrigger, type FireTriggerDeps } from './trigger-runner.js'
5
+ import { verifySignature, verifyCustomSignature, type CustomVerifier } from './webhook-verify.js'
6
+ import { DeliveryCache } from './delivery-cache.js'
7
+
8
+ // GitHub's delivery times out at 10s; a 1MB cap leaves headroom to hash and
9
+ // respond well inside that even on a slow box. Checked before hashing, per
10
+ // [[ZOD082]] §Design 4 rule 5.
11
+ const MAX_BODY = 1_000_000
12
+ // `${output}` is a chat message body, not a blob store — cap it well under
13
+ // Matrix's ~64KiB event-size ceiling once the surrounding `text:` is added.
14
+ const MAX_OUTPUT_CHARS = 60_000
15
+ const TRUNCATION_MARKER = '\n\n… (truncated)'
16
+ // Comfortably past any provider's redelivery window.
17
+ const DELIVERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
18
+
19
+ /**
20
+ * Where each provider puts its event name. GitHub is the only one that uses
21
+ * a header; the rest carry it in the payload. Reading it per provider is the
22
+ * point — it keeps provider knowledge in code (the same knowledge the
23
+ * signature table encodes) so `event` means the same thing in an operator's
24
+ * `match:` whatever the sender. `custom` is absent on purpose: only the
25
+ * operator knows their service's shape, so their predicate reads `body`.
26
+ */
27
+ const EVENT_SOURCE_BY_PROVIDER: Partial<
28
+ Record<WebhookTriggerConfig['provider'], { header: string } | { path: readonly string[] }>
29
+ > = {
30
+ github: { header: 'x-github-event' },
31
+ stripe: { path: ['type'] },
32
+ standard: { path: ['type'] },
33
+ slack: { path: ['event', 'type'] },
34
+ }
35
+
36
+ /** Follow a dotted path through a parsed payload, yielding a string or nothing. */
37
+ function stringAt(body: unknown, path: readonly string[]): string | undefined {
38
+ let cursor: unknown = body
39
+ for (const key of path) {
40
+ if (typeof cursor !== 'object' || cursor === null) return undefined
41
+ cursor = (cursor as Record<string, unknown>)[key]
42
+ }
43
+ return typeof cursor === 'string' ? cursor : undefined
44
+ }
45
+
46
+ /** The `event` binding for `match:` and `${...}`, per provider. */
47
+ export function eventNameFor(
48
+ provider: WebhookTriggerConfig['provider'],
49
+ headers: Record<string, string | undefined>,
50
+ body: unknown,
51
+ ): string | undefined {
52
+ const source = EVENT_SOURCE_BY_PROVIDER[provider]
53
+ if (!source) return undefined
54
+ return 'header' in source ? headers[source.header] : stringAt(body, source.path)
55
+ }
56
+
57
+ const DELIVERY_ID_HEADER_BY_PROVIDER: Partial<Record<WebhookTriggerConfig['provider'], string>> = {
58
+ github: 'x-github-delivery',
59
+ standard: 'webhook-id',
60
+ }
61
+
62
+ // Headers any supported provider might send, gathered once per request.
63
+ const RELEVANT_HEADERS = [
64
+ 'x-hub-signature-256',
65
+ 'x-github-event',
66
+ 'x-github-delivery',
67
+ 'stripe-signature',
68
+ 'x-slack-signature',
69
+ 'x-slack-request-timestamp',
70
+ 'webhook-signature',
71
+ 'webhook-id',
72
+ 'webhook-timestamp',
73
+ ] as const
74
+
75
+ export interface WebhookDeps {
76
+ triggers: Record<string, TriggerConfig>
77
+ /**
78
+ * Verifier function per `provider: custom` trigger name, imported at
79
+ * daemon start by `loadCustomVerifiers`. A custom trigger with no entry
80
+ * here rejects every delivery — fail closed.
81
+ */
82
+ customVerifiers?: Record<string, CustomVerifier>
83
+ agentUserIds: Record<string, string>
84
+ resolveRoom: FireTriggerDeps['resolveRoom']
85
+ ensureBot: FireTriggerDeps['ensureBot']
86
+ sendMessage: FireTriggerDeps['sendMessage']
87
+ }
88
+
89
+ /**
90
+ * Gather the headers verification might need. Named providers read a fixed
91
+ * set; `provider: custom` gets every header, lower-cased, since only the
92
+ * operator's verifier knows which ones its service sends.
93
+ */
94
+ function headersOf(
95
+ c: { req: { header: (name: string) => string | undefined; raw: Request } },
96
+ all: boolean,
97
+ ): Record<string, string | undefined> {
98
+ const out: Record<string, string | undefined> = {}
99
+ if (all) {
100
+ c.req.raw.headers.forEach((value, key) => {
101
+ out[key.toLowerCase()] = value
102
+ })
103
+ return out
104
+ }
105
+ for (const h of RELEVANT_HEADERS) out[h] = c.req.header(h)
106
+ return out
107
+ }
108
+
109
+ /** Headers as a dense record, for handing to an operator's verifier. */
110
+ function definedHeaders(headers: Record<string, string | undefined>): Record<string, string> {
111
+ const out: Record<string, string> = {}
112
+ for (const [k, v] of Object.entries(headers)) {
113
+ if (v !== undefined) out[k] = v
114
+ }
115
+ return out
116
+ }
117
+
118
+ function renderPayload(raw: string): string {
119
+ let pretty: string
120
+ try {
121
+ pretty = JSON.stringify(JSON.parse(raw), null, 2)
122
+ } catch {
123
+ pretty = raw
124
+ }
125
+ if (pretty.length <= MAX_OUTPUT_CHARS) return pretty
126
+ return pretty.slice(0, MAX_OUTPUT_CHARS) + TRUNCATION_MARKER
127
+ }
128
+
129
+ async function handleDelivery(
130
+ deps: WebhookDeps,
131
+ name: string,
132
+ trigger: TriggerConfig,
133
+ raw: string,
134
+ headers: Record<string, string | undefined>,
135
+ cache: DeliveryCache,
136
+ customDeliveryId: string | undefined,
137
+ ): Promise<void> {
138
+ try {
139
+ const webhook = trigger.webhook
140
+ if (!webhook) return
141
+
142
+ const idHeader = DELIVERY_ID_HEADER_BY_PROVIDER[webhook.provider]
143
+ // A custom verifier reports its own delivery id, since only it knows
144
+ // where the service puts one.
145
+ const deliveryId = customDeliveryId ?? (idHeader ? headers[idHeader] : undefined)
146
+ if (deliveryId !== undefined && cache.seen(`${name}:${deliveryId}`)) return
147
+
148
+ let body: unknown
149
+ try {
150
+ body = JSON.parse(raw)
151
+ } catch {
152
+ body = undefined
153
+ }
154
+
155
+ // After parsing: every provider but GitHub carries its event name in the
156
+ // payload, not a header.
157
+ const event = eventNameFor(webhook.provider, headers, body)
158
+
159
+ const ctx: MatchContext = {
160
+ event,
161
+ body,
162
+ headers: definedHeaders(headers),
163
+ output: renderPayload(raw),
164
+ }
165
+
166
+ for (const message of trigger.messages) {
167
+ if (message.match !== undefined && !evaluateMatch(message.match, ctx)) continue
168
+
169
+ const agentUserId = deps.agentUserIds[message.mention]
170
+ if (!agentUserId) {
171
+ console.warn(`[webhook:${name}] unknown agent "${message.mention}" — skipping`)
172
+ continue
173
+ }
174
+
175
+ await fireTrigger({
176
+ name,
177
+ as: trigger.as,
178
+ message: { ...message, text: renderTemplate(message.text, ctx) },
179
+ agentUserId,
180
+ resolveRoom: deps.resolveRoom,
181
+ ensureBot: deps.ensureBot,
182
+ sendMessage: deps.sendMessage,
183
+ })
184
+ }
185
+ } catch (err) {
186
+ // Never throw: the response has already been sent, and one bad delivery
187
+ // must not take down the daemon.
188
+ console.warn(`[webhook:${name}] failed:`, (err as Error).message)
189
+ }
190
+ }
191
+
192
+ export const WEBHOOK_ROUTE_PREFIX = '/_zooid/webhooks'
193
+
194
+ export function mountWebhookRoutes(app: Hono, deps: WebhookDeps): void {
195
+ const cache = new DeliveryCache(DELIVERY_CACHE_TTL_MS)
196
+
197
+ const receive = async (c: Context<any, '/:name'>) => {
198
+ const name = c.req.param('name')
199
+ const trigger = deps.triggers[name]
200
+
201
+ // Read the RAW body first. Parsing and re-serializing changes the bytes
202
+ // and breaks every signature — the classic webhook bug.
203
+ const raw = await c.req.text()
204
+ if (raw.length > MAX_BODY) return c.text('too large', 413)
205
+
206
+ // Unknown trigger and bad signature return the identical response, so
207
+ // the endpoint cannot be probed to discover which triggers exist.
208
+ if (!trigger?.webhook) return c.text('unauthorized', 401)
209
+ const webhook = trigger.webhook
210
+ const headers = headersOf(c, webhook.provider === 'custom')
211
+
212
+ let customDeliveryId: string | undefined
213
+ if (webhook.provider === 'custom') {
214
+ const v = await verifyCustomSignature(deps.customVerifiers?.[name], {
215
+ rawBody: raw,
216
+ headers: definedHeaders(headers),
217
+ secret: webhook.secret,
218
+ })
219
+ if (!v.ok) return c.text('unauthorized', 401)
220
+ customDeliveryId = v.deliveryId
221
+ } else {
222
+ const v = verifySignature(webhook.provider, {
223
+ rawBody: raw,
224
+ headers,
225
+ secret: webhook.secret,
226
+ })
227
+ if (!v.ok) return c.text('unauthorized', 401)
228
+ }
229
+
230
+ // Accepted. Everything below is fire-and-forget: GitHub times out at
231
+ // 10s and an agent turn does not fit in that.
232
+ void handleDelivery(deps, name, trigger, raw, headers, cache, customDeliveryId)
233
+ return c.text('accepted', 202)
234
+ }
235
+
236
+ app.post(`${WEBHOOK_ROUTE_PREFIX}/:name`, receive)
237
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createHmac } from 'node:crypto'
3
+ import { verifySignature, verifyCustomSignature, type CustomVerifierInput } from './webhook-verify.js'
4
+
5
+ const secret = 'shh'
6
+ const body = '{"action":"closed","number":42}'
7
+
8
+ const githubSig = (b = body, s = secret) =>
9
+ `sha256=${createHmac('sha256', s).update(b).digest('hex')}`
10
+
11
+ describe('verifySignature — github', () => {
12
+ it('accepts a correct signature over the raw body', () => {
13
+ expect(
14
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': githubSig() }, secret }),
15
+ ).toEqual({ ok: true })
16
+ })
17
+
18
+ it('rejects a signature computed over different bytes', () => {
19
+ expect(
20
+ verifySignature('github', {
21
+ rawBody: '{"action":"opened","number":42}',
22
+ headers: { 'x-hub-signature-256': githubSig() },
23
+ secret,
24
+ }).ok,
25
+ ).toBe(false)
26
+ })
27
+
28
+ it('rejects a signature made with a different secret', () => {
29
+ expect(
30
+ verifySignature('github', {
31
+ rawBody: body,
32
+ headers: { 'x-hub-signature-256': githubSig(body, 'wrong') },
33
+ secret,
34
+ }).ok,
35
+ ).toBe(false)
36
+ })
37
+
38
+ it('rejects a missing signature header — fail closed', () => {
39
+ expect(verifySignature('github', { rawBody: body, headers: {}, secret }).ok).toBe(false)
40
+ })
41
+
42
+ it('rejects a malformed header without throwing', () => {
43
+ for (const h of ['', 'sha256=', 'garbage', 'sha256=zzzz', 'sha1=abcd']) {
44
+ expect(() =>
45
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': h }, secret }),
46
+ ).not.toThrow()
47
+ expect(
48
+ verifySignature('github', { rawBody: body, headers: { 'x-hub-signature-256': h }, secret }).ok,
49
+ ).toBe(false)
50
+ }
51
+ })
52
+
53
+ it('rejects a signature of the wrong length without throwing (timingSafeEqual throws on length mismatch)', () => {
54
+ expect(
55
+ verifySignature('github', {
56
+ rawBody: body,
57
+ headers: { 'x-hub-signature-256': 'sha256=abcd' },
58
+ secret,
59
+ }).ok,
60
+ ).toBe(false)
61
+ })
62
+ })
63
+
64
+ describe('verifyCustomSignature', () => {
65
+ const shopifyish = ({ rawBody, headers, secret: sec }: CustomVerifierInput) => {
66
+ const expected = createHmac('sha256', sec).update(rawBody).digest('base64')
67
+ return { ok: headers['x-shopify-hmac-sha256'] === expected, deliveryId: headers['x-delivery'] }
68
+ }
69
+ const input = (headers: Record<string, string>) => ({ rawBody: body, headers, secret })
70
+ const sigOf = (b = body, s = secret) => createHmac('sha256', s).update(b).digest('base64')
71
+
72
+ it('accepts what the operator function accepts, and passes back its delivery id', async () => {
73
+ expect(
74
+ await verifyCustomSignature(shopifyish, input({ 'x-shopify-hmac-sha256': sigOf(), 'x-delivery': 'd9' })),
75
+ ).toEqual({ ok: true, deliveryId: 'd9' })
76
+ })
77
+
78
+ it('rejects what the operator function rejects', async () => {
79
+ expect(
80
+ (await verifyCustomSignature(shopifyish, input({ 'x-shopify-hmac-sha256': sigOf(body, 'wrong') }))).ok,
81
+ ).toBe(false)
82
+ })
83
+
84
+ it('accepts a verifier that returns a bare boolean', async () => {
85
+ expect(await verifyCustomSignature(() => true, input({}))).toEqual({ ok: true })
86
+ expect(await verifyCustomSignature(() => false, input({}))).toEqual({ ok: false })
87
+ })
88
+
89
+ it('awaits an async verifier', async () => {
90
+ expect(await verifyCustomSignature(async () => ({ ok: true, deliveryId: 'a1' }), input({}))).toEqual({
91
+ ok: true,
92
+ deliveryId: 'a1',
93
+ })
94
+ })
95
+
96
+ it('fails closed when no verifier is loaded — never "no verifier, so accept"', async () => {
97
+ expect(await verifyCustomSignature(undefined, input({}))).toEqual({ ok: false })
98
+ })
99
+
100
+ it('fails closed when the verifier throws or rejects', async () => {
101
+ expect(
102
+ (
103
+ await verifyCustomSignature(() => {
104
+ throw new Error('boom')
105
+ }, input({}))
106
+ ).ok,
107
+ ).toBe(false)
108
+ expect((await verifyCustomSignature(async () => Promise.reject(new Error('boom')), input({}))).ok).toBe(
109
+ false,
110
+ )
111
+ })
112
+
113
+ it('fails closed on a junk return value, rather than coercing it to true', async () => {
114
+ for (const junk of ['yes', 1, {}, { ok: 'true' }, null, undefined]) {
115
+ expect((await verifyCustomSignature((() => junk) as never, input({}))).ok).toBe(false)
116
+ }
117
+ })
118
+ })
119
+
120
+ describe('verifySignature — providers with a timestamp', () => {
121
+ const slackSig = (ts: string, b = body, s = secret) =>
122
+ `v0=${createHmac('sha256', s).update(`v0:${ts}:${b}`).digest('hex')}`
123
+
124
+ it('accepts a fresh slack signature over v0:ts:body', () => {
125
+ const ts = String(Math.floor(Date.now() / 1000))
126
+ expect(
127
+ verifySignature('slack', {
128
+ rawBody: body,
129
+ headers: { 'x-slack-signature': slackSig(ts), 'x-slack-request-timestamp': ts },
130
+ secret,
131
+ }),
132
+ ).toEqual({ ok: true })
133
+ })
134
+
135
+ it('rejects a slack signature outside the freshness window — replay defence', () => {
136
+ const old = String(Math.floor(Date.now() / 1000) - 60 * 60)
137
+ expect(
138
+ verifySignature('slack', {
139
+ rawBody: body,
140
+ headers: { 'x-slack-signature': slackSig(old), 'x-slack-request-timestamp': old },
141
+ secret,
142
+ }).ok,
143
+ ).toBe(false)
144
+ })
145
+
146
+ it('rejects a slack request with no timestamp header', () => {
147
+ expect(
148
+ verifySignature('slack', {
149
+ rawBody: body,
150
+ headers: { 'x-slack-signature': slackSig('123') },
151
+ secret,
152
+ }).ok,
153
+ ).toBe(false)
154
+ })
155
+ })
@@ -0,0 +1,155 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import type { WebhookTriggerConfig } from '@zooid/core'
3
+
4
+ const FRESHNESS_S = 300
5
+
6
+ /**
7
+ * What an operator's `verify:` module is called with. `headers` carries every
8
+ * request header, lower-cased — a named provider reads a fixed set, but a
9
+ * custom verifier is the only thing that knows which ones its service sends.
10
+ */
11
+ export interface CustomVerifierInput {
12
+ rawBody: string
13
+ headers: Record<string, string>
14
+ secret: string
15
+ }
16
+
17
+ /**
18
+ * An operator-supplied verifier. Returns a bare boolean, or a result that
19
+ * also carries the provider's unique delivery id — the id is what replay
20
+ * dedupe keys on, and only the verifier knows where the service puts it.
21
+ * Throwing counts as rejection; nothing it does can turn into an accept.
22
+ */
23
+ export type CustomVerifier = (
24
+ input: CustomVerifierInput,
25
+ ) =>
26
+ | boolean
27
+ | { ok: boolean; deliveryId?: string }
28
+ | Promise<boolean | { ok: boolean; deliveryId?: string }>
29
+
30
+ /** Providers whose signing scheme is built in. `custom` is verified by the operator's own function. */
31
+ export type NamedProvider = Exclude<WebhookTriggerConfig['provider'], 'custom'>
32
+
33
+ export interface VerifyInput {
34
+ rawBody: string
35
+ headers: Record<string, string | undefined>
36
+ secret: string
37
+ }
38
+
39
+ export type VerifyResult = { ok: true } | { ok: false }
40
+
41
+ // Length-check first: timingSafeEqual throws when buffers differ in length.
42
+ function safeEqual(a: string, b: string): boolean {
43
+ const ab = Buffer.from(a, 'utf8')
44
+ const bb = Buffer.from(b, 'utf8')
45
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
46
+ }
47
+
48
+ function hmacHex(secret: string, baseString: string): string {
49
+ return createHmac('sha256', secret).update(baseString).digest('hex')
50
+ }
51
+
52
+ function verifyGithub(input: VerifyInput): VerifyResult {
53
+ const header = input.headers['x-hub-signature-256']
54
+ if (!header) return { ok: false }
55
+ const [scheme, sig] = header.split('=')
56
+ if (scheme !== 'sha256' || !sig) return { ok: false }
57
+ const expected = hmacHex(input.secret, input.rawBody)
58
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
59
+ }
60
+
61
+ function verifyStripe(input: VerifyInput): VerifyResult {
62
+ const header = input.headers['stripe-signature']
63
+ if (!header) return { ok: false }
64
+ const parts = Object.fromEntries(
65
+ header
66
+ .split(',')
67
+ .map((p) => p.split('=', 2) as [string, string | undefined])
68
+ .filter(([, v]) => v !== undefined),
69
+ )
70
+ const ts = parts.t
71
+ const sig = parts.v1
72
+ if (!ts || !sig) return { ok: false }
73
+ if (!isFresh(ts)) return { ok: false }
74
+ const expected = hmacHex(input.secret, `${ts}.${input.rawBody}`)
75
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
76
+ }
77
+
78
+ function verifySlack(input: VerifyInput): VerifyResult {
79
+ const header = input.headers['x-slack-signature']
80
+ const ts = input.headers['x-slack-request-timestamp']
81
+ if (!header || !ts) return { ok: false }
82
+ if (!header.startsWith('v0=')) return { ok: false }
83
+ const sig = header.slice('v0='.length)
84
+ if (!isFresh(ts)) return { ok: false }
85
+ const expected = hmacHex(input.secret, `v0:${ts}:${input.rawBody}`)
86
+ return safeEqual(sig, expected) ? { ok: true } : { ok: false }
87
+ }
88
+
89
+ function verifyStandard(input: VerifyInput): VerifyResult {
90
+ const header = input.headers['webhook-signature']
91
+ const id = input.headers['webhook-id']
92
+ const ts = input.headers['webhook-timestamp']
93
+ if (!header || !id || !ts) return { ok: false }
94
+ if (!isFresh(ts)) return { ok: false }
95
+ const candidate = header
96
+ .split(' ')
97
+ .map((p) => (p.startsWith('v1,') ? p.slice('v1,'.length) : undefined))
98
+ .find((v) => v !== undefined)
99
+ if (!candidate) return { ok: false }
100
+ const expected = createHmac('sha256', input.secret)
101
+ .update(`${id}.${ts}.${input.rawBody}`)
102
+ .digest('base64')
103
+ return safeEqual(candidate, expected) ? { ok: true } : { ok: false }
104
+ }
105
+
106
+ function isFresh(tsRaw: string): boolean {
107
+ const ts = Number(tsRaw)
108
+ if (!Number.isFinite(ts)) return false
109
+ const nowS = Date.now() / 1000
110
+ return Math.abs(nowS - ts) <= FRESHNESS_S
111
+ }
112
+
113
+ const VERIFIERS: Record<NamedProvider, (input: VerifyInput) => VerifyResult> = {
114
+ github: verifyGithub,
115
+ stripe: verifyStripe,
116
+ slack: verifySlack,
117
+ standard: verifyStandard,
118
+ }
119
+
120
+ /**
121
+ * Verify a webhook delivery's signature. Every failure path returns
122
+ * `{ ok: false }` — nothing throws, and no reason is returned to the
123
+ * caller, since the route must not explain *why* it rejected a request.
124
+ */
125
+ export function verifySignature(provider: NamedProvider, input: VerifyInput): VerifyResult {
126
+ try {
127
+ return VERIFIERS[provider](input)
128
+ } catch {
129
+ return { ok: false }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Run an operator-supplied verifier for `provider: custom`. Fails closed on
135
+ * every abnormal path — no verifier loaded, a throw, a rejected promise, or
136
+ * a return value that is not a recognised shape. A verifier can only ever
137
+ * *grant* acceptance by explicitly returning true.
138
+ */
139
+ export async function verifyCustomSignature(
140
+ verifier: CustomVerifier | undefined,
141
+ input: CustomVerifierInput,
142
+ ): Promise<{ ok: boolean; deliveryId?: string }> {
143
+ if (typeof verifier !== 'function') return { ok: false }
144
+ try {
145
+ const result = await verifier(input)
146
+ if (result === true) return { ok: true }
147
+ if (result === false || result === null || typeof result !== 'object') return { ok: false }
148
+ if (result.ok !== true) return { ok: false }
149
+ return typeof result.deliveryId === 'string'
150
+ ? { ok: true, deliveryId: result.deliveryId }
151
+ : { ok: true }
152
+ } catch {
153
+ return { ok: false }
154
+ }
155
+ }