@voxiness/n8n-nodes-voxisms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/credentials/VoxiSmsApi.credentials.d.ts +7 -0
- package/dist/credentials/VoxiSmsApi.credentials.js +49 -0
- package/dist/nodes/VoxiSms/VoxiSms.node.d.ts +10 -0
- package/dist/nodes/VoxiSms/VoxiSms.node.js +256 -0
- package/dist/nodes/VoxiSms/voxisms.svg +6 -0
- package/dist/nodes/VoxiSmsTrigger/VoxiSmsTrigger.node.d.ts +12 -0
- package/dist/nodes/VoxiSmsTrigger/VoxiSmsTrigger.node.js +298 -0
- package/dist/nodes/VoxiSmsTrigger/voxisms.svg +5 -0
- package/dist/nodes/shared/signing.d.ts +13 -0
- package/dist/nodes/shared/signing.js +41 -0
- package/dist/nodes/shared/verifySignature.d.ts +16 -0
- package/dist/nodes/shared/verifySignature.js +68 -0
- package/index.js +4 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VoxiSMS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @voxiness/n8n-nodes-voxisms
|
|
2
|
+
|
|
3
|
+
n8n community nodes for [VoxiSMS](https://voxisms.com): send SMS through the VoxiSMS ecosystem, and start workflows on inbound SMS.
|
|
4
|
+
|
|
5
|
+
Two nodes:
|
|
6
|
+
|
|
7
|
+
- **VoxiSMS** (action node): sends an SMS.
|
|
8
|
+
- **VoxiSMS Trigger** (trigger node, "New Inbound SMS"): starts the workflow when your VoxiSMS number receives an inbound SMS.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
### n8n GUI (Community Nodes)
|
|
13
|
+
|
|
14
|
+
**Settings → Community Nodes → Install**, then enter the package name:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
@voxiness/n8n-nodes-voxisms
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Self-hosted (npm)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @voxiness/n8n-nodes-voxisms
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Restart n8n after installing.
|
|
27
|
+
|
|
28
|
+
## Credentials
|
|
29
|
+
|
|
30
|
+
Both nodes use the **VoxiSMS API** credential type: `Customer ID` + `Token`. Get both from the "Link your phone" page in your VoxiPlan dashboard: https://app.voxiplan.com/voxisms
|
|
31
|
+
|
|
32
|
+
- **Customer ID**: the phone number you registered, without the leading `+` (e.g. `33639980000`). Listed under "Register your number".
|
|
33
|
+
- **Token**: listed under "Activate your token". The same token you paste into the VoxiSMS Android app during setup.
|
|
34
|
+
|
|
35
|
+
Use the credential's "Test" button to verify your setup. It performs a signed, read-only status check and does **not** send an SMS.
|
|
36
|
+
|
|
37
|
+
## Nodes
|
|
38
|
+
|
|
39
|
+
### VoxiSMS (Send SMS)
|
|
40
|
+
|
|
41
|
+
| Field | Required | Description |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Recipient | yes | Phone number in E.164 format (e.g. `+15551234567`) |
|
|
44
|
+
| Message | yes | The SMS text content |
|
|
45
|
+
| Message ID | no | Optional custom message id; a UUID is generated automatically if left empty |
|
|
46
|
+
|
|
47
|
+
On success, the node outputs `{ messageId, sqsMessageId }`.
|
|
48
|
+
|
|
49
|
+
### VoxiSMS Trigger (New Inbound SMS)
|
|
50
|
+
|
|
51
|
+
Instant (webhook) trigger, no configurable parameters. Activating the workflow registers a webhook subscription with VoxiSMS using n8n's auto-generated webhook URL; deactivating the workflow removes it.
|
|
52
|
+
|
|
53
|
+
Each verified delivery emits one item shaped as an `InboundMessageEvent`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"version": "1",
|
|
58
|
+
"event": "inbound_message.received",
|
|
59
|
+
"eventId": "evt_0f2f8f0e-924a-5b8b-8352-6fc04c31ddbd",
|
|
60
|
+
"messageId": "69d44359-1c82-4f84-a9a0-45c89bb4ce98",
|
|
61
|
+
"customerId": "33639980000",
|
|
62
|
+
"occurredAt": "2026-07-14T14:42:18.123Z",
|
|
63
|
+
"messageType": "sms",
|
|
64
|
+
"fromNumber": "+32470987654",
|
|
65
|
+
"toNumber": "+33639980000",
|
|
66
|
+
"content": "Yes, that works for me"
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Notes:
|
|
71
|
+
|
|
72
|
+
- Every delivery is HMAC-verified (`X-VoxiSMS-Signature` / `X-VoxiSMS-Timestamp`) before the workflow fires. An unsigned request is dropped quietly; a present-but-invalid signature is rejected and never fires the workflow.
|
|
73
|
+
- Deliveries are **at-least-once**: VoxiSMS may retry a delivery, and n8n does not de-duplicate incoming webhook payloads. Use `eventId` to de-duplicate downstream if your workflow needs exactly-once processing.
|
|
74
|
+
|
|
75
|
+
## Building from source
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm install # install dependencies
|
|
79
|
+
npm run build # compile TypeScript and copy node icons into dist/
|
|
80
|
+
npm test # run the unit tests
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If your n8n instance restricts outbound network access, allow `api.voxisms.com`.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoxiSmsApi = void 0;
|
|
4
|
+
// VoxiSMS credential definition.
|
|
5
|
+
//
|
|
6
|
+
// Auth is per-user HMAC (VoxiSMS v2): every request is signed with the user's secret
|
|
7
|
+
// token — there is NO shared api-key, and neither `customerId` nor `secretKey` is ever
|
|
8
|
+
// sent in the request body. The three signed headers (customer-id, timestamp, signature)
|
|
9
|
+
// are built per-request by `buildSignedHeaders` in `nodes/shared/signing.ts`.
|
|
10
|
+
//
|
|
11
|
+
// We deliberately do NOT declare a generic `authenticate` block here. n8n's generic auth
|
|
12
|
+
// can only inject static headers/query params; our signature depends on the HTTP method,
|
|
13
|
+
// the base-path-stripped route, a fresh timestamp, AND the exact request-body bytes. That
|
|
14
|
+
// is request-specific, so signing happens inside each node (the Send node and the
|
|
15
|
+
// connection test) where those values are known — never as a one-size-fits-all header.
|
|
16
|
+
class VoxiSmsApi {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.name = 'voxiSmsApi';
|
|
19
|
+
this.displayName = 'VoxiSMS API';
|
|
20
|
+
// Points at the VoxiPlan dashboard page where users find both fields below.
|
|
21
|
+
this.documentationUrl = 'https://app.voxiplan.com/voxisms';
|
|
22
|
+
this.properties = [
|
|
23
|
+
{
|
|
24
|
+
// The customer identifier is the registered phone number. We normalize away a
|
|
25
|
+
// pasted leading "+" / spaces at sign time (normalizeCustomerId), but ask users
|
|
26
|
+
// to enter it without the "+" to match what they see in the dashboard.
|
|
27
|
+
displayName: 'Customer ID',
|
|
28
|
+
name: 'customerId',
|
|
29
|
+
type: 'string',
|
|
30
|
+
default: '',
|
|
31
|
+
required: true,
|
|
32
|
+
placeholder: '33639980000',
|
|
33
|
+
description: 'Enter the phone number you registered, without the leading "+" (e.g. 33639980000). You can find it under "Register your number" on the "Link your phone" page (https://app.voxiplan.com/voxisms) in your VoxiPlan dashboard.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
// Labeled "Token" (not "Secret Key") to match the wording in the VoxiPlan
|
|
37
|
+
// dashboard and the VoxiSMS Android app. Masked in the UI via password option.
|
|
38
|
+
displayName: 'Token',
|
|
39
|
+
name: 'secretKey',
|
|
40
|
+
type: 'string',
|
|
41
|
+
typeOptions: { password: true },
|
|
42
|
+
default: '',
|
|
43
|
+
required: true,
|
|
44
|
+
description: 'Copy the token shown under "Activate your token" on the "Link your phone" page (https://app.voxiplan.com/voxisms) in your VoxiPlan dashboard — it\'s the same token you paste from the VoxiSMS Android app during setup.',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.VoxiSmsApi = VoxiSmsApi;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ICredentialTestFunctions, ICredentialsDecrypted, IExecuteFunctions, INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
2
|
+
export declare class VoxiSms implements INodeType {
|
|
3
|
+
description: INodeTypeDescription;
|
|
4
|
+
methods: {
|
|
5
|
+
credentialTest: {
|
|
6
|
+
voxiSmsApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise<INodeCredentialTestResult>;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoxiSms = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
6
|
+
const signing_1 = require("../shared/signing");
|
|
7
|
+
// Full URLs (the `/v2` base path lives here, in the URL). The SIGNED path is the
|
|
8
|
+
// base-path-stripped route constant (ENQUEUE_PATH / STATUS_PATH) — API Gateway strips
|
|
9
|
+
// `/v2` before the Lambda signs, so the URL path and the signed path differ on purpose.
|
|
10
|
+
const ENQUEUE_URL = 'https://api.voxisms.com/v2/enqueue-message';
|
|
11
|
+
const STATUS_URL = 'https://api.voxisms.com/v2/user/status';
|
|
12
|
+
// Default delivery-status callback baked into every send (matches the Zapier action).
|
|
13
|
+
const WEBHOOK_URL = 'https://app.voxiplan.com/webhooks/sms/events';
|
|
14
|
+
// Best-effort JSON parse of an HTTP response body. n8n's httpRequest usually parses a
|
|
15
|
+
// JSON response into an object already, but a string can come back (e.g. non-JSON
|
|
16
|
+
// content-type); handle both so the caller always gets a plain object.
|
|
17
|
+
function toObject(body) {
|
|
18
|
+
if (body && typeof body === 'object') {
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
if (typeof body === 'string') {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(body);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { response: body };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
// Extract the server's `{ error }` message from a v2 error body, if present.
|
|
32
|
+
function serverErrorMessage(body) {
|
|
33
|
+
const parsed = toObject(body);
|
|
34
|
+
return typeof parsed.error === 'string' ? parsed.error : undefined;
|
|
35
|
+
}
|
|
36
|
+
class VoxiSms {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.description = {
|
|
39
|
+
displayName: 'VoxiSMS',
|
|
40
|
+
name: 'voxiSms',
|
|
41
|
+
icon: 'file:voxisms.svg',
|
|
42
|
+
group: ['output'],
|
|
43
|
+
version: 1,
|
|
44
|
+
description: 'Send an SMS via VoxiSMS',
|
|
45
|
+
defaults: {
|
|
46
|
+
name: 'VoxiSMS',
|
|
47
|
+
},
|
|
48
|
+
inputs: ['main'],
|
|
49
|
+
outputs: ['main'],
|
|
50
|
+
credentials: [
|
|
51
|
+
{
|
|
52
|
+
name: 'voxiSmsApi',
|
|
53
|
+
required: true,
|
|
54
|
+
// Wires the "Test" button in the credential modal to voxiSmsApiTest below.
|
|
55
|
+
testedBy: 'voxiSmsApiTest',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
// Flat, single-action node (like the Zapier "Send SMS" action) — no
|
|
59
|
+
// resource/operation dropdowns for a package that does exactly one thing.
|
|
60
|
+
properties: [
|
|
61
|
+
{
|
|
62
|
+
displayName: 'Recipient',
|
|
63
|
+
name: 'recipient',
|
|
64
|
+
type: 'string',
|
|
65
|
+
default: '',
|
|
66
|
+
required: true,
|
|
67
|
+
placeholder: '+15551234567',
|
|
68
|
+
description: 'Phone number in E.164 format (e.g. +15551234567)',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
displayName: 'Message',
|
|
72
|
+
name: 'message',
|
|
73
|
+
type: 'string',
|
|
74
|
+
typeOptions: { rows: 4 },
|
|
75
|
+
default: '',
|
|
76
|
+
required: true,
|
|
77
|
+
description: 'The SMS message content',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
displayName: 'Message ID',
|
|
81
|
+
name: 'id',
|
|
82
|
+
type: 'string',
|
|
83
|
+
default: '',
|
|
84
|
+
description: 'Optional custom message ID. A UUID will be generated automatically if left empty.',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
this.methods = {
|
|
89
|
+
credentialTest: {
|
|
90
|
+
// Side-effect-free credential check: a signed GET to the read-only status
|
|
91
|
+
// endpoint (does NOT send an SMS). Success is HTTP 200; the endpoint returns
|
|
92
|
+
// distinct failure codes we map to the same friendly messages as the Zapier
|
|
93
|
+
// integration's authentication test.
|
|
94
|
+
async voxiSmsApiTest(credential) {
|
|
95
|
+
var _a, _b, _c;
|
|
96
|
+
const data = ((_a = credential.data) !== null && _a !== void 0 ? _a : {});
|
|
97
|
+
// Normalize ONCE and reuse for both the signed canonical and the customer-id
|
|
98
|
+
// header (buildSignedHeaders uses this same value for both, so they can't
|
|
99
|
+
// disagree).
|
|
100
|
+
const customerId = (0, signing_1.normalizeCustomerId)((_b = data.customerId) !== null && _b !== void 0 ? _b : '');
|
|
101
|
+
const secretKey = (_c = data.secretKey) !== null && _c !== void 0 ? _c : '';
|
|
102
|
+
// Body-less GET: bodyString defaults to '' inside buildSignedHeaders, so the
|
|
103
|
+
// canonical ends in a trailing '\n' + empty segment, matching the server's
|
|
104
|
+
// `body: b""`.
|
|
105
|
+
const headers = (0, signing_1.buildSignedHeaders)({
|
|
106
|
+
method: 'GET',
|
|
107
|
+
path: signing_1.STATUS_PATH,
|
|
108
|
+
customerId,
|
|
109
|
+
secretKey,
|
|
110
|
+
});
|
|
111
|
+
let statusCode;
|
|
112
|
+
try {
|
|
113
|
+
// Legacy request helper (the only one exposed to credential tests). We
|
|
114
|
+
// inspect the status ourselves — resolveWithFullResponse gives us the
|
|
115
|
+
// status, simple:false stops it throwing on non-2xx.
|
|
116
|
+
const response = await this.helpers.request({
|
|
117
|
+
method: 'GET',
|
|
118
|
+
uri: STATUS_URL,
|
|
119
|
+
headers,
|
|
120
|
+
resolveWithFullResponse: true,
|
|
121
|
+
simple: false,
|
|
122
|
+
});
|
|
123
|
+
statusCode = response.statusCode;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// A transport-level failure (DNS, TLS, timeout) — not an HTTP status.
|
|
127
|
+
return {
|
|
128
|
+
status: 'Error',
|
|
129
|
+
message: `Could not reach VoxiSMS: ${error.message}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (statusCode === 200) {
|
|
133
|
+
return { status: 'OK', message: 'Connection successful!' };
|
|
134
|
+
}
|
|
135
|
+
if (statusCode === 403) {
|
|
136
|
+
return {
|
|
137
|
+
status: 'Error',
|
|
138
|
+
message: 'Authentication failed. Check your Token, and make sure your system clock is accurate (requests must be within 5 minutes of server time).',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (statusCode === 404) {
|
|
142
|
+
return {
|
|
143
|
+
status: 'Error',
|
|
144
|
+
message: 'Customer ID not found. Check the phone number you registered.',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (statusCode === 400) {
|
|
148
|
+
return {
|
|
149
|
+
status: 'Error',
|
|
150
|
+
message: 'This account is not fully set up yet. Contact VoxiSMS support.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
status: 'Error',
|
|
155
|
+
message: 'Could not verify credentials. Check your Customer ID and Token.',
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
async execute() {
|
|
162
|
+
var _a, _b;
|
|
163
|
+
const items = this.getInputData();
|
|
164
|
+
const returnData = [];
|
|
165
|
+
// Credentials are the same for every item; fetch and normalize ONCE.
|
|
166
|
+
const credentials = (await this.getCredentials('voxiSmsApi'));
|
|
167
|
+
const customerId = (0, signing_1.normalizeCustomerId)((_a = credentials.customerId) !== null && _a !== void 0 ? _a : '');
|
|
168
|
+
const secretKey = (_b = credentials.secretKey) !== null && _b !== void 0 ? _b : '';
|
|
169
|
+
for (let i = 0; i < items.length; i++) {
|
|
170
|
+
try {
|
|
171
|
+
const recipient = this.getNodeParameter('recipient', i);
|
|
172
|
+
const message = this.getNodeParameter('message', i);
|
|
173
|
+
const id = this.getNodeParameter('id', i, '');
|
|
174
|
+
// CRITICAL INVARIANT — sign-once / send-same-bytes:
|
|
175
|
+
// Serialize the body EXACTLY ONCE with JSON.stringify, sign THAT string, then
|
|
176
|
+
// send THAT string verbatim on the wire. Re-serializing (or letting the HTTP
|
|
177
|
+
// client re-encode an object) could reorder keys or change spacing and break
|
|
178
|
+
// the signature. That's why `body` below is the string, NOT an object, and we
|
|
179
|
+
// set Content-Type ourselves (json:true is intentionally NOT used).
|
|
180
|
+
const bodyString = JSON.stringify({
|
|
181
|
+
id: id || (0, crypto_1.randomUUID)(),
|
|
182
|
+
recipient,
|
|
183
|
+
message,
|
|
184
|
+
webhookUrl: WEBHOOK_URL,
|
|
185
|
+
source: 'n8n',
|
|
186
|
+
});
|
|
187
|
+
const headers = {
|
|
188
|
+
...(0, signing_1.buildSignedHeaders)({
|
|
189
|
+
method: 'POST',
|
|
190
|
+
path: signing_1.ENQUEUE_PATH,
|
|
191
|
+
customerId,
|
|
192
|
+
secretKey,
|
|
193
|
+
bodyString,
|
|
194
|
+
}),
|
|
195
|
+
'Content-Type': 'application/json',
|
|
196
|
+
};
|
|
197
|
+
// returnFullResponse: read the status ourselves for friendly errors.
|
|
198
|
+
// ignoreHttpStatusErrors: don't throw on non-2xx — we map them below.
|
|
199
|
+
// body is the already-serialized string; httpRequest sends it verbatim.
|
|
200
|
+
const response = await this.helpers.httpRequest({
|
|
201
|
+
method: 'POST',
|
|
202
|
+
url: ENQUEUE_URL,
|
|
203
|
+
headers,
|
|
204
|
+
body: bodyString,
|
|
205
|
+
returnFullResponse: true,
|
|
206
|
+
ignoreHttpStatusErrors: true,
|
|
207
|
+
});
|
|
208
|
+
const statusCode = response.statusCode;
|
|
209
|
+
const body = response.body;
|
|
210
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
211
|
+
// Success body: { messageId, sqsMessageId }.
|
|
212
|
+
returnData.push({ json: toObject(body), pairedItem: { item: i } });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Map the v2 API's real HTTP error statuses to friendly messages, mirroring
|
|
216
|
+
// the Zapier middleware. The v2 error body shape is { error }.
|
|
217
|
+
const serverError = serverErrorMessage(body);
|
|
218
|
+
let friendly;
|
|
219
|
+
if (statusCode === 401) {
|
|
220
|
+
friendly =
|
|
221
|
+
'Authentication failed. Check your Customer ID and Token, and make sure your system clock is accurate (within 5 minutes of server time).';
|
|
222
|
+
}
|
|
223
|
+
else if (statusCode === 400) {
|
|
224
|
+
friendly = serverError || 'Bad request';
|
|
225
|
+
}
|
|
226
|
+
else if (statusCode === 405) {
|
|
227
|
+
friendly = 'Method not allowed';
|
|
228
|
+
}
|
|
229
|
+
else if (statusCode >= 500) {
|
|
230
|
+
friendly = serverError || 'Internal server error';
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
friendly = serverError || `Request failed with status code ${statusCode}`;
|
|
234
|
+
}
|
|
235
|
+
throw new n8n_workflow_1.NodeApiError(this.getNode(), toObject(body), {
|
|
236
|
+
message: friendly,
|
|
237
|
+
httpCode: String(statusCode),
|
|
238
|
+
itemIndex: i,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
// Honor "Continue On Fail": emit the error on this item and keep going.
|
|
243
|
+
if (this.continueOnFail()) {
|
|
244
|
+
returnData.push({
|
|
245
|
+
json: { error: error.message },
|
|
246
|
+
pairedItem: { item: i },
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [returnData];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
exports.VoxiSms = VoxiSms;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
|
|
2
|
+
<path d="M12 6h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H26L14 54V44h-2a8 8 0 0 1-8-8V14a8 8 0 0 1 8-8Z" fill="#4F46E5"/>
|
|
3
|
+
<circle cx="20" cy="25" r="3.2" fill="#FFFFFF"/>
|
|
4
|
+
<circle cx="30" cy="25" r="3.2" fill="#FFFFFF"/>
|
|
5
|
+
<circle cx="40" cy="25" r="3.2" fill="#FFFFFF"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type IHookFunctions, type INodeType, type INodeTypeDescription, type IWebhookFunctions, type IWebhookResponseData } from 'n8n-workflow';
|
|
2
|
+
export declare class VoxiSmsTrigger implements INodeType {
|
|
3
|
+
description: INodeTypeDescription;
|
|
4
|
+
webhookMethods: {
|
|
5
|
+
default: {
|
|
6
|
+
checkExists(this: IHookFunctions): Promise<boolean>;
|
|
7
|
+
create(this: IHookFunctions): Promise<boolean>;
|
|
8
|
+
delete(this: IHookFunctions): Promise<boolean>;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
webhook(this: IWebhookFunctions): Promise<IWebhookResponseData>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.VoxiSmsTrigger = void 0;
|
|
4
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
|
+
const signing_1 = require("../shared/signing");
|
|
6
|
+
const verifySignature_1 = require("../shared/verifySignature");
|
|
7
|
+
// Full v2 URLs. The signed canonical uses the base-path-stripped path (SUBSCRIPTIONS_PATH,
|
|
8
|
+
// no `/v2`) because API Gateway strips the `/v2` prefix before the Lambda sees it — the
|
|
9
|
+
// wire URL and the signed path deliberately differ.
|
|
10
|
+
const SUBSCRIPTIONS_URL = 'https://api.voxisms.com/v2/webhook-subscriptions';
|
|
11
|
+
// The single event type this trigger subscribes to (see openapi SubscribeRequest).
|
|
12
|
+
const EVENT_TYPE = 'inbound_message.received';
|
|
13
|
+
// Reads a header case-INsensitively. Express lowercases incoming header names, but we do not
|
|
14
|
+
// want to depend on that: read `x-voxisms-signature` / `x-voxisms-timestamp` defensively so a
|
|
15
|
+
// differently-cased proxy in front of n8n cannot make every delivery look unsigned.
|
|
16
|
+
function getHeaderCI(headers, name) {
|
|
17
|
+
const wanted = name.toLowerCase();
|
|
18
|
+
for (const key of Object.keys(headers)) {
|
|
19
|
+
if (key.toLowerCase() === wanted) {
|
|
20
|
+
return headers[key];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
class VoxiSmsTrigger {
|
|
26
|
+
constructor() {
|
|
27
|
+
this.description = {
|
|
28
|
+
displayName: 'VoxiSMS Trigger',
|
|
29
|
+
name: 'voxiSmsTrigger',
|
|
30
|
+
icon: 'file:voxisms.svg',
|
|
31
|
+
group: ['trigger'],
|
|
32
|
+
version: 1,
|
|
33
|
+
description: 'Starts the workflow when your VoxiSMS number receives an inbound SMS.',
|
|
34
|
+
defaults: {
|
|
35
|
+
name: 'VoxiSMS Trigger',
|
|
36
|
+
},
|
|
37
|
+
// No user-configurable parameters: the subscription is driven entirely by the
|
|
38
|
+
// credential and the auto-generated webhook URL. Required by INodeTypeDescription.
|
|
39
|
+
properties: [],
|
|
40
|
+
// A trigger has no data inputs; it is fed by an incoming webhook, not an upstream node.
|
|
41
|
+
inputs: [],
|
|
42
|
+
outputs: ['main'],
|
|
43
|
+
credentials: [
|
|
44
|
+
{
|
|
45
|
+
// Credential type owned by the parallel Send-SMS agent; referenced here by name.
|
|
46
|
+
name: 'voxiSmsApi',
|
|
47
|
+
required: true,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
webhooks: [
|
|
51
|
+
{
|
|
52
|
+
name: 'default',
|
|
53
|
+
httpMethod: 'POST',
|
|
54
|
+
// We ACK the delivery ourselves from inside webhook() so we can control the HTTP
|
|
55
|
+
// status (200 vs 401) per the signature outcome — see the response policy there.
|
|
56
|
+
responseMode: 'onReceived',
|
|
57
|
+
path: 'webhook',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
// Lifecycle hooks for the subscription REST-hook, run by n8n when the workflow is
|
|
62
|
+
// activated/deactivated. `this` is IHookFunctions here.
|
|
63
|
+
this.webhookMethods = {
|
|
64
|
+
default: {
|
|
65
|
+
// checkExists — n8n asks whether a live subscription already exists so it can skip a
|
|
66
|
+
// redundant create. We treat the presence of a persisted subscriptionId as truth.
|
|
67
|
+
async checkExists() {
|
|
68
|
+
const staticData = this.getWorkflowStaticData('node');
|
|
69
|
+
return staticData.subscriptionId !== undefined && staticData.subscriptionId !== null;
|
|
70
|
+
},
|
|
71
|
+
// create — called when the workflow is activated. Registers our webhook URL with the
|
|
72
|
+
// VoxiSMS API so future inbound SMS are POSTed to it, and stashes both the returned
|
|
73
|
+
// subscriptionId (to unsubscribe later) and signingSecret (to verify each delivery).
|
|
74
|
+
async create() {
|
|
75
|
+
var _a;
|
|
76
|
+
const webhookUrl = this.getNodeWebhookUrl('default');
|
|
77
|
+
if (!webhookUrl) {
|
|
78
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Could not determine the webhook URL for this trigger.');
|
|
79
|
+
}
|
|
80
|
+
const credentials = await this.getCredentials('voxiSmsApi');
|
|
81
|
+
// Normalize ONCE and reuse the same value for both the signed canonical and the
|
|
82
|
+
// `customer-id` header, so they cannot disagree.
|
|
83
|
+
const customerId = (0, signing_1.normalizeCustomerId)(credentials.customerId);
|
|
84
|
+
const secretKey = credentials.secretKey;
|
|
85
|
+
// CRITICAL INVARIANT: serialize the body ONCE, then sign and send that exact
|
|
86
|
+
// string. Never let the HTTP helper re-serialize a parsed object — a re-serialize
|
|
87
|
+
// can reorder keys or change whitespace and break the signature the server checks.
|
|
88
|
+
const bodyString = JSON.stringify({
|
|
89
|
+
targetUrl: webhookUrl,
|
|
90
|
+
eventTypes: [EVENT_TYPE],
|
|
91
|
+
// The API's SubscribeRequest.provider enum is currently only
|
|
92
|
+
// ['zapier','make','generic'] — 'n8n' is rejected with a 400 "unknown provider".
|
|
93
|
+
// Use 'generic' until the backend adds an 'n8n' value (then flip this).
|
|
94
|
+
provider: 'generic',
|
|
95
|
+
});
|
|
96
|
+
const headers = {
|
|
97
|
+
...(0, signing_1.buildSignedHeaders)({
|
|
98
|
+
method: 'POST',
|
|
99
|
+
path: signing_1.SUBSCRIPTIONS_PATH,
|
|
100
|
+
customerId,
|
|
101
|
+
secretKey,
|
|
102
|
+
bodyString,
|
|
103
|
+
}),
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
};
|
|
106
|
+
const options = {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
url: SUBSCRIPTIONS_URL,
|
|
109
|
+
headers,
|
|
110
|
+
// Pass the pre-serialized string as the body and DO NOT set `json: true`; that
|
|
111
|
+
// keeps the exact signed bytes on the wire (see the invariant above).
|
|
112
|
+
body: bodyString,
|
|
113
|
+
json: false,
|
|
114
|
+
// Inspect the status ourselves so a non-2xx becomes a friendly, mapped error
|
|
115
|
+
// instead of the raw axios-style throw. Mirrors the sibling integrations'
|
|
116
|
+
// error mapping (Zapier middleware.js, Make attach.response.error).
|
|
117
|
+
returnFullResponse: true,
|
|
118
|
+
ignoreHttpStatusErrors: true,
|
|
119
|
+
};
|
|
120
|
+
const response = (await this.helpers.httpRequest(options));
|
|
121
|
+
// The API returns a JSON string when json:false; the helper may still parse it, so
|
|
122
|
+
// tolerate both an already-parsed object and a raw string.
|
|
123
|
+
const parseBody = (raw) => {
|
|
124
|
+
var _a;
|
|
125
|
+
if (typeof raw === 'string') {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return (_a = raw) !== null && _a !== void 0 ? _a : {};
|
|
134
|
+
};
|
|
135
|
+
const data = parseBody(response.body);
|
|
136
|
+
// Non-2xx: map to the same friendly messages the sibling integrations surface, so
|
|
137
|
+
// the user sees an actionable message rather than a raw HTTP error when they turn
|
|
138
|
+
// the workflow on. The v2 error body shape is `{ error }`.
|
|
139
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
140
|
+
const serverError = typeof data.error === 'string' ? data.error : undefined;
|
|
141
|
+
let message;
|
|
142
|
+
if (response.statusCode === 403 || response.statusCode === 401) {
|
|
143
|
+
message =
|
|
144
|
+
'Authentication failed. Check your Token and make sure your system clock is accurate (requests must be within 5 minutes of server time).';
|
|
145
|
+
}
|
|
146
|
+
else if (response.statusCode === 404) {
|
|
147
|
+
message = 'Customer ID not found. Check the phone number you registered.';
|
|
148
|
+
}
|
|
149
|
+
else if (response.statusCode === 400) {
|
|
150
|
+
// Per the API spec, a subscribe 400 means: invalid JSON body, a non-HTTPS or
|
|
151
|
+
// private/internal targetUrl, or an unknown eventTypes/provider value. By far
|
|
152
|
+
// the most common cause in practice is a webhook URL VoxiSMS cannot accept —
|
|
153
|
+
// e.g. a local n8n without a public HTTPS URL — so say that explicitly.
|
|
154
|
+
message = `VoxiSMS rejected the webhook subscription${serverError ? ` (${serverError})` : ''}. The webhook URL was "${webhookUrl}" — it must be public HTTPS (not localhost/private). For local development start n8n with --tunnel or set WEBHOOK_URL to a public HTTPS tunnel.`;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
message = `[${response.statusCode}] ${serverError !== null && serverError !== void 0 ? serverError : 'VoxiSMS request failed'}`;
|
|
158
|
+
}
|
|
159
|
+
throw new n8n_workflow_1.NodeApiError(this.getNode(), (_a = data) !== null && _a !== void 0 ? _a : {}, { message, httpCode: String(response.statusCode) });
|
|
160
|
+
}
|
|
161
|
+
if (!data.subscriptionId || !data.signingSecret) {
|
|
162
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'VoxiSMS did not return a subscriptionId and signingSecret when creating the webhook subscription.');
|
|
163
|
+
}
|
|
164
|
+
const staticData = this.getWorkflowStaticData('node');
|
|
165
|
+
staticData.subscriptionId = data.subscriptionId;
|
|
166
|
+
// The signingSecret is what verifies every future delivery. Without it we cannot
|
|
167
|
+
// authenticate deliveries, so it MUST be persisted alongside the subscriptionId.
|
|
168
|
+
staticData.signingSecret = data.signingSecret;
|
|
169
|
+
return true;
|
|
170
|
+
},
|
|
171
|
+
// delete — called when the workflow is deactivated. Removes the subscription so the
|
|
172
|
+
// API stops POSTing to a webhook URL that is no longer live, then clears static data.
|
|
173
|
+
async delete() {
|
|
174
|
+
const staticData = this.getWorkflowStaticData('node');
|
|
175
|
+
const subscriptionId = staticData.subscriptionId;
|
|
176
|
+
// Nothing registered (or already cleared) — treat as success, nothing to undo.
|
|
177
|
+
if (!subscriptionId) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
const credentials = await this.getCredentials('voxiSmsApi');
|
|
181
|
+
const customerId = (0, signing_1.normalizeCustomerId)(credentials.customerId);
|
|
182
|
+
const secretKey = credentials.secretKey;
|
|
183
|
+
// The DELETE route is dynamic and the id is part of the signed path
|
|
184
|
+
// (base-path-stripped, no `/v2`). Body is empty, so the canonical ends in a
|
|
185
|
+
// trailing '\n' (buildSignedHeaders default bodyString = '').
|
|
186
|
+
const path = `${signing_1.SUBSCRIPTIONS_PATH}/${subscriptionId}`;
|
|
187
|
+
const headers = (0, signing_1.buildSignedHeaders)({
|
|
188
|
+
method: 'DELETE',
|
|
189
|
+
path,
|
|
190
|
+
customerId,
|
|
191
|
+
secretKey,
|
|
192
|
+
});
|
|
193
|
+
const options = {
|
|
194
|
+
method: 'DELETE',
|
|
195
|
+
url: `${SUBSCRIPTIONS_URL}/${subscriptionId}`,
|
|
196
|
+
headers,
|
|
197
|
+
// A 404 means the subscription is already gone server-side — that is the desired
|
|
198
|
+
// end state, so do not throw on it; ignore HTTP status errors and treat as done.
|
|
199
|
+
ignoreHttpStatusErrors: true,
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
await this.helpers.httpRequest(options);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Network/other failure while unsubscribing: swallow it so deactivation still
|
|
206
|
+
// clears local state. A stale server-side subscription will simply deliver to a
|
|
207
|
+
// URL that now rejects, which is harmless (and re-activation re-creates one).
|
|
208
|
+
}
|
|
209
|
+
// Always clear both keys so a later checkExists reports "no subscription" and a
|
|
210
|
+
// re-activation creates a fresh one with a fresh signing secret.
|
|
211
|
+
delete staticData.subscriptionId;
|
|
212
|
+
delete staticData.signingSecret;
|
|
213
|
+
return true;
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// webhook — runs for every delivery the API POSTs to our URL. n8n has NOT yet responded to
|
|
219
|
+
// the HTTP request, so this method both decides the HTTP status AND whether to fire the
|
|
220
|
+
// workflow. We mirror the Make webhook's response policy exactly (see communication.json):
|
|
221
|
+
// - signing secret missing → surface loudly (config broken; user must recreate trigger)
|
|
222
|
+
// - reason 'missing' → 200 {ok:false}, DO NOT fire (unsigned noise, drop quietly)
|
|
223
|
+
// - reason invalid|skew|unverifiable → 401, DO NOT fire (present but bad → surface)
|
|
224
|
+
// - ok → 200 {ok:true}, fire with the parsed event JSON (one item)
|
|
225
|
+
async webhook() {
|
|
226
|
+
const req = this.getRequestObject();
|
|
227
|
+
const res = this.getResponseObject();
|
|
228
|
+
// The signature is computed over the EXACT raw request bytes. n8n populates
|
|
229
|
+
// `req.rawBody` (a Buffer) for webhook requests via its raw-body reader middleware;
|
|
230
|
+
// the property is declared on http.IncomingMessage by n8n-workflow's type augmentation.
|
|
231
|
+
// We deliberately do NOT fall back to re-serializing the parsed body (getBodyData()) —
|
|
232
|
+
// a re-serialize reorders keys / changes whitespace and would break the digest by
|
|
233
|
+
// design. If the raw bytes are somehow absent, verifySignature returns 'unverifiable'
|
|
234
|
+
// (a present signature we cannot check) and we surface a 401 rather than trust it.
|
|
235
|
+
const rawBuffer = req.rawBody;
|
|
236
|
+
const rawBody = Buffer.isBuffer(rawBuffer) ? rawBuffer.toString('utf8') : undefined;
|
|
237
|
+
const headers = req.headers;
|
|
238
|
+
const signatureHeader = getHeaderCI(headers, 'x-voxisms-signature');
|
|
239
|
+
const timestampHeader = getHeaderCI(headers, 'x-voxisms-timestamp');
|
|
240
|
+
// Config-drift guard: without a signing secret we can verify nothing, and
|
|
241
|
+
// createHmac('sha256', undefined) would throw a bare TypeError. This means the
|
|
242
|
+
// subscription was created without persisting the secret (or static data was wiped) —
|
|
243
|
+
// surface it loudly so the user recreates the trigger. Do not ACK, do not fire.
|
|
244
|
+
const staticData = this.getWorkflowStaticData('node');
|
|
245
|
+
const signingSecret = staticData.signingSecret;
|
|
246
|
+
if (!signingSecret) {
|
|
247
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), "Cannot verify the inbound SMS delivery: the subscription's signing secret is missing. Please deactivate and reactivate this trigger to recreate the subscription.");
|
|
248
|
+
}
|
|
249
|
+
const result = (0, verifySignature_1.verifySignature)({
|
|
250
|
+
signingSecret,
|
|
251
|
+
rawBody,
|
|
252
|
+
signatureHeader,
|
|
253
|
+
timestampHeader,
|
|
254
|
+
});
|
|
255
|
+
if (!result.ok) {
|
|
256
|
+
if (result.reason === 'missing') {
|
|
257
|
+
// No usable signature header: unsigned noise (health checks, scanners, a stray
|
|
258
|
+
// POST). ACK with 200 {ok:false} so the sender does not retry, but DO NOT fire —
|
|
259
|
+
// returning noWebhookResponse hands the HTTP response to us, and no workflowData
|
|
260
|
+
// means no execution.
|
|
261
|
+
res.status(200).json({ ok: false });
|
|
262
|
+
return { noWebhookResponse: true };
|
|
263
|
+
}
|
|
264
|
+
// Present but invalid / skewed / unverifiable: almost always secret/config drift or a
|
|
265
|
+
// replay attempt. Reject with 401 and DO NOT fire — surfaced via the HTTP status so
|
|
266
|
+
// the caller (and VoxiSMS delivery logs) can see the rejection.
|
|
267
|
+
res.status(401).json({ ok: false, reason: result.reason });
|
|
268
|
+
return { noWebhookResponse: true };
|
|
269
|
+
}
|
|
270
|
+
// Verified. Parse the (now trusted) raw bytes into the InboundMessageEvent object. We
|
|
271
|
+
// parse rawBody ourselves rather than getBodyData() so the emitted item is exactly the
|
|
272
|
+
// bytes we authenticated — no chance of a divergent second parse.
|
|
273
|
+
//
|
|
274
|
+
// Can't-happen-unless-server-bug guard: the signature already verified, so the sender is
|
|
275
|
+
// authentic and the bytes are what VoxiSMS signed — a malformed JSON body here would mean
|
|
276
|
+
// a server-side bug. If we let JSON.parse throw, it would escape webhook(), n8n would 500
|
|
277
|
+
// the delivery, and VoxiSMS would retry the same poison delivery indefinitely. Instead we
|
|
278
|
+
// ACK with 400 {ok:false} (so the sender stops retrying) and DO NOT fire the workflow.
|
|
279
|
+
let event;
|
|
280
|
+
try {
|
|
281
|
+
event = JSON.parse(rawBody);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
res.status(400).json({ ok: false, reason: 'malformed' });
|
|
285
|
+
return { noWebhookResponse: true };
|
|
286
|
+
}
|
|
287
|
+
// NOTE: webhook deliveries are AT-LEAST-ONCE and n8n does NOT de-duplicate them. A
|
|
288
|
+
// dispatcher retry carrying the same `eventId` WILL fire this workflow again. We expose
|
|
289
|
+
// `eventId` on the emitted item so downstream steps (or the user) can de-duplicate on it
|
|
290
|
+
// if they need exactly-once semantics.
|
|
291
|
+
res.status(200).json({ ok: true });
|
|
292
|
+
return {
|
|
293
|
+
noWebhookResponse: true,
|
|
294
|
+
workflowData: [this.helpers.returnJsonArray([event])],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
exports.VoxiSmsTrigger = VoxiSmsTrigger;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
|
|
2
|
+
<rect x="6" y="10" width="48" height="34" rx="10" fill="#4F46E5"/>
|
|
3
|
+
<path d="M18 44l-2 8 10-6z" fill="#4F46E5"/>
|
|
4
|
+
<path d="M36 20l-9 9m0 0h6m-6 0v-6" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const ENQUEUE_PATH = "/enqueue-message";
|
|
2
|
+
export declare const STATUS_PATH = "/user/status";
|
|
3
|
+
export declare const SUBSCRIPTIONS_PATH = "/webhook-subscriptions";
|
|
4
|
+
export interface SignedHeadersParams {
|
|
5
|
+
method: string;
|
|
6
|
+
path: string;
|
|
7
|
+
customerId: string;
|
|
8
|
+
secretKey: string;
|
|
9
|
+
bodyString?: string;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const buildSignedHeaders: ({ method, path, customerId, secretKey, bodyString, timestamp, }: SignedHeadersParams) => Record<string, string>;
|
|
13
|
+
export declare const normalizeCustomerId: (customerId: string) => string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeCustomerId = exports.buildSignedHeaders = exports.SUBSCRIPTIONS_PATH = exports.STATUS_PATH = exports.ENQUEUE_PATH = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
// Base-path-stripped route constants. API Gateway strips the `/v2` prefix, so these
|
|
6
|
+
// are the exact paths the Lambda signs — NOT the full URL paths.
|
|
7
|
+
exports.ENQUEUE_PATH = '/enqueue-message';
|
|
8
|
+
exports.STATUS_PATH = '/user/status';
|
|
9
|
+
// Webhook-subscription collection path. The DELETE route is dynamic
|
|
10
|
+
// (`/webhook-subscriptions/{subscriptionId}`); build it inline from this constant.
|
|
11
|
+
exports.SUBSCRIPTIONS_PATH = '/webhook-subscriptions';
|
|
12
|
+
// Builds the VoxiSMS HMAC auth headers for a request.
|
|
13
|
+
//
|
|
14
|
+
// The canonical string signed by the server is:
|
|
15
|
+
// METHOD \n PATH \n TIMESTAMP \n CUSTOMER_ID \n BODY
|
|
16
|
+
// keyed by the user's secretKey (raw UTF-8), HMAC-SHA256, lowercase hex.
|
|
17
|
+
//
|
|
18
|
+
// `bodyString` MUST be the exact bytes sent on the wire. For a body-less GET, leave it
|
|
19
|
+
// empty ('') — the trailing '\n' + empty segment matches the server's `body: b""`.
|
|
20
|
+
// `timestamp` is injectable for deterministic tests; it must be reused verbatim in both
|
|
21
|
+
// the signed canonical and the `timestamp` header.
|
|
22
|
+
//
|
|
23
|
+
// Content-Type is intentionally NOT returned here (it is not part of the signature); the
|
|
24
|
+
// caller adds it when sending a request body.
|
|
25
|
+
const buildSignedHeaders = ({ method, path, customerId, secretKey, bodyString = '', timestamp = Math.floor(Date.now() / 1000).toString(), }) => {
|
|
26
|
+
const canonical = [method, path, timestamp, customerId, bodyString].join('\n');
|
|
27
|
+
const signature = (0, crypto_1.createHmac)('sha256', secretKey)
|
|
28
|
+
.update(canonical, 'utf8')
|
|
29
|
+
.digest('hex');
|
|
30
|
+
return {
|
|
31
|
+
'customer-id': customerId,
|
|
32
|
+
timestamp,
|
|
33
|
+
signature,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
exports.buildSignedHeaders = buildSignedHeaders;
|
|
37
|
+
// Customer ID is the registered phone number; tolerate a pasted "+" or spaces.
|
|
38
|
+
// Normalize ONCE and reuse the same value for both the signed canonical and the
|
|
39
|
+
// `customer-id` header, so they cannot disagree.
|
|
40
|
+
const normalizeCustomerId = (customerId) => customerId.replace(/[\s+]/g, '');
|
|
41
|
+
exports.normalizeCustomerId = normalizeCustomerId;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
declare const MAX_SKEW_SECONDS = 300;
|
|
2
|
+
export type VerifyResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
reason: 'missing' | 'unverifiable' | 'invalid' | 'skew';
|
|
7
|
+
};
|
|
8
|
+
export interface VerifySignatureParams {
|
|
9
|
+
signingSecret: string;
|
|
10
|
+
rawBody: unknown;
|
|
11
|
+
signatureHeader: unknown;
|
|
12
|
+
timestampHeader: unknown;
|
|
13
|
+
now?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const verifySignature: ({ signingSecret, rawBody, signatureHeader, timestampHeader, now, }: VerifySignatureParams) => VerifyResult;
|
|
16
|
+
export { MAX_SKEW_SECONDS };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SKEW_SECONDS = exports.verifySignature = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
// Max allowed clock skew (seconds) between the delivery's signed timestamp and our own
|
|
6
|
+
// clock. Matches the server's replay window documented in the callback spec (±300s).
|
|
7
|
+
const MAX_SKEW_SECONDS = 300;
|
|
8
|
+
exports.MAX_SKEW_SECONDS = MAX_SKEW_SECONDS;
|
|
9
|
+
// Pure, n8n-free verifier for an inbound event delivery — mirrors how `signing.ts` keeps
|
|
10
|
+
// the crypto separate from the node plumbing so it can be unit-tested in isolation.
|
|
11
|
+
//
|
|
12
|
+
// Per the `inboundMessageReceived` callback contract, each delivery is signed as:
|
|
13
|
+
// expected = HMAC_SHA256(signingSecret, `${timestamp}.${rawBody}`) (lowercase hex)
|
|
14
|
+
// and delivered in the `X-VoxiSMS-Signature` header with a `sha256=` prefix, alongside the
|
|
15
|
+
// signed `X-VoxiSMS-Timestamp` (Unix seconds). `rawBody` MUST be the exact raw request
|
|
16
|
+
// bytes — never a re-serialized parse — or key reordering/whitespace breaks the digest.
|
|
17
|
+
//
|
|
18
|
+
// Returns a plain result object rather than throwing, so the caller decides how each
|
|
19
|
+
// outcome maps onto the node's throw-vs-drop policy:
|
|
20
|
+
// { ok: true } — signature valid and timestamp fresh
|
|
21
|
+
// { ok: false, reason: 'missing' } — no (usable) signature header / no timestamp
|
|
22
|
+
// (unsigned noise → caller drops)
|
|
23
|
+
// { ok: false, reason: 'unverifiable' } — signature present but there are no raw bytes
|
|
24
|
+
// to check it against (→ caller surfaces)
|
|
25
|
+
// { ok: false, reason: 'invalid' } — signature present but does not match
|
|
26
|
+
// (secret/config drift → caller surfaces)
|
|
27
|
+
// { ok: false, reason: 'skew' } — signature valid but timestamp out of window
|
|
28
|
+
// (replay / clock drift → caller surfaces)
|
|
29
|
+
const verifySignature = ({ signingSecret, rawBody, signatureHeader, timestampHeader, now = Math.floor(Date.now() / 1000), }) => {
|
|
30
|
+
// A non-string header (e.g. a multi-value array) is unusable — treat as unsigned noise,
|
|
31
|
+
// same as a fully absent header. Guarding here keeps the `.startsWith`/length ops below
|
|
32
|
+
// from throwing on a non-string.
|
|
33
|
+
if (typeof signatureHeader !== 'string' || !signatureHeader || !timestampHeader) {
|
|
34
|
+
return { ok: false, reason: 'missing' };
|
|
35
|
+
}
|
|
36
|
+
// Signature IS present but we have no raw bytes to verify it against. We can't clear it,
|
|
37
|
+
// and a present-but-unverifiable signature must not be silently dropped — surface it.
|
|
38
|
+
if (typeof rawBody !== 'string') {
|
|
39
|
+
return { ok: false, reason: 'unverifiable' };
|
|
40
|
+
}
|
|
41
|
+
// `timestampHeader` is truthy here, but may still be a non-string (e.g. array); coerce to
|
|
42
|
+
// a string so it is safe to interpolate into the canonical and to `Number()` below.
|
|
43
|
+
const timestampString = String(timestampHeader);
|
|
44
|
+
// Strip the `sha256=` prefix the header carries (tolerate its absence defensively) and
|
|
45
|
+
// lowercase it — the expected digest is lowercase hex, so an uppercase-but-correct digest
|
|
46
|
+
// should still compare equal.
|
|
47
|
+
const provided = (signatureHeader.startsWith('sha256=')
|
|
48
|
+
? signatureHeader.slice('sha256='.length)
|
|
49
|
+
: signatureHeader).toLowerCase();
|
|
50
|
+
const expected = (0, crypto_1.createHmac)('sha256', signingSecret)
|
|
51
|
+
.update(`${timestampString}.${rawBody}`, 'utf8')
|
|
52
|
+
.digest('hex');
|
|
53
|
+
// Constant-time compare. timingSafeEqual throws on length mismatch, so guard lengths
|
|
54
|
+
// first — a wrong length is, itself, a non-match (present but invalid).
|
|
55
|
+
const providedBuf = Buffer.from(provided, 'utf8');
|
|
56
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
57
|
+
if (providedBuf.length !== expectedBuf.length ||
|
|
58
|
+
!(0, crypto_1.timingSafeEqual)(providedBuf, expectedBuf)) {
|
|
59
|
+
return { ok: false, reason: 'invalid' };
|
|
60
|
+
}
|
|
61
|
+
// Signature is authentic; enforce the replay window on the (now trusted) timestamp.
|
|
62
|
+
const ts = Number(timestampString);
|
|
63
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > MAX_SKEW_SECONDS) {
|
|
64
|
+
return { ok: false, reason: 'skew' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
};
|
|
68
|
+
exports.verifySignature = verifySignature;
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voxiness/n8n-nodes-voxisms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "n8n community nodes for VoxiSMS: send SMS and trigger on inbound SMS",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"n8n-community-node-package",
|
|
10
|
+
"sms",
|
|
11
|
+
"voxisms"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://voxisms.com",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "VoxiSMS"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/aboven/voxisms-automations.git",
|
|
21
|
+
"directory": "voxisms-n8n"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"main": "index.js",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc && node scripts/copy-icons.mjs",
|
|
32
|
+
"dev": "tsc --watch",
|
|
33
|
+
"lint": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run build && npm test",
|
|
35
|
+
"test": "jest"
|
|
36
|
+
},
|
|
37
|
+
"n8n": {
|
|
38
|
+
"n8nNodesApiVersion": 1,
|
|
39
|
+
"credentials": [
|
|
40
|
+
"dist/credentials/VoxiSmsApi.credentials.js"
|
|
41
|
+
],
|
|
42
|
+
"nodes": [
|
|
43
|
+
"dist/nodes/VoxiSms/VoxiSms.node.js",
|
|
44
|
+
"dist/nodes/VoxiSmsTrigger/VoxiSmsTrigger.node.js"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/jest": "^29.5.0",
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"jest": "^29.6.0",
|
|
51
|
+
"n8n-workflow": "*",
|
|
52
|
+
"ts-jest": "^29.1.0",
|
|
53
|
+
"typescript": "^5.4.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"n8n-workflow": "*"
|
|
57
|
+
}
|
|
58
|
+
}
|