@siymo/otp-sdk-core 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,146 @@
1
+ # siymo-otp-sdk
2
+
3
+ TypeScript SDK for the REST and WebSocket contract exposed by `siymo-otp-service`.
4
+
5
+ ## Features
6
+
7
+ - Typed wrappers for OTP voice, SMS, inbound call, inbound SMS, verification, direct SMS, dongles, and health endpoints
8
+ - WebSocket subscription helper for `/ws/otp`
9
+ - Live attempt and lockout events during OTP verification
10
+ - `waitForConfirmation()` helper for inbound OTP confirmation flows
11
+ - `waitForConfirmationLongPoll()` helper for one-request HTTP long polling
12
+ - Base URL can be passed directly or resolved from environment variables
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install siymo-otp-sdk
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ The client resolves the service base URL in this order:
23
+
24
+ 1. `baseUrl` passed to the constructor
25
+ 2. `SIYMO_OTP_BASE_URL`
26
+ 3. `OTP_BASE_URL`
27
+
28
+ ```ts
29
+ import { SiymoOtpClient } from 'siymo-otp-sdk';
30
+
31
+ const client = new SiymoOtpClient({
32
+ baseUrl: 'http://localhost:3000',
33
+ });
34
+ ```
35
+
36
+ Or with environment variables:
37
+
38
+ ```bash
39
+ export SIYMO_OTP_BASE_URL=http://localhost:3000
40
+ ```
41
+
42
+ ```ts
43
+ import { SiymoOtpClient } from 'siymo-otp-sdk';
44
+
45
+ const client = new SiymoOtpClient();
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ### Outbound OTP
51
+
52
+ ```ts
53
+ import { SiymoOtpClient } from 'siymo-otp-sdk';
54
+
55
+ const client = new SiymoOtpClient();
56
+
57
+ const session = await client.initiateVoice({
58
+ phone: '+998901234567',
59
+ languages: ['uz', 'ru', 'en'],
60
+ repeat: true,
61
+ });
62
+
63
+ const verification = await client.verify({
64
+ sessionId: session.sessionId,
65
+ otp: '123456',
66
+ });
67
+ ```
68
+
69
+ ### Inbound OTP with WebSocket confirmation
70
+
71
+ ```ts
72
+ import { SiymoOtpClient } from 'siymo-otp-sdk';
73
+
74
+ const client = new SiymoOtpClient({
75
+ baseUrl: 'http://localhost:3000',
76
+ });
77
+
78
+ const session = await client.initiateInboundSms({
79
+ phone: '+998990649000',
80
+ });
81
+
82
+ const confirmed = await client.waitForConfirmation(session.sessionId, {
83
+ timeoutMs: session.expiresIn * 1000,
84
+ onAttempt(event) {
85
+ console.log('Invalid attempt', event.data.attempts, 'tries left', event.data.triesLeft);
86
+ },
87
+ });
88
+
89
+ console.log(confirmed.data.verifiedAt);
90
+ ```
91
+
92
+ ### Inbound OTP with HTTP long polling
93
+
94
+ ```ts
95
+ import { SiymoOtpClient } from 'siymo-otp-sdk';
96
+
97
+ const client = new SiymoOtpClient({
98
+ baseUrl: 'http://localhost:3000',
99
+ });
100
+
101
+ const session = await client.initiateInboundSms({
102
+ phone: '+998990649000',
103
+ expiration: 60,
104
+ });
105
+
106
+ const confirmed = await client.waitForConfirmationLongPoll(session.sessionId);
107
+
108
+ console.log(confirmed.status, confirmed.verifiedAt);
109
+ ```
110
+
111
+ ### Low-level WebSocket subscription
112
+
113
+ ```ts
114
+ const subscription = client.subscribeToSession(session.sessionId, {
115
+ onSubscribed(event) {
116
+ console.log('Subscribed until', event.data.expiresAt);
117
+ },
118
+ onAttempt(event) {
119
+ console.log('Attempts used', event.data.attempts, 'tries left', event.data.triesLeft);
120
+ },
121
+ onLocked(event) {
122
+ console.log('Locked after', event.data.attempts, 'attempts');
123
+ },
124
+ onVerified(event) {
125
+ console.log('Verified', event.data.sessionId);
126
+ },
127
+ onExpired(event) {
128
+ console.log('Expired', event.data.sessionId);
129
+ },
130
+ });
131
+
132
+ // Later, if needed:
133
+ subscription.close();
134
+ await subscription.closed;
135
+ ```
136
+
137
+ ## Notes
138
+
139
+ - The SDK uses the service's existing WebSocket contract at `/ws/otp?sessionId=<uuid>`.
140
+ - The SDK also supports the long-poll endpoint at `GET /otp/wait?sessionId=<uuid>`.
141
+ - Inbound call and SMS initiation requests accept an optional `qrCode: true` flag. When requested, the response includes `qrCodeImage` as a data URL.
142
+ - The WebSocket stream can emit `otp.subscribed`, `otp.attempt`, `otp.locked`, `otp.verified`, and `otp.expired`.
143
+ - In modern Node runtimes such as Node `22.x`, `WebSocket` is available globally. If you need to override it, pass `webSocketFactory`.
144
+ - `waitForConfirmation()` keeps waiting through `otp.attempt` events, forwards WebSocket callbacks like `onAttempt`, and rejects on `otp.locked` or `otp.expired`.
145
+ - `waitForConfirmationLongPoll()` resolves on verification and rejects with the service error response if the session locks or expires.
146
+ - Verification endpoints intentionally return `200 OK` for both success and logical verification failures. Inspect the typed response fields like `verified`, `message`, and `triesLeft`.
@@ -0,0 +1,25 @@
1
+ import type { DongleDevice, HealthCheckResponse, InitiateInboundCallResponse, InitiateInboundRequest, InitiateInboundSmsResponse, InitiateSmsRequest, InitiateSmsResponse, InitiateVoiceRequest, InitiateVoiceResponse, OtpLongPollSuccessResponse, OtpSessionSubscription, OtpVerifiedEvent, SendSmsRequest, SendSmsResponse, SiymoOtpClientOptions, SubscribeToSessionOptions, VerifyInboundReceiptRequest, VerifyInboundResponse, VerifyOtpRequest, VerifyOtpResponse, WaitForConfirmationOptions, WaitForConfirmationLongPollOptions } from './types.js';
2
+ export declare class SiymoOtpClient {
3
+ private readonly baseUrl;
4
+ private readonly websocketBaseUrl;
5
+ private readonly defaultHeaders;
6
+ private readonly fetchImpl;
7
+ private readonly webSocketFactory?;
8
+ constructor(options?: SiymoOtpClientOptions);
9
+ initiateVoice(body: InitiateVoiceRequest): Promise<InitiateVoiceResponse>;
10
+ initiateSms(body: InitiateSmsRequest): Promise<InitiateSmsResponse>;
11
+ initiateInboundCall(body: InitiateInboundRequest): Promise<InitiateInboundCallResponse>;
12
+ initiateInboundSms(body: InitiateInboundRequest): Promise<InitiateInboundSmsResponse>;
13
+ verify(body: VerifyOtpRequest): Promise<VerifyOtpResponse>;
14
+ verifyInbound(body: VerifyInboundReceiptRequest): Promise<VerifyInboundResponse>;
15
+ sendSms(body: SendSmsRequest): Promise<SendSmsResponse>;
16
+ listDongles(): Promise<DongleDevice[]>;
17
+ health(): Promise<HealthCheckResponse>;
18
+ subscribeToSession(sessionId: string, options?: SubscribeToSessionOptions): OtpSessionSubscription;
19
+ waitForConfirmation(sessionId: string, options?: WaitForConfirmationOptions): Promise<OtpVerifiedEvent>;
20
+ waitForConfirmationLongPoll(sessionId: string, options?: WaitForConfirmationLongPollOptions): Promise<OtpLongPollSuccessResponse>;
21
+ private request;
22
+ private buildWebSocketUrl;
23
+ private createWebSocket;
24
+ private handleSocketMessage;
25
+ }
package/dist/client.js ADDED
@@ -0,0 +1,502 @@
1
+ import { SiymoOtpApiError, SiymoOtpConfigurationError, SiymoOtpWebSocketError, } from './errors.js';
2
+ const DEFAULT_BASE_URL_ENV_VARS = ['SIYMO_OTP_BASE_URL', 'OTP_BASE_URL'];
3
+ const OTP_WEBSOCKET_PATH = '/ws/otp';
4
+ const WS_READY_STATE_CLOSING = 2;
5
+ const WS_READY_STATE_CLOSED = 3;
6
+ export class SiymoOtpClient {
7
+ baseUrl;
8
+ websocketBaseUrl;
9
+ defaultHeaders;
10
+ fetchImpl;
11
+ webSocketFactory;
12
+ constructor(options = {}) {
13
+ this.baseUrl = normalizeBaseUrl(resolveBaseUrl(options));
14
+ this.websocketBaseUrl = normalizeBaseUrl(options.websocketBaseUrl ?? httpBaseUrlToWebSocketBaseUrl(this.baseUrl));
15
+ this.defaultHeaders = new Headers(options.defaultHeaders);
16
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
17
+ this.webSocketFactory = options.webSocketFactory;
18
+ }
19
+ initiateVoice(body) {
20
+ return this.request('POST', '/otp/initiate/voice', body);
21
+ }
22
+ initiateSms(body) {
23
+ return this.request('POST', '/otp/initiate/sms', body);
24
+ }
25
+ initiateInboundCall(body) {
26
+ return this.request('POST', '/otp/initiate/inbound/call', body);
27
+ }
28
+ initiateInboundSms(body) {
29
+ return this.request('POST', '/otp/initiate/inbound/sms', body);
30
+ }
31
+ verify(body) {
32
+ return this.request('POST', '/otp/verify', body);
33
+ }
34
+ verifyInbound(body) {
35
+ return this.request('POST', '/otp/verify/inbound', body);
36
+ }
37
+ sendSms(body) {
38
+ return this.request('POST', '/sms/send', body);
39
+ }
40
+ listDongles() {
41
+ return this.request('GET', '/dongles');
42
+ }
43
+ health() {
44
+ return this.request('GET', '/health');
45
+ }
46
+ subscribeToSession(sessionId, options = {}) {
47
+ if (options.signal?.aborted) {
48
+ throw new SiymoOtpWebSocketError(`Subscription aborted before connecting to session ${sessionId}.`);
49
+ }
50
+ const socket = this.createWebSocket(this.buildWebSocketUrl(sessionId));
51
+ let resolveClosed;
52
+ const closed = new Promise((resolve) => {
53
+ resolveClosed = resolve;
54
+ });
55
+ let closedResolved = false;
56
+ const handleMessage = (event) => {
57
+ void this.handleSocketMessage(event, options, socket).catch((error) => {
58
+ options.onError?.(error);
59
+ closeSocket(socket, 1002, 'Invalid message');
60
+ });
61
+ };
62
+ const handleError = () => {
63
+ options.onError?.(new SiymoOtpWebSocketError(`WebSocket error while listening to session ${sessionId}.`));
64
+ };
65
+ const handleClose = (event) => {
66
+ try {
67
+ options.onClose?.(event);
68
+ }
69
+ finally {
70
+ finalizeClose();
71
+ }
72
+ };
73
+ socket.addEventListener('message', handleMessage);
74
+ socket.addEventListener('error', handleError);
75
+ socket.addEventListener('close', handleClose);
76
+ const cleanup = () => {
77
+ socket.removeEventListener('message', handleMessage);
78
+ socket.removeEventListener('error', handleError);
79
+ socket.removeEventListener('close', handleClose);
80
+ options.signal?.removeEventListener('abort', handleAbort);
81
+ };
82
+ const finalizeClose = () => {
83
+ if (closedResolved) {
84
+ return;
85
+ }
86
+ closedResolved = true;
87
+ cleanup();
88
+ resolveClosed();
89
+ };
90
+ const handleAbort = () => {
91
+ closeSocket(socket, 1000, 'Aborted');
92
+ };
93
+ options.signal?.addEventListener('abort', handleAbort, { once: true });
94
+ return {
95
+ sessionId,
96
+ closed,
97
+ close(code, reason) {
98
+ if (socket.readyState === WS_READY_STATE_CLOSING ||
99
+ socket.readyState === WS_READY_STATE_CLOSED) {
100
+ finalizeClose();
101
+ return;
102
+ }
103
+ closeSocket(socket, code, reason);
104
+ },
105
+ };
106
+ }
107
+ waitForConfirmation(sessionId, options = {}) {
108
+ return new Promise((resolve, reject) => {
109
+ let settled = false;
110
+ let timeoutId;
111
+ let subscription;
112
+ const finish = (result) => {
113
+ if (settled) {
114
+ return;
115
+ }
116
+ settled = true;
117
+ if (timeoutId) {
118
+ clearTimeout(timeoutId);
119
+ }
120
+ resolve(result);
121
+ subscription.close(1000, 'Confirmed');
122
+ };
123
+ const fail = (error) => {
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ if (timeoutId) {
129
+ clearTimeout(timeoutId);
130
+ }
131
+ reject(error instanceof Error
132
+ ? error
133
+ : new SiymoOtpWebSocketError(`Failed while waiting for confirmation for session ${sessionId}.`));
134
+ subscription.close(1000, 'Stopped');
135
+ };
136
+ subscription = this.subscribeToSession(sessionId, {
137
+ signal: options.signal,
138
+ onEvent: options.onEvent,
139
+ onSubscribed: options.onSubscribed,
140
+ onAttempt: (event) => {
141
+ options.onAttempt?.(event);
142
+ },
143
+ onLocked: (event) => {
144
+ options.onLocked?.(event);
145
+ fail(new SiymoOtpWebSocketError(`${event.data.message} for session ${event.data.sessionId} after ${event.data.attempts} attempt(s).`));
146
+ },
147
+ onVerified: (event) => {
148
+ options.onVerified?.(event);
149
+ finish(event);
150
+ },
151
+ onExpired: (event) => {
152
+ options.onExpired?.(event);
153
+ fail(new SiymoOtpWebSocketError(`OTP session ${event.data.sessionId} expired before confirmation arrived.`));
154
+ },
155
+ onError: (error) => {
156
+ options.onError?.(error);
157
+ fail(error);
158
+ },
159
+ onClose: (event) => {
160
+ options.onClose?.(event);
161
+ if (!settled) {
162
+ fail(new SiymoOtpWebSocketError(`WebSocket closed before confirmation arrived for session ${sessionId}.`));
163
+ }
164
+ },
165
+ });
166
+ if (typeof options.timeoutMs === 'number' && options.timeoutMs > 0) {
167
+ timeoutId = setTimeout(() => {
168
+ fail(new SiymoOtpWebSocketError(`Timed out after ${options.timeoutMs} ms while waiting for session ${sessionId}.`));
169
+ }, options.timeoutMs);
170
+ }
171
+ });
172
+ }
173
+ waitForConfirmationLongPoll(sessionId, options = {}) {
174
+ const query = new URLSearchParams({ sessionId }).toString();
175
+ return this.request('GET', `/otp/wait?${query}`, undefined, {
176
+ signal: options.signal,
177
+ });
178
+ }
179
+ async request(method, path, body, options = {}) {
180
+ const url = new URL(stripLeadingSlash(path), `${this.baseUrl}/`);
181
+ const headers = new Headers(this.defaultHeaders);
182
+ headers.set('accept', 'application/json');
183
+ let requestBody;
184
+ if (body !== undefined) {
185
+ requestBody = JSON.stringify(body);
186
+ if (!headers.has('content-type')) {
187
+ headers.set('content-type', 'application/json');
188
+ }
189
+ }
190
+ let response;
191
+ try {
192
+ response = await this.fetchImpl(url, {
193
+ method,
194
+ headers,
195
+ body: requestBody,
196
+ signal: options.signal,
197
+ });
198
+ }
199
+ catch (error) {
200
+ throw new SiymoOtpApiError({
201
+ message: `Request to ${url.toString()} failed before the service responded.`,
202
+ status: 0,
203
+ statusText: 'FETCH_ERROR',
204
+ url: url.toString(),
205
+ body: null,
206
+ cause: error,
207
+ });
208
+ }
209
+ const payload = await parseResponse(response);
210
+ if (!response.ok) {
211
+ throw new SiymoOtpApiError({
212
+ message: `Request to ${url.toString()} failed with ${response.status} ${response.statusText}.`,
213
+ status: response.status,
214
+ statusText: response.statusText,
215
+ url: url.toString(),
216
+ body: payload,
217
+ });
218
+ }
219
+ return payload;
220
+ }
221
+ buildWebSocketUrl(sessionId) {
222
+ const url = new URL(stripLeadingSlash(OTP_WEBSOCKET_PATH), `${this.websocketBaseUrl}/`);
223
+ url.searchParams.set('sessionId', sessionId);
224
+ return url.toString();
225
+ }
226
+ createWebSocket(url) {
227
+ if (this.webSocketFactory) {
228
+ return this.webSocketFactory(url);
229
+ }
230
+ if (typeof globalThis.WebSocket !== 'function') {
231
+ throw new SiymoOtpConfigurationError('WebSocket is not available in this runtime. Pass webSocketFactory to override it.');
232
+ }
233
+ return new globalThis.WebSocket(url);
234
+ }
235
+ async handleSocketMessage(event, options, socket) {
236
+ const message = event;
237
+ const rawPayload = await readMessagePayload(message.data);
238
+ const parsedPayload = parseSocketEvent(rawPayload);
239
+ options.onEvent?.(parsedPayload);
240
+ switch (parsedPayload.event) {
241
+ case 'otp.subscribed':
242
+ options.onSubscribed?.(parsedPayload);
243
+ return;
244
+ case 'otp.attempt':
245
+ options.onAttempt?.(parsedPayload);
246
+ return;
247
+ case 'otp.locked':
248
+ options.onLocked?.(parsedPayload);
249
+ return;
250
+ case 'otp.verified':
251
+ options.onVerified?.(parsedPayload);
252
+ return;
253
+ case 'otp.expired':
254
+ options.onExpired?.(parsedPayload);
255
+ closeSocket(socket, 1000, 'Expired');
256
+ return;
257
+ default:
258
+ assertNever(parsedPayload);
259
+ }
260
+ }
261
+ }
262
+ function resolveBaseUrl(options) {
263
+ if (options.baseUrl) {
264
+ return options.baseUrl;
265
+ }
266
+ const env = globalThis.process?.env ?? {};
267
+ const candidates = Array.isArray(options.baseUrlEnvVar)
268
+ ? options.baseUrlEnvVar
269
+ : options.baseUrlEnvVar
270
+ ? [options.baseUrlEnvVar]
271
+ : DEFAULT_BASE_URL_ENV_VARS;
272
+ for (const key of candidates) {
273
+ const value = env[key];
274
+ if (typeof value === 'string' && value.trim().length > 0) {
275
+ return value;
276
+ }
277
+ }
278
+ throw new SiymoOtpConfigurationError(`Missing service base URL. Pass baseUrl or set one of: ${candidates.join(', ')}.`);
279
+ }
280
+ function normalizeBaseUrl(value) {
281
+ const trimmed = value.trim();
282
+ if (trimmed.length === 0) {
283
+ throw new SiymoOtpConfigurationError('Base URL cannot be empty.');
284
+ }
285
+ let url;
286
+ try {
287
+ url = new URL(trimmed);
288
+ }
289
+ catch (error) {
290
+ throw new SiymoOtpConfigurationError(`Invalid base URL: ${trimmed}.`, {
291
+ cause: error instanceof Error ? error : undefined,
292
+ });
293
+ }
294
+ url.pathname = stripTrailingSlash(url.pathname);
295
+ url.search = '';
296
+ url.hash = '';
297
+ return url.toString().replace(/\/$/, '');
298
+ }
299
+ function httpBaseUrlToWebSocketBaseUrl(baseUrl) {
300
+ const url = new URL(baseUrl);
301
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
302
+ return normalizeBaseUrl(url.toString());
303
+ }
304
+ async function parseResponse(response) {
305
+ if (response.status === 204) {
306
+ return null;
307
+ }
308
+ const contentType = response.headers.get('content-type') ?? '';
309
+ if (contentType.includes('application/json')) {
310
+ return response.json();
311
+ }
312
+ const text = await response.text();
313
+ if (text.length === 0) {
314
+ return null;
315
+ }
316
+ try {
317
+ return JSON.parse(text);
318
+ }
319
+ catch {
320
+ return text;
321
+ }
322
+ }
323
+ async function readMessagePayload(value) {
324
+ if (typeof value === 'string') {
325
+ return value;
326
+ }
327
+ if (value instanceof ArrayBuffer) {
328
+ return new TextDecoder().decode(value);
329
+ }
330
+ if (ArrayBuffer.isView(value)) {
331
+ return new TextDecoder().decode(value);
332
+ }
333
+ if (typeof Blob !== 'undefined' && value instanceof Blob) {
334
+ return value.text();
335
+ }
336
+ throw new SiymoOtpWebSocketError('Received an unsupported WebSocket message payload.');
337
+ }
338
+ function parseSocketEvent(rawPayload) {
339
+ let parsed;
340
+ try {
341
+ parsed = JSON.parse(rawPayload);
342
+ }
343
+ catch (error) {
344
+ throw new SiymoOtpWebSocketError('Failed to parse WebSocket payload as JSON.', {
345
+ cause: error instanceof Error ? error : undefined,
346
+ });
347
+ }
348
+ if (!isRecord(parsed) || typeof parsed.event !== 'string' || !isRecord(parsed.data)) {
349
+ throw new SiymoOtpWebSocketError('Received an invalid WebSocket event payload.');
350
+ }
351
+ if (parsed.event === 'otp.subscribed') {
352
+ const expiresAt = parsed.data['expiresAt'];
353
+ const sessionId = parsed.data['sessionId'];
354
+ if (typeof expiresAt !== 'number' || typeof sessionId !== 'string') {
355
+ throw new SiymoOtpWebSocketError('Received an invalid otp.subscribed payload.');
356
+ }
357
+ return {
358
+ event: 'otp.subscribed',
359
+ data: {
360
+ expiresAt,
361
+ sessionId,
362
+ },
363
+ };
364
+ }
365
+ if (parsed.event === 'otp.expired') {
366
+ const sessionId = parsed.data['sessionId'];
367
+ if (typeof sessionId !== 'string') {
368
+ throw new SiymoOtpWebSocketError('Received an invalid otp.expired payload.');
369
+ }
370
+ return {
371
+ event: 'otp.expired',
372
+ data: {
373
+ sessionId,
374
+ },
375
+ };
376
+ }
377
+ if (parsed.event === 'otp.attempt') {
378
+ const payload = parseVerificationProgressPayload(parsed.data, 'otp.attempt');
379
+ if (payload.status !== 'PENDING' ||
380
+ payload.message !== 'Invalid or expired OTP' ||
381
+ payload.triesLeft <= 0) {
382
+ throw new SiymoOtpWebSocketError('Received an invalid otp.attempt payload.');
383
+ }
384
+ return {
385
+ event: 'otp.attempt',
386
+ data: {
387
+ ...payload,
388
+ status: 'PENDING',
389
+ message: 'Invalid or expired OTP',
390
+ attemptedAt: payload.timestamp,
391
+ },
392
+ };
393
+ }
394
+ if (parsed.event === 'otp.locked') {
395
+ const payload = parseVerificationProgressPayload(parsed.data, 'otp.locked');
396
+ if (payload.status !== 'LOCKED' ||
397
+ payload.message !== 'Too many attempts' ||
398
+ payload.triesLeft !== 0) {
399
+ throw new SiymoOtpWebSocketError('Received an invalid otp.locked payload.');
400
+ }
401
+ return {
402
+ event: 'otp.locked',
403
+ data: {
404
+ ...payload,
405
+ triesLeft: 0,
406
+ status: 'LOCKED',
407
+ message: 'Too many attempts',
408
+ lockedAt: payload.timestamp,
409
+ },
410
+ };
411
+ }
412
+ if (parsed.event === 'otp.verified') {
413
+ const sessionId = parsed.data['sessionId'];
414
+ const channel = parsed.data['channel'];
415
+ const direction = parsed.data['direction'];
416
+ const phone = parsed.data['phone'];
417
+ const serverPhone = parsed.data['serverPhone'];
418
+ const verifiedAt = parsed.data['verifiedAt'];
419
+ if (typeof sessionId !== 'string' ||
420
+ typeof channel !== 'string' ||
421
+ typeof direction !== 'string' ||
422
+ typeof phone !== 'string' ||
423
+ typeof serverPhone !== 'string' ||
424
+ typeof verifiedAt !== 'string') {
425
+ throw new SiymoOtpWebSocketError('Received an invalid otp.verified payload.');
426
+ }
427
+ return {
428
+ event: 'otp.verified',
429
+ data: {
430
+ sessionId,
431
+ channel,
432
+ direction,
433
+ phone,
434
+ serverPhone,
435
+ verifiedAt,
436
+ },
437
+ };
438
+ }
439
+ throw new SiymoOtpWebSocketError(`Received unsupported WebSocket event: ${parsed.event}.`);
440
+ }
441
+ function parseVerificationProgressPayload(data, eventName) {
442
+ const sessionId = data['sessionId'];
443
+ const channel = data['channel'];
444
+ const direction = data['direction'];
445
+ const phone = data['phone'];
446
+ const serverPhone = data['serverPhone'];
447
+ const attempts = data['attempts'];
448
+ const maxTries = data['maxTries'];
449
+ const triesLeft = data['triesLeft'];
450
+ const status = data['status'];
451
+ const message = data['message'];
452
+ const timestampKey = eventName === 'otp.attempt' ? 'attemptedAt' : 'lockedAt';
453
+ const timestamp = data[timestampKey];
454
+ if (typeof sessionId !== 'string' ||
455
+ typeof channel !== 'string' ||
456
+ typeof direction !== 'string' ||
457
+ typeof phone !== 'string' ||
458
+ typeof serverPhone !== 'string' ||
459
+ typeof attempts !== 'number' ||
460
+ typeof maxTries !== 'number' ||
461
+ typeof triesLeft !== 'number' ||
462
+ typeof status !== 'string' ||
463
+ typeof message !== 'string' ||
464
+ typeof timestamp !== 'string') {
465
+ throw new SiymoOtpWebSocketError(`Received an invalid ${eventName} payload.`);
466
+ }
467
+ return {
468
+ sessionId,
469
+ channel,
470
+ direction,
471
+ phone,
472
+ serverPhone,
473
+ attempts,
474
+ maxTries,
475
+ triesLeft,
476
+ status,
477
+ message,
478
+ timestamp,
479
+ };
480
+ }
481
+ function closeSocket(socket, code, reason) {
482
+ if (socket.readyState === WS_READY_STATE_CLOSING ||
483
+ socket.readyState === WS_READY_STATE_CLOSED) {
484
+ return;
485
+ }
486
+ socket.close(code, reason);
487
+ }
488
+ function stripLeadingSlash(value) {
489
+ return value.replace(/^\/+/, '');
490
+ }
491
+ function stripTrailingSlash(value) {
492
+ if (value === '/') {
493
+ return '';
494
+ }
495
+ return value.replace(/\/+$/, '');
496
+ }
497
+ function isRecord(value) {
498
+ return typeof value === 'object' && value !== null;
499
+ }
500
+ function assertNever(value) {
501
+ throw new SiymoOtpWebSocketError(`Unhandled WebSocket event: ${String(value)}.`);
502
+ }
@@ -0,0 +1,21 @@
1
+ export declare class SiymoOtpError extends Error {
2
+ constructor(message: string, options?: ErrorOptions);
3
+ }
4
+ export declare class SiymoOtpConfigurationError extends SiymoOtpError {
5
+ }
6
+ export declare class SiymoOtpApiError extends SiymoOtpError {
7
+ readonly status: number;
8
+ readonly statusText: string;
9
+ readonly url: string;
10
+ readonly body: unknown;
11
+ constructor(params: {
12
+ message: string;
13
+ status: number;
14
+ statusText: string;
15
+ url: string;
16
+ body: unknown;
17
+ cause?: unknown;
18
+ });
19
+ }
20
+ export declare class SiymoOtpWebSocketError extends SiymoOtpError {
21
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ export class SiymoOtpError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options);
4
+ this.name = new.target.name;
5
+ }
6
+ }
7
+ export class SiymoOtpConfigurationError extends SiymoOtpError {
8
+ }
9
+ export class SiymoOtpApiError extends SiymoOtpError {
10
+ status;
11
+ statusText;
12
+ url;
13
+ body;
14
+ constructor(params) {
15
+ super(params.message, { cause: params.cause instanceof Error ? params.cause : undefined });
16
+ this.status = params.status;
17
+ this.statusText = params.statusText;
18
+ this.url = params.url;
19
+ this.body = params.body;
20
+ }
21
+ }
22
+ export class SiymoOtpWebSocketError extends SiymoOtpError {
23
+ }
@@ -0,0 +1,3 @@
1
+ export { SiymoOtpClient } from './client.js';
2
+ export { SiymoOtpApiError, SiymoOtpConfigurationError, SiymoOtpError, SiymoOtpWebSocketError, } from './errors.js';
3
+ export type { DongleDevice, DongleState, HealthCheckResponse, HealthIndicatorResult, InboundReceiptType, InitiateInboundCallResponse, InitiateInboundRequest, InitiateInboundSmsResponse, InitiateSmsRequest, InitiateSmsResponse, InitiateVoiceRequest, InitiateVoiceResponse, OtpAttemptEvent, OtpChannel, OtpDirection, OtpExpiredEvent, OtpLockedEvent, OtpLongPollFailureResponse, OtpLongPollSuccessResponse, OtpSessionEvent, OtpSessionSubscription, OtpStatus, OtpSubscribedEvent, OtpVerifiedEvent, SendSmsRequest, SendSmsResponse, SiymoOtpClientOptions, SubscribeToSessionOptions, VerifyInboundFailureResponse, VerifyInboundReceiptRequest, VerifyInboundResponse, VerifyInboundSuccessResponse, VerifyOtpRequest, VerifyOtpResponse, VerifyOtpRetryableFailureResponse, VerifyOtpSuccessResponse, VerifyOtpTerminalFailureResponse, WaitForConfirmationOptions, WaitForConfirmationLongPollOptions, WebSocketFactory, WebSocketLike, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { SiymoOtpClient } from './client.js';
2
+ export { SiymoOtpApiError, SiymoOtpConfigurationError, SiymoOtpError, SiymoOtpWebSocketError, } from './errors.js';
@@ -0,0 +1,273 @@
1
+ export type OtpChannel = 'voice' | 'sms' | 'call';
2
+ export type OtpDirection = 'outbound' | 'inbound';
3
+ export type OtpStatus = 'PENDING' | 'VERIFIED' | 'LOCKED';
4
+ export type InboundReceiptType = 'call' | 'sms';
5
+ export type DongleState = 'Free' | 'Busy' | 'Not connected' | string;
6
+ export interface InitiateVoiceRequest {
7
+ phone: string;
8
+ languages: string[];
9
+ length?: number;
10
+ repeat?: boolean;
11
+ expiration?: number;
12
+ maxTries?: number;
13
+ }
14
+ export interface InitiateSmsRequest {
15
+ phone: string;
16
+ language: string;
17
+ length?: number;
18
+ expiration?: number;
19
+ maxTries?: number;
20
+ }
21
+ export interface InitiateInboundRequest {
22
+ phone: string;
23
+ length?: number;
24
+ expiration?: number;
25
+ maxTries?: number;
26
+ qrCode?: boolean;
27
+ }
28
+ interface PendingOtpSessionResponseBase {
29
+ success: true;
30
+ sessionId: string;
31
+ channel: OtpChannel;
32
+ length: number;
33
+ expiresIn: number;
34
+ maxTries: number;
35
+ status: 'PENDING';
36
+ }
37
+ export interface InitiateVoiceResponse extends PendingOtpSessionResponseBase {
38
+ channel: 'voice';
39
+ phone: string;
40
+ languages: string[];
41
+ repeat: boolean;
42
+ }
43
+ export interface InitiateSmsResponse extends PendingOtpSessionResponseBase {
44
+ channel: 'sms';
45
+ phone: string;
46
+ language: string;
47
+ }
48
+ interface InitiateInboundResponseBase extends PendingOtpSessionResponseBase {
49
+ direction: 'inbound';
50
+ phone: string;
51
+ from: string;
52
+ qrCodeImage?: string;
53
+ }
54
+ export interface InitiateInboundCallResponse extends InitiateInboundResponseBase {
55
+ channel: 'call';
56
+ code: string;
57
+ }
58
+ export interface InitiateInboundSmsResponse extends InitiateInboundResponseBase {
59
+ channel: 'sms';
60
+ text: string;
61
+ textEncoding: 'base64';
62
+ }
63
+ export interface VerifyOtpRequest {
64
+ sessionId: string;
65
+ otp: string;
66
+ }
67
+ export interface VerifyOtpSuccessResponse {
68
+ success: true;
69
+ verified: true;
70
+ sessionId: string;
71
+ channel: OtpChannel;
72
+ direction: OtpDirection;
73
+ message: 'OTP verified';
74
+ }
75
+ export interface VerifyOtpRetryableFailureResponse {
76
+ success: false;
77
+ verified: false;
78
+ channel: OtpChannel;
79
+ direction?: OtpDirection;
80
+ message: 'Invalid or expired OTP';
81
+ triesLeft: number;
82
+ }
83
+ export interface VerifyOtpTerminalFailureResponse {
84
+ success: false;
85
+ verified: false;
86
+ message: 'Too many attempts' | 'Invalid or expired OTP';
87
+ }
88
+ export type VerifyOtpResponse = VerifyOtpSuccessResponse | VerifyOtpRetryableFailureResponse | VerifyOtpTerminalFailureResponse;
89
+ export interface VerifyInboundReceiptRequest {
90
+ type: InboundReceiptType;
91
+ dongle: string;
92
+ from: string;
93
+ code?: string;
94
+ text?: string;
95
+ }
96
+ export interface VerifyInboundSuccessResponse {
97
+ success: true;
98
+ verified: true;
99
+ sessionId: string;
100
+ channel: 'call' | 'sms';
101
+ direction: 'inbound';
102
+ type: InboundReceiptType;
103
+ message: 'OTP verified';
104
+ }
105
+ export interface VerifyInboundFailureResponse {
106
+ success: false;
107
+ verified: false;
108
+ type: InboundReceiptType;
109
+ channel?: 'call' | 'sms';
110
+ direction?: 'inbound';
111
+ message: 'Invalid or expired OTP' | 'Too many attempts';
112
+ triesLeft?: number;
113
+ }
114
+ export type VerifyInboundResponse = VerifyInboundSuccessResponse | VerifyInboundFailureResponse;
115
+ export interface SendSmsRequest {
116
+ phone: string;
117
+ text: string;
118
+ device?: string;
119
+ }
120
+ export interface SendSmsResponse {
121
+ success: true;
122
+ phone: string;
123
+ device: string;
124
+ }
125
+ export interface DongleDevice {
126
+ id: string;
127
+ group: number;
128
+ state: DongleState;
129
+ rssi: number;
130
+ mode: number;
131
+ submode: number;
132
+ provider: string;
133
+ model: string;
134
+ firmware: string;
135
+ imei: string;
136
+ imsi: string;
137
+ number: string;
138
+ updatedAt: string;
139
+ }
140
+ export interface HealthIndicatorResult {
141
+ status: string;
142
+ message?: string;
143
+ [key: string]: unknown;
144
+ }
145
+ export interface HealthCheckResponse {
146
+ status: string;
147
+ info: Record<string, HealthIndicatorResult>;
148
+ error: Record<string, HealthIndicatorResult>;
149
+ details: Record<string, HealthIndicatorResult>;
150
+ }
151
+ export interface OtpSubscribedEvent {
152
+ event: 'otp.subscribed';
153
+ data: {
154
+ sessionId: string;
155
+ expiresAt: number;
156
+ };
157
+ }
158
+ export interface OtpAttemptEvent {
159
+ event: 'otp.attempt';
160
+ data: {
161
+ sessionId: string;
162
+ channel: OtpChannel;
163
+ direction: OtpDirection;
164
+ phone: string;
165
+ serverPhone: string;
166
+ attempts: number;
167
+ maxTries: number;
168
+ triesLeft: number;
169
+ status: 'PENDING';
170
+ attemptedAt: string;
171
+ message: 'Invalid or expired OTP';
172
+ };
173
+ }
174
+ export interface OtpLockedEvent {
175
+ event: 'otp.locked';
176
+ data: {
177
+ sessionId: string;
178
+ channel: OtpChannel;
179
+ direction: OtpDirection;
180
+ phone: string;
181
+ serverPhone: string;
182
+ attempts: number;
183
+ maxTries: number;
184
+ triesLeft: 0;
185
+ status: 'LOCKED';
186
+ lockedAt: string;
187
+ message: 'Too many attempts';
188
+ };
189
+ }
190
+ export interface OtpVerifiedEvent {
191
+ event: 'otp.verified';
192
+ data: {
193
+ sessionId: string;
194
+ channel: OtpChannel;
195
+ direction: OtpDirection;
196
+ phone: string;
197
+ serverPhone: string;
198
+ verifiedAt: string;
199
+ };
200
+ }
201
+ export interface OtpExpiredEvent {
202
+ event: 'otp.expired';
203
+ data: {
204
+ sessionId: string;
205
+ };
206
+ }
207
+ export interface OtpLongPollSuccessResponse {
208
+ success: true;
209
+ verified: true;
210
+ sessionId: string;
211
+ channel: OtpChannel;
212
+ direction: OtpDirection;
213
+ phone: string;
214
+ serverPhone: string;
215
+ status: 'VERIFIED';
216
+ verifiedAt: string;
217
+ message: 'OTP verified';
218
+ }
219
+ export interface OtpLongPollFailureResponse {
220
+ success: false;
221
+ verified: false;
222
+ sessionId: string;
223
+ channel?: OtpChannel;
224
+ direction?: OtpDirection;
225
+ phone?: string;
226
+ serverPhone?: string;
227
+ status?: 'LOCKED' | 'EXPIRED';
228
+ attempts?: number;
229
+ maxTries?: number;
230
+ triesLeft?: number;
231
+ lockedAt?: string;
232
+ expiredAt?: string;
233
+ message: string;
234
+ }
235
+ export type OtpSessionEvent = OtpSubscribedEvent | OtpAttemptEvent | OtpLockedEvent | OtpVerifiedEvent | OtpExpiredEvent;
236
+ export interface WebSocketLike extends EventTarget {
237
+ readonly readyState: number;
238
+ close(code?: number, reason?: string): void;
239
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
240
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null): void;
241
+ }
242
+ export type WebSocketFactory = (url: string) => WebSocketLike;
243
+ export interface SubscribeToSessionOptions {
244
+ signal?: AbortSignal;
245
+ onEvent?: (event: OtpSessionEvent) => void;
246
+ onSubscribed?: (event: OtpSubscribedEvent) => void;
247
+ onAttempt?: (event: OtpAttemptEvent) => void;
248
+ onLocked?: (event: OtpLockedEvent) => void;
249
+ onVerified?: (event: OtpVerifiedEvent) => void;
250
+ onExpired?: (event: OtpExpiredEvent) => void;
251
+ onError?: (error: unknown) => void;
252
+ onClose?: (event: CloseEvent) => void;
253
+ }
254
+ export interface WaitForConfirmationOptions extends Pick<SubscribeToSessionOptions, 'signal' | 'onEvent' | 'onSubscribed' | 'onAttempt' | 'onLocked' | 'onVerified' | 'onExpired' | 'onError' | 'onClose'> {
255
+ timeoutMs?: number;
256
+ }
257
+ export interface WaitForConfirmationLongPollOptions {
258
+ signal?: AbortSignal;
259
+ }
260
+ export interface OtpSessionSubscription {
261
+ readonly sessionId: string;
262
+ readonly closed: Promise<void>;
263
+ close(code?: number, reason?: string): void;
264
+ }
265
+ export interface SiymoOtpClientOptions {
266
+ baseUrl?: string;
267
+ baseUrlEnvVar?: string | readonly string[];
268
+ websocketBaseUrl?: string;
269
+ defaultHeaders?: HeadersInit;
270
+ fetch?: typeof fetch;
271
+ webSocketFactory?: WebSocketFactory;
272
+ }
273
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@siymo/otp-sdk-core",
3
+ "version": "1.0.0",
4
+ "description": "TypeScript SDK for the siymo-otp-service REST and WebSocket APIs",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "prepack": "npm run build",
25
+ "test": "npm run build && node --test ./test/**/*.test.js"
26
+ },
27
+ "keywords": [
28
+ "otp",
29
+ "sdk",
30
+ "typescript",
31
+ "websocket",
32
+ "nest"
33
+ ],
34
+ "devDependencies": {
35
+ "typescript": "^5.0.0"
36
+ }
37
+ }