@zi2/relay-sdk 1.0.3 → 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-sIoVYfJj.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-sIoVYfJj.js';
2
- export { M as MessageList, i as PairingCompleteResult, j as PairingResult, k as RelayDetail, l as RelayListItem, m as SendResult } from './types-sIoVYfJj.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) {
@@ -334,11 +358,12 @@ function createPairingService(deps) {
334
358
  while (existingNames.includes(`${deviceName} (${counter})`)) counter++;
335
359
  uniqueName = `${deviceName} (${counter})`;
336
360
  }
361
+ const relayIdentifier = generateRelayIdentifier();
337
362
  const relay = await db.createRelay({
338
363
  organizationId: pairing.organizationId,
339
364
  deviceName: uniqueName,
340
365
  platform,
341
- phoneNumber: phoneNumber || null,
366
+ relayIdentifier,
342
367
  devicePublicKey,
343
368
  sharedKeyEncrypted: encryption.encrypt(sharedKey.toString("hex")),
344
369
  authTokenHash: authTokenHashed,
@@ -356,7 +381,7 @@ function createPairingService(deps) {
356
381
  });
357
382
  await db.updatePairingStatus(pairingId, RELAY_PAIRING_STATUS.COMPLETED);
358
383
  try {
359
- await deps.onPairingComplete?.({ relayId: relay.id, orgId: pairing.organizationId, deviceName: uniqueName, phoneNumber });
384
+ await deps.onPairingComplete?.({ relayId: relay.id, orgId: pairing.organizationId, deviceName: uniqueName, relayIdentifier });
360
385
  } catch (err) {
361
386
  deps.logger.error("onPairingComplete callback failed", { relayId: relay.id, error: String(err) });
362
387
  }
@@ -384,14 +409,32 @@ function createPairingService(deps) {
384
409
  }
385
410
  const { publicKey, privateKey } = generateX25519KeyPair();
386
411
  ws.send(JSON.stringify({ type: "rekey", serverPublicKey: publicKey }));
412
+ const previousKey = relay.sharedKeyEncrypted;
387
413
  await db.updateRelay(relayId, {
388
414
  sharedKeyEncrypted: encryption.encrypt(JSON.stringify({
389
415
  pending: true,
390
416
  serverPrivateKey: privateKey,
391
417
  serverPublicKey: publicKey,
392
- previousSharedKey: relay.sharedKeyEncrypted
418
+ previousSharedKey: previousKey
393
419
  }))
394
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);
395
438
  }
396
439
  return { initiatePairing, completePairing, revokeRelay, rotateKeys };
397
440
  }
@@ -643,7 +686,7 @@ var FallbackProvider = class {
643
686
  var RATE_LIMIT_WINDOW_MS = 1e4;
644
687
  var RATE_LIMIT_MAX_MESSAGES = 100;
645
688
  function createWebSocketHandler(deps) {
646
- const { db, encryption, broadcast, logger, events, queue, limits } = deps;
689
+ const { db, encryption, broadcast, logger, events, queue, limits, audit } = deps;
647
690
  return function handleWebSocket(socket, request) {
648
691
  const log = request.log || logger;
649
692
  let authenticated = false;
@@ -669,6 +712,8 @@ function createWebSocketHandler(deps) {
669
712
  messageCount++;
670
713
  if (messageCount > RATE_LIMIT_MAX_MESSAGES) {
671
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
+ });
672
717
  socket.close(4008, "Rate limit exceeded");
673
718
  return;
674
719
  }
@@ -688,6 +733,11 @@ function createWebSocketHandler(deps) {
688
733
  socket.close(4003, "Missing relayId or token");
689
734
  return;
690
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
+ }
691
741
  const tokenHash = hashToken(token);
692
742
  const verifiedRelay = await db.findRelayByTokenHash(
693
743
  rid,
@@ -695,10 +745,14 @@ function createWebSocketHandler(deps) {
695
745
  [RELAY_STATUS.ACTIVE, RELAY_STATUS.DEGRADED]
696
746
  ).catch(() => null);
697
747
  if (!verifiedRelay) {
748
+ deps.authLimiter.recordFailure(ip);
698
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
+ });
699
752
  socket.close(4004, "Invalid credentials");
700
753
  return;
701
754
  }
755
+ deps.authLimiter.recordSuccess(ip);
702
756
  clearTimeout(authTimeout);
703
757
  authenticated = true;
704
758
  relayId = rid;
@@ -709,12 +763,14 @@ function createWebSocketHandler(deps) {
709
763
  existing.close(4005, "Replaced by new connection");
710
764
  }
711
765
  setRelaySocket(rid, socket);
712
- await db.updateRelay(rid, {
766
+ await db.updateRelayByOrg(rid, relayOrgId, {
713
767
  lastSeenAt: /* @__PURE__ */ new Date(),
714
768
  lastIpAddress: ip,
715
769
  status: RELAY_STATUS.ACTIVE
716
770
  });
717
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
+ });
718
774
  broadcast.broadcast(verifiedRelay.organizationId, {
719
775
  type: "relay:status_change",
720
776
  relayId: rid,
@@ -747,42 +803,52 @@ function createWebSocketHandler(deps) {
747
803
  const nativeId = isValidStr(message.nativeMessageId) ? message.nativeMessageId : void 0;
748
804
  const errorMsg = isValidStr(message.errorMessage, 500) ? message.errorMessage : void 0;
749
805
  log.debug("Relay SMS ACK", { relayId, messageId: message.messageId, status });
750
- queue.handleAck(message.messageId, status, nativeId, errorMsg).catch(() => {
806
+ queue.handleAck(message.messageId, relayId, status, nativeId, errorMsg).catch(() => {
751
807
  });
752
808
  }
753
809
  break;
754
810
  case RELAY_MESSAGE_TYPES.DELIVERY_RECEIPT:
755
811
  if (isValidStr(message.messageId) && isValidStr(message.deliveryStatus, 50)) {
756
- queue.handleDeliveryReceipt(message.messageId, message.deliveryStatus).catch(() => {
812
+ queue.handleDeliveryReceipt(message.messageId, relayId, message.deliveryStatus).catch(() => {
757
813
  });
758
814
  }
759
815
  break;
760
816
  case RELAY_MESSAGE_TYPES.PONG:
761
- if (relayId) {
762
- 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(() => {
763
819
  });
764
820
  }
765
821
  break;
766
822
  case RELAY_MESSAGE_TYPES.STATUS:
767
- if (relayId) {
823
+ if (relayId && relayOrgId) {
768
824
  const battery = typeof message.batteryLevel === "number" && Number.isFinite(message.batteryLevel) ? Math.max(0, Math.min(100, Math.round(message.batteryLevel))) : null;
769
825
  const signal = typeof message.signalStrength === "number" && Number.isFinite(message.signalStrength) ? Math.max(0, Math.min(100, Math.round(message.signalStrength))) : null;
770
826
  const statusUpdate = { lastSeenAt: /* @__PURE__ */ new Date() };
771
827
  if (battery !== null) statusUpdate.batteryLevel = battery;
772
828
  if (signal !== null) statusUpdate.signalStrength = signal;
773
- db.updateRelay(relayId, statusUpdate).catch(() => {
829
+ db.updateRelayByOrg(relayId, relayOrgId, statusUpdate).catch(() => {
774
830
  });
775
831
  }
776
832
  break;
777
833
  case RELAY_MESSAGE_TYPES.REKEY_ACK:
778
- if (relayId && isValidStr(message.devicePublicKey, 500)) {
834
+ if (relayId && relayOrgId && isValidStr(message.devicePublicKey, 500)) {
779
835
  try {
780
836
  const relay = await db.findRelay(relayId);
781
837
  if (!relay) break;
782
838
  const rekeyData = JSON.parse(encryption.decrypt(relay.sharedKeyEncrypted));
783
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
+ }
784
850
  const newSharedKey = deriveSharedKey(rekeyData.serverPrivateKey, message.devicePublicKey);
785
- await db.updateRelay(relayId, {
851
+ await db.updateRelayByOrg(relayId, relayOrgId, {
786
852
  sharedKeyEncrypted: encryption.encrypt(newSharedKey.toString("hex")),
787
853
  devicePublicKey: message.devicePublicKey,
788
854
  keyVersion: relay.keyVersion + 1,
@@ -790,8 +856,18 @@ function createWebSocketHandler(deps) {
790
856
  });
791
857
  socket.send(JSON.stringify({ type: "rekey_complete" }));
792
858
  } catch {
793
- log.warn("Rekey failed, notifying device", { relayId });
859
+ log.warn("Rekey failed, rolling back to previous key", { relayId });
794
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
+ }
795
871
  }
796
872
  }
797
873
  break;
@@ -803,6 +879,10 @@ function createWebSocketHandler(deps) {
803
879
  if (heartbeatInterval) clearInterval(heartbeatInterval);
804
880
  if (relayId) {
805
881
  deleteRelaySocket(relayId);
882
+ if (relayOrgId) {
883
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.disconnected", organizationId: relayOrgId, relayId, ip }).catch(() => {
884
+ });
885
+ }
806
886
  try {
807
887
  if (relayOrgId) {
808
888
  broadcast.broadcast(relayOrgId, {
@@ -933,8 +1013,7 @@ var completePairingSchema = z.object({
933
1013
  pairingToken: z.string().min(1),
934
1014
  devicePublicKey: z.string().min(44).max(5e3),
935
1015
  deviceName: z.string().min(1).max(200),
936
- platform: z.enum(["android", "ios"]),
937
- phoneNumber: z.string().max(30).optional()
1016
+ platform: z.enum(["android", "ios"])
938
1017
  });
939
1018
  var updatePhoneRelaySchema = z.object({
940
1019
  deviceName: z.string().min(1).max(200).optional(),
@@ -943,7 +1022,7 @@ var updatePhoneRelaySchema = z.object({
943
1022
  maxSmsPerDay: z.number().int().min(1).max(1e4).optional()
944
1023
  });
945
1024
  var testRelaySmsSchema = z.object({
946
- to: z.string().min(1),
1025
+ to: z.string().min(1).regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format (E.164)"),
947
1026
  body: z.string().min(1).max(160).optional()
948
1027
  });
949
1028
  var relayMessagesQuerySchema = z.object({
@@ -2401,24 +2480,33 @@ function createRelay(config) {
2401
2480
  encryption: config.encryption,
2402
2481
  broadcast,
2403
2482
  logger,
2483
+ audit,
2404
2484
  events,
2405
2485
  queue,
2406
- limits
2486
+ limits,
2487
+ authLimiter
2407
2488
  });
2408
2489
  const sdk = {
2409
2490
  // Pairing
2410
2491
  async initiatePairing(orgId, userId) {
2411
- 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;
2412
2496
  },
2413
2497
  async completePairing(input) {
2414
- return pairing.completePairing(
2498
+ const result = await pairing.completePairing(
2415
2499
  input.pairingId,
2416
2500
  input.pairingToken,
2417
2501
  input.devicePublicKey,
2418
2502
  input.deviceName,
2419
- input.platform,
2420
- input.phoneNumber
2503
+ input.platform
2421
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;
2422
2510
  },
2423
2511
  // Relay management
2424
2512
  async listRelays(orgId) {
@@ -2443,16 +2531,26 @@ function createRelay(config) {
2443
2531
  },
2444
2532
  async updateRelay(id, orgId, data) {
2445
2533
  await config.db.updateRelayByOrg(id, orgId, data);
2534
+ audit.log({ timestamp: /* @__PURE__ */ new Date(), action: "relay.updated", organizationId: orgId, relayId: id }).catch(() => {
2535
+ });
2446
2536
  },
2447
2537
  async revokeRelay(id, orgId) {
2448
- 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
+ });
2449
2541
  },
2450
2542
  async rotateKeys(id, orgId) {
2451
- 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
+ });
2452
2546
  },
2453
2547
  // Messaging
2454
2548
  async sendSMS(options) {
2455
- 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;
2456
2554
  },
2457
2555
  async sendSMSToOrg(options) {
2458
2556
  const { orgId, to, body, timeoutMs } = options;
@@ -2565,6 +2663,7 @@ export {
2565
2663
  decryptE2E,
2566
2664
  deriveSharedKey,
2567
2665
  encryptE2E,
2666
+ generateRelayIdentifier,
2568
2667
  generateSecureToken,
2569
2668
  generateX25519KeyPair,
2570
2669
  getRelaySocket,
@@ -1,4 +1,4 @@
1
- import { h as RelaySDK } from '../types-sIoVYfJj.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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zi2/relay-sdk",
3
- "version": "1.0.3",
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)