@avvio/payments 0.1.0
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/CHANGELOG.md +66 -0
- package/ERRORS.md +252 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +317 -0
- package/README.md +411 -0
- package/index.d.ts +680 -0
- package/package.json +55 -0
- package/src/cli.js +635 -0
- package/src/client.js +799 -0
- package/src/mcp.js +434 -0
- package/src/webhooks.js +191 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP server over stdio, so an agent can drive payouts directly.
|
|
5
|
+
*
|
|
6
|
+
* Newline-delimited JSON-RPC 2.0 on stdin/stdout. That is the whole protocol
|
|
7
|
+
* for this transport, which is why there is no SDK here — a dependency to
|
|
8
|
+
* format JSON would not earn its place in a package that holds a credential
|
|
9
|
+
* capable of moving money.
|
|
10
|
+
*
|
|
11
|
+
* STDOUT IS THE PROTOCOL. Nothing may be written to it except responses, so
|
|
12
|
+
* every diagnostic goes to stderr.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { PayoutsClient, PayoutsError } = require('./client');
|
|
16
|
+
|
|
17
|
+
// Read, not hardcoded: an agent uses this in the handshake to decide what the
|
|
18
|
+
// server can do, and a literal is wrong the moment the package is released.
|
|
19
|
+
const SERVER = {
|
|
20
|
+
name: 'avvio-payments',
|
|
21
|
+
version: require('../package.json').version,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Money-moving tools are marked and gated.
|
|
26
|
+
*
|
|
27
|
+
* `send_payout` additionally requires `confirm: true`. An agent that has been
|
|
28
|
+
* told "pay Maria" should have to state the intent to move money as a separate
|
|
29
|
+
* argument, so a half-parsed instruction cannot become a payment. It costs one
|
|
30
|
+
* field and removes a category of accident.
|
|
31
|
+
*/
|
|
32
|
+
const TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
name: 'list_corridors',
|
|
35
|
+
annotations: { readOnlyHint: true },
|
|
36
|
+
description:
|
|
37
|
+
'List every currency this organization can pay out to, with the beneficiary fields each one requires. Read this instead of hardcoding a form — field names differ by corridor and can change.',
|
|
38
|
+
inputSchema: { type: 'object', properties: {} },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'get_requirements',
|
|
42
|
+
annotations: { readOnlyHint: true },
|
|
43
|
+
description:
|
|
44
|
+
'The exact beneficiary fields needed to pay out in one currency, with validation patterns. Call this before create_beneficiary.',
|
|
45
|
+
inputSchema: {
|
|
46
|
+
type: 'object',
|
|
47
|
+
properties: {
|
|
48
|
+
currency: { type: 'string', description: 'ISO code, e.g. MXN' },
|
|
49
|
+
},
|
|
50
|
+
required: ['currency'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'quote',
|
|
55
|
+
annotations: { readOnlyHint: true },
|
|
56
|
+
description:
|
|
57
|
+
'Indicative price for a corridor with no beneficiary needed: what the recipient gets, the fee, the rate, and the corridor minimum and maximum. Use this while a user is still choosing an amount. It is an estimate, not a locked rate.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
amount: { type: 'string', description: 'Amount to send, e.g. "200.00"' },
|
|
62
|
+
to: { type: 'string', description: 'Destination currency, e.g. MXN' },
|
|
63
|
+
from: { type: 'string', description: 'Source currency. Defaults to USD.' },
|
|
64
|
+
},
|
|
65
|
+
required: ['amount', 'to'],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'create_beneficiary',
|
|
70
|
+
description:
|
|
71
|
+
'Register who is being paid. Scope it to the end user paying them via endUserId, so each of your users only ever sees their own beneficiaries. Returns destinationAccountId, which send_payout needs.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
name: { type: 'string' },
|
|
76
|
+
email: { type: 'string' },
|
|
77
|
+
country: { type: 'string', description: 'ISO-3166 alpha-2, e.g. MX' },
|
|
78
|
+
currency: { type: 'string' },
|
|
79
|
+
endUserId: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'Your id for the person SENDING the money.',
|
|
82
|
+
},
|
|
83
|
+
externalId: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
description: 'Your own id for this beneficiary. Makes creation idempotent.',
|
|
86
|
+
},
|
|
87
|
+
details: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
description: 'Corridor fields from get_requirements, e.g. {"clabeNumber":"012..."}',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
required: ['name', 'currency', 'details'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'list_beneficiaries',
|
|
97
|
+
annotations: { readOnlyHint: true },
|
|
98
|
+
description:
|
|
99
|
+
'Beneficiaries saved for one end user. Always pass endUserId for anything shown to a user; omitting it returns the whole organization.',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: { endUserId: { type: 'string' } },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'send_payout',
|
|
107
|
+
annotations: { destructiveHint: true, readOnlyHint: false },
|
|
108
|
+
description:
|
|
109
|
+
'MOVES MONEY. Prices the payout and executes it, debiting your balance. Requires confirm:true. If it times out, the outcome is unknown — call again with the same idempotencyKey rather than starting over.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
amount: { type: 'string' },
|
|
114
|
+
destinationAccountId: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: 'From create_beneficiary or list_beneficiaries.',
|
|
117
|
+
},
|
|
118
|
+
endUserId: { type: 'string' },
|
|
119
|
+
endUserName: { type: 'string' },
|
|
120
|
+
reference: { type: 'string', description: 'Your payment reference.' },
|
|
121
|
+
purposeOfPayment: { type: 'string' },
|
|
122
|
+
expectDestinationAmount: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description:
|
|
125
|
+
'What you told the payer they would receive. The send is refused if the binding quote drifts more than 2% from it.',
|
|
126
|
+
},
|
|
127
|
+
idempotencyKey: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
description:
|
|
130
|
+
'REQUIRED. A unique id you generate for this payout. If the call times out, call again with this SAME value — that replays the original payout instead of sending a second one.',
|
|
131
|
+
},
|
|
132
|
+
confirm: {
|
|
133
|
+
type: 'boolean',
|
|
134
|
+
description: 'Must be true. Explicit acknowledgement that this moves money.',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
// idempotencyKey is required because a timeout is the case this tool is
|
|
138
|
+
// most likely to hit, and retrying is what an agent does by reflex.
|
|
139
|
+
// Without a caller-supplied key that retry is a second payment.
|
|
140
|
+
required: [
|
|
141
|
+
'amount',
|
|
142
|
+
'destinationAccountId',
|
|
143
|
+
'confirm',
|
|
144
|
+
'idempotencyKey',
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'get_payout',
|
|
150
|
+
annotations: { readOnlyHint: true },
|
|
151
|
+
description:
|
|
152
|
+
'Current state of one payout. Always live — this is authoritative, more so than a webhook you may have missed.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: { payoutId: { type: 'string' } },
|
|
156
|
+
required: ['payoutId'],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'list_payouts',
|
|
161
|
+
annotations: { readOnlyHint: true },
|
|
162
|
+
description: 'Recent payouts for this organization.',
|
|
163
|
+
inputSchema: { type: 'object', properties: {} },
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'funding_accounts',
|
|
167
|
+
annotations: { readOnlyHint: true },
|
|
168
|
+
description:
|
|
169
|
+
'Where to wire money to top up the balance that payouts debit.',
|
|
170
|
+
inputSchema: { type: 'object', properties: {} },
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'list_events',
|
|
174
|
+
annotations: { readOnlyHint: true },
|
|
175
|
+
description:
|
|
176
|
+
'The change feed: one row per payout transition, with a `sequence` cursor. This is the reconciliation primitive — carry `nextSince` back as `since` and you observe every revision. A payout that completed and was then returned by the bank appears here as a second row.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
since: { type: 'string', description: 'A `sequence` from a previous page. Digits only.' },
|
|
181
|
+
limit: { type: 'number', description: '1-500, default 100.' },
|
|
182
|
+
payoutId: { type: 'string', description: 'Only this payout\u2019s transitions.' },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'get_balance',
|
|
188
|
+
annotations: { readOnlyHint: true },
|
|
189
|
+
description:
|
|
190
|
+
'The organization\u2019s available balance. Check this before sending: an underfunded payout is refused, and the refusal is a 400 rather than a queued payment.',
|
|
191
|
+
inputSchema: { type: 'object', properties: {} },
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'get_funding',
|
|
195
|
+
annotations: { readOnlyHint: true },
|
|
196
|
+
description:
|
|
197
|
+
'Deposit instructions for a payout that requires funding: the address, the exact amount, the network, and an expiry. Only some routings need this; the payout says so with `requiresFunding`.',
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: { payoutId: { type: 'string' } },
|
|
201
|
+
required: ['payoutId'],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'create_payout_link',
|
|
206
|
+
annotations: { readOnlyHint: false },
|
|
207
|
+
description:
|
|
208
|
+
'Mint a one-time link that collects the recipient\u2019s own bank details and pays them, so you never handle the details yourself. The link is a credential: it is the only thing needed to be paid, so send it to the person being paid and nobody else.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
amount: { type: 'string', description: 'Decimal string, e.g. "200.00".' },
|
|
213
|
+
destinationCurrency: { type: 'string', description: 'ISO code, e.g. MXN' },
|
|
214
|
+
endUserId: { type: 'string', description: 'Your id for the person being paid.' },
|
|
215
|
+
reference: { type: 'string' },
|
|
216
|
+
expiresInMinutes: { type: 'number', description: '1-10080, default 60.' },
|
|
217
|
+
},
|
|
218
|
+
required: ['amount', 'destinationCurrency', 'endUserId'],
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'confirm_funding',
|
|
223
|
+
annotations: { readOnlyHint: false },
|
|
224
|
+
description:
|
|
225
|
+
'Report the transaction that funded a payout. We read the chain before recording it: a hash that does not fund this payout is refused and nothing is written, so a rejection is always safe to correct. One transfer funds exactly one payout.',
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
properties: {
|
|
229
|
+
payoutId: { type: 'string' },
|
|
230
|
+
transactionHash: { type: 'string', description: '0x-prefixed 32-byte hex.' },
|
|
231
|
+
confirm: {
|
|
232
|
+
type: 'boolean',
|
|
233
|
+
description: 'Must be true. This commits funds you have already sent.',
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
required: ['payoutId', 'transactionHash', 'confirm'],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'cancel_payout',
|
|
241
|
+
annotations: { readOnlyHint: false },
|
|
242
|
+
description:
|
|
243
|
+
'Stop a payout that has NOT been funded yet \u2014 the recovery for one created by mistake. Once funded it cannot be cancelled and you get PAYOUT_NOT_CANCELABLE, which is the honest answer rather than a cancellation that does not happen.',
|
|
244
|
+
inputSchema: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: {
|
|
247
|
+
payoutId: { type: 'string' },
|
|
248
|
+
confirm: {
|
|
249
|
+
type: 'boolean',
|
|
250
|
+
description: 'Must be true. Cancelling is irreversible.',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
required: ['payoutId', 'confirm'],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
async function dispatch(client, name, args) {
|
|
259
|
+
switch (name) {
|
|
260
|
+
case 'list_corridors':
|
|
261
|
+
return client.corridors();
|
|
262
|
+
case 'get_requirements':
|
|
263
|
+
return client.requirements(args.currency);
|
|
264
|
+
case 'quote':
|
|
265
|
+
return client.quote({ amount: args.amount, to: args.to, from: args.from });
|
|
266
|
+
case 'create_beneficiary':
|
|
267
|
+
return client.createBeneficiary(args);
|
|
268
|
+
case 'list_beneficiaries':
|
|
269
|
+
return client.listBeneficiaries({ endUserId: args.endUserId });
|
|
270
|
+
case 'send_payout':
|
|
271
|
+
if (args.confirm !== true) {
|
|
272
|
+
throw new Error(
|
|
273
|
+
'send_payout moves money and requires confirm:true. Confirm the amount, currency and recipient with whoever is paying before calling again.',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return client.payout({
|
|
277
|
+
amount: args.amount,
|
|
278
|
+
destinationAccountId: args.destinationAccountId,
|
|
279
|
+
purposeOfPayment: args.purposeOfPayment,
|
|
280
|
+
reference: args.reference,
|
|
281
|
+
expectDestinationAmount: args.expectDestinationAmount,
|
|
282
|
+
idempotencyKey: args.idempotencyKey,
|
|
283
|
+
endUser: args.endUserId
|
|
284
|
+
? { id: args.endUserId, name: args.endUserName }
|
|
285
|
+
: undefined,
|
|
286
|
+
});
|
|
287
|
+
case 'get_payout':
|
|
288
|
+
return client.getPayout(args.payoutId);
|
|
289
|
+
case 'list_payouts':
|
|
290
|
+
return client.listPayouts();
|
|
291
|
+
case 'funding_accounts':
|
|
292
|
+
return client.fundingAccounts();
|
|
293
|
+
case 'list_events':
|
|
294
|
+
return client.listEvents({
|
|
295
|
+
since: args.since,
|
|
296
|
+
limit: args.limit,
|
|
297
|
+
payoutId: args.payoutId,
|
|
298
|
+
});
|
|
299
|
+
case 'get_balance':
|
|
300
|
+
return client.balance();
|
|
301
|
+
case 'get_funding':
|
|
302
|
+
return client.getFunding(args.payoutId);
|
|
303
|
+
case 'create_payout_link':
|
|
304
|
+
return client.createPayoutLink({
|
|
305
|
+
amount: args.amount,
|
|
306
|
+
destinationCurrency: args.destinationCurrency,
|
|
307
|
+
endUserId: args.endUserId,
|
|
308
|
+
reference: args.reference,
|
|
309
|
+
expiresInMinutes: args.expiresInMinutes,
|
|
310
|
+
});
|
|
311
|
+
// The two below commit money, so they carry the same confirm gate
|
|
312
|
+
// `send_payout` does. An agent should not be able to fund or stop a payment
|
|
313
|
+
// on its own reading of a conversation.
|
|
314
|
+
case 'confirm_funding':
|
|
315
|
+
if (args.confirm !== true) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
'confirm_funding commits funds you have already sent and requires confirm:true. Check the transaction hash against what you actually broadcast before calling again.',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return client.confirmFunding(args.payoutId, {
|
|
321
|
+
transactionHash: args.transactionHash,
|
|
322
|
+
});
|
|
323
|
+
case 'cancel_payout':
|
|
324
|
+
if (args.confirm !== true) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
'cancel_payout is irreversible and requires confirm:true. Confirm with whoever is paying that this payout should not go out.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return client.cancelPayout(args.payoutId);
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** JSON-RPC plumbing. Split out so the routing above stays readable. */
|
|
336
|
+
function createServer({ makeClient = () => new PayoutsClient(), write } = {}) {
|
|
337
|
+
let client;
|
|
338
|
+
const clientOnce = () => (client = client || makeClient());
|
|
339
|
+
|
|
340
|
+
return async function handle(msg) {
|
|
341
|
+
const { id, method, params } = msg || {};
|
|
342
|
+
const reply = (result) => ({ jsonrpc: '2.0', id, result });
|
|
343
|
+
const fail = (code, message, data) => ({
|
|
344
|
+
jsonrpc: '2.0',
|
|
345
|
+
id,
|
|
346
|
+
error: { code, message, ...(data ? { data } : {}) },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
switch (method) {
|
|
351
|
+
case 'initialize':
|
|
352
|
+
return reply({
|
|
353
|
+
// Echo the client's version when they name one: this server's
|
|
354
|
+
// surface is stable across the versions that matter, and refusing
|
|
355
|
+
// over a version string helps nobody.
|
|
356
|
+
protocolVersion:
|
|
357
|
+
(params && params.protocolVersion) || '2025-06-18',
|
|
358
|
+
capabilities: { tools: {} },
|
|
359
|
+
serverInfo: SERVER,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
case 'notifications/initialized':
|
|
363
|
+
return null; // notification — no response
|
|
364
|
+
|
|
365
|
+
case 'tools/list':
|
|
366
|
+
return reply({ tools: TOOLS });
|
|
367
|
+
|
|
368
|
+
case 'tools/call': {
|
|
369
|
+
const name = params && params.name;
|
|
370
|
+
const args = (params && params.arguments) || {};
|
|
371
|
+
try {
|
|
372
|
+
const out = await dispatch(clientOnce(), name, args);
|
|
373
|
+
return reply({
|
|
374
|
+
content: [
|
|
375
|
+
{ type: 'text', text: JSON.stringify(out, null, 2) },
|
|
376
|
+
],
|
|
377
|
+
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
// Tool errors belong in the result with isError, not as protocol
|
|
380
|
+
// errors — the agent needs to read them and adjust.
|
|
381
|
+
const text =
|
|
382
|
+
err instanceof PayoutsError
|
|
383
|
+
? `${err.type}: ${err.message}` +
|
|
384
|
+
(err.retryable ? '\n\nThis is safe to retry unchanged.' : '')
|
|
385
|
+
: String((err && err.message) || err);
|
|
386
|
+
return reply({ content: [{ type: 'text', text }], isError: true });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
default:
|
|
391
|
+
if (id === undefined) return null; // unknown notification
|
|
392
|
+
return fail(-32601, `Method not found: ${method}`);
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
return fail(-32603, String((err && err.message) || err));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function main() {
|
|
401
|
+
const handle = createServer();
|
|
402
|
+
let buffer = '';
|
|
403
|
+
|
|
404
|
+
process.stdin.setEncoding('utf8');
|
|
405
|
+
process.stdin.on('data', (chunk) => {
|
|
406
|
+
buffer += chunk;
|
|
407
|
+
let nl;
|
|
408
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
409
|
+
const line = buffer.slice(0, nl).trim();
|
|
410
|
+
buffer = buffer.slice(nl + 1);
|
|
411
|
+
if (!line) continue;
|
|
412
|
+
let msg;
|
|
413
|
+
try {
|
|
414
|
+
msg = JSON.parse(line);
|
|
415
|
+
} catch {
|
|
416
|
+
process.stderr.write(`avvio-payments: unparseable line ignored\n`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
Promise.resolve(handle(msg))
|
|
420
|
+
.then((res) => {
|
|
421
|
+
if (res) process.stdout.write(JSON.stringify(res) + '\n');
|
|
422
|
+
})
|
|
423
|
+
.catch((err) => {
|
|
424
|
+
process.stderr.write(`avvio-payments: ${err && err.message}\n`);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
process.stdin.on('end', () => process.exit(0));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = { createServer, TOOLS, dispatch, main, SERVER };
|
|
433
|
+
|
|
434
|
+
if (require.main === module) main();
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webhook signature verification.
|
|
5
|
+
*
|
|
6
|
+
* The docs used to tell a partner to go install a Svix library for this. That
|
|
7
|
+
* did not remove a dependency, it moved one onto them and made it bigger — the
|
|
8
|
+
* whole argument for shipping zero dependencies is undermined by outsourcing
|
|
9
|
+
* fifteen lines of HMAC to somebody else's package.
|
|
10
|
+
*
|
|
11
|
+
* Standard Webhooks format, so an off-the-shelf verifier still works if they
|
|
12
|
+
* prefer one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { createHmac, timingSafeEqual } = require('node:crypto');
|
|
16
|
+
|
|
17
|
+
/** Default tolerance. A replayed delivery outside this window is rejected. */
|
|
18
|
+
const TOLERANCE_SECONDS = 300;
|
|
19
|
+
|
|
20
|
+
class WebhookVerificationError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'WebhookVerificationError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verify a delivery and return its parsed body.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} p
|
|
31
|
+
* @param {string|Buffer} p.body The RAW request body. Not a re-serialized
|
|
32
|
+
* object — JSON.stringify does not guarantee byte-identical output, and a
|
|
33
|
+
* single reordered key fails every signature.
|
|
34
|
+
* @param {object} p.headers The request headers.
|
|
35
|
+
* @param {string} p.secret `whsec_…` from the dashboard.
|
|
36
|
+
* @param {number} [p.toleranceSeconds]
|
|
37
|
+
*/
|
|
38
|
+
function verifyWebhook({ body, headers, secret, toleranceSeconds }) {
|
|
39
|
+
if (!secret || !secret.startsWith('whsec_')) {
|
|
40
|
+
throw new WebhookVerificationError(
|
|
41
|
+
'Secret must be the whsec_… value shown when the endpoint was created.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const get = (name) => {
|
|
45
|
+
const v = headers?.[name] ?? headers?.[name.toUpperCase()];
|
|
46
|
+
return Array.isArray(v) ? v[0] : v;
|
|
47
|
+
};
|
|
48
|
+
const id = get('svix-id');
|
|
49
|
+
const timestamp = get('svix-timestamp');
|
|
50
|
+
const signature = get('svix-signature');
|
|
51
|
+
if (!id || !timestamp || !signature) {
|
|
52
|
+
throw new WebhookVerificationError(
|
|
53
|
+
'Missing svix-id, svix-timestamp or svix-signature.',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
|
|
58
|
+
const tolerance = toleranceSeconds ?? TOLERANCE_SECONDS;
|
|
59
|
+
if (!Number.isFinite(age) || age > tolerance) {
|
|
60
|
+
throw new WebhookVerificationError(
|
|
61
|
+
`Timestamp is ${age}s away from now, outside the ${tolerance}s window. This may be a replay.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const raw = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
|
|
66
|
+
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
|
|
67
|
+
const expected = createHmac('sha256', key)
|
|
68
|
+
.update(`${id}.${timestamp}.${raw}`)
|
|
69
|
+
.digest('base64');
|
|
70
|
+
|
|
71
|
+
// A delivery can carry several `v1,` signatures during a secret rotation.
|
|
72
|
+
// Accept if ANY matches, so a rotation drops no events.
|
|
73
|
+
const presented = String(signature)
|
|
74
|
+
.split(' ')
|
|
75
|
+
.map((part) => part.split(',')[1])
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
|
|
78
|
+
const ok = presented.some((candidate) => {
|
|
79
|
+
const a = Buffer.from(candidate);
|
|
80
|
+
const b = Buffer.from(expected);
|
|
81
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
82
|
+
});
|
|
83
|
+
if (!ok) {
|
|
84
|
+
throw new WebhookVerificationError(
|
|
85
|
+
'Signature did not match. Verify over the RAW body, before any JSON parsing.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new WebhookVerificationError('Body is not valid JSON.');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A request handler that verifies for you.
|
|
98
|
+
*
|
|
99
|
+
* ── Why this exists ──
|
|
100
|
+
*
|
|
101
|
+
* `verifyWebhook` is correct and partners still get it wrong, because the
|
|
102
|
+
* failure is upstream of it: `express.json()` parses and discards the raw body
|
|
103
|
+
* before your handler runs, and the signature is over the RAW BYTES. Re-encoding
|
|
104
|
+
* the parsed object almost works — until a payload has a non-ASCII character or
|
|
105
|
+
* different key order, and then signatures fail in production for a subset of
|
|
106
|
+
* events with no pattern to it.
|
|
107
|
+
*
|
|
108
|
+
* Documentation can only warn about that. This removes it: the handler reads the
|
|
109
|
+
* stream itself, so it is correct whether or not a body parser is mounted, and
|
|
110
|
+
* whichever order the middleware happens to run in.
|
|
111
|
+
*
|
|
112
|
+
* Framework-agnostic on purpose — it takes (req, res) and works under Express,
|
|
113
|
+
* Fastify's raw handler, and a bare node:http server, because a zero-dependency
|
|
114
|
+
* package cannot depend on any of them.
|
|
115
|
+
*
|
|
116
|
+
* const handler = createWebhookHandler({
|
|
117
|
+
* secret: process.env.AVVIO_WEBHOOK_SECRET,
|
|
118
|
+
* onEvent: async (event) => { … },
|
|
119
|
+
* });
|
|
120
|
+
* app.post('/hooks/avvio', handler);
|
|
121
|
+
*/
|
|
122
|
+
function createWebhookHandler({ secret, onEvent, toleranceSeconds } = {}) {
|
|
123
|
+
if (!secret) throw new Error('createWebhookHandler needs { secret }.');
|
|
124
|
+
if (typeof onEvent !== 'function') {
|
|
125
|
+
throw new Error('createWebhookHandler needs { onEvent }.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return async function avvioWebhookHandler(req, res) {
|
|
129
|
+
const send = (code, message) => {
|
|
130
|
+
res.statusCode = code;
|
|
131
|
+
res.end(message);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
// `req.rawBody` is set by some setups (Next.js, a configured Express).
|
|
137
|
+
// Otherwise read the stream — which is why a parser mounted ahead of us
|
|
138
|
+
// does not break verification.
|
|
139
|
+
raw =
|
|
140
|
+
req.rawBody !== undefined
|
|
141
|
+
? Buffer.from(req.rawBody)
|
|
142
|
+
: await readRawBody(req);
|
|
143
|
+
} catch {
|
|
144
|
+
return send(400, 'Could not read the request body.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let event;
|
|
148
|
+
try {
|
|
149
|
+
event = verifyWebhook({
|
|
150
|
+
body: raw,
|
|
151
|
+
headers: req.headers,
|
|
152
|
+
secret,
|
|
153
|
+
toleranceSeconds,
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// 400, never 401: a signature that does not verify is not an auth
|
|
157
|
+
// challenge and must never be retried into a loop. The sender should give
|
|
158
|
+
// up on this delivery and move on.
|
|
159
|
+
return send(400, err && err.message ? err.message : 'Invalid signature');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await onEvent(event);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// 500 so the delivery is RETRIED. A handler that throws has not processed
|
|
166
|
+
// the event, and acknowledging it anyway loses it permanently — which for
|
|
167
|
+
// a bank return means a ledger that never learns the money came back.
|
|
168
|
+
return send(500, 'Handler failed; retry this delivery.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2xx quickly. Anything slow belongs in a queue, not in the handler: the
|
|
172
|
+
// sender times out and retries, and a non-idempotent handler then runs
|
|
173
|
+
// twice.
|
|
174
|
+
return send(200, 'ok');
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function readRawBody(req) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const chunks = [];
|
|
181
|
+
req.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
182
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
183
|
+
req.on('error', reject);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
verifyWebhook,
|
|
189
|
+
createWebhookHandler,
|
|
190
|
+
WebhookVerificationError,
|
|
191
|
+
};
|