@zi2/relay-sdk 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,855 @@
1
+ # @zi2/relay-sdk
2
+
3
+ Enterprise-grade SMS relay SDK that routes messages through physical Android devices using end-to-end encryption. Instead of paying per-message fees to cloud SMS providers, deploy your own relay devices and send SMS through their native SIM cards — with full compliance, multi-tenant isolation, and automatic provider fallback.
4
+
5
+ ## Key Features
6
+
7
+ - **End-to-end encryption** — X25519 key exchange + HKDF-SHA256 + AES-256-GCM. The server never sees plaintext message content.
8
+ - **Provider fallback** — Automatically fail over to Twilio, Vonage, or any cloud provider when a device goes offline.
9
+ - **Multi-tenant** — Organization-scoped relay devices, rate limits, and audit trails.
10
+ - **10 languages** — Built-in i18n for EN, FR, ES, DE, JA, AR, ZH, PT, KO, IT.
11
+ - **PCI DSS v4 + SOC 2 compliant** — Encryption at rest, TLS enforcement, brute-force protection, audit logging, auto-redacting logger.
12
+ - **Database-agnostic** — Ships with Prisma and in-memory adapters; implement the `DatabaseAdapter` interface for any storage backend.
13
+ - **White-label Android app** — Rebrand and deploy custom relay apps with a single config file.
14
+
15
+ ---
16
+
17
+ ## Table of Contents
18
+
19
+ 1. [Installation](#installation)
20
+ 2. [Quick Start](#quick-start)
21
+ 3. [Architecture](#architecture)
22
+ 4. [Configuration](#configuration)
23
+ 5. [Database Adapters](#database-adapters)
24
+ 6. [Pairing Flow](#pairing-flow)
25
+ 7. [Sending SMS](#sending-sms)
26
+ 8. [Provider Fallback](#provider-fallback)
27
+ 9. [WebSocket Protocol](#websocket-protocol)
28
+ 10. [Fastify Plugin](#fastify-plugin)
29
+ 11. [Error Codes](#error-codes)
30
+ 12. [Events](#events)
31
+ 13. [Health Service](#health-service)
32
+ 14. [Security & Compliance](#security--compliance)
33
+ 15. [i18n](#i18n)
34
+ 16. [White-Label Android App](#white-label-android-app)
35
+ 17. [License](#license)
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pnpm add @zi2/relay-sdk
43
+ ```
44
+
45
+ Optional peer dependencies — install only what you need:
46
+
47
+ ```bash
48
+ # For PrismaAdapter (production database)
49
+ pnpm add @prisma/client
50
+
51
+ # For Fastify plugin (REST + WebSocket server)
52
+ pnpm add fastify @fastify/websocket
53
+
54
+ # For WebSocket support (Node.js server)
55
+ pnpm add ws
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Quick Start
61
+
62
+ ```typescript
63
+ import { createRelay, AesGcmEncryption } from '@zi2/relay-sdk';
64
+ import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
65
+ import { PrismaClient } from '@prisma/client';
66
+
67
+ const prisma = new PrismaClient();
68
+
69
+ const sdk = createRelay({
70
+ db: new PrismaAdapter(prisma),
71
+ encryption: new AesGcmEncryption(process.env.RELAY_ENCRYPTION_KEY!),
72
+ apiUrl: 'https://api.yourapp.com',
73
+ wsUrl: 'wss://api.yourapp.com/ws/relay',
74
+ });
75
+
76
+ // 1. Pair a device (returns QR code data for the Android app to scan)
77
+ const pairing = await sdk.initiatePairing('org_123', 'user_456');
78
+ // → { pairingId, serverPublicKey, pairingToken, wsUrl }
79
+
80
+ // 2. Send an SMS through the paired relay device
81
+ const result = await sdk.sendSMS({
82
+ relayId: 'relay_abc',
83
+ orgId: 'org_123',
84
+ to: '+1234567890',
85
+ body: 'Your verification code is 847291',
86
+ });
87
+ // → { messageId: 'msg_xyz', status: 'delivered' }
88
+
89
+ // 3. Check device status
90
+ const online = sdk.isRelayOnline('relay_abc'); // true
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Architecture
96
+
97
+ The SDK is organized into six layers:
98
+
99
+ ```
100
+ ┌─────────────────────────────────────────────────────────┐
101
+ │ Your Application │
102
+ ├─────────────────────────────────────────────────────────┤
103
+ │ Server Layer │ Routes, Fastify plugin, WS handler │
104
+ ├─────────────────────────────────────────────────────────┤
105
+ │ Core Layer │ Crypto, Queue, Pairing, Health │
106
+ ├─────────────────────────────────────────────────────────┤
107
+ │ Adapter Layer │ DB, Encryption, Broadcast, Audit │
108
+ ├─────────────────────────────────────────────────────────┤
109
+ │ Provider Layer │ PhoneRelay, Fallback │
110
+ ├─────────────────────────────────────────────────────────┤
111
+ │ i18n Layer │ 10 locales, error translations │
112
+ └─────────────────────────────────────────────────────────┘
113
+ ```
114
+
115
+ **Message flow:**
116
+
117
+ ```
118
+ Your Server Android Device Carrier
119
+ │ │ │
120
+ │ sdk.sendSMS(...) │ │
121
+ │──encrypt payload──► │ │
122
+ │ queue message │ │
123
+ │ │ │
124
+ │ ◄── WebSocket (wss://) ──► │ │
125
+ │ send_sms (encrypted) ──► │ │
126
+ │ │──native SMS──► │
127
+ │ │ │──► Recipient
128
+ │ │ ◄── delivery receipt ───│
129
+ │ ◄── sms_ack ──────────── │ │
130
+ │ ◄── delivery_receipt ─── │ │
131
+ │ │ │
132
+ │ broadcast to web UI │ │
133
+ └───────────────────────────────┘──────────────────────────┘
134
+ ```
135
+
136
+ ### Module Exports
137
+
138
+ | Import Path | Contents |
139
+ |---|---|
140
+ | `@zi2/relay-sdk` | `createRelay()`, types, constants, errors, crypto, adapters |
141
+ | `@zi2/relay-sdk/client` | Android/client-side: WebSocket client, E2E crypto, pairing, SMS sender, secure storage |
142
+ | `@zi2/relay-sdk/adapters/prisma` | `PrismaAdapter` |
143
+ | `@zi2/relay-sdk/fastify` | `relayPlugin` for Fastify |
144
+
145
+ ---
146
+
147
+ ## Configuration
148
+
149
+ ```typescript
150
+ interface RelaySDKConfig {
151
+ /** Required — Database adapter (PrismaAdapter, MemoryAdapter, or custom) */
152
+ db: DatabaseAdapter;
153
+
154
+ /** Required — Encryption adapter for data at rest (AesGcmEncryption or custom) */
155
+ encryption: EncryptionAdapter;
156
+
157
+ /** Optional — Broadcast adapter for real-time UI updates (e.g., SSE or WebSocket push) */
158
+ broadcast?: BroadcastAdapter;
159
+
160
+ /** Optional — Audit trail adapter for PCI DSS v4 Req 10 / SOC 2 CC7.2 compliance */
161
+ audit?: AuditAdapter;
162
+
163
+ /** Optional — Logger adapter with auto-redaction of sensitive fields */
164
+ logger?: LoggerAdapter;
165
+
166
+ /** Base URL for the API server (used in QR code pairing data). Default: 'http://localhost:3000' */
167
+ apiUrl?: string;
168
+
169
+ /** WebSocket URL for device connections (included in QR code). Default: empty string (falls back to apiUrl-derived WSS URL in pairing service) */
170
+ wsUrl?: string;
171
+
172
+ /** Enforce TLS-only WebSocket connections (PCI DSS v4 Req 4.2.1). Default: true */
173
+ enforceTls?: boolean;
174
+
175
+ /** Maximum auth failures per IP before lockout (PCI DSS v4 Req 8.3.4). Default: 10 */
176
+ maxAuthFailuresPerIp?: number;
177
+
178
+ /** Overridable rate limits and timeouts (use SCREAMING_CASE keys) */
179
+ limits?: {
180
+ AUTH_TIMEOUT_MS?: number; // Default: 5000
181
+ HEARTBEAT_INTERVAL_MS?: number; // Default: 30000
182
+ PAIRING_EXPIRY_MINUTES?: number; // Default: 5
183
+ MESSAGE_EXPIRY_HOURS?: number; // Default: 24
184
+ QUEUE_DRAIN_DELAY_MS?: number; // Default: 3000
185
+ DEFAULT_SMS_TIMEOUT_MS?: number; // Default: 30000
186
+ MAX_SMS_PER_MINUTE?: number; // Default: 20
187
+ MAX_SMS_PER_HOUR?: number; // Default: 200
188
+ MAX_SMS_PER_DAY?: number; // Default: 1000
189
+ DEGRADED_THRESHOLD_MS?: number; // Default: 300000 (5 minutes)
190
+ };
191
+ }
192
+ ```
193
+
194
+ ### Encryption Key Generation
195
+
196
+ Generate a 256-bit encryption key for `AesGcmEncryption`:
197
+
198
+ ```bash
199
+ openssl rand -hex 32
200
+ # → e.g. a1b2c3d4e5f6... (64 hex characters)
201
+ ```
202
+
203
+ Store it securely in your environment:
204
+
205
+ ```bash
206
+ RELAY_ENCRYPTION_KEY=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
207
+ ```
208
+
209
+ ---
210
+
211
+ ## Database Adapters
212
+
213
+ ### PrismaAdapter (Production)
214
+
215
+ ```typescript
216
+ import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
217
+ import { PrismaClient } from '@prisma/client';
218
+
219
+ const prisma = new PrismaClient();
220
+ const db = new PrismaAdapter(prisma);
221
+ ```
222
+
223
+ Required Prisma schema models:
224
+
225
+ ```prisma
226
+ model PhoneRelay {
227
+ id String @id @default(uuid())
228
+ organizationId String
229
+ deviceName String
230
+ platform String
231
+ phoneNumber String?
232
+ devicePublicKey String
233
+ sharedKeyEncrypted String
234
+ authTokenHash String
235
+ status String @default("active")
236
+ keyVersion Int @default(1)
237
+ lastKeyRotation DateTime @default(now())
238
+ lastSeenAt DateTime?
239
+ lastIpAddress String?
240
+ batteryLevel Int?
241
+ signalStrength Int?
242
+ totalSmsSent Int @default(0)
243
+ totalSmsFailed Int @default(0)
244
+ dailySmsSent Int @default(0)
245
+ dailyResetAt DateTime @default(now())
246
+ maxSmsPerMinute Int @default(20)
247
+ maxSmsPerHour Int @default(200)
248
+ maxSmsPerDay Int @default(1000)
249
+ pairedById String
250
+ createdAt DateTime @default(now())
251
+ updatedAt DateTime @updatedAt
252
+
253
+ organization Organization @relation(fields: [organizationId], references: [id])
254
+ messages RelayMessageQueue[]
255
+ }
256
+
257
+ model PhoneRelayPairing {
258
+ id String @id @default(uuid())
259
+ organizationId String
260
+ pairingToken String
261
+ serverPublicKey String
262
+ serverPrivateKeyEncrypted String
263
+ status String @default("pending")
264
+ initiatedById String
265
+ expiresAt DateTime
266
+ createdAt DateTime @default(now())
267
+ }
268
+
269
+ model RelayMessageQueue {
270
+ id String @id @default(uuid())
271
+ relayId String
272
+ organizationId String
273
+ encryptedPayload String
274
+ status String @default("pending")
275
+ attempts Int @default(0)
276
+ maxAttempts Int @default(3)
277
+ nativeMessageId String?
278
+ errorMessage String?
279
+ deliveryStatus String?
280
+ sentToDeviceAt DateTime?
281
+ ackedAt DateTime?
282
+ expiresAt DateTime
283
+ createdAt DateTime @default(now())
284
+ updatedAt DateTime @updatedAt
285
+
286
+ relay PhoneRelay @relation(fields: [relayId], references: [id])
287
+ }
288
+ ```
289
+
290
+ ### MemoryAdapter (Testing)
291
+
292
+ ```typescript
293
+ import { MemoryAdapter } from '@zi2/relay-sdk';
294
+
295
+ const db = new MemoryAdapter();
296
+ // Use db.clear() to reset between tests
297
+ ```
298
+
299
+ ### Custom Adapter
300
+
301
+ Implement the `DatabaseAdapter` interface to use any storage backend:
302
+
303
+ ```typescript
304
+ import type { DatabaseAdapter } from '@zi2/relay-sdk';
305
+
306
+ class MyCustomAdapter implements DatabaseAdapter {
307
+ // PhoneRelay CRUD
308
+ findRelay(id: string, orgId?: string): Promise<PhoneRelayRecord | null>;
309
+ findRelayByTokenHash(id: string, tokenHash: string, statuses: string[]): Promise<PhoneRelayRecord | null>;
310
+ findRelays(orgId: string, excludeStatus?: string): Promise<PhoneRelayRecord[]>;
311
+ createRelay(data: CreateRelayInput): Promise<PhoneRelayRecord>;
312
+ updateRelay(id: string, data: Partial<PhoneRelayRecord>): Promise<void>;
313
+ updateRelayByOrg(id: string, orgId: string, data: Partial<PhoneRelayRecord>): Promise<void>;
314
+ deleteRelay(id: string): Promise<void>;
315
+ incrementRelayStat(id: string, field: 'totalSmsSent' | 'totalSmsFailed' | 'dailySmsSent', amount?: number): Promise<void>;
316
+ countRelays(orgId: string): Promise<number>;
317
+ markDegradedRelays(threshold: Date): Promise<number>;
318
+ resetDailyCounters(): Promise<number>;
319
+
320
+ // PhoneRelayPairing
321
+ findPairing(id: string): Promise<PhoneRelayPairingRecord | null>;
322
+ createPairing(data: CreatePairingInput): Promise<PhoneRelayPairingRecord>;
323
+ updatePairingStatus(id: string, status: string): Promise<void>;
324
+ deleteExpiredPairings(): Promise<number>;
325
+
326
+ // RelayMessageQueue
327
+ findMessage(id: string): Promise<RelayMessageRecord | null>;
328
+ createMessage(data: CreateMessageInput): Promise<RelayMessageRecord>;
329
+ updateMessage(id: string, data: Partial<RelayMessageRecord>): Promise<void>;
330
+ findPendingMessages(relayId: string, limit: number): Promise<RelayMessageRecord[]>;
331
+ findMessages(relayId: string, limit: number, offset: number): Promise<{ messages: RelayMessageRecord[]; total: number }>;
332
+ deleteMessagesByRelay(relayId: string): Promise<number>;
333
+ expireStaleMessages(): Promise<number>;
334
+
335
+ // Batch operations
336
+ createMessages(batch: CreateMessageInput[]): Promise<RelayMessageRecord[]>;
337
+ updateMessages(ids: string[], data: Partial<RelayMessageRecord>): Promise<number>;
338
+ }
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Pairing Flow
344
+
345
+ Pairing establishes a secure E2E-encrypted channel between your server and an Android device.
346
+
347
+ ### Step-by-step
348
+
349
+ 1. **Server initiates pairing** — generates an X25519 key pair and a one-time pairing token.
350
+
351
+ ```typescript
352
+ const pairing = await sdk.initiatePairing('org_123', 'user_456');
353
+ // Returns: { pairingId, serverPublicKey, pairingToken, wsUrl }
354
+ ```
355
+
356
+ 2. **Display QR code** — encode the pairing data as a QR code in your web UI. The Android app scans it.
357
+
358
+ 3. **Device completes pairing** — the app sends its public key and device info to the server.
359
+
360
+ ```typescript
361
+ const result = await sdk.completePairing({
362
+ pairingId: pairing.pairingId,
363
+ pairingToken: pairing.pairingToken,
364
+ devicePublicKey: '<device X25519 public key>',
365
+ deviceName: 'Samsung Galaxy S24',
366
+ platform: 'android',
367
+ phoneNumber: '+15551234567',
368
+ });
369
+ // Returns: { relayId, authToken }
370
+ ```
371
+
372
+ 4. **X25519 key exchange** — the server derives a shared key from its private key and the device's public key using HKDF-SHA256. Both sides now hold the same AES-256-GCM key.
373
+
374
+ 5. **Device connects via WebSocket** — authenticates with the hashed auth token and begins relaying SMS.
375
+
376
+ ### Pairing expiry
377
+
378
+ Pairing sessions expire after 5 minutes (configurable via `limits.PAIRING_EXPIRY_MINUTES`). Expired sessions are cleaned up automatically by the health service.
379
+
380
+ ---
381
+
382
+ ## Sending SMS
383
+
384
+ ```typescript
385
+ const result = await sdk.sendSMS({
386
+ relayId: 'relay_abc',
387
+ orgId: 'org_123',
388
+ to: '+1234567890',
389
+ body: 'Your verification code is 847291',
390
+ timeoutMs: 30000, // optional, default 30s
391
+ });
392
+
393
+ console.log(result.messageId); // 'msg_xyz'
394
+ console.log(result.status); // 'delivered'
395
+ ```
396
+
397
+ ### Message Statuses
398
+
399
+ | Status | Description |
400
+ |---|---|
401
+ | `pending` | Message queued, waiting to be sent to device |
402
+ | `sent_to_device` | Encrypted payload delivered to device via WebSocket |
403
+ | `delivered` | Device confirmed SMS was sent by the carrier |
404
+ | `failed` | Delivery failed (device error, carrier rejection, etc.) |
405
+ | `expired` | Message exceeded its TTL (default: 24 hours) |
406
+
407
+ ### Message History
408
+
409
+ ```typescript
410
+ const messages = await sdk.getMessages('relay_abc', 'org_123', 50, 0);
411
+ // → { messages: [...], pagination: { total: 142, limit: 50, offset: 0 } }
412
+ ```
413
+
414
+ ### Rate Limits
415
+
416
+ Each relay device enforces three tiers of rate limiting (configurable per-device):
417
+
418
+ | Tier | Default | Max |
419
+ |---|---|---|
420
+ | Per minute | 20 | 60 |
421
+ | Per hour | 200 | 1,000 |
422
+ | Per day | 1,000 | 10,000 |
423
+
424
+ ---
425
+
426
+ ## Provider Fallback
427
+
428
+ Use `createFallbackProvider()` to automatically fall back to cloud SMS providers when a relay device goes offline:
429
+
430
+ ```typescript
431
+ import { createRelay } from '@zi2/relay-sdk';
432
+
433
+ const sdk = createRelay({ /* ... */ });
434
+
435
+ const provider = sdk.createFallbackProvider({
436
+ primary: sdk.createProvider('relay_abc', 'org_123'),
437
+ fallbacks: [twilioProvider, vonageProvider],
438
+ isOnline: () => sdk.isRelayOnline('relay_abc'),
439
+ onFallback: (providerName) => {
440
+ console.log(`Relay offline — fell back to ${providerName}`);
441
+ },
442
+ });
443
+
444
+ // Use as a unified SMS provider
445
+ const result = await provider.sendSms('+1234567890', 'Hello from ZI2');
446
+ ```
447
+
448
+ ### Implementing a Fallback Provider
449
+
450
+ Any cloud provider can serve as a fallback by implementing `SmsProviderAdapter`:
451
+
452
+ ```typescript
453
+ import type { SmsProviderAdapter, SmsSendResponse } from '@zi2/relay-sdk';
454
+
455
+ class TwilioProvider implements SmsProviderAdapter {
456
+ readonly name = 'twilio';
457
+
458
+ initialize(credentials: Record<string, string>): void {
459
+ // Set up Twilio client with credentials.accountSid, credentials.authToken
460
+ }
461
+
462
+ async sendSms(to: string, body: string): Promise<SmsSendResponse> {
463
+ // Call Twilio API
464
+ return { success: true, messageId: 'SM...' };
465
+ }
466
+
467
+ async validateCredentials(credentials: Record<string, string>): Promise<boolean> {
468
+ // Verify Twilio credentials are valid
469
+ return true;
470
+ }
471
+ }
472
+ ```
473
+
474
+ ---
475
+
476
+ ## WebSocket Protocol
477
+
478
+ Relay devices communicate with the server over a persistent WebSocket connection (`wss://`). All SMS payloads are E2E encrypted.
479
+
480
+ ### Message Types
481
+
482
+ | Type | Direction | Description |
483
+ |---|---|---|
484
+ | `auth` | Device -> Server | Authenticate with `relayId` and `token` |
485
+ | `auth_ok` | Server -> Device | Authentication successful |
486
+ | `send_sms` | Server -> Device | Encrypted SMS payload to deliver |
487
+ | `sms_ack` | Device -> Server | SMS send result (`messageId`, `status`, `nativeMessageId`, `errorMessage`) |
488
+ | `delivery_receipt` | Device -> Server | Carrier delivery confirmation (`messageId`, `deliveryStatus`) |
489
+ | `status` | Device -> Server | Device telemetry (`batteryLevel`, `signalStrength`) |
490
+ | `rekey` | Server -> Device | Initiate key rotation (new server public key) |
491
+ | `rekey_ack` | Device -> Server | Device's new public key for key rotation |
492
+
493
+ ### Connection Lifecycle
494
+
495
+ 1. Device opens WebSocket to `wss://your-server/ws/relay`
496
+ 2. Server starts a 5-second auth timeout
497
+ 3. Device sends `{ type: "auth", relayId: "...", token: "..." }`
498
+ 4. Server verifies token hash against database (timing-safe comparison)
499
+ 5. Server responds `{ type: "auth_ok" }` and begins heartbeat pings every 30s
500
+ 6. Server drains any pending messages from the queue
501
+ 7. On disconnect, server broadcasts offline status to web clients
502
+
503
+ ### Rate Limiting
504
+
505
+ Each WebSocket connection is rate-limited to 100 messages per 10-second window. Exceeding this closes the connection with code `4008`.
506
+
507
+ ### Close Codes
508
+
509
+ | Code | Meaning |
510
+ |---|---|
511
+ | `4001` | Authentication timeout (5s) |
512
+ | `4002` | Message sent before authentication |
513
+ | `4003` | Missing relayId or token in auth message |
514
+ | `4004` | Invalid credentials |
515
+ | `4005` | Replaced by new connection from same device |
516
+ | `4008` | Rate limit exceeded |
517
+ | `1000` | Pong timeout — device failed to respond to heartbeat ping |
518
+
519
+ ---
520
+
521
+ ## Fastify Plugin
522
+
523
+ Register the Fastify plugin to expose all relay REST endpoints and the WebSocket upgrade handler:
524
+
525
+ ```typescript
526
+ import Fastify from 'fastify';
527
+ import websocket from '@fastify/websocket';
528
+ import { relayPlugin } from '@zi2/relay-sdk/fastify';
529
+ import { createRelay, AesGcmEncryption } from '@zi2/relay-sdk';
530
+ import { PrismaAdapter } from '@zi2/relay-sdk/adapters/prisma';
531
+
532
+ const app = Fastify();
533
+ const sdk = createRelay({
534
+ db: new PrismaAdapter(prisma),
535
+ encryption: new AesGcmEncryption(process.env.RELAY_ENCRYPTION_KEY!),
536
+ });
537
+
538
+ await app.register(websocket);
539
+ await app.register(relayPlugin, { sdk, prefix: '/phone-relay' });
540
+
541
+ await app.listen({ port: 3000 });
542
+ ```
543
+
544
+ ### Registered Routes
545
+
546
+ | Method | Path | Description |
547
+ |---|---|---|
548
+ | `POST` | `/phone-relay/pair/initiate` | Start a new pairing session |
549
+ | `POST` | `/phone-relay/pair/complete` | Complete device pairing |
550
+ | `GET` | `/phone-relay/` | List all relay devices for the org |
551
+ | `GET` | `/phone-relay/:id` | Get relay device details |
552
+ | `PATCH` | `/phone-relay/:id` | Update relay settings (name, rate limits) |
553
+ | `DELETE` | `/phone-relay/:id` | Revoke a relay device |
554
+ | `POST` | `/phone-relay/:id/test` | Send a test SMS |
555
+ | `GET` | `/phone-relay/:id/messages` | List messages (paginated) |
556
+ | `GET` | `/ws/relay` | WebSocket upgrade for relay devices |
557
+
558
+ ---
559
+
560
+ ## Error Codes
561
+
562
+ All errors are returned as `RelayError` instances with a machine-readable code, HTTP status, user-safe message, and i18n key. Error messages are intentionally generic to comply with PCI DSS v4 Req 6.2.4 (no internal details leaked).
563
+
564
+ | Code | HTTP | Message | i18n Key |
565
+ |---|---|---|---|
566
+ | `RELAY_NOT_FOUND` | 404 | Relay device not found | `errors.relayNotFound` |
567
+ | `RELAY_INACTIVE` | 409 | Relay is not active | `errors.relayInactive` |
568
+ | `RELAY_OFFLINE` | 503 | Relay device is offline | `errors.relayOffline` |
569
+ | `PAIRING_NOT_FOUND` | 404 | Pairing session not found | `errors.pairingNotFound` |
570
+ | `PAIRING_EXPIRED` | 410 | Pairing session has expired | `errors.pairingExpired` |
571
+ | `PAIRING_INVALID_TOKEN` | 403 | Invalid pairing token | `errors.pairingInvalidToken` |
572
+ | `AUTH_TIMEOUT` | 408 | WebSocket authentication timeout | `errors.authTimeout` |
573
+ | `AUTH_FAILED` | 401 | Invalid relay credentials | `errors.authFailed` |
574
+ | `RATE_LIMITED` | 429 | Rate limit exceeded | `errors.rateLimited` |
575
+ | `ENCRYPTION_FAILED` | 500 | Encryption operation failed | `errors.encryptionFailed` |
576
+ | `REKEY_FAILED` | 500 | Key rotation failed | `errors.rekeyFailed` |
577
+ | `SMS_SEND_FAILED` | 502 | SMS delivery failed | `errors.smsSendFailed` |
578
+ | `SMS_TIMEOUT` | 504 | SMS acknowledgement timeout | `errors.smsTimeout` |
579
+
580
+ **Extended Error Codes (`RELAY_ERRORS_EXTENDED`)** — These are exported separately from `RELAY_ERRORS`:
581
+
582
+ | Code | HTTP | Message | i18n Key |
583
+ |---|---|---|---|
584
+ | `AUTH_LOCKED` | 423 | Too many failed attempts. Try again later. | `errors.authLocked` |
585
+ | `TLS_REQUIRED` | 426 | TLS/SSL connection required | `errors.tlsRequired` |
586
+ | `ORG_MISMATCH` | 403 | Organization access denied | `errors.orgMismatch` |
587
+
588
+ ### Error Handling
589
+
590
+ ```typescript
591
+ import { RelayError, RELAY_ERRORS } from '@zi2/relay-sdk';
592
+
593
+ try {
594
+ await sdk.sendSMS({ relayId, orgId, to, body });
595
+ } catch (err) {
596
+ if (err instanceof RelayError) {
597
+ console.log(err.code); // 'RELAY_OFFLINE'
598
+ console.log(err.statusCode); // 503
599
+ console.log(err.i18nKey); // 'errors.relayOffline'
600
+ console.log(err.toJSON()); // Safe for client response (no stack trace)
601
+ }
602
+ }
603
+ ```
604
+
605
+ ---
606
+
607
+ ## Events
608
+
609
+ Subscribe to real-time relay events for monitoring, alerting, or UI updates. Each subscription returns an unsubscribe function.
610
+
611
+ ```typescript
612
+ // Device came online
613
+ const unsub1 = sdk.onRelayOnline((relayId, orgId) => {
614
+ console.log(`Relay ${relayId} is online`);
615
+ });
616
+
617
+ // Device went offline
618
+ const unsub2 = sdk.onRelayOffline((relayId, orgId) => {
619
+ console.log(`Relay ${relayId} went offline`);
620
+ // Trigger fallback provider switch, send alert, etc.
621
+ });
622
+
623
+ // SMS delivered successfully
624
+ const unsub3 = sdk.onMessageDelivered((messageId, relayId) => {
625
+ console.log(`Message ${messageId} delivered via ${relayId}`);
626
+ });
627
+
628
+ // SMS delivery failed
629
+ const unsub4 = sdk.onMessageFailed((messageId, relayId, error) => {
630
+ console.error(`Message ${messageId} failed on ${relayId}: ${error}`);
631
+ });
632
+
633
+ // Clean up when shutting down
634
+ unsub1();
635
+ unsub2();
636
+ unsub3();
637
+ unsub4();
638
+ ```
639
+
640
+ ---
641
+
642
+ ## Health Service
643
+
644
+ The health service performs periodic maintenance tasks required for compliance and reliability.
645
+
646
+ ```typescript
647
+ // Start automatic health checks (runs every 60 seconds)
648
+ sdk.startHealthService();
649
+
650
+ // Or run a manual check
651
+ const report = await sdk.runHealthCheck();
652
+ console.log(report);
653
+ // {
654
+ // expiredMessages: 3, — stale messages marked as expired
655
+ // degradedRelays: 1, — relays with no heartbeat for 5+ minutes
656
+ // cleanedPairings: 0 — expired pairing sessions removed
657
+ // }
658
+
659
+ // Stop the service when shutting down
660
+ sdk.stopHealthService();
661
+ ```
662
+
663
+ ### What the Health Service Does
664
+
665
+ | Task | Description | Compliance |
666
+ |---|---|---|
667
+ | Expire stale messages | Marks messages past their TTL as `expired` | Data retention |
668
+ | Mark degraded relays | Flags devices not seen for 5+ minutes as `degraded` | Availability monitoring |
669
+ | Clean expired pairings | Removes pending pairing sessions past their expiry | PCI DSS v4 Req 8 |
670
+ | Reset daily counters | Resets per-device daily SMS counters at midnight | Rate limit enforcement |
671
+
672
+ ---
673
+
674
+ ## Security & Compliance
675
+
676
+ ### PCI DSS v4
677
+
678
+ | Requirement | Implementation |
679
+ |---|---|
680
+ | **Req 3.4.1** — Encryption at rest | `AesGcmEncryption` encrypts all stored keys and message payloads. No plaintext fallback in strict mode. |
681
+ | **Req 3.5.1** — Key management | X25519 key pairs generated per pairing. Shared keys encrypted at rest. Key material zero-filled after use. `destroy()` method for cleanup. |
682
+ | **Req 3.7.1** — Key strength | Enforces 256-bit (32-byte) encryption keys at construction time. |
683
+ | **Req 4.2.1** — TLS enforcement | `enforceTls: true` (default) rejects non-TLS WebSocket connections. |
684
+ | **Req 6.2.4** — Error handling | `RelayError.toJSON()` returns only code + message. No stack traces, database details, or crypto internals leak to clients. |
685
+ | **Req 8.3.4** — Brute-force protection | `AuthLimiter` locks out IPs after configurable failed auth attempts (default: 10). |
686
+ | **Req 10** — Audit trail | `AuditAdapter` interface logs all security-relevant actions: pairing, auth, SMS, revocation, key rotation. |
687
+ | **Req 11.3** — Security monitoring | Health service runs periodic checks on system integrity. |
688
+
689
+ ### SOC 2
690
+
691
+ | Control | Implementation |
692
+ |---|---|
693
+ | **CC6.1** — Logical access | Organization-scoped queries. Auto-redacting logger strips tokens, keys, SMS body, and credentials from all log output. |
694
+ | **CC7.2** — Audit trail | `AuditAdapter` records timestamped entries with action, org, relay, user, IP, and metadata. |
695
+
696
+ ### E2E Encryption Details
697
+
698
+ ```
699
+ Key Exchange: X25519 (Curve25519 Diffie-Hellman)
700
+ Key Derivation: HKDF-SHA256 with info string "zi2-relay-e2e-v1"
701
+ Payload Cipher: AES-256-GCM (12-byte IV, 16-byte auth tag)
702
+ Token Security: SHA-256 hashing, timing-safe comparison
703
+ Key Rotation: Server-initiated rekey via WebSocket protocol
704
+ ```
705
+
706
+ ### Auto-Redacting Logger
707
+
708
+ The built-in `ConsoleLogger` (and the `withRedaction()` wrapper for custom loggers) automatically redacts these fields from all log output:
709
+
710
+ `token`, `authToken`, `pairingToken`, `authTokenHash`, `sharedKey`, `sharedKeyEncrypted`, `privateKey`, `serverPrivateKey`, `serverPrivateKeyEncrypted`, `devicePublicKey`, `serverPublicKey`, `encryptedPayload`, `password`, `secret`, `credentials`, `authorization`, `cookie`, `body`
711
+
712
+ ```typescript
713
+ import { withRedaction } from '@zi2/relay-sdk';
714
+
715
+ const safeLogger = withRedaction(myPinoLogger);
716
+ const sdk = createRelay({ logger: safeLogger, /* ... */ });
717
+ ```
718
+
719
+ ---
720
+
721
+ ## i18n
722
+
723
+ The SDK ships with translations for 10 languages covering all UI strings and error messages.
724
+
725
+ ```typescript
726
+ import { getTranslation, getSupportedLocales, getRelayTranslations } from '@zi2/relay-sdk';
727
+
728
+ // Get a single translation
729
+ getTranslation('fr', 'relay.connected');
730
+ // → "Connect\u00e9"
731
+
732
+ getTranslation('ja', 'relay.e2eEncrypted');
733
+ // → "E2E\u6697\u53f7\u5316"
734
+
735
+ // List all supported locales
736
+ getSupportedLocales();
737
+ // → ['en', 'fr', 'es', 'de', 'ja', 'ar', 'zh', 'pt', 'ko', 'it']
738
+
739
+ // Get all translations for a locale (useful for client-side hydration)
740
+ const strings = getRelayTranslations('es');
741
+ ```
742
+
743
+ ### Supported Locales
744
+
745
+ | Code | Language |
746
+ |---|---|
747
+ | `en` | English |
748
+ | `fr` | French |
749
+ | `es` | Spanish |
750
+ | `de` | German |
751
+ | `ja` | Japanese |
752
+ | `ar` | Arabic |
753
+ | `zh` | Chinese (Simplified) |
754
+ | `pt` | Portuguese |
755
+ | `ko` | Korean |
756
+ | `it` | Italian |
757
+
758
+ ---
759
+
760
+ ## White-Label Android App
761
+
762
+ The relay Android app can be white-labeled for custom deployments. A configuration file and build script handle rebranding.
763
+
764
+ ### Configuration
765
+
766
+ Create a `relay.config.json` in your project:
767
+
768
+ ```json
769
+ {
770
+ "appId": "com.yourcompany.smsrelay",
771
+ "appName": "YourBrand SMS Relay",
772
+ "serverUrl": "wss://api.yourcompany.com/ws/relay",
773
+ "brandColor": "#FF6600",
774
+ "notificationTitle": "YourBrand SMS Relay",
775
+ "notificationText": "Relay is active — ready to send SMS"
776
+ }
777
+ ```
778
+
779
+ | Field | Description |
780
+ |---|---|
781
+ | `appId` | Android application ID (reverse domain). Must be unique on Google Play. |
782
+ | `appName` | Display name shown on the device home screen and in settings. |
783
+ | `serverUrl` | WebSocket URL the app connects to. Must use `wss://` in production. |
784
+ | `brandColor` | Primary brand color (hex). Applied to the app theme. |
785
+ | `notificationTitle` | Title of the persistent foreground service notification. |
786
+ | `notificationText` | Body text of the foreground service notification. |
787
+
788
+ ### Building
789
+
790
+ ```bash
791
+ # Default config (./relay.config.json)
792
+ ./build-whitelabel.sh
793
+
794
+ # Custom config path
795
+ ./build-whitelabel.sh /path/to/client-config.json
796
+ ```
797
+
798
+ The script:
799
+ 1. Reads the config JSON
800
+ 2. Generates `capacitor.config.ts` with the custom `appId` and `appName`
801
+ 3. Updates `android/app/src/main/res/values/strings.xml` with notification strings
802
+ 4. Runs `pnpm build` (Vite production build)
803
+ 5. Syncs the web assets to the Android project via `npx cap sync android`
804
+ 6. Builds a release APK via Gradle
805
+ 7. Outputs the final APK path
806
+
807
+ ### Requirements
808
+
809
+ - Node.js 18+
810
+ - pnpm
811
+ - Android SDK (API level 24+)
812
+ - Java 17+ (for Gradle)
813
+ - `jq` (for JSON parsing in the build script)
814
+
815
+ ---
816
+
817
+ ## Relay Management
818
+
819
+ ### List Devices
820
+
821
+ ```typescript
822
+ const relays = await sdk.listRelays('org_123');
823
+ // Returns: RelayListItem[] with isOnline status, stats, rate limits
824
+ ```
825
+
826
+ ### Update Settings
827
+
828
+ ```typescript
829
+ await sdk.updateRelay('relay_abc', 'org_123', {
830
+ deviceName: 'Office Phone',
831
+ maxSmsPerDay: 500,
832
+ });
833
+ ```
834
+
835
+ ### Revoke a Device
836
+
837
+ ```typescript
838
+ await sdk.revokeRelay('relay_abc', 'org_123');
839
+ // Device is immediately disconnected and cannot reconnect
840
+ ```
841
+
842
+ ### Rotate Encryption Keys
843
+
844
+ ```typescript
845
+ await sdk.rotateKeys('relay_abc', 'org_123');
846
+ // Triggers rekey protocol over WebSocket — both sides derive new shared key
847
+ ```
848
+
849
+ ---
850
+
851
+ ## License
852
+
853
+ Proprietary. Copyright Zenith Intelligence Technologies / ZI2 Systems. All rights reserved.
854
+
855
+ This software is licensed exclusively for use within authorized ZI2 deployments. Unauthorized copying, modification, distribution, or use of this software is strictly prohibited. Contact [support@zisquare.app](mailto:support@zisquare.app) for licensing inquiries.