@zi2/relay-sdk 1.0.2 → 2.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 CHANGED
@@ -228,7 +228,7 @@ model PhoneRelay {
228
228
  organizationId String
229
229
  deviceName String
230
230
  platform String
231
- phoneNumber String?
231
+ relayIdentifier String @unique
232
232
  devicePublicKey String
233
233
  sharedKeyEncrypted String
234
234
  authTokenHash String
@@ -364,7 +364,6 @@ const result = await sdk.completePairing({
364
364
  devicePublicKey: '<device X25519 public key>',
365
365
  deviceName: 'Samsung Galaxy S24',
366
366
  platform: 'android',
367
- phoneNumber: '+15551234567',
368
367
  });
369
368
  // Returns: { relayId, authToken }
370
369
  ```
@@ -1,4 +1,4 @@
1
- import { D as DatabaseAdapter, P as PhoneRelayRecord, c as CreateRelayInput, d as PhoneRelayPairingRecord, e as CreatePairingInput, R as RelayMessageRecord, f as CreateMessageInput } from '../types-CPJUrmcy.js';
1
+ import { D as DatabaseAdapter, P as PhoneRelayRecord, c as CreateRelayInput, d as PhoneRelayPairingRecord, e as CreatePairingInput, R as RelayMessageRecord, f as CreateMessageInput } from '../types-BlrN83F-.js';
2
2
  import 'ws';
3
3
 
4
4
  /**
@@ -39,7 +39,7 @@ interface QrPayload {
39
39
  url: string;
40
40
  }
41
41
  declare function parseQrPayload(data: string): QrPayload;
42
- declare function completePairing(qr: QrPayload, deviceName: string, platform: string, phoneNumber?: string): Promise<any>;
42
+ declare function completePairing(qr: QrPayload, deviceName: string, platform: string): Promise<any>;
43
43
 
44
44
  declare function requestSmsPermission(): Promise<boolean>;
45
45
  declare function sendNativeSms(to: string, body: string): Promise<boolean>;
@@ -208,7 +208,7 @@ function parseQrPayload(data) {
208
208
  if (!parsed.pid || !parsed.spk || !parsed.pt || !parsed.url) throw new Error("Invalid QR data");
209
209
  return parsed;
210
210
  }
211
- async function completePairing(qr, deviceName, platform, phoneNumber) {
211
+ async function completePairing(qr, deviceName, platform) {
212
212
  console.log("[Pairing] Starting pairing...");
213
213
  console.log("[Pairing] QR url:", qr.url);
214
214
  const keyPair = await generateX25519KeyPair();
@@ -226,8 +226,7 @@ async function completePairing(qr, deviceName, platform, phoneNumber) {
226
226
  pairingToken: qr.pt,
227
227
  devicePublicKey: keyPair.publicKey,
228
228
  deviceName,
229
- platform,
230
- phoneNumber
229
+ platform
231
230
  };
232
231
  const response = await fetch(endpoint, {
233
232
  method: "POST",
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { E as EncryptionAdapter, B as BroadcastAdapter, L as LoggerAdapter, A as AuditAdapter, a as AuditEntry, D as DatabaseAdapter, S as SmsProviderAdapter, b as SmsSendResponse, F as FallbackConfig, C as ConnectionBroker, P as PhoneRelayRecord, c as CreateRelayInput, d as PhoneRelayPairingRecord, e as CreatePairingInput, R as RelayMessageRecord, f as CreateMessageInput, g as RelaySDKConfig, h as RelaySDK } from './types-CPJUrmcy.js';
2
- export { M as MessageList, i as PairingCompleteResult, j as PairingResult, k as RelayDetail, l as RelayListItem, m as SendResult } from './types-CPJUrmcy.js';
1
+ import { E as EncryptionAdapter, B as BroadcastAdapter, L as LoggerAdapter, A as AuditAdapter, a as AuditEntry, D as DatabaseAdapter, S as SmsProviderAdapter, b as SmsSendResponse, F as FallbackConfig, C as ConnectionBroker, P as PhoneRelayRecord, c as CreateRelayInput, d as PhoneRelayPairingRecord, e as CreatePairingInput, R as RelayMessageRecord, f as CreateMessageInput, g as RelaySDKConfig, h as RelaySDK } from './types-BlrN83F-.js';
2
+ export { M as MessageList, i as PairingCompleteResult, j as PairingResult, k as RelayDetail, l as RelayListItem, m as SendResult } from './types-BlrN83F-.js';
3
3
  import { z } from 'zod';
4
4
  import { WebSocket } from 'ws';
5
5
 
@@ -63,21 +63,18 @@ declare const completePairingSchema: z.ZodObject<{
63
63
  devicePublicKey: z.ZodString;
64
64
  deviceName: z.ZodString;
65
65
  platform: z.ZodEnum<["android", "ios"]>;
66
- phoneNumber: z.ZodOptional<z.ZodString>;
67
66
  }, "strip", z.ZodTypeAny, {
68
67
  deviceName: string;
69
68
  platform: "android" | "ios";
70
69
  devicePublicKey: string;
71
70
  pairingToken: string;
72
71
  pairingId: string;
73
- phoneNumber?: string | undefined;
74
72
  }, {
75
73
  deviceName: string;
76
74
  platform: "android" | "ios";
77
75
  devicePublicKey: string;
78
76
  pairingToken: string;
79
77
  pairingId: string;
80
- phoneNumber?: string | undefined;
81
78
  }>;
82
79
  declare const updatePhoneRelaySchema: z.ZodObject<{
83
80
  deviceName: z.ZodOptional<z.ZodString>;
@@ -453,8 +450,8 @@ declare function createQueueService(deps: QueueServiceDeps): {
453
450
  messageId: string;
454
451
  status: string;
455
452
  }>;
456
- handleAck: (messageId: string, status: string, nativeMessageId?: string, errorMessage?: string) => Promise<void>;
457
- handleDeliveryReceipt: (messageId: string, deliveryStatus: string) => Promise<void>;
453
+ handleAck: (messageId: string, relayId: string, status: string, nativeMessageId?: string, errorMessage?: string) => Promise<void>;
454
+ handleDeliveryReceipt: (messageId: string, relayId: string, deliveryStatus: string) => Promise<void>;
458
455
  drainQueue: (relayId: string) => Promise<void>;
459
456
  };
460
457
  type QueueService = ReturnType<typeof createQueueService>;
@@ -565,6 +562,16 @@ declare function decryptE2E(ciphertext: string, sharedKey: Buffer): string;
565
562
  declare function hashToken(token: string): string;
566
563
  declare function generateSecureToken(bytes?: number): string;
567
564
  declare function timingSafeCompare(a: string, b: string): boolean;
565
+ /**
566
+ * Generate a unique anonymous relay identifier.
567
+ * Format: RELAY-XXXX (4 random hex chars, uppercase)
568
+ *
569
+ * Patent claim: The relay server assigns cryptographic identifiers
570
+ * to physical devices WITHOUT ever collecting or storing the device's
571
+ * phone number. The identifier has no mathematical relationship to
572
+ * the phone number.
573
+ */
574
+ declare function generateRelayIdentifier(): string;
568
575
 
569
576
  /**
570
577
  * @zi2/relay-sdk — Standalone SMS Relay SDK
@@ -590,4 +597,4 @@ declare function timingSafeCompare(a: string, b: string): boolean;
590
597
 
591
598
  declare function createRelay(config: RelaySDKConfig): RelaySDK;
592
599
 
593
- export { AesGcmEncryption, AuditAdapter, AuditEntry, AuthLimiter, BroadcastAdapter, type CompletePairingInput, ConnectionBroker, ConsoleAudit, ConsoleLogger, CreateMessageInput, CreatePairingInput, CreateRelayInput, DatabaseAdapter, EncryptionAdapter, FallbackConfig, FallbackProvider, LoggerAdapter, MemoryAdapter, NoopAudit, NoopBroadcast, PhoneRelayPairingRecord, PhoneRelayProvider, PhoneRelayRecord, RELAY_DELIVERY_STATUS, RELAY_ERRORS, RELAY_ERRORS_EXTENDED, RELAY_LIMITS, RELAY_MESSAGE_STATUS, RELAY_MESSAGE_TYPES, RELAY_PAIRING_STATUS, RELAY_PLATFORMS, RELAY_STATUS, RedisBroker, RelayError, RelayMessageRecord, type RelayMessageStatus, type RelayMessageType, type RelayPlatform, RelaySDK, RelaySDKConfig, type RelayStatus, SmsProviderAdapter, SmsSendResponse, type TestRelaySmsInput, type UpdatePhoneRelayInput, completePairingSchema, createRelay, createRelayError, decryptE2E, deriveSharedKey, encryptE2E, generateSecureToken, generateX25519KeyPair, getRelaySocket, getRelayTranslations, getSupportedLocales, getTranslation, hashToken, isRelayOnline, relayMessagesQuerySchema, testRelaySmsSchema, timingSafeCompare, updatePhoneRelaySchema, withRedaction };
600
+ export { AesGcmEncryption, AuditAdapter, AuditEntry, AuthLimiter, BroadcastAdapter, type CompletePairingInput, ConnectionBroker, ConsoleAudit, ConsoleLogger, CreateMessageInput, CreatePairingInput, CreateRelayInput, DatabaseAdapter, EncryptionAdapter, FallbackConfig, FallbackProvider, LoggerAdapter, MemoryAdapter, NoopAudit, NoopBroadcast, PhoneRelayPairingRecord, PhoneRelayProvider, PhoneRelayRecord, RELAY_DELIVERY_STATUS, RELAY_ERRORS, RELAY_ERRORS_EXTENDED, RELAY_LIMITS, RELAY_MESSAGE_STATUS, RELAY_MESSAGE_TYPES, RELAY_PAIRING_STATUS, RELAY_PLATFORMS, RELAY_STATUS, RedisBroker, RelayError, RelayMessageRecord, type RelayMessageStatus, type RelayMessageType, type RelayPlatform, RelaySDK, RelaySDKConfig, type RelayStatus, SmsProviderAdapter, SmsSendResponse, type TestRelaySmsInput, type UpdatePhoneRelayInput, completePairingSchema, createRelay, createRelayError, decryptE2E, deriveSharedKey, encryptE2E, generateRelayIdentifier, generateSecureToken, generateX25519KeyPair, getRelaySocket, getRelayTranslations, getSupportedLocales, getTranslation, hashToken, isRelayOnline, relayMessagesQuerySchema, testRelaySmsSchema, timingSafeCompare, updatePhoneRelaySchema, withRedaction };
package/dist/index.js CHANGED
@@ -128,11 +128,21 @@ function createQueueService(deps) {
128
128
  if (!relay || relay.status !== RELAY_STATUS.ACTIVE && relay.status !== RELAY_STATUS.DEGRADED) {
129
129
  throw new Error("Relay not found or inactive");
130
130
  }
131
+ const todayUTC = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
132
+ const dailyCount = relay.dailyResetAt && relay.dailyResetAt.toISOString().slice(0, 10) === todayUTC ? relay.dailySmsSent : 0;
133
+ if (dailyCount >= relay.maxSmsPerDay) {
134
+ throw new Error(`Daily SMS limit reached (${relay.maxSmsPerDay}/day)`);
135
+ }
131
136
  let sharedKeyHex;
132
137
  try {
133
- sharedKeyHex = encryption.decrypt(relay.sharedKeyEncrypted);
138
+ const decrypted = encryption.decrypt(relay.sharedKeyEncrypted);
139
+ if (decrypted.startsWith("{")) {
140
+ throw new Error("Relay is in pending key rotation \u2014 retry after rotation completes");
141
+ }
142
+ sharedKeyHex = decrypted;
134
143
  } catch (err) {
135
- throw new Error(`Failed to decrypt relay shared key: ${err}`);
144
+ logger.error("Failed to decrypt relay shared key", { relayId, error: String(err) });
145
+ throw new Error("Failed to prepare message for relay");
136
146
  }
137
147
  const sharedKey = Buffer.from(sharedKeyHex, "hex");
138
148
  const payload = JSON.stringify({ to, body });
@@ -170,7 +180,12 @@ function createQueueService(deps) {
170
180
  });
171
181
  });
172
182
  }
173
- async function handleAck(messageId, status, nativeMessageId, errorMessage) {
183
+ async function handleAck(messageId, relayId, status, nativeMessageId, errorMessage) {
184
+ const message = await db.findMessage(messageId).catch(() => null);
185
+ if (!message || message.relayId !== relayId) {
186
+ logger.warn("ACK rejected \u2014 message does not belong to relay", { messageId, relayId });
187
+ return;
188
+ }
174
189
  const updateData = {
175
190
  status: status === "sent" ? RELAY_MESSAGE_STATUS.DELIVERED : RELAY_MESSAGE_STATUS.FAILED,
176
191
  ackedAt: /* @__PURE__ */ new Date()
@@ -178,32 +193,37 @@ function createQueueService(deps) {
178
193
  if (nativeMessageId) updateData.nativeMessageId = nativeMessageId;
179
194
  if (errorMessage) updateData.errorMessage = errorMessage;
180
195
  await db.updateMessage(messageId, updateData);
181
- const message = await db.findMessage(messageId).catch(() => null);
182
196
  try {
183
- if (message) {
184
- if (status === "sent") {
185
- await db.incrementRelayStat(message.relayId, "totalSmsSent");
186
- await db.incrementRelayStat(message.relayId, "dailySmsSent");
187
- } else {
188
- await db.incrementRelayStat(message.relayId, "totalSmsFailed");
189
- }
197
+ if (status === "sent") {
198
+ await db.incrementRelayStat(message.relayId, "totalSmsSent");
199
+ await db.incrementRelayStat(message.relayId, "dailySmsSent");
200
+ } else {
201
+ await db.incrementRelayStat(message.relayId, "totalSmsFailed");
190
202
  }
191
203
  } catch (err) {
192
204
  logger.error("Failed to update relay stats after ack", { messageId, error: String(err) });
193
205
  }
194
206
  ackEmitter.emit(`ack:${messageId}`, status);
195
- if (message) {
196
- if (status === "sent") {
197
- events.emit("message:delivered", messageId, message.relayId);
198
- } else {
199
- events.emit("message:failed", messageId, message.relayId, errorMessage || "Unknown error");
200
- }
207
+ if (status === "sent") {
208
+ events.emit("message:delivered", messageId, message.relayId);
209
+ } else {
210
+ events.emit("message:failed", messageId, message.relayId, errorMessage || "Unknown error");
201
211
  }
202
212
  }
203
- async function handleDeliveryReceipt(messageId, deliveryStatus) {
213
+ async function handleDeliveryReceipt(messageId, relayId, deliveryStatus) {
214
+ const message = await db.findMessage(messageId).catch(() => null);
215
+ if (!message || message.relayId !== relayId) {
216
+ logger.warn("Delivery receipt rejected \u2014 message does not belong to relay", { messageId, relayId });
217
+ return;
218
+ }
204
219
  await db.updateMessage(messageId, { deliveryStatus });
205
220
  }
206
221
  async function drainQueue(relayId) {
222
+ const relay = await db.findRelay(relayId);
223
+ if (!relay || relay.status === "revoked") {
224
+ logger.warn("drainQueue skipped \u2014 relay revoked or not found", { relayId });
225
+ return;
226
+ }
207
227
  const pendingMessages = await db.findPendingMessages(relayId, 50);
208
228
  const ws = getRelaySocket(relayId);
209
229
  if (!ws || ws.readyState !== 1) return;
@@ -240,6 +260,9 @@ function timingSafeCompare(a, b) {
240
260
  return false;
241
261
  }
242
262
  }
263
+ function generateRelayIdentifier() {
264
+ return `RELAY-${crypto3.randomBytes(2).toString("hex").toUpperCase()}`;
265
+ }
243
266
 
244
267
  // src/errors.ts
245
268
  var RelayError = class extends Error {
@@ -311,11 +334,12 @@ function createPairingService(deps) {
311
334
  wsUrl
312
335
  };
313
336
  }
314
- async function completePairing(pairingId, pairingToken, devicePublicKey, deviceName, platform, phoneNumber) {
337
+ async function completePairing(pairingId, pairingToken, devicePublicKey, deviceName, platform) {
315
338
  const pairing = await db.findPairing(pairingId);
316
339
  if (!pairing) throw createRelayError("PAIRING_NOT_FOUND");
317
340
  if (pairing.status !== RELAY_PAIRING_STATUS.PENDING) throw createRelayError("PAIRING_EXPIRED");
318
341
  if (!timingSafeCompare(pairing.pairingToken, pairingToken)) {
342
+ await db.updatePairingStatus(pairingId, RELAY_PAIRING_STATUS.EXPIRED);
319
343
  throw createRelayError("PAIRING_INVALID_TOKEN");
320
344
  }
321
345
  if (/* @__PURE__ */ new Date() > pairing.expiresAt) {
@@ -326,11 +350,20 @@ function createPairingService(deps) {
326
350
  const sharedKey = deriveSharedKey(serverPrivateKey, devicePublicKey);
327
351
  const authToken = generateSecureToken(64);
328
352
  const authTokenHashed = hashToken(authToken);
353
+ const existingRelays = await db.findRelays(pairing.organizationId, "revoked");
354
+ let uniqueName = deviceName;
355
+ const existingNames = existingRelays.map((r) => r.deviceName);
356
+ if (existingNames.includes(uniqueName)) {
357
+ let counter = 2;
358
+ while (existingNames.includes(`${deviceName} (${counter})`)) counter++;
359
+ uniqueName = `${deviceName} (${counter})`;
360
+ }
361
+ const relayIdentifier = generateRelayIdentifier();
329
362
  const relay = await db.createRelay({
330
363
  organizationId: pairing.organizationId,
331
- deviceName,
364
+ deviceName: uniqueName,
332
365
  platform,
333
- phoneNumber: phoneNumber || null,
366
+ relayIdentifier,
334
367
  devicePublicKey,
335
368
  sharedKeyEncrypted: encryption.encrypt(sharedKey.toString("hex")),
336
369
  authTokenHash: authTokenHashed,
@@ -348,7 +381,7 @@ function createPairingService(deps) {
348
381
  });
349
382
  await db.updatePairingStatus(pairingId, RELAY_PAIRING_STATUS.COMPLETED);
350
383
  try {
351
- await deps.onPairingComplete?.({ relayId: relay.id, orgId: pairing.organizationId, deviceName, phoneNumber });
384
+ await deps.onPairingComplete?.({ relayId: relay.id, orgId: pairing.organizationId, deviceName: uniqueName, relayIdentifier });
352
385
  } catch (err) {
353
386
  deps.logger.error("onPairingComplete callback failed", { relayId: relay.id, error: String(err) });
354
387
  }
@@ -376,14 +409,32 @@ function createPairingService(deps) {
376
409
  }
377
410
  const { publicKey, privateKey } = generateX25519KeyPair();
378
411
  ws.send(JSON.stringify({ type: "rekey", serverPublicKey: publicKey }));
412
+ const previousKey = relay.sharedKeyEncrypted;
379
413
  await db.updateRelay(relayId, {
380
414
  sharedKeyEncrypted: encryption.encrypt(JSON.stringify({
381
415
  pending: true,
382
416
  serverPrivateKey: privateKey,
383
417
  serverPublicKey: publicKey,
384
- previousSharedKey: relay.sharedKeyEncrypted
418
+ previousSharedKey: previousKey
385
419
  }))
386
420
  });
421
+ setTimeout(async () => {
422
+ try {
423
+ const current = await db.findRelay(relayId, orgId);
424
+ if (!current) return;
425
+ const decrypted = encryption.decrypt(current.sharedKeyEncrypted);
426
+ try {
427
+ const parsed = JSON.parse(decrypted);
428
+ if (parsed.pending) {
429
+ logger.warn("Rekey timeout \u2014 rolling back to previous key", { relayId });
430
+ await db.updateRelay(relayId, { sharedKeyEncrypted: parsed.previousSharedKey });
431
+ }
432
+ } catch {
433
+ }
434
+ } catch (err) {
435
+ logger.error("Rekey rollback failed", { relayId, error: String(err) });
436
+ }
437
+ }, 6e4);
387
438
  }
388
439
  return { initiatePairing, completePairing, revokeRelay, rotateKeys };
389
440
  }
@@ -635,7 +686,7 @@ var FallbackProvider = class {
635
686
  var RATE_LIMIT_WINDOW_MS = 1e4;
636
687
  var RATE_LIMIT_MAX_MESSAGES = 100;
637
688
  function createWebSocketHandler(deps) {
638
- const { db, encryption, broadcast, logger, events, queue, limits } = deps;
689
+ const { db, encryption, broadcast, logger, events, queue, limits, audit } = deps;
639
690
  return function handleWebSocket(socket, request) {
640
691
  const log = request.log || logger;
641
692
  let authenticated = false;
@@ -661,6 +712,8 @@ function createWebSocketHandler(deps) {
661
712
  messageCount++;
662
713
  if (messageCount > RATE_LIMIT_MAX_MESSAGES) {
663
714
  log.warn("Relay WebSocket rate limit exceeded", { relayId, ip });
715
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.rate_limited", organizationId: relayOrgId || "", relayId: relayId || void 0, ip }).catch(() => {
716
+ });
664
717
  socket.close(4008, "Rate limit exceeded");
665
718
  return;
666
719
  }
@@ -680,6 +733,11 @@ function createWebSocketHandler(deps) {
680
733
  socket.close(4003, "Missing relayId or token");
681
734
  return;
682
735
  }
736
+ if (deps.authLimiter.isLocked(ip)) {
737
+ log.warn("WebSocket auth blocked \u2014 IP locked out", { ip });
738
+ socket.close(4009, "Too many failed attempts");
739
+ return;
740
+ }
683
741
  const tokenHash = hashToken(token);
684
742
  const verifiedRelay = await db.findRelayByTokenHash(
685
743
  rid,
@@ -687,10 +745,14 @@ function createWebSocketHandler(deps) {
687
745
  [RELAY_STATUS.ACTIVE, RELAY_STATUS.DEGRADED]
688
746
  ).catch(() => null);
689
747
  if (!verifiedRelay) {
748
+ deps.authLimiter.recordFailure(ip);
690
749
  log.warn("Relay WebSocket auth failed", { relayId: rid });
750
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.auth_failed", organizationId: "", relayId: rid, ip }).catch(() => {
751
+ });
691
752
  socket.close(4004, "Invalid credentials");
692
753
  return;
693
754
  }
755
+ deps.authLimiter.recordSuccess(ip);
694
756
  clearTimeout(authTimeout);
695
757
  authenticated = true;
696
758
  relayId = rid;
@@ -701,12 +763,14 @@ function createWebSocketHandler(deps) {
701
763
  existing.close(4005, "Replaced by new connection");
702
764
  }
703
765
  setRelaySocket(rid, socket);
704
- await db.updateRelay(rid, {
766
+ await db.updateRelayByOrg(rid, relayOrgId, {
705
767
  lastSeenAt: /* @__PURE__ */ new Date(),
706
768
  lastIpAddress: ip,
707
769
  status: RELAY_STATUS.ACTIVE
708
770
  });
709
771
  socket.send(JSON.stringify({ type: "auth_ok" }));
772
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.connected", organizationId: relayOrgId, relayId: rid, ip }).catch(() => {
773
+ });
710
774
  broadcast.broadcast(verifiedRelay.organizationId, {
711
775
  type: "relay:status_change",
712
776
  relayId: rid,
@@ -739,42 +803,52 @@ function createWebSocketHandler(deps) {
739
803
  const nativeId = isValidStr(message.nativeMessageId) ? message.nativeMessageId : void 0;
740
804
  const errorMsg = isValidStr(message.errorMessage, 500) ? message.errorMessage : void 0;
741
805
  log.debug("Relay SMS ACK", { relayId, messageId: message.messageId, status });
742
- queue.handleAck(message.messageId, status, nativeId, errorMsg).catch(() => {
806
+ queue.handleAck(message.messageId, relayId, status, nativeId, errorMsg).catch(() => {
743
807
  });
744
808
  }
745
809
  break;
746
810
  case RELAY_MESSAGE_TYPES.DELIVERY_RECEIPT:
747
811
  if (isValidStr(message.messageId) && isValidStr(message.deliveryStatus, 50)) {
748
- queue.handleDeliveryReceipt(message.messageId, message.deliveryStatus).catch(() => {
812
+ queue.handleDeliveryReceipt(message.messageId, relayId, message.deliveryStatus).catch(() => {
749
813
  });
750
814
  }
751
815
  break;
752
816
  case RELAY_MESSAGE_TYPES.PONG:
753
- if (relayId) {
754
- db.updateRelay(relayId, { lastSeenAt: /* @__PURE__ */ new Date(), lastIpAddress: ip }).catch(() => {
817
+ if (relayId && relayOrgId) {
818
+ db.updateRelayByOrg(relayId, relayOrgId, { lastSeenAt: /* @__PURE__ */ new Date(), lastIpAddress: ip }).catch(() => {
755
819
  });
756
820
  }
757
821
  break;
758
822
  case RELAY_MESSAGE_TYPES.STATUS:
759
- if (relayId) {
823
+ if (relayId && relayOrgId) {
760
824
  const battery = typeof message.batteryLevel === "number" && Number.isFinite(message.batteryLevel) ? Math.max(0, Math.min(100, Math.round(message.batteryLevel))) : null;
761
825
  const signal = typeof message.signalStrength === "number" && Number.isFinite(message.signalStrength) ? Math.max(0, Math.min(100, Math.round(message.signalStrength))) : null;
762
826
  const statusUpdate = { lastSeenAt: /* @__PURE__ */ new Date() };
763
827
  if (battery !== null) statusUpdate.batteryLevel = battery;
764
828
  if (signal !== null) statusUpdate.signalStrength = signal;
765
- db.updateRelay(relayId, statusUpdate).catch(() => {
829
+ db.updateRelayByOrg(relayId, relayOrgId, statusUpdate).catch(() => {
766
830
  });
767
831
  }
768
832
  break;
769
833
  case RELAY_MESSAGE_TYPES.REKEY_ACK:
770
- if (relayId && isValidStr(message.devicePublicKey, 500)) {
834
+ if (relayId && relayOrgId && isValidStr(message.devicePublicKey, 500)) {
771
835
  try {
772
836
  const relay = await db.findRelay(relayId);
773
837
  if (!relay) break;
774
838
  const rekeyData = JSON.parse(encryption.decrypt(relay.sharedKeyEncrypted));
775
839
  if (!rekeyData.pending) break;
840
+ try {
841
+ const keyBuf = Buffer.from(message.devicePublicKey, "hex");
842
+ if (keyBuf.length < 32) throw new Error("Key too short");
843
+ } catch {
844
+ socket.send(JSON.stringify({ type: "rekey_failed" }));
845
+ if (rekeyData.previousSharedKey) {
846
+ await db.updateRelayByOrg(relayId, relayOrgId, { sharedKeyEncrypted: rekeyData.previousSharedKey });
847
+ }
848
+ break;
849
+ }
776
850
  const newSharedKey = deriveSharedKey(rekeyData.serverPrivateKey, message.devicePublicKey);
777
- await db.updateRelay(relayId, {
851
+ await db.updateRelayByOrg(relayId, relayOrgId, {
778
852
  sharedKeyEncrypted: encryption.encrypt(newSharedKey.toString("hex")),
779
853
  devicePublicKey: message.devicePublicKey,
780
854
  keyVersion: relay.keyVersion + 1,
@@ -782,8 +856,18 @@ function createWebSocketHandler(deps) {
782
856
  });
783
857
  socket.send(JSON.stringify({ type: "rekey_complete" }));
784
858
  } catch {
785
- log.warn("Rekey failed, notifying device", { relayId });
859
+ log.warn("Rekey failed, rolling back to previous key", { relayId });
786
860
  socket.send(JSON.stringify({ type: "rekey_failed" }));
861
+ try {
862
+ const current = await deps.db.findRelay(relayId);
863
+ if (current) {
864
+ const data = JSON.parse(deps.encryption.decrypt(current.sharedKeyEncrypted));
865
+ if (data.pending && data.previousSharedKey) {
866
+ await deps.db.updateRelayByOrg(relayId, relayOrgId, { sharedKeyEncrypted: data.previousSharedKey });
867
+ }
868
+ }
869
+ } catch {
870
+ }
787
871
  }
788
872
  }
789
873
  break;
@@ -795,6 +879,10 @@ function createWebSocketHandler(deps) {
795
879
  if (heartbeatInterval) clearInterval(heartbeatInterval);
796
880
  if (relayId) {
797
881
  deleteRelaySocket(relayId);
882
+ if (relayOrgId) {
883
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.disconnected", organizationId: relayOrgId, relayId, ip }).catch(() => {
884
+ });
885
+ }
798
886
  try {
799
887
  if (relayOrgId) {
800
888
  broadcast.broadcast(relayOrgId, {
@@ -925,8 +1013,7 @@ var completePairingSchema = z.object({
925
1013
  pairingToken: z.string().min(1),
926
1014
  devicePublicKey: z.string().min(44).max(5e3),
927
1015
  deviceName: z.string().min(1).max(200),
928
- platform: z.enum(["android", "ios"]),
929
- phoneNumber: z.string().max(30).optional()
1016
+ platform: z.enum(["android", "ios"])
930
1017
  });
931
1018
  var updatePhoneRelaySchema = z.object({
932
1019
  deviceName: z.string().min(1).max(200).optional(),
@@ -935,7 +1022,7 @@ var updatePhoneRelaySchema = z.object({
935
1022
  maxSmsPerDay: z.number().int().min(1).max(1e4).optional()
936
1023
  });
937
1024
  var testRelaySmsSchema = z.object({
938
- to: z.string().min(1),
1025
+ to: z.string().min(1).regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format (E.164)"),
939
1026
  body: z.string().min(1).max(160).optional()
940
1027
  });
941
1028
  var relayMessagesQuerySchema = z.object({
@@ -2393,24 +2480,33 @@ function createRelay(config) {
2393
2480
  encryption: config.encryption,
2394
2481
  broadcast,
2395
2482
  logger,
2483
+ audit,
2396
2484
  events,
2397
2485
  queue,
2398
- limits
2486
+ limits,
2487
+ authLimiter
2399
2488
  });
2400
2489
  const sdk = {
2401
2490
  // Pairing
2402
2491
  async initiatePairing(orgId, userId) {
2403
- return pairing.initiatePairing(orgId, userId);
2492
+ const result = await pairing.initiatePairing(orgId, userId);
2493
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "pairing.initiated", organizationId: orgId, userId }).catch(() => {
2494
+ });
2495
+ return result;
2404
2496
  },
2405
2497
  async completePairing(input) {
2406
- return pairing.completePairing(
2498
+ const result = await pairing.completePairing(
2407
2499
  input.pairingId,
2408
2500
  input.pairingToken,
2409
2501
  input.devicePublicKey,
2410
2502
  input.deviceName,
2411
- input.platform,
2412
- input.phoneNumber
2503
+ input.platform
2413
2504
  );
2505
+ const relay = await config.db.findRelay(result.relayId);
2506
+ const orgId = relay?.organizationId || "";
2507
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "pairing.completed", organizationId: orgId, relayId: result.relayId }).catch(() => {
2508
+ });
2509
+ return result;
2414
2510
  },
2415
2511
  // Relay management
2416
2512
  async listRelays(orgId) {
@@ -2435,16 +2531,58 @@ function createRelay(config) {
2435
2531
  },
2436
2532
  async updateRelay(id, orgId, data) {
2437
2533
  await config.db.updateRelayByOrg(id, orgId, data);
2534
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.updated", organizationId: orgId, relayId: id }).catch(() => {
2535
+ });
2438
2536
  },
2439
2537
  async revokeRelay(id, orgId) {
2440
- return pairing.revokeRelay(id, orgId);
2538
+ await pairing.revokeRelay(id, orgId);
2539
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.revoked", organizationId: orgId, relayId: id }).catch(() => {
2540
+ });
2441
2541
  },
2442
2542
  async rotateKeys(id, orgId) {
2443
- return pairing.rotateKeys(id, orgId);
2543
+ await pairing.rotateKeys(id, orgId);
2544
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.keys_rotated", organizationId: orgId, relayId: id }).catch(() => {
2545
+ });
2444
2546
  },
2445
2547
  // Messaging
2446
2548
  async sendSMS(options) {
2447
- return queue.enqueueAndWait(options);
2549
+ const result = await queue.enqueueAndWait(options);
2550
+ const action = result.status === "failed" ? "sms.failed" : "sms.sent";
2551
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action, organizationId: options.orgId, relayId: options.relayId, metadata: { messageId: result.messageId } }).catch(() => {
2552
+ });
2553
+ return result;
2554
+ },
2555
+ async sendSMSToOrg(options) {
2556
+ const { orgId, to, body, timeoutMs } = options;
2557
+ const allRelays = await config.db.findRelays(orgId, "revoked");
2558
+ const activeRelays = allRelays.filter((r) => r.status === "active" || r.status === "degraded");
2559
+ if (activeRelays.length === 0) {
2560
+ throw new Error("No active relays available for this organization");
2561
+ }
2562
+ const todayUTC = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2563
+ const onlineRelays = activeRelays.filter((r) => isRelayOnline(r.id));
2564
+ const offlineRelays = activeRelays.filter((r) => !isRelayOnline(r.id));
2565
+ const sortedOnline = [...onlineRelays].sort((a, b) => {
2566
+ const aDaily = a.dailyResetAt && a.dailyResetAt.toISOString().slice(0, 10) === todayUTC ? a.dailySmsSent : 0;
2567
+ const bDaily = b.dailyResetAt && b.dailyResetAt.toISOString().slice(0, 10) === todayUTC ? b.dailySmsSent : 0;
2568
+ return aDaily - bDaily;
2569
+ });
2570
+ const sortedOffline = [...offlineRelays].sort((a, b) => {
2571
+ const aTime = a.lastSeenAt ? a.lastSeenAt.getTime() : 0;
2572
+ const bTime = b.lastSeenAt ? b.lastSeenAt.getTime() : 0;
2573
+ return bTime - aTime;
2574
+ });
2575
+ const candidates = [...sortedOnline, ...sortedOffline];
2576
+ let lastError;
2577
+ for (const candidate of candidates) {
2578
+ try {
2579
+ return await queue.enqueueAndWait({ relayId: candidate.id, orgId, to, body, timeoutMs });
2580
+ } catch (err) {
2581
+ lastError = err instanceof Error ? err : new Error(String(err));
2582
+ logger.warn("sendSMSToOrg: relay failed, trying next", { relayId: candidate.id, error: lastError.message });
2583
+ }
2584
+ }
2585
+ throw lastError || new Error("All relays failed");
2448
2586
  },
2449
2587
  async getMessages(relayId, orgId, limit = 50, offset = 0) {
2450
2588
  const relay = await config.db.findRelay(relayId, orgId);
@@ -2525,6 +2663,7 @@ export {
2525
2663
  decryptE2E,
2526
2664
  deriveSharedKey,
2527
2665
  encryptE2E,
2666
+ generateRelayIdentifier,
2528
2667
  generateSecureToken,
2529
2668
  generateX25519KeyPair,
2530
2669
  getRelaySocket,
@@ -1,4 +1,4 @@
1
- import { h as RelaySDK } from '../types-CPJUrmcy.js';
1
+ import { h as RelaySDK } from '../types-BlrN83F-.js';
2
2
  import 'ws';
3
3
 
4
4
  /**
@@ -7,6 +7,10 @@ import 'ws';
7
7
  *
8
8
  * Requires @fastify/websocket for WebSocket support.
9
9
  *
10
+ * **WebSocket maxPayload:** To limit inbound WebSocket frame sizes (recommended
11
+ * 64 KB for relay messages), configure `maxPayload` when registering the
12
+ * `@fastify/websocket` plugin — it cannot be set per-route:
13
+ *
10
14
  * @example
11
15
  * ```typescript
12
16
  * import Fastify from 'fastify';
@@ -14,7 +18,7 @@ import 'ws';
14
18
  * import { relayPlugin } from '@zi2/relay-sdk/fastify';
15
19
  *
16
20
  * const app = Fastify();
17
- * await app.register(websocket);
21
+ * await app.register(websocket, { options: { maxPayload: 65536 } });
18
22
  * // Use Fastify's prefix option for route namespacing:
19
23
  * await app.register(relayPlugin, { sdk, prefix: '/phone-relay' });
20
24
  * ```
@@ -5,8 +5,7 @@ var completePairingSchema = z.object({
5
5
  pairingToken: z.string().min(1),
6
6
  devicePublicKey: z.string().min(44).max(5e3),
7
7
  deviceName: z.string().min(1).max(200),
8
- platform: z.enum(["android", "ios"]),
9
- phoneNumber: z.string().max(30).optional()
8
+ platform: z.enum(["android", "ios"])
10
9
  });
11
10
  var updatePhoneRelaySchema = z.object({
12
11
  deviceName: z.string().min(1).max(200).optional(),
@@ -15,7 +14,7 @@ var updatePhoneRelaySchema = z.object({
15
14
  maxSmsPerDay: z.number().int().min(1).max(1e4).optional()
16
15
  });
17
16
  var testRelaySmsSchema = z.object({
18
- to: z.string().min(1),
17
+ to: z.string().min(1).regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format (E.164)"),
19
18
  body: z.string().min(1).max(160).optional()
20
19
  });
21
20
  var relayMessagesQuerySchema = z.object({
@@ -37,8 +36,7 @@ function createRouteHandlers(sdk) {
37
36
  pairingToken: parsed.pairingToken,
38
37
  devicePublicKey: parsed.devicePublicKey,
39
38
  deviceName: parsed.deviceName,
40
- platform: parsed.platform,
41
- phoneNumber: parsed.phoneNumber
39
+ platform: parsed.platform
42
40
  });
43
41
  return result;
44
42
  },
@@ -5,7 +5,7 @@ interface PhoneRelayRecord {
5
5
  organizationId: string;
6
6
  deviceName: string;
7
7
  platform: string;
8
- phoneNumber: string | null;
8
+ relayIdentifier: string;
9
9
  devicePublicKey: string;
10
10
  sharedKeyEncrypted: string;
11
11
  authTokenHash: string;
@@ -149,7 +149,7 @@ interface RelaySDKConfig {
149
149
  relayId: string;
150
150
  orgId: string;
151
151
  deviceName: string;
152
- phoneNumber?: string;
152
+ relayIdentifier: string;
153
153
  }) => Promise<void>;
154
154
  /** Called after relay is revoked. Use to clean up SmsProvider records. */
155
155
  onRelayRevoked?: (relay: {
@@ -188,7 +188,7 @@ interface RelayListItem {
188
188
  id: string;
189
189
  deviceName: string;
190
190
  platform: string;
191
- phoneNumber: string | null;
191
+ relayIdentifier: string;
192
192
  status: string;
193
193
  lastSeenAt: Date | null;
194
194
  batteryLevel: number | null;
@@ -207,7 +207,7 @@ interface RelayDetail {
207
207
  organizationId: string;
208
208
  deviceName: string;
209
209
  platform: string;
210
- phoneNumber: string | null;
210
+ relayIdentifier: string;
211
211
  status: string;
212
212
  keyVersion: number;
213
213
  lastKeyRotation: Date;
@@ -249,7 +249,6 @@ interface RelaySDK {
249
249
  devicePublicKey: string;
250
250
  deviceName: string;
251
251
  platform: string;
252
- phoneNumber?: string;
253
252
  }): Promise<PairingCompleteResult>;
254
253
  listRelays(orgId: string): Promise<RelayListItem[]>;
255
254
  getRelay(id: string, orgId: string): Promise<RelayDetail | null>;
@@ -263,6 +262,12 @@ interface RelaySDK {
263
262
  body: string;
264
263
  timeoutMs?: number;
265
264
  }): Promise<SendResult>;
265
+ sendSMSToOrg(options: {
266
+ orgId: string;
267
+ to: string;
268
+ body: string;
269
+ timeoutMs?: number;
270
+ }): Promise<SendResult>;
266
271
  getMessages(relayId: string, orgId: string, limit?: number, offset?: number): Promise<MessageList>;
267
272
  isRelayOnline(relayId: string): boolean;
268
273
  getRelaySocket(relayId: string): WebSocket | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zi2/relay-sdk",
3
- "version": "1.0.2",
3
+ "version": "2.0.0",
4
4
  "description": "Enterprise SMS relay SDK with E2E encryption, provider fallback, and PCI DSS v4 compliance",
5
5
  "author": "Zenith Intelligence Technologies <dev@zisquare.app>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,7 +20,7 @@ model PhoneRelay {
20
20
  organizationId String @map("organization_id") @db.Uuid
21
21
  deviceName String @map("device_name") @db.VarChar(200)
22
22
  platform String @db.VarChar(20)
23
- phoneNumber String? @map("phone_number") @db.VarChar(30)
23
+ relayIdentifier String @unique @map("relay_identifier") @db.VarChar(10)
24
24
  devicePublicKey String @map("device_public_key") @db.Text
25
25
  sharedKeyEncrypted String @map("shared_key_encrypted") @db.Text
26
26
  authTokenHash String @map("auth_token_hash") @db.VarChar(128)