@signalhousellc/sdk 1.0.68 → 1.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/README.md +0 -10
- package/package.json +3 -2
- 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 +4 -0
- package/src/domains/Agents.js +131 -0
- package/src/domains/Brands.js +56 -3
- package/src/domains/Dnc.js +168 -0
- package/src/domains/Messages.js +75 -33
- package/src/domains/Numbers.js +6 -5
- package/src/domains/Registrations.js +240 -0
- package/src/domains/Users.js +6 -2
- package/src/domains/Webhooks.js +31 -5
- package/src/webhooks/verifySignature.js +66 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/** @typedef {import('../SignalHouseSDK.js').RequestOptions} RequestOptions */
|
|
2
|
+
/**
|
|
3
|
+
* @template T
|
|
4
|
+
* @typedef {import('../SignalHouseSDK.js').SdkResponse<T>} SdkResponse
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sender registrations for every jurisdiction and type: the catalog of what a region offers, retail quotes,
|
|
9
|
+
* customer submissions and staff review operations. `type` selects the shape of `data`
|
|
10
|
+
* (for VIRTUAL_LONG_CODE: quantity, useCases and the optional voice dossier).
|
|
11
|
+
*/
|
|
12
|
+
export class Registrations {
|
|
13
|
+
constructor(client, enableAdmin) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
this.enableAdmin = enableAdmin;
|
|
16
|
+
|
|
17
|
+
if (enableAdmin) {
|
|
18
|
+
this.admin = {
|
|
19
|
+
/**
|
|
20
|
+
* Approve or reject a registration awaiting Signal House review (signalhouse only)
|
|
21
|
+
* @async
|
|
22
|
+
* @roles signalhouse
|
|
23
|
+
* @param {Object} params - The parameters for the review decision
|
|
24
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
25
|
+
* @param {("APPROVE"|"REJECT")} params.action - The review decision
|
|
26
|
+
* @param {string} [params.reason] - Why the registration was rejected; required when action is REJECT
|
|
27
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
28
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the updated registration
|
|
29
|
+
*/
|
|
30
|
+
reviewRegistration: async ({ registrationId, action, reason, options = {} }) => {
|
|
31
|
+
this.client._require({ registrationId, action });
|
|
32
|
+
const safeId = encodeURIComponent(registrationId);
|
|
33
|
+
return this.client(`/registration/${safeId}/review`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
body: { action, reason },
|
|
36
|
+
...options,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save corrections to the type's correctable fields for a registration the provider sent back for
|
|
42
|
+
* more information; owner, quantity, bundle and provider order never change (signalhouse only)
|
|
43
|
+
* @async
|
|
44
|
+
* @roles signalhouse
|
|
45
|
+
* @param {Object} params - The parameters for the correction
|
|
46
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
47
|
+
* @param {string} params.reason - Why the registration was corrected
|
|
48
|
+
* @param {Object} params.data - The type's correctable fields (for VIRTUAL_LONG_CODE: useCases and, for SMS + Voice, voice)
|
|
49
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
50
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the updated registration
|
|
51
|
+
*/
|
|
52
|
+
correctRegistration: async ({ registrationId, reason, data, options = {} }) => {
|
|
53
|
+
this.client._require({ registrationId, reason, data });
|
|
54
|
+
const safeId = encodeURIComponent(registrationId);
|
|
55
|
+
return this.client(`/registration/${safeId}/corrections`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
body: { reason, data },
|
|
58
|
+
...options,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Allocate a provider-owned number to a registration through the staff recovery path (signalhouse only)
|
|
64
|
+
* @async
|
|
65
|
+
* @roles signalhouse
|
|
66
|
+
* @param {Object} params - The parameters for the allocation
|
|
67
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
68
|
+
* @param {string} params.phoneNumber - The E.164 number (10-15 digits, optional leading +) to allocate
|
|
69
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
70
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the updated registration
|
|
71
|
+
*/
|
|
72
|
+
addNumber: async ({ registrationId, phoneNumber, options = {} }) => {
|
|
73
|
+
this.client._require({ registrationId, phoneNumber });
|
|
74
|
+
const safeId = encodeURIComponent(registrationId);
|
|
75
|
+
return this.client(`/registration/${safeId}/numbers`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
body: { phoneNumber },
|
|
78
|
+
...options,
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Link an existing provider order to a registration parked in RECONCILIATION_REQUIRED (signalhouse only)
|
|
84
|
+
* @async
|
|
85
|
+
* @roles signalhouse
|
|
86
|
+
* @param {Object} params - The parameters for the reconciliation
|
|
87
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
88
|
+
* @param {string} params.externalId - The provider's identifier for the existing order
|
|
89
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
90
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the updated registration
|
|
91
|
+
*/
|
|
92
|
+
reconcile: async ({ registrationId, externalId, options = {} }) => {
|
|
93
|
+
this.client._require({ registrationId, externalId });
|
|
94
|
+
const safeId = encodeURIComponent(registrationId);
|
|
95
|
+
return this.client(`/registration/${safeId}/reconcile`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
body: { externalId },
|
|
98
|
+
...options,
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Download the completed carrier application as DOCX bytes (signalhouse only)
|
|
104
|
+
* @async
|
|
105
|
+
* @roles signalhouse
|
|
106
|
+
* @param {Object} params - The parameters for the download
|
|
107
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
108
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
109
|
+
* @returns {Promise<SdkResponse<ArrayBuffer>>} A promise that resolves to the DOCX document as an ArrayBuffer
|
|
110
|
+
*/
|
|
111
|
+
getApplication: async ({ registrationId, options = {} }) => {
|
|
112
|
+
this.client._require({ registrationId });
|
|
113
|
+
const safeId = encodeURIComponent(registrationId);
|
|
114
|
+
return this.client(`/registration/${safeId}/application`, {
|
|
115
|
+
method: "GET",
|
|
116
|
+
responseType: "arraybuffer",
|
|
117
|
+
...options,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get the registration types a region offers and whether each can be acquired right now
|
|
126
|
+
* @async
|
|
127
|
+
* @roles api, admin, developer, billing, user
|
|
128
|
+
* @param {Object} params - The parameters for the catalog
|
|
129
|
+
* @param {string} params.region - ISO-2 jurisdiction (US, GB or CA; UK is accepted for GB)
|
|
130
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
131
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to { region, jurisdiction, sendingRequiresRegistration, registeredThrough, provisioning, types }
|
|
132
|
+
*/
|
|
133
|
+
async getCatalog({ region, options = {} }) {
|
|
134
|
+
this.client._require({ region });
|
|
135
|
+
const queryString = this.client._getQueryString({ region });
|
|
136
|
+
return this.client(`/registration/catalog${queryString}`, { method: "GET", ...options });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get retail quotes for a registration type: one quote per capability bundle with per-number setupAmount
|
|
141
|
+
* and monthlyAmount, the totals for the quantity (integer microdollars) and etaDays.
|
|
142
|
+
* Unavailable (400) while the type cannot be acquired in the region.
|
|
143
|
+
* @async
|
|
144
|
+
* @roles api, admin, developer, billing, user
|
|
145
|
+
* @param {Object} params - The parameters for the quote
|
|
146
|
+
* @param {string} params.region - ISO-2 jurisdiction (UK is accepted for GB)
|
|
147
|
+
* @param {string} params.type - Registration type (e.g. VIRTUAL_LONG_CODE)
|
|
148
|
+
* @param {number} [params.quantity=1] - Number of numbers to quote (1-100)
|
|
149
|
+
* @param {string} [params.groupId] - Group to price for; staff only
|
|
150
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
151
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to { data: [quotes] }
|
|
152
|
+
*/
|
|
153
|
+
async getQuotes({ region, type, quantity = 1, groupId, options = {} }) {
|
|
154
|
+
this.client._require({ region, type });
|
|
155
|
+
const queryString = this.client._getQueryString({ region, type, quantity, groupId });
|
|
156
|
+
return this.client(`/registration/quotes${queryString}`, { method: "GET", ...options });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* List registrations for an authorized group; staff may omit groupId to read the review queue
|
|
161
|
+
* @async
|
|
162
|
+
* @roles api, admin, developer, billing, user
|
|
163
|
+
* @param {Object} [params] - The parameters for filtering the registrations
|
|
164
|
+
* @param {string} [params.groupId] - Filter by group ID
|
|
165
|
+
* @param {string} [params.subgroupId] - Filter by subgroup ID
|
|
166
|
+
* @param {string|string[]} [params.region] - Filter by one ISO-2 jurisdiction or a list
|
|
167
|
+
* @param {string} [params.type] - Filter by registration type
|
|
168
|
+
* @param {string} [params.kind] - Filter by kind (identity, program, sender)
|
|
169
|
+
* @param {string|string[]} [params.status] - Filter by one registration status or a list
|
|
170
|
+
* @param {string} [params.parentRegistrationId] - Filter by the parent registration ID
|
|
171
|
+
* @param {string} [params.phoneNumber] - Filter to registrations holding this phone number
|
|
172
|
+
* @param {string} [params.sortBy] - Sort field: createdAt (default), subgroupId, registrationId, region or status
|
|
173
|
+
* @param {string} [params.sortOrder] - Sort direction: asc or desc (default)
|
|
174
|
+
* @param {number} [params.page] - Page number (default 1)
|
|
175
|
+
* @param {number} [params.limit] - Registrations per page (default 25, max 100)
|
|
176
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
177
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to { data: [registrations], totalCount, page, limit }
|
|
178
|
+
*/
|
|
179
|
+
async getRegistrations({ groupId, subgroupId, region, type, kind, status, parentRegistrationId, phoneNumber, sortBy, sortOrder, page, limit, options = {} } = {}) {
|
|
180
|
+
const queryString = this.client._getQueryString({ groupId, subgroupId, region, type, kind, status, parentRegistrationId, phoneNumber, sortBy, sortOrder, page, limit });
|
|
181
|
+
return this.client(`/registration${queryString}`, { method: "GET", ...options });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get a single registration with its review outcome, the type's public data and its status history
|
|
186
|
+
* @async
|
|
187
|
+
* @roles api, admin, developer, billing, user
|
|
188
|
+
* @param {Object} params - The parameters for getting the registration
|
|
189
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
190
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
191
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the registration
|
|
192
|
+
*/
|
|
193
|
+
async getRegistration({ registrationId, options = {} }) {
|
|
194
|
+
this.client._require({ registrationId });
|
|
195
|
+
const safeId = encodeURIComponent(registrationId);
|
|
196
|
+
return this.client(`/registration/${safeId}`, { method: "GET", ...options });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Cancel a registration. Allowed from SIGNAL_HOUSE_REVIEW, SIGNAL_HOUSE_APPROVED, PENDING_PROVIDER, NEEDS_INFORMATION,
|
|
201
|
+
* PAYMENT_REQUIRED and APPROVED (409 otherwise). Numbers already allocated stay on the account and stop sending.
|
|
202
|
+
* @async
|
|
203
|
+
* @roles api, admin, developer, user
|
|
204
|
+
* @param {Object} params - The parameters for cancelling the registration
|
|
205
|
+
* @param {string} params.registrationId - The registration ID (e.g. GB8T9GTD5AZY)
|
|
206
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
207
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the cancelled registration
|
|
208
|
+
*/
|
|
209
|
+
async cancelRegistration({ registrationId, options = {} }) {
|
|
210
|
+
this.client._require({ registrationId });
|
|
211
|
+
const safeId = encodeURIComponent(registrationId);
|
|
212
|
+
return this.client(`/registration/${safeId}`, { method: "DELETE", ...options });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Submit a registration. `type` selects the shape of `data`: for VIRTUAL_LONG_CODE it is { quantity (1-100),
|
|
217
|
+
* useCases (1-10 descriptions), voice? } where voice is required exactly when capabilities include VOICE.
|
|
218
|
+
* Reuse the same clientRequestId only when retrying an identical payload (409 otherwise).
|
|
219
|
+
* Unavailable (400) while the type cannot be acquired in the region.
|
|
220
|
+
* @async
|
|
221
|
+
* @roles api, admin, developer, billing, user
|
|
222
|
+
* @param {Object} params - The parameters for the registration
|
|
223
|
+
* @param {string} params.region - ISO-2 jurisdiction (UK is accepted for GB)
|
|
224
|
+
* @param {string} params.type - Registration type (e.g. VIRTUAL_LONG_CODE)
|
|
225
|
+
* @param {string} params.subgroupId - Subgroup that will own the registration and any allocated numbers
|
|
226
|
+
* @param {string} params.clientRequestId - Idempotency key (UUID)
|
|
227
|
+
* @param {string[]} params.capabilities - One of the type's bundles, e.g. ["SMS"] or ["SMS", "VOICE"]
|
|
228
|
+
* @param {Object} params.data - The type's submission fields
|
|
229
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
230
|
+
* @returns {Promise<SdkResponse<any>>} A promise that resolves to the created registration
|
|
231
|
+
*/
|
|
232
|
+
async createRegistration({ region, type, subgroupId, clientRequestId, capabilities, data, options = {} }) {
|
|
233
|
+
this.client._require({ region, type, subgroupId, clientRequestId, capabilities, data });
|
|
234
|
+
return this.client("/registration", {
|
|
235
|
+
method: "POST",
|
|
236
|
+
body: { region, type, subgroupId, clientRequestId, capabilities, data },
|
|
237
|
+
...options,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/domains/Users.js
CHANGED
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* @typedef {Object} CreateServiceUserData
|
|
29
|
-
* @property {string} groupId - The ID of the group to which the service user belongs (required)
|
|
29
|
+
* @property {string} groupId - The ID of the group to which the service user belongs (required for customer API keys)
|
|
30
30
|
* @property {string} name - The name of the service user (required)
|
|
31
|
-
* @property {string} role - The role of the service user
|
|
31
|
+
* @property {string} role - The role of the service user: "api" for a customer API key (required)
|
|
32
|
+
* @property {string[]} [allowedIps] - Only accept this API key from these IPv4/IPv6 addresses or CIDR ranges (max 100). Omit or pass [] for no IP restriction. Customer API keys only.
|
|
33
|
+
* @property {string[]} [allowedCountries] - Only accept this API key from these countries, as ISO 3166-1 alpha-2 codes such as "US" (max 250). Omit or pass [] for no country restriction. Customer API keys only.
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
36
|
/**
|
|
@@ -47,6 +49,8 @@
|
|
|
47
49
|
* @property {string} [country] - The country of the user's address (optional)
|
|
48
50
|
* @property {string} [postalCode] - The postal code of the user's address (optional)
|
|
49
51
|
* @property {string} [status] - The status of the user (e.g., "active", "inactive")
|
|
52
|
+
* @property {string[]} [allowedIps] - Customer API keys only: replace the key's allowed IPv4/IPv6 addresses or CIDR ranges ([] removes the restriction). Requires an interactive admin or developer session; an API key cannot change its own restrictions.
|
|
53
|
+
* @property {string[]} [allowedCountries] - Customer API keys only: replace the key's allowed ISO 3166-1 alpha-2 country codes ([] removes the restriction). Requires an interactive admin or developer session.
|
|
50
54
|
*/
|
|
51
55
|
|
|
52
56
|
export class Users {
|
package/src/domains/Webhooks.js
CHANGED
|
@@ -37,8 +37,10 @@ export class Webhooks {
|
|
|
37
37
|
* plaintext HMAC-SHA256 secret used to sign every delivery to this endpoint, sent as the
|
|
38
38
|
* `X-SignalHouse-Signature` header alongside `X-SignalHouse-Timestamp` (the signed string is
|
|
39
39
|
* `${timestamp}.${rawRequestBody}`) and `X-SignalHouse-Delivery-Id` (stable across retries of the
|
|
40
|
-
* same event, for receiver-side dedup)
|
|
41
|
-
*
|
|
40
|
+
* same event, for receiver-side dedup), plus `X-SignalHouse-Signature-V1` (`t=<ms>,v1=<hex>[,v1=<hex>]`,
|
|
41
|
+
* every currently valid signature). Store this secret now: it is never returned again by any read
|
|
42
|
+
* (SHGHL-2944). If it's lost, call `rotateWebhookSecret`. Verify deliveries with
|
|
43
|
+
* `verifyWebhookSignature` from `@signalhousellc/sdk/webhooks` (Node only).
|
|
42
44
|
* @async
|
|
43
45
|
* @roles api, admin, developer
|
|
44
46
|
* @param {Object} params - The parameters for creating a webhook
|
|
@@ -53,9 +55,11 @@ export class Webhooks {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
|
-
* Update an existing webhook with the specified data.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
58
|
+
* Update an existing webhook with the specified data. `updateData` may include `name`, `url`,
|
|
59
|
+
* `subscribedEvents` and (SHGHL-3486) `authType`, `apiHeaderPrefix` and `credentials` (`{ key, secret? }`)
|
|
60
|
+
* to change the endpoint's own credentials in place: credentials are required when switching to an auth
|
|
61
|
+
* type that needs them, and omitted credentials keep the stored ones. Like every read of a webhook, the
|
|
62
|
+
* response carries `hasSigningSecret` and `hasCredentials` (booleans), never a secret or credential.
|
|
59
63
|
* @async
|
|
60
64
|
* @roles api, admin, developer
|
|
61
65
|
* @param {Object} params - The parameters for updating a webhook
|
|
@@ -71,6 +75,28 @@ export class Webhooks {
|
|
|
71
75
|
return this.client(`/webhook/${safeId}`, { method: "PUT", body: updateData, ...options });
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Rotate a webhook's signing secret, or give a webhook created before signing existed its first one
|
|
80
|
+
* (SHGHL-3486). The response includes the new plaintext `signingSecret`, returned this one time only.
|
|
81
|
+
* The replaced secret keeps working for `graceSeconds` (default 86400, max 604800, 0 = stop now):
|
|
82
|
+
* during that window each delivery's `X-SignalHouse-Signature-V1` header carries a signature for both,
|
|
83
|
+
* and `previousSecretExpiresAt` in the response says when the old one stops. Not available to OAuth
|
|
84
|
+
* connector tokens.
|
|
85
|
+
* @async
|
|
86
|
+
* @roles api, admin, developer
|
|
87
|
+
* @param {Object} params - The parameters for rotating the secret
|
|
88
|
+
* @param {string} params.id - The ID of the webhook
|
|
89
|
+
* @param {number} [params.graceSeconds] - How long the replaced secret stays valid
|
|
90
|
+
* @param {RequestOptions} [params.options] - Additional options for the request
|
|
91
|
+
* @throws {Error} Throws an error if the id parameter is missing
|
|
92
|
+
* @returns {Promise<SdkResponse<any>>} The webhook, including the new plaintext `signingSecret` (this one time only)
|
|
93
|
+
*/
|
|
94
|
+
async rotateWebhookSecret({ id, graceSeconds, options = {} }) {
|
|
95
|
+
this.client._require({ id });
|
|
96
|
+
const safeId = encodeURIComponent(id);
|
|
97
|
+
return this.client(`/webhook/${safeId}/rotate-secret`, { method: "POST", body: graceSeconds === undefined ? {} : { graceSeconds }, ...options });
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/**
|
|
75
101
|
* Delete a webhook by its ID (mark as inactive)
|
|
76
102
|
* @async
|
|
@@ -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
|
+
}
|