engagelab-otp 1.0.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 ADDED
@@ -0,0 +1,136 @@
1
+ # engagelab-otp · Node.js
2
+
3
+ Official Node.js SDK for [EngageLab](https://www.engagelab.com) OTP.
4
+ Zero dependencies. Node.js 14+. TypeScript declarations included.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install engagelab-otp
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```js
15
+ const { OTPClient } = require('engagelab-otp');
16
+
17
+ const otp = new OTPClient(
18
+ process.env.ENGAGELAB_DEV_KEY,
19
+ process.env.ENGAGELAB_DEV_SECRET
20
+ );
21
+
22
+ // Platform-generated OTP — easiest path
23
+ const { message_id } = await otp.send('+6591234567', 'your-template-id', {}, 'en');
24
+ const { verified } = await otp.verify(message_id, userTypedCode);
25
+ ```
26
+
27
+ ## Two send modes
28
+
29
+ | Mode | Method | When to use |
30
+ |------------------------|-----------------|-------------|
31
+ | Platform-generated | `otp.send()` | EngageLab generates and stores the code. You only call `verify()` later. Simplest path. |
32
+ | Caller-generated | `otp.sendCustom()` | You generate the code yourself. EngageLab is just the carrier. Use when you need the code in your own DB. |
33
+
34
+ ```js
35
+ // Platform-generated
36
+ const r = await otp.send('+6591234567', 'tpl-id', { name: 'Alice' }, 'en');
37
+ const v = await otp.verify(r.message_id, '123456');
38
+
39
+ // Caller-generated
40
+ await otp.sendCustom('+6591234567', 'custom-tpl', { code: '482910', name: 'Alice' });
41
+ ```
42
+
43
+ ## Webhook callbacks
44
+
45
+ EngageLab signs callbacks with `X-CALLBACK-ID` (HMAC-SHA256). The SDK verifies signatures and parses events for you.
46
+
47
+ > Whitelist source IPs in your firewall: `119.8.170.74`, `114.119.180.30`
48
+
49
+ ```js
50
+ const express = require('express');
51
+ const { WebhookVerifier } = require('engagelab-otp');
52
+
53
+ const app = express();
54
+ app.use(express.json());
55
+
56
+ const verifier = new WebhookVerifier({
57
+ username: process.env.ENGAGELAB_WEBHOOK_USERNAME,
58
+ secret: process.env.ENGAGELAB_WEBHOOK_SECRET,
59
+ });
60
+
61
+ app.post('/webhook', verifier.middleware(async (events) => {
62
+ for (const e of events) {
63
+ if (e.kind !== 'message_status') continue;
64
+
65
+ if (!e.is_terminal) {
66
+ // mid-flight (e.g. fallback in progress) — wait, do not act
67
+ continue;
68
+ }
69
+
70
+ if (e.status === 'delivered') await markDelivered(e.message_id);
71
+ else if (e.status === 'verified') await markVerified(e.message_id);
72
+ else await handleFailure(e);
73
+ }
74
+ }));
75
+ ```
76
+
77
+ ## Event types
78
+
79
+ `parseEvents()` returns one of four typed objects:
80
+
81
+ | `kind` | When | Key fields |
82
+ |------------------|---------------------------------------|------------|
83
+ | `message_status` | per-message lifecycle | `message_id`, `status`, `is_terminal`, `current_send_channel`, `error_code` |
84
+ | `notification` | account-level alert | `event` (`insufficient_balance`, …), `data` |
85
+ | `uplink` | inbound user reply | `from`, `to`, `body` |
86
+ | `system_event` | console action audit | `event` (`account_login`, …) |
87
+
88
+ ### Message status enum
89
+
90
+ ```
91
+ plan · target_valid · target_invalid
92
+ sent · sent_failed
93
+ delivered · delivered_failed
94
+ verified · verified_failed · verified_timeout
95
+ ```
96
+
97
+ `is_terminal === true` for: `delivered`, `delivered_failed`, `sent_failed`, `verified*`, `target_invalid`.
98
+
99
+ ## Error handling
100
+
101
+ ```js
102
+ const { EngagelabError } = require('engagelab-otp');
103
+
104
+ try {
105
+ await otp.send('+6591234567', 'tpl', {});
106
+ } catch (err) {
107
+ if (err instanceof EngagelabError) {
108
+ if (err.retryable) {
109
+ // HTTP 429/5xx, or API codes 1000/5001/5016
110
+ // → exponential backoff
111
+ } else {
112
+ // Permanent failure — fix your call or notify the user
113
+ console.log(err.code, err.httpStatus, err.message);
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ ## Examples
120
+
121
+ See [`examples/`](./examples) for runnable code:
122
+
123
+ - `01-send-and-verify.js` — platform-generated OTP, full flow
124
+ - `02-send-custom.js` — caller-generated code, single + bulk
125
+ - `03-webhook-express.js` — receive callbacks via Express middleware
126
+ - `04-error-handling.js` — retry strategy and error categorization
127
+
128
+ ## Run tests
129
+
130
+ ```bash
131
+ npm test
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "engagelab-otp",
3
+ "version": "1.0.0",
4
+ "description": "Official Node.js SDK for EngageLab OTP — send, verify, and parse webhook callbacks",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "files": [
8
+ "src/",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "engines": {
13
+ "node": ">=14"
14
+ },
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/engagelab/engagelab-otp-node.git"
19
+ },
20
+ "homepage": "https://www.engagelab.com",
21
+ "keywords": [
22
+ "engagelab",
23
+ "otp",
24
+ "sms",
25
+ "verification",
26
+ "webhook",
27
+ "two-factor",
28
+ "2fa"
29
+ ],
30
+ "scripts": {
31
+ "test": "node test/test.js"
32
+ },
33
+ "dependencies": {
34
+ "engagelab-otp": "file:engagelab-otp-1.0.0.tgz"
35
+ }
36
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ export interface SendResult {
2
+ message_id: string;
3
+ send_channel: 'sms' | 'whatsapp' | 'email' | 'voice';
4
+ }
5
+
6
+ export interface VerifyResult {
7
+ message_id: string;
8
+ verify_code: string;
9
+ verified: boolean;
10
+ }
11
+
12
+ export type MessageStatus =
13
+ | 'plan'
14
+ | 'target_valid' | 'target_invalid'
15
+ | 'sent' | 'sent_failed' | 'sent_fail'
16
+ | 'delivered' | 'delivered_failed'
17
+ | 'verified' | 'verified_failed' | 'verified_timeout';
18
+
19
+ export interface MessageStatusEvent {
20
+ kind: 'message_status';
21
+ message_id: string;
22
+ to: string;
23
+ server: string;
24
+ channel: string;
25
+ timestamp: number;
26
+ custom_args: Record<string, unknown>;
27
+ status: MessageStatus;
28
+ is_terminal: boolean;
29
+ current_send_channel?: string;
30
+ template_key?: string;
31
+ business_id?: string;
32
+ msg_time?: number;
33
+ billing: { cost: number; currency: string } | null;
34
+ error_code: number;
35
+ error_message?: string;
36
+ raw: unknown;
37
+ }
38
+
39
+ export interface NotificationEvent {
40
+ kind: 'notification';
41
+ server: string;
42
+ timestamp: number;
43
+ event: 'insufficient_verification_rate' | 'insufficient_balance' | 'template_audit_result' | string;
44
+ data: Record<string, unknown>;
45
+ raw: unknown;
46
+ }
47
+
48
+ export interface UplinkEvent {
49
+ kind: 'uplink';
50
+ server: string;
51
+ timestamp: number;
52
+ message_id: string;
53
+ business_id: string;
54
+ event: 'uplink_message' | string;
55
+ data: { from: string; to: string; body: string; message_sid: string; account_sid: string };
56
+ raw: unknown;
57
+ }
58
+
59
+ export interface SystemEvent {
60
+ kind: 'system_event';
61
+ server: string;
62
+ timestamp: number;
63
+ event: 'account_login' | 'key_manage' | 'template_manage' | 'msg_history' | 'api_call' | string;
64
+ data: Record<string, unknown>;
65
+ raw: unknown;
66
+ }
67
+
68
+ export type CallbackEvent = MessageStatusEvent | NotificationEvent | UplinkEvent | SystemEvent;
69
+
70
+ export class EngagelabError extends Error {
71
+ code: number;
72
+ httpStatus: number;
73
+ retryable: boolean;
74
+ }
75
+ export class WebhookSignatureError extends Error {}
76
+
77
+ export const TERMINAL_STATUSES: Set<MessageStatus>;
78
+
79
+ export class OTPClient {
80
+ constructor(devKey: string, devSecret: string, opts?: { timeout?: number });
81
+ send(to: string, templateId: string, params?: Record<string, string>, language?: string): Promise<SendResult>;
82
+ sendCustom(to: string | string[], templateId: string, params?: Record<string, string>): Promise<SendResult>;
83
+ verify(messageId: string, code: string): Promise<VerifyResult>;
84
+ }
85
+
86
+ export class WebhookVerifier {
87
+ constructor(opts: { username: string; secret: string; toleranceSeconds?: number });
88
+ verify(headerValue: string, now?: number): true;
89
+ parseEvents(body: string | object): CallbackEvent[];
90
+ middleware(
91
+ handler: (events: CallbackEvent[], req: any) => Promise<void> | void
92
+ ): (req: any, res: any) => Promise<void>;
93
+ }
package/src/index.js ADDED
@@ -0,0 +1,318 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const crypto = require('crypto');
5
+ const { URL } = require('url');
6
+
7
+ const SDK_VERSION = '1.0.0';
8
+
9
+ // ─── Errors ───────────────────────────────────────────────────────────────────
10
+
11
+ const RETRYABLE_API_CODES = new Set([1000, 5001, 5016]);
12
+ const RETRYABLE_HTTP = new Set([429, 500, 502, 503, 504]);
13
+
14
+ class EngagelabError extends Error {
15
+ constructor(message, code = 0, httpStatus = 0) {
16
+ super(message);
17
+ this.name = 'EngagelabError';
18
+ this.code = code;
19
+ this.httpStatus = httpStatus;
20
+ this.retryable = RETRYABLE_API_CODES.has(code) || RETRYABLE_HTTP.has(httpStatus);
21
+ }
22
+ }
23
+
24
+ class WebhookSignatureError extends Error {
25
+ constructor(message) { super(message); this.name = 'WebhookSignatureError'; }
26
+ }
27
+
28
+ // ─── HTTP helper ──────────────────────────────────────────────────────────────
29
+
30
+ function request({ url, auth, body, timeout }) {
31
+ return new Promise((resolve, reject) => {
32
+ const u = new URL(url);
33
+ const payload = JSON.stringify(body);
34
+ const req = https.request({
35
+ hostname: u.hostname,
36
+ port: u.port || 443,
37
+ path: u.pathname + u.search,
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ 'Content-Length': Buffer.byteLength(payload),
42
+ 'Authorization': auth,
43
+ 'User-Agent': `engagelab-otp-node/${SDK_VERSION}`,
44
+ },
45
+ timeout: timeout || 10000,
46
+ }, (res) => {
47
+ let data = '';
48
+ res.on('data', c => { data += c; });
49
+ res.on('end', () => {
50
+ let json;
51
+ try { json = JSON.parse(data); } catch (_) { json = { message: data }; }
52
+ if (res.statusCode >= 200 && res.statusCode < 300) {
53
+ resolve(json);
54
+ } else {
55
+ reject(new EngagelabError(json.message || 'Unknown error', json.code || 0, res.statusCode));
56
+ }
57
+ });
58
+ });
59
+ req.on('timeout', () => { req.destroy(); reject(new EngagelabError('Request timed out', 0, 0)); });
60
+ req.on('error', reject);
61
+ req.write(payload);
62
+ req.end();
63
+ });
64
+ }
65
+
66
+ // ─── OTPClient ────────────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Client for the EngageLab OTP API.
70
+ * Covers both modes:
71
+ * - send() → platform generates the code (POST /v1/messages)
72
+ * - sendCustom() → caller supplies the code (POST /v1/custom-messages)
73
+ * - verify() → verify user-entered code (POST /v1/verifications)
74
+ */
75
+ class OTPClient {
76
+ /**
77
+ * @param {string} devKey
78
+ * @param {string} devSecret
79
+ * @param {object} [opts]
80
+ * @param {number} [opts.timeout=10000]
81
+ */
82
+ constructor(devKey, devSecret, opts = {}) {
83
+ if (!devKey || !devSecret) throw new Error('devKey and devSecret are required');
84
+ this._auth = 'Basic ' + Buffer.from(`${devKey}:${devSecret}`).toString('base64');
85
+ this._timeout = opts.timeout || 10000;
86
+ this._base = 'https://otp.api.engagelab.cc/v1';
87
+ }
88
+
89
+ _post(path, body) {
90
+ return request({ url: this._base + path, auth: this._auth, timeout: this._timeout, body });
91
+ }
92
+
93
+ /**
94
+ * Send an OTP — platform generates the code.
95
+ * @param {string} to E.164 phone or email
96
+ * @param {string} templateId
97
+ * @param {object} [params] extra template variables (no `code`, platform fills it)
98
+ * @param {string} [language='default'] default | zh_CN | zh_HK | en | ja | th | es
99
+ * @returns {Promise<{ message_id: string, send_channel: string }>}
100
+ */
101
+ send(to, templateId, params = {}, language = 'default') {
102
+ if (!to) throw new Error('to is required');
103
+ if (!templateId) throw new Error('templateId is required');
104
+ return this._post('/messages', {
105
+ to,
106
+ template: { id: templateId, language, params },
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Send a custom message — caller supplies all template variables (including `code`).
112
+ * @param {string|string[]} to
113
+ * @param {string} templateId
114
+ * @param {object} [params]
115
+ * @returns {Promise<{ message_id: string, send_channel: string }>}
116
+ */
117
+ sendCustom(to, templateId, params = {}) {
118
+ if (!to) throw new Error('to is required');
119
+ if (!templateId) throw new Error('templateId is required');
120
+ return this._post('/custom-messages', {
121
+ to,
122
+ template: { id: templateId, params },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Verify a code the user entered.
128
+ * @param {string} messageId
129
+ * @param {string} code
130
+ * @returns {Promise<{ message_id: string, verify_code: string, verified: boolean }>}
131
+ */
132
+ verify(messageId, code) {
133
+ if (!messageId) throw new Error('messageId is required');
134
+ if (!code) throw new Error('code is required');
135
+ return this._post('/verifications', {
136
+ message_id: messageId,
137
+ verify_code: String(code),
138
+ });
139
+ }
140
+ }
141
+
142
+ // ─── WebhookVerifier ──────────────────────────────────────────────────────────
143
+
144
+ const TERMINAL_STATUSES = new Set([
145
+ 'delivered',
146
+ 'delivered_failed',
147
+ 'sent_failed',
148
+ 'sent_fail',
149
+ 'verified',
150
+ 'verified_failed',
151
+ 'verified_timeout',
152
+ 'target_invalid',
153
+ ]);
154
+
155
+ /**
156
+ * Verifies and parses EngageLab OTP webhook callbacks.
157
+ *
158
+ * EngageLab signs each callback with header X-CALLBACK-ID:
159
+ * timestamp={ts};nonce={n};username={u};signature={sig}
160
+ * where signature = HMAC-SHA256(secret, `${ts}${nonce}${username}`).hexdigest()
161
+ *
162
+ * Source webhook IPs to whitelist: 119.8.170.74, 114.119.180.30
163
+ */
164
+ class WebhookVerifier {
165
+ /**
166
+ * @param {object} opts
167
+ * @param {string} opts.username
168
+ * @param {string} opts.secret
169
+ * @param {number} [opts.toleranceSeconds=300]
170
+ */
171
+ constructor({ username, secret, toleranceSeconds = 300 } = {}) {
172
+ if (!username || !secret) throw new Error('username and secret are required');
173
+ this._username = username;
174
+ this._secret = secret;
175
+ this._tol = toleranceSeconds;
176
+ }
177
+
178
+ /**
179
+ * Verify the X-CALLBACK-ID header. Throws WebhookSignatureError on failure.
180
+ */
181
+ verify(headerValue, now = Math.floor(Date.now() / 1000)) {
182
+ if (!headerValue) throw new WebhookSignatureError('missing X-CALLBACK-ID header');
183
+
184
+ const parts = {};
185
+ for (const seg of headerValue.split(';')) {
186
+ const idx = seg.indexOf('=');
187
+ if (idx > 0) parts[seg.slice(0, idx).trim()] = seg.slice(idx + 1).trim();
188
+ }
189
+
190
+ const { timestamp, nonce, username, signature } = parts;
191
+ if (!timestamp || !nonce || !username || !signature) {
192
+ throw new WebhookSignatureError('malformed X-CALLBACK-ID header');
193
+ }
194
+ if (username !== this._username) {
195
+ throw new WebhookSignatureError('username mismatch');
196
+ }
197
+
198
+ const ts = parseInt(timestamp, 10);
199
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > this._tol) {
200
+ throw new WebhookSignatureError('timestamp outside tolerance window');
201
+ }
202
+
203
+ const expected = crypto
204
+ .createHmac('sha256', this._secret)
205
+ .update(`${timestamp}${nonce}${username}`)
206
+ .digest('hex');
207
+
208
+ const a = Buffer.from(expected, 'hex');
209
+ const b = Buffer.from(signature, 'hex');
210
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
211
+ throw new WebhookSignatureError('signature mismatch');
212
+ }
213
+ return true;
214
+ }
215
+
216
+ /**
217
+ * Parse a callback body into typed events.
218
+ */
219
+ parseEvents(body) {
220
+ const obj = typeof body === 'string' ? JSON.parse(body) : body;
221
+ const rows = Array.isArray(obj.rows) ? obj.rows : [];
222
+ return rows.map(parseRow).filter(Boolean);
223
+ }
224
+
225
+ /**
226
+ * Express-style middleware factory.
227
+ * app.post('/webhook', verifier.middleware(handler))
228
+ */
229
+ middleware(handler) {
230
+ return async (req, res) => {
231
+ try {
232
+ this.verify(req.headers['x-callback-id'] || req.headers['X-CALLBACK-ID']);
233
+ } catch (err) {
234
+ return res.status(401).end();
235
+ }
236
+ try {
237
+ const events = this.parseEvents(req.body);
238
+ await handler(events, req);
239
+ } catch (err) {
240
+ // Always 200 on internal errors to avoid retry storms.
241
+ if (typeof console !== 'undefined') console.error('engagelab webhook handler error:', err);
242
+ }
243
+ res.status(200).end();
244
+ };
245
+ }
246
+ }
247
+
248
+ // ─── Internal: row → typed event ──────────────────────────────────────────────
249
+
250
+ function parseRow(row) {
251
+ if (!row || typeof row !== 'object') return null;
252
+
253
+ if (row.status && typeof row.status.message_status === 'string') {
254
+ const s = row.status;
255
+ const sd = s.status_data || {};
256
+ const ed = s.error_detail || {};
257
+ return {
258
+ kind: 'message_status',
259
+ message_id: row.message_id,
260
+ to: row.to,
261
+ server: row.server,
262
+ channel: row.channel,
263
+ timestamp: row.itime,
264
+ custom_args: row.custom_args || {},
265
+ status: s.message_status,
266
+ is_terminal: TERMINAL_STATUSES.has(s.message_status),
267
+ current_send_channel: sd.current_send_channel,
268
+ template_key: sd.template_key,
269
+ business_id: sd.business_id,
270
+ msg_time: sd.msg_time,
271
+ billing: s.billing || null,
272
+ error_code: s.error_code || 0,
273
+ error_message: ed.message,
274
+ raw: row,
275
+ };
276
+ }
277
+ if (row.notification) {
278
+ return {
279
+ kind: 'notification',
280
+ server: row.server,
281
+ timestamp: row.itime,
282
+ event: row.notification.event,
283
+ data: row.notification.notification_data,
284
+ raw: row,
285
+ };
286
+ }
287
+ if (row.response) {
288
+ return {
289
+ kind: 'uplink',
290
+ server: row.server,
291
+ timestamp: row.itime,
292
+ message_id: row.message_id,
293
+ business_id: row.business_id,
294
+ event: row.response.event,
295
+ data: row.response.response_data,
296
+ raw: row,
297
+ };
298
+ }
299
+ if (row.system_event) {
300
+ return {
301
+ kind: 'system_event',
302
+ server: row.server,
303
+ timestamp: row.itime,
304
+ event: row.system_event.event,
305
+ data: row.system_event.data,
306
+ raw: row,
307
+ };
308
+ }
309
+ return null;
310
+ }
311
+
312
+ module.exports = {
313
+ OTPClient,
314
+ WebhookVerifier,
315
+ EngagelabError,
316
+ WebhookSignatureError,
317
+ TERMINAL_STATUSES,
318
+ };