@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.
@@ -22,17 +22,20 @@
22
22
  *
23
23
  * ## Webhook verification: HMAC-SHA256 + nonce (v3 scheme)
24
24
  *
25
- * Plivo's v3 webhook signature scheme:
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
- * 1. Plivo generates a random `nonce` and includes it in the
28
- * `X-Plivo-Signature-V3-Nonce` header.
29
- * 2. The signed data is: `{fullRequestURL}{nonce}` (concatenated, no separator).
30
- * 3. Compute `HMAC-SHA256(authToken, signedData)`.
31
- * 4. Base64-encode the HMAC digest.
32
- * 5. Compare the result with the `X-Plivo-Signature-V3` header.
33
- *
34
- * Note: Unlike Twilio's scheme, Plivo does NOT include the POST body in the
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
- // PlivoVoiceProvider
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
- * ## Algorithm (step by step)
102
- *
103
- * 1. Extract the `X-Plivo-Signature-V3-Nonce` and `X-Plivo-Signature-V3` headers.
104
- * 2. Build the signed data string: `{fullRequestURL}{nonce}`.
105
- * Note: the POST body is NOT included in the signed data (unlike Twilio).
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 the signature matches.
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 signature = ctx.headers['x-plivo-signature-v3'];
116
- if (Array.isArray(nonce) || Array.isArray(signature)) {
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
- // Use empty string as nonce fallback when header is missing --
120
- // the HMAC will still compute but won't match the expected signature.
121
- const nonceStr = nonce ?? '';
122
- const data = ctx.url + nonceStr;
123
- const expected = createHmac('sha256', this.config.authToken)
124
- .update(data)
125
- .digest('base64');
126
- const valid = expected === signature;
127
- return { valid };
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 body into normalized {@link NormalizedCallEvent}s.
727
+ * Parse a Plivo webhook into normalized {@link NormalizedCallEvent}s.
131
728
  *
132
- * Plivo sends most webhooks with URL-encoded bodies, but some callbacks
133
- * may arrive as JSON. This parser handles both formats by inspecting
134
- * whether the body starts with `{` (JSON) or not (form-encoded).
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
- const body = ctx.body.toString();
146
- let params;
147
- // Plivo sends URL-encoded bodies for most events; try JSON as fallback.
742
+ let fields;
148
743
  try {
149
- if (body.trimStart().startsWith('{')) {
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
- params = new URLSearchParams(body);
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 = params.get('CallUUID') ?? params.get('call_uuid') ?? '';
162
- const callStatus = params.get('CallStatus') ?? params.get('call_status') ?? '';
163
- const digits = params.get('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. */