@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 +1 -2
- package/dist/adapters/prisma-adapter.d.ts +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +2 -3
- package/dist/index.d.ts +15 -8
- package/dist/index.js +143 -44
- package/dist/server/fastify-plugin.d.ts +6 -2
- package/dist/server/fastify-plugin.js +3 -5
- package/dist/{types-sIoVYfJj.d.ts → types-BlrN83F-.d.ts} +4 -5
- package/package.json +1 -1
- package/prisma/schema.prisma +1 -1
package/README.md
CHANGED
|
@@ -228,7 +228,7 @@ model PhoneRelay {
|
|
|
228
228
|
organizationId String
|
|
229
229
|
deviceName String
|
|
230
230
|
platform String
|
|
231
|
-
|
|
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-
|
|
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
|
/**
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
|
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-
|
|
2
|
-
export { M as MessageList, i as PairingCompleteResult, j as PairingResult, k as RelayDetail, l as RelayListItem, m as SendResult } from './types-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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",
|
package/prisma/schema.prisma
CHANGED
|
@@ -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
|
-
|
|
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)
|