@signalhousellc/sdk 1.0.67 → 1.0.69
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/README.md +0 -10
- package/package.json +11 -3
- package/skills/VENDORED.json +8 -8
- package/skills/signalhouse-10dlc/SKILL.md +5 -9
- package/skills/signalhouse-10dlc/references/tcr-rejection-causes.md +5 -12
- package/skills/signalhouse-advisor/SKILL.md +3 -3
- package/skills/signalhouse-numbers/SKILL.md +1 -2
- package/skills/signalhouse-setup/SKILL.md +41 -11
- package/skills/signalhouse-sms/SKILL.md +4 -4
- package/skills/signalhouse-webhooks/SKILL.md +76 -56
- package/src/SignalHouseSDK.js +26 -13
- package/src/domains/Agents.js +204 -55
- package/src/domains/Auth.js +30 -24
- package/src/domains/Billing.js +32 -26
- package/src/domains/Brands.js +45 -34
- package/src/domains/Campaigns.js +106 -47
- package/src/domains/Dnc.js +168 -0
- package/src/domains/Groups.js +53 -16
- package/src/domains/Landings.js +36 -30
- package/src/domains/Messages.js +110 -65
- package/src/domains/Notifications.js +12 -6
- package/src/domains/Numbers.js +58 -52
- package/src/domains/Onboarding.js +14 -8
- package/src/domains/Shortlinks.js +10 -4
- package/src/domains/Subgroups.js +16 -10
- package/src/domains/Subscriptions.js +22 -16
- package/src/domains/Tickets.js +18 -12
- package/src/domains/Users.js +32 -22
- package/src/domains/Webhooks.js +45 -13
- package/src/domains/voice/Analytics.js +8 -2
- package/src/domains/voice/CallLogs.js +14 -8
- package/src/domains/voice/Calls.js +14 -8
- package/src/domains/voice/GlobalVoiceSettings.js +10 -4
- package/src/domains/voice/ProgrammableVoiceProfiles.js +22 -16
- package/src/domains/voice/SipProfiles.js +24 -18
- package/src/domains/voice/SipTrunks.js +26 -20
- package/src/domains/voice/Tokens.js +10 -4
- package/src/domains/voice/browser/Call.js +2 -0
- package/src/domains/voice/browser/Device.js +23 -11
- package/src/webhooks/verifySignature.js +66 -0
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** @typedef {import('../../SignalHouseSDK.js').RequestOptions} RequestOptions */
|
|
2
|
+
/**
|
|
3
|
+
* @template T
|
|
4
|
+
* @typedef {import('../../SignalHouseSDK.js').SdkResponse<T>} SdkResponse
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* @typedef {Object} SipTrunkHost
|
|
3
9
|
* @property {string} host - IP address, hostname, FQDN, or CIDR.
|
|
@@ -43,8 +49,8 @@ export class SipTrunks {
|
|
|
43
49
|
* @async
|
|
44
50
|
* @roles api, admin, developer, billing, user
|
|
45
51
|
* @param {Object} [params] - Request parameters.
|
|
46
|
-
* @param {
|
|
47
|
-
* @returns {Promise<
|
|
52
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
53
|
+
* @returns {Promise<SdkResponse<any>>} The list of SIP trunks: `{ sipTrunks: [...] }`.
|
|
48
54
|
*/
|
|
49
55
|
async list({ options = {} } = {}) {
|
|
50
56
|
return this.client(`/voice/sip-trunks`, { method: "GET", ...options });
|
|
@@ -56,9 +62,9 @@ export class SipTrunks {
|
|
|
56
62
|
* @roles api, admin, developer, billing, user
|
|
57
63
|
* @param {Object} params - Request parameters.
|
|
58
64
|
* @param {string} params.id - The SIP trunk UUID.
|
|
59
|
-
* @param {
|
|
65
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
60
66
|
* @throws {Error} If id is missing.
|
|
61
|
-
* @returns {Promise<
|
|
67
|
+
* @returns {Promise<SdkResponse<any>>} The SIP trunk: `{ sipTrunk: {...} }`.
|
|
62
68
|
*/
|
|
63
69
|
async get({ id, options = {} }) {
|
|
64
70
|
this.client._require({ id });
|
|
@@ -72,9 +78,9 @@ export class SipTrunks {
|
|
|
72
78
|
* @roles api, admin, developer, billing, user
|
|
73
79
|
* @param {Object} params - Request parameters.
|
|
74
80
|
* @param {CreateSipTrunkData} params.trunkData - The SIP trunk to create.
|
|
75
|
-
* @param {
|
|
81
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
76
82
|
* @throws {Error} If trunkData is missing.
|
|
77
|
-
* @returns {Promise<
|
|
83
|
+
* @returns {Promise<SdkResponse<any>>} The created SIP trunk: `{ sipTrunk: {...} }`.
|
|
78
84
|
*/
|
|
79
85
|
async create({ trunkData, options = {} }) {
|
|
80
86
|
this.client._require({ trunkData });
|
|
@@ -88,9 +94,9 @@ export class SipTrunks {
|
|
|
88
94
|
* @param {Object} params - Request parameters.
|
|
89
95
|
* @param {string} params.id - The SIP trunk UUID.
|
|
90
96
|
* @param {UpdateSipTrunkData} params.updateData - Fields to update.
|
|
91
|
-
* @param {
|
|
97
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
92
98
|
* @throws {Error} If id or updateData is missing.
|
|
93
|
-
* @returns {Promise<
|
|
99
|
+
* @returns {Promise<SdkResponse<any>>} The updated SIP trunk: `{ sipTrunk: {...} }`.
|
|
94
100
|
*/
|
|
95
101
|
async update({ id, updateData, options = {} }) {
|
|
96
102
|
this.client._require({ id, updateData });
|
|
@@ -104,9 +110,9 @@ export class SipTrunks {
|
|
|
104
110
|
* @roles api, admin, developer, billing, user
|
|
105
111
|
* @param {Object} params - Request parameters.
|
|
106
112
|
* @param {string} params.id - The SIP trunk UUID.
|
|
107
|
-
* @param {
|
|
113
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
108
114
|
* @throws {Error} If id is missing.
|
|
109
|
-
* @returns {Promise<
|
|
115
|
+
* @returns {Promise<SdkResponse<any>>} `{ success: boolean, message: string }`.
|
|
110
116
|
*/
|
|
111
117
|
async delete({ id, options = {} }) {
|
|
112
118
|
this.client._require({ id });
|
|
@@ -120,9 +126,9 @@ export class SipTrunks {
|
|
|
120
126
|
* @roles api, admin, developer, billing, user
|
|
121
127
|
* @param {Object} params - Request parameters.
|
|
122
128
|
* @param {string} params.id - The SIP trunk UUID.
|
|
123
|
-
* @param {
|
|
129
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
124
130
|
* @throws {Error} If id is missing.
|
|
125
|
-
* @returns {Promise<
|
|
131
|
+
* @returns {Promise<SdkResponse<any>>} The updated SIP trunk: `{ sipTrunk: {...} }`.
|
|
126
132
|
*/
|
|
127
133
|
async toggleActive({ id, options = {} }) {
|
|
128
134
|
this.client._require({ id });
|
|
@@ -136,9 +142,9 @@ export class SipTrunks {
|
|
|
136
142
|
* @roles api, admin, developer, billing, user
|
|
137
143
|
* @param {Object} params - Request parameters.
|
|
138
144
|
* @param {string} params.id - The SIP trunk UUID.
|
|
139
|
-
* @param {
|
|
145
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
140
146
|
* @throws {Error} If id is missing.
|
|
141
|
-
* @returns {Promise<
|
|
147
|
+
* @returns {Promise<SdkResponse<any>>} `{ password: string }`.
|
|
142
148
|
*/
|
|
143
149
|
async regeneratePassword({ id, options = {} }) {
|
|
144
150
|
this.client._require({ id });
|
|
@@ -151,8 +157,8 @@ export class SipTrunks {
|
|
|
151
157
|
* @async
|
|
152
158
|
* @roles api, admin, developer, billing, user
|
|
153
159
|
* @param {Object} [params] - Request parameters.
|
|
154
|
-
* @param {
|
|
155
|
-
* @returns {Promise<
|
|
160
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
161
|
+
* @returns {Promise<SdkResponse<any>>} `{ sipPops: [...] }`.
|
|
156
162
|
*/
|
|
157
163
|
async getPops({ options = {} } = {}) {
|
|
158
164
|
return this.client(`/voice/sip-trunks/pops`, { method: "GET", ...options });
|
|
@@ -167,9 +173,9 @@ export class SipTrunks {
|
|
|
167
173
|
* @param {Object} params - Request parameters.
|
|
168
174
|
* @param {string} params.id - The SIP trunk UUID.
|
|
169
175
|
* @param {string[]} params.phoneNumbers - Phone numbers in E.164 (a leading "+" is optional).
|
|
170
|
-
* @param {
|
|
176
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
171
177
|
* @throws {Error} If id or phoneNumbers is missing.
|
|
172
|
-
* @returns {Promise<
|
|
178
|
+
* @returns {Promise<SdkResponse<any>>} `{ assigned: string[], skipped: { e164, reason }[] }`.
|
|
173
179
|
*/
|
|
174
180
|
async assignNumbers({ id, phoneNumbers, options = {} }) {
|
|
175
181
|
this.client._require({ id, phoneNumbers });
|
|
@@ -185,9 +191,9 @@ export class SipTrunks {
|
|
|
185
191
|
* @param {Object} params - Request parameters.
|
|
186
192
|
* @param {string} params.id - The SIP trunk UUID.
|
|
187
193
|
* @param {string[]} params.phoneNumbers - Phone numbers in E.164 (a leading "+" is optional).
|
|
188
|
-
* @param {
|
|
194
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
189
195
|
* @throws {Error} If id or phoneNumbers is missing.
|
|
190
|
-
* @returns {Promise<
|
|
196
|
+
* @returns {Promise<SdkResponse<any>>} `{ unassigned: string[] }`.
|
|
191
197
|
*/
|
|
192
198
|
async unassignNumbers({ id, phoneNumbers, options = {} }) {
|
|
193
199
|
this.client._require({ id, phoneNumbers });
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** @typedef {import('../../SignalHouseSDK.js').RequestOptions} RequestOptions */
|
|
2
|
+
/**
|
|
3
|
+
* @template T
|
|
4
|
+
* @typedef {import('../../SignalHouseSDK.js').SdkResponse<T>} SdkResponse
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* @typedef {Object} VoiceGrants
|
|
3
9
|
* @property {Object} [voice] - Voice grants object.
|
|
@@ -55,8 +61,8 @@ export class Tokens {
|
|
|
55
61
|
* @roles api, admin, developer, billing, user
|
|
56
62
|
* @param {Object} [params] - Request parameters.
|
|
57
63
|
* @param {CreateTokenData} [params.tokenData] - Token mint options (identity, ttl, grants). All fields optional.
|
|
58
|
-
* @param {
|
|
59
|
-
* @returns {Promise<CreateTokenResponse
|
|
64
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
65
|
+
* @returns {Promise<SdkResponse<CreateTokenResponse>>} The token + embedded SIP credentials.
|
|
60
66
|
*/
|
|
61
67
|
async create({ tokenData = {}, options = {} } = {}) {
|
|
62
68
|
return this.client(`/voice/tokens`, { method: "POST", body: tokenData, ...options });
|
|
@@ -77,8 +83,8 @@ export class Tokens {
|
|
|
77
83
|
* @param {string} [params.token] - The current voice token (JWT) whose identity to extend.
|
|
78
84
|
* @param {string} [params.identity] - The wrtc_ identity to extend, if held directly (from a prior mint/refresh response).
|
|
79
85
|
* @param {number} [params.ttl] - New TTL in seconds. Defaults to the server default, capped at the server maximum.
|
|
80
|
-
* @param {
|
|
81
|
-
* @returns {Promise<CreateTokenResponse
|
|
86
|
+
* @param {RequestOptions} [params.options] - Additional options for the request.
|
|
87
|
+
* @returns {Promise<SdkResponse<CreateTokenResponse>>} The refreshed token + embedded SIP credentials (same shape as create()).
|
|
82
88
|
*/
|
|
83
89
|
async refresh({ token, identity, ttl, options = {} } = {}) {
|
|
84
90
|
const body = {};
|
|
@@ -31,6 +31,8 @@ export class Call {
|
|
|
31
31
|
this._session = session;
|
|
32
32
|
this._listeners = new Map();
|
|
33
33
|
this._remoteAudio = null;
|
|
34
|
+
/** @type {boolean} Set true before accept/connect to route remote audio yourself instead of the hidden <audio>. */
|
|
35
|
+
this.disableDefaultAudioOutput = false;
|
|
34
36
|
|
|
35
37
|
this.direction = direction;
|
|
36
38
|
this.id = session.id || session._request?.call_id || null;
|
|
@@ -73,6 +73,15 @@ import { Call } from "./Call.js";
|
|
|
73
73
|
* "connected" (WSS established), "disconnected", "tokenWillExpire",
|
|
74
74
|
* "tokenRefreshFailed", "error".
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* Narrow a token-or-credentials argument: a VoiceTokenResponse wraps its SIP credentials under `sip_credentials`.
|
|
78
|
+
* @param {VoiceTokenResponse | SipCredentials} value - The argument to inspect.
|
|
79
|
+
* @returns {value is VoiceTokenResponse} - True when the value is a token response.
|
|
80
|
+
*/
|
|
81
|
+
function isTokenResponse(value) {
|
|
82
|
+
return !!value && typeof value === "object" && "sip_credentials" in value;
|
|
83
|
+
}
|
|
84
|
+
|
|
76
85
|
export class Device {
|
|
77
86
|
/**
|
|
78
87
|
* @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
|
|
@@ -90,8 +99,9 @@ export class Device {
|
|
|
90
99
|
*/
|
|
91
100
|
constructor(tokenOrCreds, opts = {}) {
|
|
92
101
|
this._credentials = this._parseCreds(tokenOrCreds, "Device");
|
|
93
|
-
|
|
94
|
-
this.
|
|
102
|
+
const token = isTokenResponse(tokenOrCreds) ? tokenOrCreds : null;
|
|
103
|
+
this.identity = token?.identity || this._credentials.username;
|
|
104
|
+
this.expiresAt = token?.expires_at || null;
|
|
95
105
|
|
|
96
106
|
this._opts = {
|
|
97
107
|
registerExpires: opts.registerExpires ?? 600,
|
|
@@ -179,12 +189,13 @@ export class Device {
|
|
|
179
189
|
*/
|
|
180
190
|
async updateToken(tokenOrCreds) {
|
|
181
191
|
const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
|
|
182
|
-
const
|
|
192
|
+
const token = isTokenResponse(tokenOrCreds) ? tokenOrCreds : null;
|
|
193
|
+
const newIdentity = token?.identity || creds.username;
|
|
183
194
|
|
|
184
195
|
// Identity-preserving fast path — same credential, just extended expiry.
|
|
185
196
|
if (this._ua && this._registered && !this._stopped &&
|
|
186
197
|
newIdentity === this.identity && creds.password === this._credentials.password) {
|
|
187
|
-
this.expiresAt =
|
|
198
|
+
this.expiresAt = token?.expires_at || this.expiresAt;
|
|
188
199
|
this._scheduleTokenRefresh();
|
|
189
200
|
return;
|
|
190
201
|
}
|
|
@@ -205,7 +216,7 @@ export class Device {
|
|
|
205
216
|
* @param {ConnectParams} params
|
|
206
217
|
* @returns {Promise<Call>}
|
|
207
218
|
*/
|
|
208
|
-
async connect({ to, from, mediaStream, pcConfig, extraHeaders = [] } = {}) {
|
|
219
|
+
async connect({ to, from, mediaStream, pcConfig, extraHeaders = [] } = /** @type {ConnectParams} */ ({})) {
|
|
209
220
|
if (!this._ua || !this._registered) {
|
|
210
221
|
throw new Error("Device.connect: not registered yet — call register() first");
|
|
211
222
|
}
|
|
@@ -235,7 +246,7 @@ export class Device {
|
|
|
235
246
|
mediaConstraints: { audio: true, video: false },
|
|
236
247
|
mediaStream: stream,
|
|
237
248
|
pcConfig: pcConfig || { iceServers: [] },
|
|
238
|
-
rtcOfferConstraints: { offerToReceiveAudio:
|
|
249
|
+
rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false },
|
|
239
250
|
extraHeaders: headers,
|
|
240
251
|
});
|
|
241
252
|
|
|
@@ -279,7 +290,7 @@ export class Device {
|
|
|
279
290
|
* @throws {Error} when a required field is missing.
|
|
280
291
|
*/
|
|
281
292
|
_parseCreds(tokenOrCreds, ctx) {
|
|
282
|
-
const creds = tokenOrCreds
|
|
293
|
+
const creds = /** @type {SipCredentials} */ (isTokenResponse(tokenOrCreds) ? tokenOrCreds.sip_credentials : tokenOrCreds);
|
|
283
294
|
if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
|
|
284
295
|
throw new Error(`${ctx}: token/credentials missing required fields (username, password, domain, wss_url)`);
|
|
285
296
|
}
|
|
@@ -320,8 +331,9 @@ export class Device {
|
|
|
320
331
|
this._ua = null;
|
|
321
332
|
this._registered = false;
|
|
322
333
|
this._credentials = creds;
|
|
323
|
-
|
|
324
|
-
this.
|
|
334
|
+
const token = isTokenResponse(tokenOrCreds) ? tokenOrCreds : null;
|
|
335
|
+
this.identity = token?.identity || creds.username;
|
|
336
|
+
this.expiresAt = token?.expires_at || null;
|
|
325
337
|
|
|
326
338
|
try {
|
|
327
339
|
await this.register(); // rebuilds the UA + reschedules the refresh timer
|
|
@@ -345,11 +357,11 @@ export class Device {
|
|
|
345
357
|
* True if the given token has too little life left to safely apply (would
|
|
346
358
|
* expire within tokenRefreshMs). Tokens with no parseable expiry are treated
|
|
347
359
|
* as usable.
|
|
348
|
-
* @param {VoiceTokenResponse} tokenOrCreds
|
|
360
|
+
* @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
|
|
349
361
|
* @returns {boolean}
|
|
350
362
|
*/
|
|
351
363
|
_isTokenStale(tokenOrCreds) {
|
|
352
|
-
const exp = Date.parse(tokenOrCreds
|
|
364
|
+
const exp = Date.parse((isTokenResponse(tokenOrCreds) && tokenOrCreds.expires_at) || "");
|
|
353
365
|
if (!Number.isFinite(exp)) return false;
|
|
354
366
|
return exp - Date.now() <= this._opts.tokenRefreshMs;
|
|
355
367
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Node-only helpers for receiving Signal House webhooks (SHGHL-3486). Kept out of the main entry point,
|
|
4
|
+
// which also runs in browsers, because it depends on node:crypto. Import from "@signalhousellc/sdk/webhooks".
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Verify that a webhook delivery came from Signal House and is recent.
|
|
10
|
+
*
|
|
11
|
+
* Checks `X-SignalHouse-Signature-V1` (`t=<ms>,v1=<hex>[,v1=<hex>]`, which carries every currently valid
|
|
12
|
+
* signature during a secret rotation) when present, otherwise the original `X-SignalHouse-Signature` +
|
|
13
|
+
* `X-SignalHouse-Timestamp` pair. Each signature is a hex HMAC-SHA256 of `${timestamp}.${rawBody}`. Comparisons
|
|
14
|
+
* are constant-time.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} params
|
|
17
|
+
* @param {string|Buffer} params.rawBody - The exact request body as received, before any JSON parsing.
|
|
18
|
+
* @param {Object} params.headers - Request headers (any casing; e.g. Express `req.headers`).
|
|
19
|
+
* @param {string} [params.secret] - Your endpoint's signing secret.
|
|
20
|
+
* @param {string[]} [params.secrets] - Several secrets to accept, e.g. old and new while you roll over.
|
|
21
|
+
* @param {number} [params.toleranceMs=300000] - Reject deliveries whose timestamp is further than this from now.
|
|
22
|
+
* @param {number} [params.now=Date.now()] - Current time in ms (for tests).
|
|
23
|
+
* @returns {{valid: boolean, reason?: string}} - `valid` true when a signature matches within the time window;
|
|
24
|
+
* otherwise `reason` is one of `no_secret`, `no_signature`, `bad_timestamp`, `stale_timestamp`, `bad_signature`.
|
|
25
|
+
*/
|
|
26
|
+
export function verifyWebhookSignature({ rawBody, headers, secret, secrets, toleranceMs = DEFAULT_TOLERANCE_MS, now = Date.now() }) {
|
|
27
|
+
const candidates = (secrets ?? (secret ? [secret] : [])).filter(Boolean);
|
|
28
|
+
if (candidates.length === 0) return { valid: false, reason: "no_secret" };
|
|
29
|
+
|
|
30
|
+
const header = (name) => {
|
|
31
|
+
if (!headers) return undefined;
|
|
32
|
+
const match = Object.keys(headers).find((key) => key.toLowerCase() === name);
|
|
33
|
+
const value = match === undefined ? undefined : headers[match];
|
|
34
|
+
return Array.isArray(value) ? value[0] : value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let timestamp;
|
|
38
|
+
let signatures = [];
|
|
39
|
+
const v1 = header("x-signalhouse-signature-v1");
|
|
40
|
+
if (v1) {
|
|
41
|
+
const parts = String(v1).split(",").map((part) => part.trim());
|
|
42
|
+
timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
|
|
43
|
+
signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3)).filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
// No usable V1 (absent, or mangled by an intermediary): fall back to the original header pair
|
|
46
|
+
if (signatures.length === 0) {
|
|
47
|
+
timestamp = header("x-signalhouse-timestamp");
|
|
48
|
+
const legacy = header("x-signalhouse-signature");
|
|
49
|
+
signatures = legacy ? [String(legacy)] : [];
|
|
50
|
+
}
|
|
51
|
+
if (signatures.length === 0) return { valid: false, reason: "no_signature" };
|
|
52
|
+
|
|
53
|
+
const sentAt = Number(timestamp);
|
|
54
|
+
if (!timestamp || !Number.isFinite(sentAt)) return { valid: false, reason: "bad_timestamp" };
|
|
55
|
+
if (Math.abs(now - sentAt) > toleranceMs) return { valid: false, reason: "stale_timestamp" };
|
|
56
|
+
|
|
57
|
+
const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : String(rawBody ?? "");
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
const expected = Buffer.from(crypto.createHmac("sha256", candidate).update(`${timestamp}.${body}`).digest("hex"), "utf8");
|
|
60
|
+
for (const signature of signatures) {
|
|
61
|
+
const given = Buffer.from(signature, "utf8");
|
|
62
|
+
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) return { valid: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { valid: false, reason: "bad_signature" };
|
|
66
|
+
}
|