@narcisbodea/smstunnel-sdk
v1.2.1
Published
SMSTunnel SDK - Node.js + NestJS + React + Vue + Angular + framework-agnostic client for SMS pairing & E2E encryption
Readme
@narcisbodea/smstunnel-sdk
SMSTunnel SDK - Node.js + NestJS + React + Vue + Angular + framework-agnostic client for SMS pairing & E2E encryption.
| Import path | Scope |
|---|---|
| @narcisbodea/smstunnel-sdk | SmsTunnelClient class + types + labels (Svelte, Astro, Solid, Vanilla JS) |
| @narcisbodea/smstunnel-sdk/server | NestJS module (backend proxy) |
| @narcisbodea/smstunnel-sdk/react | React components + hook (Next.js, Remix, Gatsby) |
| @narcisbodea/smstunnel-sdk/vue | Vue 3 components + composable (Nuxt) |
| @narcisbodea/smstunnel-sdk/angular | Angular 17+ injectable service |
| @narcisbodea/smstunnel-sdk/node | Standalone Node.js client (no framework, API key auth) |
Install
npm install @narcisbodea/smstunnel-sdkNode.js (Standalone)
Works without NestJS or any framework. Uses native fetch (Node 18+), zero external dependencies.
import { SmsTunnelNodeClient } from '@narcisbodea/smstunnel-sdk/node';
const client = new SmsTunnelNodeClient({
apiKey: 'sk_live_xxx',
serverUrl: 'https://smstunnel.io',
});
// Send SMS
const result = await client.sendSms('+40721123456', 'Hello!');
// Send 2FA
await client.send2FaSms('+40721123456', 'Your code is 123456');
// Send bulk SMS
await client.sendBulkSms(['+40721111111', '+40722222222'], 'Bulk message');
// Get message status
const status = await client.getMessageStatus('msg-id');
// Get received SMS (inbox)
const inbox = await client.getReceivedSms({ limit: 20, page: 1 });
// List devices
const devices = await client.getDevices();
// E2E Encryption
const e2eStatus = await client.getDeviceE2EStatus('device-id');
const publicKey = await client.getDevicePublicKey('device-id');
const verification = await client.verifyDeviceKey('device-id', 'stored-fingerprint');
await client.sendEncryptedSms('encrypted-payload', 'device-id', {
is2FA: false,
expectedFingerprint: 'a1b2c3d4',
});
// Unified Pairing v2
const pairing = await client.createPairingToken({
source: 'api',
displayName: 'My App',
});
const pairingStatus = await client.getPairingTokenStatus(pairing.data.token);
// E2E Pairing (with public key exchange)
const e2ePairing = await client.createE2EPairing({ siteName: 'My App' });
const e2eResult = await client.pollE2EPairing(e2ePairing.data.token);
// e2eResult.data.publicKey contains the device's RSA public keyServer (NestJS)
1. Implement a Storage Adapter
The SDK is database-agnostic. You provide a simple adapter with 3 methods:
MongoDB (Mongoose)
import { SmsTunnelStorageAdapter, SmsTunnelConfig } from '@narcisbodea/smstunnel-sdk/server';
import { Model } from 'mongoose';
export class MongoSmsTunnelStorage implements SmsTunnelStorageAdapter {
constructor(private model: Model<any>) {}
async getConfig(): Promise<Partial<SmsTunnelConfig>> {
const doc = await this.model.findOne().lean().exec();
return {
serverUrl: doc?.smstunnelServerUrl || '',
apiKey: doc?.smstunnelApiKey || '',
siteToken: doc?.smstunnelSiteToken || '',
deviceName: doc?.smstunnelDeviceName || '',
};
}
async updateConfig(partial: Partial<SmsTunnelConfig>): Promise<void> {
const $set: Record<string, string> = {};
if (partial.serverUrl !== undefined) $set.smstunnelServerUrl = partial.serverUrl;
if (partial.apiKey !== undefined) $set.smstunnelApiKey = partial.apiKey;
if (partial.siteToken !== undefined) $set.smstunnelSiteToken = partial.siteToken;
if (partial.deviceName !== undefined) $set.smstunnelDeviceName = partial.deviceName;
await this.model.updateOne({}, { $set });
}
async clearConfig(): Promise<void> {
await this.model.updateOne({}, {
$set: { smstunnelApiKey: '', smstunnelDeviceName: '', smstunnelSiteToken: '' },
});
}
}Prisma (PostgreSQL)
import { SmsTunnelStorageAdapter, SmsTunnelConfig } from '@narcisbodea/smstunnel-sdk/server';
import { PrismaClient } from '@prisma/client';
export class PrismaSmsTunnelStorage implements SmsTunnelStorageAdapter {
constructor(private prisma: PrismaClient) {}
async getConfig(): Promise<Partial<SmsTunnelConfig>> {
const row = await this.prisma.appSettings.findFirst();
return {
serverUrl: row?.smstunnelServerUrl || '',
apiKey: row?.smstunnelApiKey || '',
siteToken: row?.smstunnelSiteToken || '',
deviceName: row?.smstunnelDeviceName || '',
};
}
async updateConfig(partial: Partial<SmsTunnelConfig>): Promise<void> {
await this.prisma.appSettings.updateMany({ data: partial });
}
async clearConfig(): Promise<void> {
await this.prisma.appSettings.updateMany({
data: { apiKey: '', deviceName: '', siteToken: '' },
});
}
}JSON File
import { SmsTunnelStorageAdapter, SmsTunnelConfig } from '@narcisbodea/smstunnel-sdk/server';
import { readFile, writeFile } from 'fs/promises';
export class JsonFileStorage implements SmsTunnelStorageAdapter {
constructor(private filePath: string) {}
async getConfig(): Promise<Partial<SmsTunnelConfig>> {
try { return JSON.parse(await readFile(this.filePath, 'utf-8')); }
catch { return {}; }
}
async updateConfig(partial: Partial<SmsTunnelConfig>): Promise<void> {
const config = { ...(await this.getConfig()), ...partial };
await writeFile(this.filePath, JSON.stringify(config, null, 2));
}
async clearConfig(): Promise<void> {
await this.updateConfig({ apiKey: '', deviceName: '', siteToken: '' });
}
}2. Register the Module
import { SmsTunnelModule } from '@narcisbodea/smstunnel-sdk/server';
@Module({
imports: [
SmsTunnelModule.forRoot({
storage: new MongoSmsTunnelStorage(settingsModel),
callbackBaseUrl: 'https://myapp.com/api',
displayName: 'My App',
}),
],
})
export class AppModule {}Or async:
SmsTunnelModule.forRootAsync({
useFactory: (settingsModel) => ({
storage: new MongoSmsTunnelStorage(settingsModel),
callbackBaseUrl: process.env.CALLBACK_URL,
displayName: 'My App',
}),
inject: [getModelToken('Settings')],
})Multi-Tenant (Enterprise/SaaS) Mode
For multi-tenant applications (SaaS platforms, CRMs, ERPs) where each tenant needs their own SMS device:
SmsTunnelModule.forRootAsync({
useFactory: (settingsModel, tenantService) => ({
storage: new MongoSmsTunnelStorage(settingsModel),
callbackBaseUrl: process.env.CALLBACK_URL,
displayName: 'My SaaS App',
enterpriseApiKey: process.env.SMSTUNNEL_ENTERPRISE_API_KEY,
externalId: tenantService.getCurrentTenantId(),
}),
inject: [getModelToken('Settings'), TenantService],
})3. Auth Guard Integration
import { SMSTUNNEL_PUBLIC_PATHS } from '@narcisbodea/smstunnel-sdk/server';
// In your JwtAuthGuard:
if (SMSTUNNEL_PUBLIC_PATHS.some(p => request.url.includes(p))) return true;REST Endpoints
| Route | Method | Auth | Purpose |
|-------|--------|------|---------|
| GET /smstunnel/status | getStatus | Yes | Show pairing status |
| POST /smstunnel/create-token | createToken | Yes | Generate QR |
| GET /smstunnel/pairing-status/:token | pairingStatus | No | Polling proxy |
| POST /smstunnel/callback | callback | No | Receive API key |
| POST /smstunnel/unpair | unpair | Yes | Disconnect device |
| POST /smstunnel/send | sendSms | Yes | Send SMS |
| POST /smstunnel/update-config | updateConfig | Yes | Set server URL |
| GET /smstunnel/e2e-status/:deviceId | getDeviceE2EStatus | Yes | E2E encryption status |
| GET /smstunnel/public-key/:deviceId | getDevicePublicKey | Yes | Get device public key |
| POST /smstunnel/verify-key | verifyDeviceKey | Yes | Verify key fingerprint |
| POST /smstunnel/send-encrypted | sendEncryptedSms | Yes | Send encrypted SMS |
| GET /smstunnel/pairings | getPairings | Yes | List active pairings |
React
Works with: React, Next.js, Remix, Gatsby
Plug and Play Component
import { SmsTunnelPairing, RO_LABELS } from '@narcisbodea/smstunnel-sdk/react';
function SettingsPage() {
return (
<SmsTunnelPairing
apiBaseUrl="/api"
getAuthHeaders={() => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
})}
labels={RO_LABELS}
onPaired={(device) => console.log('Paired:', device)}
showTestSms={true}
showServerUrlInput={true}
qrSize={220}
/>
);
}Next.js
Add 'use client' in your page/component:
'use client';
import { SmsTunnelPairing, RO_LABELS } from '@narcisbodea/smstunnel-sdk/react';
export default function SmsSettings() {
return <SmsTunnelPairing apiBaseUrl="/api" labels={RO_LABELS} />;
}Custom UI with Hook
import { useSmsTunnel, QrCodeCanvas } from '@narcisbodea/smstunnel-sdk/react';
function MySmsSettings() {
const tunnel = useSmsTunnel({
apiBaseUrl: '/api',
getAuthHeaders: () => ({ Authorization: `Bearer ${token}` }),
});
// E2E Encryption
const e2eStatus = await tunnel.getDeviceE2EStatus('device-id');
const publicKey = await tunnel.getDevicePublicKey('device-id');
const keyValid = await tunnel.verifyDeviceKey('device-id', 'fingerprint');
await tunnel.sendEncryptedSms('payload', 'device-id');
if (tunnel.status === 'paired') {
return <div>Connected: {tunnel.deviceName}</div>;
}
return (
<div>
{tunnel.showQr ? (
<>
<QrCodeCanvas value={tunnel.qrData} size={200} />
<button onClick={tunnel.cancelPairing}>Cancel</button>
</>
) : (
<button onClick={tunnel.generateQr} disabled={tunnel.generating}>
Connect Phone
</button>
)}
</div>
);
}Vue
Works with: Vue 3, Nuxt 3
Plug and Play Component
<script setup>
import { SmsTunnelPairing, RO_LABELS } from '@narcisbodea/smstunnel-sdk/vue';
function onPaired(device) {
console.log('Paired:', device);
}
</script>
<template>
<SmsTunnelPairing
api-base-url="/api"
:labels="RO_LABELS"
:show-test-sms="true"
:show-server-url-input="true"
@paired="onPaired"
/>
</template>Nuxt 3
Wrap in <ClientOnly> since pairing uses browser APIs:
<template>
<ClientOnly>
<SmsTunnelPairing api-base-url="/api" :labels="RO_LABELS" />
</ClientOnly>
</template>
<script setup>
import { SmsTunnelPairing, RO_LABELS } from '@narcisbodea/smstunnel-sdk/vue';
</script>Custom UI with Composable
<script setup>
import { useSmsTunnel, QrCodeCanvas } from '@narcisbodea/smstunnel-sdk/vue';
const tunnel = useSmsTunnel({
apiBaseUrl: '/api',
getAuthHeaders: () => ({ Authorization: `Bearer ${token}` }),
});
// E2E Encryption
const e2eStatus = await tunnel.getDeviceE2EStatus('device-id');
const publicKey = await tunnel.getDevicePublicKey('device-id');
await tunnel.sendEncryptedSms('payload', 'device-id');
</script>
<template>
<div v-if="tunnel.status.value === 'paired'">
Connected: {{ tunnel.deviceName.value }}
<button @click="tunnel.unpair()">Disconnect</button>
</div>
<div v-else>
<div v-if="tunnel.showQr.value">
<QrCodeCanvas :value="tunnel.qrData.value" :size="200" />
<button @click="tunnel.cancelPairing()">Cancel</button>
</div>
<button v-else @click="tunnel.generateQr()" :disabled="tunnel.generating.value">
Connect Phone
</button>
</div>
</template>Angular
Works with: Angular 17+ (standalone)
Setup
// app.config.ts
import { provideSmsTunnel } from '@narcisbodea/smstunnel-sdk/angular';
export const appConfig: ApplicationConfig = {
providers: [
provideSmsTunnel({
apiBaseUrl: 'https://your-backend.com',
getAuthHeaders: () => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
}),
}),
],
};Usage
import { Component, inject } from '@angular/core';
import { SmsTunnelService } from '@narcisbodea/smstunnel-sdk/angular';
@Component({ /* ... */ })
export class SmsPairingComponent {
private sms = inject(SmsTunnelService);
async connect() {
// Pairing
const status = await this.sms.getStatus();
const token = await this.sms.createToken();
const pairings = await this.sms.getPairings();
await this.sms.unpair();
// SMS
await this.sms.sendSms('+40721123456', 'Hello!');
await this.sms.send2fa('+40721123456', '123456');
await this.sms.sendBulk([{ to: '+40721111111', message: 'Hi' }]);
const msgStatus = await this.sms.getSmsStatus('msg-id');
const inbox = await this.sms.getReceivedSms();
// Devices
const devices = await this.sms.getDevices();
const usage = await this.sms.getUsage();
// E2E Encryption
const e2eStatus = await this.sms.getDeviceE2EStatus('device-id');
const publicKey = await this.sms.getDevicePublicKey('device-id');
const verification = await this.sms.verifyDeviceKey('device-id', 'fingerprint');
await this.sms.sendEncryptedSms('payload', 'device-id');
}
}Svelte
<script>
import { SmsTunnelClient } from '@narcisbodea/smstunnel-sdk';
import { onMount, onDestroy } from 'svelte';
let status = 'loading';
let qrData = '';
let deviceName = '';
const client = new SmsTunnelClient({
apiBaseUrl: '/api',
getAuthHeaders: () => ({
Authorization: `Bearer ${localStorage.getItem('token')}`,
}),
});
onMount(async () => {
const s = await client.getStatus();
status = s.paired ? 'paired' : 'unpaired';
deviceName = s.deviceName || '';
});
onDestroy(() => client.destroy());
async function connect() {
const result = await client.createToken();
if (result.success) {
qrData = result.qrData;
client.startPolling(result.token, async (event) => {
if (event === 'completed') {
const s = await client.getStatus();
status = 'paired';
deviceName = s.deviceName || '';
}
});
}
}
</script>
{#if status === 'paired'}
<p>Connected: {deviceName}</p>
<button on:click={() => client.unpair()}>Disconnect</button>
{:else}
<button on:click={connect}>Connect Phone</button>
{/if}Astro
Astro supports React, Vue, and Svelte islands. Use any of the above:
---
// pages/settings.astro
---
<script>
import { SmsTunnelPairing, RO_LABELS } from '@narcisbodea/smstunnel-sdk/react';
</script>
<SmsTunnelPairing client:only="react" apiBaseUrl="/api" labels={RO_LABELS} />Or with Vue:
<SmsTunnelPairing client:only="vue" api-base-url="/api" />E2E Encryption
All SDKs support end-to-end encryption using RSA-2048. The device holds the private key, the server stores the public key.
Flow
- Check if device has E2E enabled:
getDeviceE2EStatus(deviceId) - Get the device's public key:
getDevicePublicKey(deviceId) - Encrypt your message with the RSA public key (client-side)
- Send encrypted:
sendEncryptedSms(encryptedPayload, deviceId) - Device decrypts with its private key
Key Verification
Store the publicKeyFingerprint locally after first pairing. Before sending, verify it hasn't changed:
const result = await client.verifyDeviceKey('device-id', 'stored-fingerprint');
if (result.needsRePairing) {
// Device was reinstalled, key changed - need to re-pair
}SmsTunnelClient API (framework-agnostic)
import { SmsTunnelClient } from '@narcisbodea/smstunnel-sdk';
const client = new SmsTunnelClient({
apiBaseUrl: '/api',
getAuthHeaders: () => ({ Authorization: 'Bearer ...' }),
routePrefix: 'smstunnel', // default
pollInterval: 3000, // default
});
// Pairing
await client.getStatus(); // { paired, serverUrl, deviceName }
await client.createToken(); // { success, token, qrData, ... }
await client.getPairingStatus(token); // { status, displayName }
client.startPolling(token, (event) => ...); // returns cleanup fn
client.stopPolling();
await client.unpair();
// SMS
await client.sendSms('+40741234567', 'Hi');
await client.updateServerUrl('https://...');
// E2E Encryption
await client.getDeviceE2EStatus('device-id');
await client.getDevicePublicKey('device-id');
await client.verifyDeviceKey('device-id', 'fingerprint');
await client.sendEncryptedSms('payload', 'device-id');
client.destroy(); // cleanupLabels (i18n)
Built-in presets: EN_LABELS (default), RO_LABELS.
Available from any import path:
import { EN_LABELS, RO_LABELS } from '@narcisbodea/smstunnel-sdk';
import { EN_LABELS, RO_LABELS } from '@narcisbodea/smstunnel-sdk/react';
import { EN_LABELS, RO_LABELS } from '@narcisbodea/smstunnel-sdk/vue';Custom labels: implement the SmsTunnelLabels interface.
Exports Summary
| Path | Exports |
|------|---------|
| @narcisbodea/smstunnel-sdk | SmsTunnelClient, EN_LABELS, RO_LABELS, types |
| @narcisbodea/smstunnel-sdk/server | SmsTunnelModule, SmsTunnelService, SMSTUNNEL_PUBLIC_PATHS |
| @narcisbodea/smstunnel-sdk/react | SmsTunnelPairing, QrCodeCanvas, useSmsTunnel |
| @narcisbodea/smstunnel-sdk/vue | SmsTunnelPairing, QrCodeCanvas, useSmsTunnel |
| @narcisbodea/smstunnel-sdk/angular | SmsTunnelService, provideSmsTunnel, SMSTUNNEL_CONFIG |
| @narcisbodea/smstunnel-sdk/node | SmsTunnelNodeClient |
License
MIT
