@framers/agentos 0.10.26 → 0.10.27
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/dist/core/llm/providers/implementations/MistralProvider.js +2 -2
- package/dist/core/llm/providers/implementations/MistralProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts +40 -0
- package/dist/core/llm/providers/implementations/OpenRouterProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/OpenRouterProvider.js +181 -8
- package/dist/core/llm/providers/implementations/OpenRouterProvider.js.map +1 -1
- package/dist/core/llm/providers/openai-cache-params.js +2 -2
- package/dist/core/llm/providers/openai-cache-params.js.map +1 -1
- package/dist/io/channels/telephony/providers/plivo.d.ts +81 -24
- package/dist/io/channels/telephony/providers/plivo.d.ts.map +1 -1
- package/dist/io/channels/telephony/providers/plivo.js +643 -52
- package/dist/io/channels/telephony/providers/plivo.js.map +1 -1
- package/dist/io/channels/telephony/providers/telnyx.d.ts +22 -8
- package/dist/io/channels/telephony/providers/telnyx.d.ts.map +1 -1
- package/dist/io/channels/telephony/providers/telnyx.js +45 -12
- package/dist/io/channels/telephony/providers/telnyx.js.map +1 -1
- package/dist/io/channels/telephony/providers/twilio.d.ts +12 -4
- package/dist/io/channels/telephony/providers/twilio.d.ts.map +1 -1
- package/dist/io/channels/telephony/providers/twilio.js +115 -19
- package/dist/io/channels/telephony/providers/twilio.js.map +1 -1
- package/package.json +1 -1
|
@@ -22,17 +22,20 @@
|
|
|
22
22
|
*
|
|
23
23
|
* ## Webhook verification: HMAC-SHA256 + nonce (v3 scheme)
|
|
24
24
|
*
|
|
25
|
-
* Plivo's v3
|
|
25
|
+
* Plivo's v3 signature covers the method, the URL, its query and the POST
|
|
26
|
+
* fields (see {@link computePlivoV3Signature}). The nonce arrives in
|
|
27
|
+
* `X-Plivo-Signature-V3-Nonce` and the signature in `X-Plivo-Signature-V3`
|
|
28
|
+
* (or `X-Plivo-Signature-Ma-V3`, signed with the main account's token), which
|
|
29
|
+
* may list several signatures separated by commas.
|
|
26
30
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* signed data -- only the URL and nonce. The nonce prevents replay attacks.
|
|
31
|
+
* Plivo's SDKs build the signed text differently in three places:
|
|
32
|
+
* plivo-python (`plivo/utils/signature_v3.py`) decodes the query one
|
|
33
|
+
* character per byte, writes JSON values with Python's `str()` and sorts by
|
|
34
|
+
* code point; plivo-node decodes the query as UTF-8, writes JSON values with
|
|
35
|
+
* `String()` and sorts by UTF-16 code unit. Plivo does not document which its
|
|
36
|
+
* servers match, so a signature over either text is accepted (see
|
|
37
|
+
* {@link PlivoCanonicalStyle}). For ASCII callbacks with string fields the two
|
|
38
|
+
* texts are the same.
|
|
36
39
|
*
|
|
37
40
|
* ## DTMF via `<GetDigits>` XML pattern
|
|
38
41
|
*
|
|
@@ -62,10 +65,583 @@
|
|
|
62
65
|
*
|
|
63
66
|
* @module @framers/agentos/voice/providers/plivo
|
|
64
67
|
*/
|
|
65
|
-
import { createHmac, randomUUID } from 'node:crypto';
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
69
|
+
/** Both signed texts, the order {@link PlivoVoiceProvider.verifyWebhook} tries them in. */
|
|
70
|
+
const PLIVO_CANONICAL_STYLES = ['python', 'js'];
|
|
71
|
+
/**
|
|
72
|
+
* Orders strings by code point, as Python's `sorted()` does. JavaScript's
|
|
73
|
+
* default sort orders by UTF-16 code unit, which differs only when a
|
|
74
|
+
* character above U+FFFF meets one from U+E000 to U+FFFF at the same place.
|
|
75
|
+
*/
|
|
76
|
+
function compareCodePoints(a, b) {
|
|
77
|
+
const length = Math.min(a.length, b.length);
|
|
78
|
+
for (let index = 0; index < length; index++) {
|
|
79
|
+
const left = a.codePointAt(index);
|
|
80
|
+
const right = b.codePointAt(index);
|
|
81
|
+
if (left !== right)
|
|
82
|
+
return left - right;
|
|
83
|
+
// Equal code points above U+FFFF are the same surrogate pair in both strings.
|
|
84
|
+
if (left > 0xffff)
|
|
85
|
+
index++;
|
|
86
|
+
}
|
|
87
|
+
return a.length - b.length;
|
|
88
|
+
}
|
|
89
|
+
/** The sort comparator for a style; `undefined` is JavaScript's default order. */
|
|
90
|
+
function comparatorFor(style) {
|
|
91
|
+
return style === 'python' ? compareCodePoints : undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One query component decoded as plivo-python decodes it: `+` becomes a
|
|
95
|
+
* space, each valid `%XX` becomes that byte, other characters keep their
|
|
96
|
+
* UTF-8 bytes, and each byte becomes one character from U+0000 to U+00FF.
|
|
97
|
+
*/
|
|
98
|
+
function decodePythonQueryComponent(component) {
|
|
99
|
+
const raw = Buffer.from(component.replace(/\+/g, ' '), 'utf8');
|
|
100
|
+
const bytes = Buffer.alloc(raw.length);
|
|
101
|
+
let length = 0;
|
|
102
|
+
for (let index = 0; index < raw.length; index++) {
|
|
103
|
+
const hex = index + 2 < raw.length ? raw.toString('latin1', index + 1, index + 3) : '';
|
|
104
|
+
if (raw[index] === 0x25 && /^[0-9A-Fa-f]{2}$/.test(hex)) {
|
|
105
|
+
bytes[length++] = parseInt(hex, 16);
|
|
106
|
+
index += 2;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
bytes[length++] = raw[index];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return bytes.toString('latin1', 0, length);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Query fields grouped by key as plivo-python's `parse_qs` reads them on
|
|
116
|
+
* Python 3.12 and later: split on `&`, empty segments skipped, a segment
|
|
117
|
+
* without `=` read as an empty value, each part decoded with
|
|
118
|
+
* {@link decodePythonQueryComponent}.
|
|
119
|
+
*/
|
|
120
|
+
function groupPythonQueryFields(encoded) {
|
|
121
|
+
const fields = new Map();
|
|
122
|
+
for (const segment of encoded.split('&')) {
|
|
123
|
+
if (!segment)
|
|
124
|
+
continue;
|
|
125
|
+
const equals = segment.indexOf('=');
|
|
126
|
+
const key = decodePythonQueryComponent(equals >= 0 ? segment.slice(0, equals) : segment);
|
|
127
|
+
const value = equals >= 0 ? decodePythonQueryComponent(segment.slice(equals + 1)) : '';
|
|
128
|
+
const values = fields.get(key);
|
|
129
|
+
if (values)
|
|
130
|
+
values.push(value);
|
|
131
|
+
else
|
|
132
|
+
fields.set(key, [value]);
|
|
133
|
+
}
|
|
134
|
+
return fields;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* URL-encoded fields grouped by key in one pass (a repeated key keeps every
|
|
138
|
+
* value in order). Calling getAll() per key would rescan the whole input for
|
|
139
|
+
* each key, quadratic work on a request that is not yet verified.
|
|
140
|
+
*/
|
|
141
|
+
function groupFormFields(encoded) {
|
|
142
|
+
const fields = new Map();
|
|
143
|
+
for (const [key, value] of new URLSearchParams(encoded)) {
|
|
144
|
+
const values = fields.get(key);
|
|
145
|
+
if (values)
|
|
146
|
+
values.push(value);
|
|
147
|
+
else
|
|
148
|
+
fields.set(key, [value]);
|
|
149
|
+
}
|
|
150
|
+
return fields;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The URL and query parts Plivo signs: everything before `?` (the fragment
|
|
154
|
+
* dropped), and the query's values by key, decoded as the style decodes
|
|
155
|
+
* them. Path parameters (`;name=value`) stay in the base, as plivo-node keeps
|
|
156
|
+
* them; plivo-python's urlparse drops the last segment's parameters, which
|
|
157
|
+
* would leave them unsigned.
|
|
158
|
+
*/
|
|
159
|
+
function splitPlivoUrl(url, style = 'js') {
|
|
160
|
+
const withoutFragment = url.split('#')[0];
|
|
161
|
+
const queryStart = withoutFragment.indexOf('?');
|
|
162
|
+
const base = queryStart >= 0 ? withoutFragment.slice(0, queryStart) : withoutFragment;
|
|
163
|
+
if (queryStart < 0)
|
|
164
|
+
return { base, query: {} };
|
|
165
|
+
const encoded = withoutFragment.slice(queryStart + 1);
|
|
166
|
+
const grouped = style === 'python' ? groupPythonQueryFields(encoded) : groupFormFields(encoded);
|
|
167
|
+
return { base, query: Object.fromEntries(grouped) };
|
|
168
|
+
}
|
|
169
|
+
/** `key=value` pairs joined by `&`, keys and each key's values sorted with `compare`. */
|
|
170
|
+
function sortedQueryString(params, compare) {
|
|
171
|
+
return Object.keys(params)
|
|
172
|
+
.sort(compare)
|
|
173
|
+
.map((key) => [...params[key]].sort(compare).map((item) => `${key}=${item}`).join('&'))
|
|
174
|
+
.join('&');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Longest text the POST fields may sign to, in UTF-16 code units. Both SDKs
|
|
178
|
+
* write a JSON array's key before every item, so a body under 100 KB with a
|
|
179
|
+
* long key over a long array would sign to gigabytes, all of it built and
|
|
180
|
+
* hashed before any signature is compared. Plivo's callbacks sign to a few
|
|
181
|
+
* kilobytes.
|
|
182
|
+
*/
|
|
183
|
+
const MAX_PLIVO_SIGNED_FIELDS_LENGTH = 1 << 20;
|
|
184
|
+
/**
|
|
185
|
+
* Every field as `key` + `value` with no separators, keys and values sorted
|
|
186
|
+
* with `compare`, nested objects in place.
|
|
187
|
+
*
|
|
188
|
+
* @throws RangeError when the text would pass {@link MAX_PLIVO_SIGNED_FIELDS_LENGTH}.
|
|
189
|
+
*/
|
|
190
|
+
function sortedParamsString(params, compare) {
|
|
191
|
+
// Every piece goes into one array joined once, so each byte is copied
|
|
192
|
+
// once. Joining per nesting level would copy a subtree again at every
|
|
193
|
+
// level above it, depth x size work on a body that is not yet verified.
|
|
194
|
+
const parts = [];
|
|
195
|
+
appendSortedParams(params, parts, compare, { remaining: MAX_PLIVO_SIGNED_FIELDS_LENGTH });
|
|
196
|
+
return parts.join('');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Appends `params` to `parts` in signing order: keys sorted, each key before
|
|
200
|
+
* its value or nested fields. Each field's length is charged to `budget`
|
|
201
|
+
* before anything is appended or sorted.
|
|
202
|
+
*/
|
|
203
|
+
function appendSortedParams(params, parts, compare, budget) {
|
|
204
|
+
for (const key of Object.keys(params).sort(compare)) {
|
|
205
|
+
const value = params[key];
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
let length = key.length * value.length;
|
|
208
|
+
for (const item of value)
|
|
209
|
+
length += item.length;
|
|
210
|
+
chargeSignedLength(budget, length);
|
|
211
|
+
for (const item of [...value].sort(compare))
|
|
212
|
+
parts.push(key, item);
|
|
213
|
+
}
|
|
214
|
+
else if (typeof value === 'object') {
|
|
215
|
+
chargeSignedLength(budget, key.length);
|
|
216
|
+
parts.push(key);
|
|
217
|
+
appendSortedParams(value, parts, compare, budget);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
chargeSignedLength(budget, key.length + value.length);
|
|
221
|
+
parts.push(key, value);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Takes `length` from `budget`, refusing the fields once it runs out. A length
|
|
227
|
+
* that is not a number (a value that is not text) leaves the budget at NaN,
|
|
228
|
+
* which also refuses, so the limit cannot be switched off.
|
|
229
|
+
*/
|
|
230
|
+
function chargeSignedLength(budget, length) {
|
|
231
|
+
budget.remaining -= length;
|
|
232
|
+
if (!(budget.remaining >= 0)) {
|
|
233
|
+
throw new RangeError(`Plivo callback fields sign to more than ${MAX_PLIVO_SIGNED_FIELDS_LENGTH} characters`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Plivo's v3 webhook signature, computed as plivo-python
|
|
238
|
+
* (`plivo/utils/signature_v3.py`) computes it by default: base64 of
|
|
239
|
+
* HMAC-SHA256 with the auth token over `{base}.{nonce}`.
|
|
240
|
+
*
|
|
241
|
+
* - GET: `base` is the URL without its query, then `?` and the query as
|
|
242
|
+
* sorted `key=value` pairs when there is one.
|
|
243
|
+
* - POST: `base` is the URL without its query; when the body has fields, `?`,
|
|
244
|
+
* the sorted query (and `.` when there is a query), then every field as
|
|
245
|
+
* `key` + `value` sorted by key. With no fields it is the GET form.
|
|
246
|
+
*
|
|
247
|
+
* @param method - The request method (GET or POST).
|
|
248
|
+
* @param url - The full URL Plivo requested.
|
|
249
|
+
* @param nonce - The `X-Plivo-Signature-V3-Nonce` header.
|
|
250
|
+
* @param authToken - The (sub)account auth token.
|
|
251
|
+
* @param params - The POST fields as text; ignored for GET. A JSON body's
|
|
252
|
+
* values must already be written in `style` (as the provider's verifier
|
|
253
|
+
* writes them).
|
|
254
|
+
* @param style - Which SDK's text to build: how the query is decoded and how
|
|
255
|
+
* keys and repeated values are sorted (see {@link PlivoCanonicalStyle}).
|
|
256
|
+
* @returns The base64 signature.
|
|
257
|
+
* @throws RangeError when the fields sign to more than
|
|
258
|
+
* {@link MAX_PLIVO_SIGNED_FIELDS_LENGTH} characters.
|
|
259
|
+
*/
|
|
260
|
+
export function computePlivoV3Signature(method, url, nonce, authToken, params = {}, style = 'python') {
|
|
261
|
+
const compare = comparatorFor(style);
|
|
262
|
+
const { base, query } = splitPlivoUrl(url, style);
|
|
263
|
+
const queryString = sortedQueryString(query, compare);
|
|
264
|
+
let signed;
|
|
265
|
+
if (method.toUpperCase() === 'GET' || Object.keys(params).length === 0) {
|
|
266
|
+
signed = queryString ? `${base}?${queryString}` : base;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
signed = `${base}?${queryString}${queryString ? '.' : ''}${sortedParamsString(params, compare)}`;
|
|
270
|
+
}
|
|
271
|
+
return createHmac('sha256', authToken).update(`${signed}.${nonce}`).digest('base64');
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* A callback's content, read one way for both verification and events, so
|
|
275
|
+
* no field reaches an event without being covered by the signature:
|
|
276
|
+
*
|
|
277
|
+
* - GET: the URL's query. The body is ignored, since the signature does
|
|
278
|
+
* not cover it.
|
|
279
|
+
* - Otherwise the raw body: a JSON object when it parses as one, else
|
|
280
|
+
* URL-encoded fields (a body that only starts like JSON is read as a
|
|
281
|
+
* form, the same way the signature check reads it); `parsedBody` when
|
|
282
|
+
* the raw body is empty, read like a JSON body.
|
|
283
|
+
*
|
|
284
|
+
* @throws RangeError when a JSON body has a shape {@link parsePlivoJson} refuses.
|
|
285
|
+
* @throws TypeError when `parsedBody` cannot be serialized as JSON.
|
|
286
|
+
*/
|
|
287
|
+
function readPlivoRequest(ctx) {
|
|
288
|
+
if (ctx.method.toUpperCase() === 'GET')
|
|
289
|
+
return { fields: splitPlivoUrl(ctx.url).query };
|
|
290
|
+
const body = ctx.body.toString();
|
|
291
|
+
if (!body.trim()) {
|
|
292
|
+
if (!ctx.parsedBody)
|
|
293
|
+
return { fields: {} };
|
|
294
|
+
// A framework's parsed body is declared as strings but can carry numbers,
|
|
295
|
+
// booleans, nulls, nested objects and arrays (express.json() returns
|
|
296
|
+
// `any`), so it goes through the same reader and limits as a raw JSON
|
|
297
|
+
// body. JSON.stringify throws on a circular or BigInt value; both callers
|
|
298
|
+
// treat that as an unreadable body.
|
|
299
|
+
const parsed = parsePlivoJson(JSON.stringify(ctx.parsedBody));
|
|
300
|
+
return isPlivoJsonObject(parsed) ? { json: parsed } : { fields: {} };
|
|
301
|
+
}
|
|
302
|
+
if (body.trimStart().startsWith('{')) {
|
|
303
|
+
let parsed;
|
|
304
|
+
try {
|
|
305
|
+
parsed = parsePlivoJson(body);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// Text that is not JSON is read as a form below; a refused JSON shape is not.
|
|
309
|
+
if (error instanceof RangeError)
|
|
310
|
+
throw error;
|
|
311
|
+
parsed = undefined;
|
|
312
|
+
}
|
|
313
|
+
if (parsed !== undefined && isPlivoJsonObject(parsed))
|
|
314
|
+
return { json: parsed };
|
|
315
|
+
}
|
|
316
|
+
// fromEntries defines each key as an own property, so a field named
|
|
317
|
+
// __proto__ is signed like any other instead of replacing the prototype.
|
|
318
|
+
return {
|
|
319
|
+
fields: Object.fromEntries([...groupFormFields(body)].map(([key, values]) => [
|
|
320
|
+
key,
|
|
321
|
+
values.length === 1 ? values[0] : values,
|
|
322
|
+
])),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** The fields that decide which call event a callback produces. */
|
|
326
|
+
const PLIVO_EVENT_FIELDS = ['CallUUID', 'call_uuid', 'CallStatus', 'call_status', 'Digits'];
|
|
327
|
+
/**
|
|
328
|
+
* The fields events are read from. For a JSON body only {@link PLIVO_EVENT_FIELDS}
|
|
329
|
+
* holding a string, or an array of strings, count: the SDKs write a number,
|
|
330
|
+
* boolean or null differently (`1.0` or `1`), so such a value has no single
|
|
331
|
+
* signed text, and Plivo sends these fields as strings.
|
|
332
|
+
*/
|
|
333
|
+
function plivoEventFields(content) {
|
|
334
|
+
if (!('json' in content))
|
|
335
|
+
return content.fields;
|
|
336
|
+
const entries = [];
|
|
337
|
+
for (const name of PLIVO_EVENT_FIELDS) {
|
|
338
|
+
if (!Object.prototype.hasOwnProperty.call(content.json, name))
|
|
339
|
+
continue;
|
|
340
|
+
const value = content.json[name];
|
|
341
|
+
if (typeof value === 'string') {
|
|
342
|
+
entries.push([name, value]);
|
|
343
|
+
}
|
|
344
|
+
else if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
345
|
+
entries.push([name, value]);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return Object.fromEntries(entries);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* A field's value when it has exactly one. The V3 signature sorts a repeated
|
|
352
|
+
* field's values, so their order in the request is not signed, and a field
|
|
353
|
+
* repeated with different values has no value to trust.
|
|
354
|
+
*
|
|
355
|
+
* @param fields - Fields from {@link plivoEventFields}.
|
|
356
|
+
* @param name - The field name.
|
|
357
|
+
* @returns The value, or undefined when the field is absent, nested or conflicting.
|
|
358
|
+
*/
|
|
359
|
+
function singlePlivoField(fields, name) {
|
|
360
|
+
if (!Object.prototype.hasOwnProperty.call(fields, name))
|
|
361
|
+
return undefined;
|
|
362
|
+
const value = fields[name];
|
|
363
|
+
if (typeof value === 'string')
|
|
364
|
+
return value;
|
|
365
|
+
if (Array.isArray(value) && value.length > 0 && value.every((item) => item === value[0]))
|
|
366
|
+
return value[0];
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
/** Whether a field that decides the event is repeated with different values. */
|
|
370
|
+
function hasConflictingPlivoEventField(fields) {
|
|
371
|
+
return PLIVO_EVENT_FIELDS.some((name) => {
|
|
372
|
+
const value = Object.prototype.hasOwnProperty.call(fields, name) ? fields[name] : undefined;
|
|
373
|
+
return Array.isArray(value) && value.some((item) => item !== value[0]);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Deepest object nesting read from a JSON callback body. Plivo's callbacks
|
|
378
|
+
* are flat or nearly so; a deeper body is refused while it is parsed.
|
|
379
|
+
*/
|
|
380
|
+
const MAX_PLIVO_JSON_DEPTH = 8;
|
|
381
|
+
/** A JSON number kept as its source text, so each SDK's way of writing it can be reproduced. */
|
|
382
|
+
class JsonNumberToken {
|
|
383
|
+
constructor(raw) {
|
|
384
|
+
this.raw = raw;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** Whether a parsed value is a JSON object. */
|
|
388
|
+
function isPlivoJsonObject(value) {
|
|
389
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof JsonNumberToken);
|
|
390
|
+
}
|
|
391
|
+
/** A JSON number token: optional minus, integer part, optional fraction and exponent. */
|
|
392
|
+
const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
393
|
+
/** JSON's literal names and their values. */
|
|
394
|
+
const JSON_LITERALS = [
|
|
395
|
+
['true', true],
|
|
396
|
+
['false', false],
|
|
397
|
+
['null', null],
|
|
398
|
+
];
|
|
399
|
+
/**
|
|
400
|
+
* Parses JSON as JSON.parse does, with two differences: each number keeps
|
|
401
|
+
* its source text (plivo-python signs `1.0` as `1.0`, which JSON.parse
|
|
402
|
+
* cannot tell from `1`), and shapes no callback has are refused while they
|
|
403
|
+
* are read: objects nested deeper than {@link MAX_PLIVO_JSON_DEPTH} and
|
|
404
|
+
* arrays holding arrays or objects. Duplicate keys keep the last value, as
|
|
405
|
+
* JSON.parse and Python's json.loads do, and a key named `__proto__` becomes
|
|
406
|
+
* an own property. Work is linear in the text length.
|
|
407
|
+
*
|
|
408
|
+
* @param text - The request body.
|
|
409
|
+
* @returns The parsed value.
|
|
410
|
+
* @throws SyntaxError when the text is not JSON.
|
|
411
|
+
* @throws RangeError when the JSON has a refused shape.
|
|
412
|
+
*/
|
|
413
|
+
function parsePlivoJson(text) {
|
|
414
|
+
let at = 0;
|
|
415
|
+
function syntaxError() {
|
|
416
|
+
return new SyntaxError(`Invalid JSON at position ${at}`);
|
|
417
|
+
}
|
|
418
|
+
function skipSpace() {
|
|
419
|
+
while (at < text.length) {
|
|
420
|
+
const code = text.charCodeAt(at);
|
|
421
|
+
if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
|
|
422
|
+
return;
|
|
423
|
+
at++;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function parseEscape() {
|
|
427
|
+
const code = text.charCodeAt(at++);
|
|
428
|
+
switch (code) {
|
|
429
|
+
case 0x22: return '"';
|
|
430
|
+
case 0x5c: return '\\';
|
|
431
|
+
case 0x2f: return '/';
|
|
432
|
+
case 0x62: return '\b';
|
|
433
|
+
case 0x66: return '\f';
|
|
434
|
+
case 0x6e: return '\n';
|
|
435
|
+
case 0x72: return '\r';
|
|
436
|
+
case 0x74: return '\t';
|
|
437
|
+
case 0x75: {
|
|
438
|
+
const hex = text.slice(at, at + 4);
|
|
439
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
440
|
+
throw syntaxError();
|
|
441
|
+
at += 4;
|
|
442
|
+
return String.fromCharCode(parseInt(hex, 16));
|
|
443
|
+
}
|
|
444
|
+
default:
|
|
445
|
+
throw syntaxError();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function parseString() {
|
|
449
|
+
at++; // the opening quote
|
|
450
|
+
let out = '';
|
|
451
|
+
let runStart = at;
|
|
452
|
+
for (;;) {
|
|
453
|
+
if (at >= text.length)
|
|
454
|
+
throw syntaxError();
|
|
455
|
+
const code = text.charCodeAt(at);
|
|
456
|
+
if (code === 0x22) {
|
|
457
|
+
out += text.slice(runStart, at);
|
|
458
|
+
at++;
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
if (code === 0x5c) {
|
|
462
|
+
out += text.slice(runStart, at);
|
|
463
|
+
at++;
|
|
464
|
+
out += parseEscape();
|
|
465
|
+
runStart = at;
|
|
466
|
+
}
|
|
467
|
+
else if (code < 0x20) {
|
|
468
|
+
throw syntaxError();
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
at++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function parseValue(depth) {
|
|
476
|
+
skipSpace();
|
|
477
|
+
const code = text.charCodeAt(at);
|
|
478
|
+
if (code === 0x7b)
|
|
479
|
+
return parseObject(depth + 1);
|
|
480
|
+
if (code === 0x5b)
|
|
481
|
+
return parseArray();
|
|
482
|
+
if (code === 0x22)
|
|
483
|
+
return parseString();
|
|
484
|
+
if (code === 0x2d || (code >= 0x30 && code <= 0x39)) {
|
|
485
|
+
JSON_NUMBER.lastIndex = at;
|
|
486
|
+
const match = JSON_NUMBER.exec(text);
|
|
487
|
+
if (!match)
|
|
488
|
+
throw syntaxError();
|
|
489
|
+
at += match[0].length;
|
|
490
|
+
return new JsonNumberToken(match[0]);
|
|
491
|
+
}
|
|
492
|
+
for (const [word, value] of JSON_LITERALS) {
|
|
493
|
+
if (text.startsWith(word, at)) {
|
|
494
|
+
at += word.length;
|
|
495
|
+
return value;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
throw syntaxError();
|
|
499
|
+
}
|
|
500
|
+
function parseArray() {
|
|
501
|
+
at++; // [
|
|
502
|
+
const items = [];
|
|
503
|
+
skipSpace();
|
|
504
|
+
if (text.charCodeAt(at) === 0x5d) {
|
|
505
|
+
at++;
|
|
506
|
+
return items;
|
|
507
|
+
}
|
|
508
|
+
for (;;) {
|
|
509
|
+
skipSpace();
|
|
510
|
+
const code = text.charCodeAt(at);
|
|
511
|
+
if (code === 0x5b || code === 0x7b) {
|
|
512
|
+
throw new RangeError('Plivo callback JSON has an array holding an array or object');
|
|
513
|
+
}
|
|
514
|
+
items.push(parseValue(0));
|
|
515
|
+
skipSpace();
|
|
516
|
+
const separator = text.charCodeAt(at++);
|
|
517
|
+
if (separator === 0x5d)
|
|
518
|
+
return items;
|
|
519
|
+
if (separator !== 0x2c)
|
|
520
|
+
throw syntaxError();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function parseObject(depth) {
|
|
524
|
+
if (depth > MAX_PLIVO_JSON_DEPTH) {
|
|
525
|
+
throw new RangeError(`Plivo callback JSON nests deeper than ${MAX_PLIVO_JSON_DEPTH} levels`);
|
|
526
|
+
}
|
|
527
|
+
at++; // {
|
|
528
|
+
// Collected in a Map and defined by fromEntries, so `__proto__` is an own key.
|
|
529
|
+
const entries = new Map();
|
|
530
|
+
skipSpace();
|
|
531
|
+
if (text.charCodeAt(at) === 0x7d) {
|
|
532
|
+
at++;
|
|
533
|
+
return {};
|
|
534
|
+
}
|
|
535
|
+
for (;;) {
|
|
536
|
+
skipSpace();
|
|
537
|
+
if (text.charCodeAt(at) !== 0x22)
|
|
538
|
+
throw syntaxError();
|
|
539
|
+
const key = parseString();
|
|
540
|
+
skipSpace();
|
|
541
|
+
if (text.charCodeAt(at++) !== 0x3a)
|
|
542
|
+
throw syntaxError();
|
|
543
|
+
entries.set(key, parseValue(depth));
|
|
544
|
+
skipSpace();
|
|
545
|
+
const separator = text.charCodeAt(at++);
|
|
546
|
+
if (separator === 0x7d)
|
|
547
|
+
return Object.fromEntries(entries);
|
|
548
|
+
if (separator !== 0x2c)
|
|
549
|
+
throw syntaxError();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const value = parseValue(0);
|
|
553
|
+
skipSpace();
|
|
554
|
+
if (at !== text.length)
|
|
555
|
+
throw syntaxError();
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* A finite float as Python's repr writes it: the shortest digits that read
|
|
560
|
+
* back to the same value (the digits JavaScript's toExponential() gives), in
|
|
561
|
+
* fixed notation with at least one fractional digit when the decimal point
|
|
562
|
+
* sits from 4 places left of the first digit up to 16 places right of it,
|
|
563
|
+
* otherwise as `d.ddde±XX`.
|
|
564
|
+
*
|
|
565
|
+
* @param value - The number.
|
|
566
|
+
* @returns Python's repr of the float.
|
|
567
|
+
*/
|
|
568
|
+
export function pythonFloatRepr(value) {
|
|
569
|
+
if (Number.isNaN(value))
|
|
570
|
+
return 'nan';
|
|
571
|
+
if (!Number.isFinite(value))
|
|
572
|
+
return value > 0 ? 'inf' : '-inf';
|
|
573
|
+
if (value === 0)
|
|
574
|
+
return Object.is(value, -0) ? '-0.0' : '0.0';
|
|
575
|
+
const sign = value < 0 ? '-' : '';
|
|
576
|
+
const [mantissa, exponentText] = Math.abs(value).toExponential().split('e');
|
|
577
|
+
const digits = mantissa.replace('.', '');
|
|
578
|
+
const exponent = Number(exponentText);
|
|
579
|
+
const pointAt = exponent + 1; // digits before the decimal point
|
|
580
|
+
if (pointAt <= -4 || pointAt > 16) {
|
|
581
|
+
const head = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits;
|
|
582
|
+
const exponentSign = exponent < 0 ? '-' : '+';
|
|
583
|
+
return `${sign}${head}e${exponentSign}${String(Math.abs(exponent)).padStart(2, '0')}`;
|
|
584
|
+
}
|
|
585
|
+
if (pointAt <= 0)
|
|
586
|
+
return `${sign}0.${'0'.repeat(-pointAt)}${digits}`;
|
|
587
|
+
if (pointAt >= digits.length)
|
|
588
|
+
return `${sign}${digits}${'0'.repeat(pointAt - digits.length)}.0`;
|
|
589
|
+
return `${sign}${digits.slice(0, pointAt)}.${digits.slice(pointAt)}`;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A JSON number token as each style writes it. `'js'` prints the number
|
|
593
|
+
* JSON.parse gives. `'python'` prints what json.loads gives: an int for a
|
|
594
|
+
* token with no fraction or exponent, else a float. JSON integer tokens have
|
|
595
|
+
* no leading zeros or plus sign, so the int's text is the token itself,
|
|
596
|
+
* except `-0`, which json.loads reads as the int 0.
|
|
597
|
+
*/
|
|
598
|
+
function formatJsonNumber(raw, style) {
|
|
599
|
+
if (style === 'js')
|
|
600
|
+
return String(Number(raw));
|
|
601
|
+
if (/^-?\d+$/.test(raw))
|
|
602
|
+
return raw === '-0' ? '0' : raw;
|
|
603
|
+
return pythonFloatRepr(Number(raw));
|
|
604
|
+
}
|
|
605
|
+
/** A JSON scalar as signature text in the given style. */
|
|
606
|
+
function formatJsonScalar(value, style) {
|
|
607
|
+
if (value instanceof JsonNumberToken)
|
|
608
|
+
return formatJsonNumber(value.raw, style);
|
|
609
|
+
if (style === 'python') {
|
|
610
|
+
if (value === true)
|
|
611
|
+
return 'True';
|
|
612
|
+
if (value === false)
|
|
613
|
+
return 'False';
|
|
614
|
+
if (value === null)
|
|
615
|
+
return 'None';
|
|
616
|
+
}
|
|
617
|
+
return String(value);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A parsed JSON body as signature fields: arrays and nested objects kept,
|
|
621
|
+
* scalars written as text in the given style.
|
|
622
|
+
*
|
|
623
|
+
* @param object - The parsed body or a nested object in it.
|
|
624
|
+
* @param style - How scalars are written.
|
|
625
|
+
* @returns The fields.
|
|
626
|
+
*/
|
|
627
|
+
function toPlivoParams(object, style) {
|
|
628
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => {
|
|
629
|
+
if (Array.isArray(value))
|
|
630
|
+
return [key, value.map((item) => formatJsonScalar(item, style))];
|
|
631
|
+
if (isPlivoJsonObject(value))
|
|
632
|
+
return [key, toPlivoParams(value, style)];
|
|
633
|
+
return [key, formatJsonScalar(value, style)];
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Compares a computed signature with a received one in constant time. A
|
|
638
|
+
* length mismatch is a plain mismatch.
|
|
639
|
+
*/
|
|
640
|
+
function signaturesMatch(expected, received) {
|
|
641
|
+
const a = Buffer.from(expected);
|
|
642
|
+
const b = Buffer.from(received);
|
|
643
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
644
|
+
}
|
|
69
645
|
/**
|
|
70
646
|
* Plivo voice call provider.
|
|
71
647
|
*
|
|
@@ -98,40 +674,61 @@ export class PlivoVoiceProvider {
|
|
|
98
674
|
/**
|
|
99
675
|
* Verify an incoming Plivo webhook request using HMAC-SHA256 (v3 scheme).
|
|
100
676
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* 3. Compute `HMAC-SHA256(authToken, signedData)`.
|
|
107
|
-
* 4. Base64-encode the digest.
|
|
108
|
-
* 5. Compare with the `X-Plivo-Signature-V3` header.
|
|
677
|
+
* Computes {@link computePlivoV3Signature} over the request's method, URL
|
|
678
|
+
* and fields (read by {@link readPlivoRequest}, as `parseWebhookEvent`
|
|
679
|
+
* reads them) in both SDKs' texts (see {@link PlivoCanonicalStyle}) and
|
|
680
|
+
* compares each in constant time with every signature listed in
|
|
681
|
+
* `X-Plivo-Signature-V3` and `X-Plivo-Signature-Ma-V3`.
|
|
109
682
|
*
|
|
110
683
|
* @param ctx - Raw webhook request context.
|
|
111
|
-
* @returns Verification result with `valid: true` if
|
|
684
|
+
* @returns Verification result with `valid: true` if a signature matches.
|
|
112
685
|
*/
|
|
113
686
|
verifyWebhook(ctx) {
|
|
114
687
|
const nonce = ctx.headers['x-plivo-signature-v3-nonce'];
|
|
115
|
-
const
|
|
116
|
-
if (Array.isArray(nonce) || Array.isArray(
|
|
688
|
+
const signatures = [ctx.headers['x-plivo-signature-v3'], ctx.headers['x-plivo-signature-ma-v3']];
|
|
689
|
+
if (Array.isArray(nonce) || signatures.some((value) => Array.isArray(value))) {
|
|
117
690
|
return { valid: false, error: 'Duplicate Plivo signature headers' };
|
|
118
691
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
692
|
+
const received = signatures
|
|
693
|
+
.filter((value) => Boolean(value))
|
|
694
|
+
.flatMap((value) => value.split(','))
|
|
695
|
+
.map((value) => value.trim())
|
|
696
|
+
.filter(Boolean);
|
|
697
|
+
if (!nonce || received.length === 0) {
|
|
698
|
+
return { valid: false, error: 'Missing Plivo signature headers' };
|
|
699
|
+
}
|
|
700
|
+
let expected;
|
|
701
|
+
try {
|
|
702
|
+
const content = readPlivoRequest(ctx);
|
|
703
|
+
// The signature sorts a repeated field's values, so it cannot tell
|
|
704
|
+
// which one comes first. A request whose event fields repeat with
|
|
705
|
+
// different values is refused instead of read in some order.
|
|
706
|
+
if (hasConflictingPlivoEventField(plivoEventFields(content))) {
|
|
707
|
+
return { valid: false, error: 'Conflicting repeated event fields' };
|
|
708
|
+
}
|
|
709
|
+
const isGet = ctx.method.toUpperCase() === 'GET';
|
|
710
|
+
expected = [
|
|
711
|
+
...new Set(PLIVO_CANONICAL_STYLES.map((style) => {
|
|
712
|
+
const params = isGet ? {} : 'json' in content ? toPlivoParams(content.json, style) : content.fields;
|
|
713
|
+
return computePlivoV3Signature(ctx.method, ctx.url, nonce, this.config.authToken, params, style);
|
|
714
|
+
})),
|
|
715
|
+
];
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// A RangeError: parsePlivoJson refused the body's shape (objects nested
|
|
719
|
+
// deeper than MAX_PLIVO_JSON_DEPTH, or an array holding an array or
|
|
720
|
+
// object), or the fields sign to more than MAX_PLIVO_SIGNED_FIELDS_LENGTH.
|
|
721
|
+
return { valid: false, error: 'Unreadable webhook body' };
|
|
722
|
+
}
|
|
723
|
+
const valid = received.some((candidate) => expected.some((signature) => signaturesMatch(signature, candidate)));
|
|
724
|
+
return valid ? { valid } : { valid, error: 'Signature mismatch' };
|
|
128
725
|
}
|
|
129
726
|
/**
|
|
130
|
-
* Parse a Plivo webhook
|
|
727
|
+
* Parse a Plivo webhook into normalized {@link NormalizedCallEvent}s.
|
|
131
728
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
729
|
+
* Fields come from {@link readPlivoRequest} and {@link plivoEventFields},
|
|
730
|
+
* as the signature check reads them: a GET callback's query, otherwise the
|
|
731
|
+
* URL-encoded or JSON body.
|
|
135
732
|
*
|
|
136
733
|
* Plivo uses two naming conventions for the same fields:
|
|
137
734
|
* - PascalCase (`CallUUID`, `CallStatus`, `Digits`) in URL callbacks.
|
|
@@ -142,25 +739,19 @@ export class PlivoVoiceProvider {
|
|
|
142
739
|
* @returns Parsed result containing zero or more normalized events.
|
|
143
740
|
*/
|
|
144
741
|
parseWebhookEvent(ctx) {
|
|
145
|
-
|
|
146
|
-
let params;
|
|
147
|
-
// Plivo sends URL-encoded bodies for most events; try JSON as fallback.
|
|
742
|
+
let fields;
|
|
148
743
|
try {
|
|
149
|
-
|
|
150
|
-
const obj = JSON.parse(body);
|
|
151
|
-
params = new URLSearchParams(obj);
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
params = new URLSearchParams(body);
|
|
155
|
-
}
|
|
744
|
+
fields = plivoEventFields(readPlivoRequest(ctx));
|
|
156
745
|
}
|
|
157
746
|
catch {
|
|
158
|
-
|
|
747
|
+
// A JSON body refused by parsePlivoJson carries no usable event.
|
|
748
|
+
return { events: [] };
|
|
159
749
|
}
|
|
750
|
+
const field = (name) => singlePlivoField(fields, name);
|
|
160
751
|
// Support both PascalCase and snake_case field naming conventions.
|
|
161
|
-
const callUuid =
|
|
162
|
-
const callStatus =
|
|
163
|
-
const digits =
|
|
752
|
+
const callUuid = field('CallUUID') ?? field('call_uuid') ?? '';
|
|
753
|
+
const callStatus = field('CallStatus') ?? field('call_status') ?? '';
|
|
754
|
+
const digits = field('Digits');
|
|
164
755
|
const timestamp = Date.now();
|
|
165
756
|
const events = [];
|
|
166
757
|
/** Helper: shared base fields with a unique event ID for idempotency. */
|