@stvor/sdk 2.0.0 → 2.0.2

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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * STVOR DX Facade - Quick Start Example
3
+ *
4
+ * Copy-paste ready example for getting started.
5
+ *
6
+ * IMPORTANT: This is a DX facade. The actual security guarantees
7
+ * depend on the STVOR core implementation.
8
+ */
9
+ export {};
@@ -0,0 +1,57 @@
1
+ /**
2
+ * STVOR DX Facade - Quick Start Example
3
+ *
4
+ * Copy-paste ready example for getting started.
5
+ *
6
+ * IMPORTANT: This is a DX facade. The actual security guarantees
7
+ * depend on the STVOR core implementation.
8
+ */
9
+ import { Stvor, StvorError } from './index';
10
+ /**
11
+ * Basic messaging example with proper error handling
12
+ */
13
+ async function main() {
14
+ try {
15
+ // 1. Initialize SDK with AppToken from environment
16
+ const app = await Stvor.init({
17
+ appToken: process.env.STVOR_APP_TOKEN || 'stvor_demo_abc123...'
18
+ });
19
+ console.log('SDK initialized');
20
+ // 2. Connect as a user
21
+ const alice = await app.connect('alice@example.com');
22
+ console.log(`Connected as ${alice.getUserId()}`);
23
+ // 3. Subscribe to incoming messages (recommended for production)
24
+ const unsubscribe = alice.onMessage((msg) => {
25
+ console.log(`[Push] ${msg.senderId}: ${msg.content}`);
26
+ });
27
+ // 4. Send encrypted message
28
+ await alice.send('bob@example.com', 'Hello Bob!');
29
+ // 5. Cleanup
30
+ await app.disconnect();
31
+ unsubscribe();
32
+ }
33
+ catch (error) {
34
+ if (error instanceof StvorError) {
35
+ console.error(`[${error.code}] ${error.message}`);
36
+ console.error(`Action: ${error.action}`);
37
+ console.error(`Retryable: ${error.retryable}`);
38
+ }
39
+ else {
40
+ console.error('Unknown error:', error);
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Note on security guarantees:
46
+ *
47
+ * The facade provides a convenient API, but actual security
48
+ * (E2EE, PFS, post-quantum resistance) depends on the STVOR core.
49
+ *
50
+ * For production use, verify:
51
+ * 1. Core implements Double Ratchet for PFS
52
+ * 2. Core implements ML-KEM for post-quantum resistance
53
+ * 3. Keys are stored securely (not in plain memory)
54
+ * 4. Relay is trusted or verified
55
+ */
56
+ // Run example
57
+ main();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * STVOR DX Facade - Main Application Classes
3
+ */
4
+ import { StvorAppConfig, UserId, MessageContent } from './types';
5
+ import { DecryptedMessage, SealedPayload } from './types';
6
+ import { Errors, StvorError, ErrorCode } from './errors';
7
+ export type { DecryptedMessage, SealedPayload, ErrorCode };
8
+ export { StvorError, Errors };
9
+ import { RelayClient } from './relay-client';
10
+ export declare class StvorApp {
11
+ private relay;
12
+ private config;
13
+ private connectedClients;
14
+ constructor(config: Required<StvorAppConfig>);
15
+ isReady(): boolean;
16
+ connect(userId: UserId): Promise<StvorFacadeClient>;
17
+ disconnect(userId?: UserId): Promise<void>;
18
+ private initClient;
19
+ }
20
+ export declare class StvorFacadeClient {
21
+ private userId;
22
+ private relay;
23
+ private initialized;
24
+ private sessionKeyPair;
25
+ private messageQueue;
26
+ private messageHandlers;
27
+ private isReceiving;
28
+ constructor(userId: UserId, relay: RelayClient);
29
+ internalInitialize(): Promise<void>;
30
+ private initialize;
31
+ send(recipientId: UserId, content: MessageContent): Promise<void>;
32
+ receive(): Promise<DecryptedMessage>;
33
+ seal(data: MessageContent, recipientId: UserId): Promise<SealedPayload>;
34
+ open(sealed: SealedPayload): Promise<Uint8Array>;
35
+ onMessage(handler: (msg: DecryptedMessage) => void): () => void;
36
+ getUserId(): UserId;
37
+ disconnect(): Promise<void>;
38
+ private deriveSharedKey;
39
+ private fetchAndDecryptMessageWithTimeout;
40
+ private decryptMessage;
41
+ private startMessagePolling;
42
+ }
43
+ export declare function init(config: StvorAppConfig): Promise<StvorApp>;
44
+ export declare const Stvor: {
45
+ init: typeof init;
46
+ };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * STVOR DX Facade - Main Application Classes
3
+ */
4
+ import { Errors, StvorError } from './errors';
5
+ export { StvorError, Errors };
6
+ import { RelayClient } from './relay-client';
7
+ export class StvorApp {
8
+ constructor(config) {
9
+ this.connectedClients = new Map();
10
+ this.config = config;
11
+ this.relay = new RelayClient(config.relayUrl, config.appToken, config.timeout);
12
+ }
13
+ isReady() {
14
+ return this.relay.isConnected();
15
+ }
16
+ async connect(userId) {
17
+ const existingClient = this.connectedClients.get(userId);
18
+ if (existingClient) {
19
+ console.warn(`[STVOR] Warning: User "${userId}" is already connected. Returning cached client.`);
20
+ return existingClient;
21
+ }
22
+ const client = new StvorFacadeClient(userId, this.relay);
23
+ await this.initClient(client);
24
+ this.connectedClients.set(userId, client);
25
+ return client;
26
+ }
27
+ async disconnect(userId) {
28
+ if (userId) {
29
+ const client = this.connectedClients.get(userId);
30
+ if (client) {
31
+ await client.disconnect();
32
+ this.connectedClients.delete(userId);
33
+ }
34
+ }
35
+ else {
36
+ for (const client of this.connectedClients.values()) {
37
+ await client.disconnect();
38
+ }
39
+ this.connectedClients.clear();
40
+ this.relay.disconnect();
41
+ }
42
+ }
43
+ async initClient(client) {
44
+ await client.internalInitialize();
45
+ }
46
+ }
47
+ export class StvorFacadeClient {
48
+ constructor(userId, relay) {
49
+ this.initialized = false;
50
+ this.sessionKeyPair = null;
51
+ this.messageQueue = [];
52
+ this.messageHandlers = new Map();
53
+ this.isReceiving = false;
54
+ this.userId = userId;
55
+ this.relay = relay;
56
+ }
57
+ async internalInitialize() {
58
+ await this.initialize();
59
+ }
60
+ async initialize() {
61
+ if (this.initialized)
62
+ return;
63
+ this.sessionKeyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'X25519' }, true, ['deriveKey', 'deriveBits']);
64
+ const publicKeyJwk = await crypto.subtle.exportKey('jwk', this.sessionKeyPair.publicKey);
65
+ await this.relay.register(this.userId, publicKeyJwk);
66
+ this.initialized = true;
67
+ this.startMessagePolling();
68
+ }
69
+ async send(recipientId, content) {
70
+ if (!this.initialized) {
71
+ throw Errors.clientNotReady();
72
+ }
73
+ const contentBytes = typeof content === 'string'
74
+ ? new TextEncoder().encode(content)
75
+ : content;
76
+ const recipientKey = await this.relay.getPublicKey(recipientId);
77
+ if (!recipientKey) {
78
+ throw Errors.recipientNotFound(recipientId);
79
+ }
80
+ const sharedKey = await this.deriveSharedKey(recipientKey);
81
+ const iv = crypto.getRandomValues(new Uint8Array(12));
82
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, contentBytes.buffer);
83
+ await this.relay.send({
84
+ to: recipientId,
85
+ from: this.userId,
86
+ ciphertext: new Uint8Array(encrypted),
87
+ nonce: iv,
88
+ });
89
+ }
90
+ async receive() {
91
+ if (!this.initialized) {
92
+ throw Errors.clientNotReady();
93
+ }
94
+ if (this.isReceiving) {
95
+ throw Errors.receiveInProgress();
96
+ }
97
+ if (this.messageQueue.length > 0) {
98
+ return this.messageQueue.shift();
99
+ }
100
+ this.isReceiving = true;
101
+ try {
102
+ return await this.fetchAndDecryptMessageWithTimeout(30000);
103
+ }
104
+ finally {
105
+ this.isReceiving = false;
106
+ }
107
+ }
108
+ async seal(data, recipientId) {
109
+ if (!this.initialized) {
110
+ throw Errors.clientNotReady();
111
+ }
112
+ const contentBytes = typeof data === 'string'
113
+ ? new TextEncoder().encode(data)
114
+ : data;
115
+ const recipientKey = await this.relay.getPublicKey(recipientId);
116
+ if (!recipientKey) {
117
+ throw Errors.recipientNotFound(recipientId);
118
+ }
119
+ const sharedKey = await this.deriveSharedKey(recipientKey);
120
+ const iv = crypto.getRandomValues(new Uint8Array(12));
121
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, contentBytes.buffer);
122
+ return {
123
+ ciphertext: new Uint8Array(encrypted),
124
+ nonce: iv,
125
+ recipientId,
126
+ };
127
+ }
128
+ async open(sealed) {
129
+ if (!this.initialized) {
130
+ throw Errors.clientNotReady();
131
+ }
132
+ const senderKey = await this.relay.getPublicKey(sealed.recipientId);
133
+ if (!senderKey) {
134
+ throw Errors.recipientNotFound(sealed.recipientId);
135
+ }
136
+ const sharedKey = await this.deriveSharedKey(senderKey);
137
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.nonce }, sharedKey, sealed.ciphertext.buffer);
138
+ return new Uint8Array(decrypted);
139
+ }
140
+ onMessage(handler) {
141
+ const id = crypto.randomUUID();
142
+ this.messageHandlers.set(id, handler);
143
+ return () => {
144
+ this.messageHandlers.delete(id);
145
+ };
146
+ }
147
+ getUserId() {
148
+ return this.userId;
149
+ }
150
+ async disconnect() {
151
+ this.messageHandlers.clear();
152
+ this.initialized = false;
153
+ this.sessionKeyPair = null;
154
+ this.messageQueue = [];
155
+ this.isReceiving = false;
156
+ }
157
+ async deriveSharedKey(peerPublicKey) {
158
+ return await crypto.subtle.deriveKey({ name: 'ECDH', public: peerPublicKey }, this.sessionKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
159
+ }
160
+ async fetchAndDecryptMessageWithTimeout(timeoutMs) {
161
+ const pollInterval = 1000;
162
+ let waited = 0;
163
+ while (waited < timeoutMs) {
164
+ try {
165
+ const messages = await this.relay.fetchMessages(this.userId);
166
+ if (messages.length > 0) {
167
+ return await this.decryptMessage(messages[0]);
168
+ }
169
+ }
170
+ catch {
171
+ // Silent error on poll
172
+ }
173
+ waited += pollInterval;
174
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
175
+ }
176
+ throw Errors.receiveTimeout();
177
+ }
178
+ async decryptMessage(msg) {
179
+ const senderKey = await this.relay.getPublicKey(msg.from);
180
+ if (!senderKey) {
181
+ throw Errors.recipientNotFound(msg.from);
182
+ }
183
+ const sharedKey = await this.deriveSharedKey(senderKey);
184
+ try {
185
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(msg.nonce) }, sharedKey, new Uint8Array(msg.ciphertext).buffer);
186
+ return {
187
+ id: msg.id || crypto.randomUUID(),
188
+ senderId: msg.from,
189
+ content: new TextDecoder().decode(decrypted),
190
+ timestamp: new Date(msg.timestamp),
191
+ };
192
+ }
193
+ catch {
194
+ throw Errors.messageIntegrityFailed();
195
+ }
196
+ }
197
+ startMessagePolling() {
198
+ const poll = async () => {
199
+ try {
200
+ const messages = await this.relay.fetchMessages(this.userId);
201
+ if (messages.length > 0) {
202
+ const msg = await this.decryptMessage(messages[0]);
203
+ this.messageQueue.push(msg);
204
+ for (const handler of this.messageHandlers.values()) {
205
+ try {
206
+ handler(msg);
207
+ }
208
+ catch {
209
+ // Handler error does not break other handlers
210
+ }
211
+ }
212
+ }
213
+ }
214
+ catch {
215
+ // Silent error on poll
216
+ }
217
+ if (this.initialized) {
218
+ setTimeout(poll, 1000);
219
+ }
220
+ };
221
+ poll();
222
+ }
223
+ }
224
+ export async function init(config) {
225
+ const relayUrl = config.relayUrl || 'https://relay.stvor.io';
226
+ const timeout = config.timeout || 10000;
227
+ if (!config.appToken || !config.appToken.startsWith('stvor_')) {
228
+ throw Errors.invalidAppToken();
229
+ }
230
+ const appConfig = {
231
+ appToken: config.appToken,
232
+ relayUrl,
233
+ timeout,
234
+ };
235
+ const app = new StvorApp(appConfig);
236
+ try {
237
+ const relay = new RelayClient(relayUrl, config.appToken, timeout);
238
+ await relay.healthCheck();
239
+ }
240
+ catch {
241
+ throw Errors.relayUnavailable();
242
+ }
243
+ return app;
244
+ }
245
+ export const Stvor = {
246
+ init,
247
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * STVOR DX Facade - Error Handling
3
+ */
4
+ export declare const ErrorCode: {
5
+ readonly AUTH_FAILED: "AUTH_FAILED";
6
+ readonly INVALID_APP_TOKEN: "INVALID_APP_TOKEN";
7
+ readonly RELAY_UNAVAILABLE: "RELAY_UNAVAILABLE";
8
+ readonly RECIPIENT_NOT_FOUND: "RECIPIENT_NOT_FOUND";
9
+ readonly MESSAGE_INTEGRITY_FAILED: "MESSAGE_INTEGRITY_FAILED";
10
+ readonly KEYSTORE_CORRUPTED: "KEYSTORE_CORRUPTED";
11
+ readonly DEVICE_COMPROMISED: "DEVICE_COMPROMISED";
12
+ readonly PROTOCOL_VERSION_MISMATCH: "PROTOCOL_VERSION_MISMATCH";
13
+ readonly CLIENT_NOT_READY: "CLIENT_NOT_READY";
14
+ readonly DELIVERY_FAILED: "DELIVERY_FAILED";
15
+ readonly RECEIVE_TIMEOUT: "RECEIVE_TIMEOUT";
16
+ readonly RECEIVE_IN_PROGRESS: "RECEIVE_IN_PROGRESS";
17
+ };
18
+ export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
19
+ export declare class StvorError extends Error {
20
+ code: string;
21
+ action?: string;
22
+ retryable?: boolean;
23
+ constructor(code: string, message: string, action?: string, retryable?: boolean);
24
+ }
25
+ export declare const Errors: {
26
+ authFailed(): StvorError;
27
+ invalidAppToken(): StvorError;
28
+ relayUnavailable(): StvorError;
29
+ recipientNotFound(userId: string): StvorError;
30
+ messageIntegrityFailed(): StvorError;
31
+ keystoreCorrupted(): StvorError;
32
+ deviceCompromised(): StvorError;
33
+ protocolMismatch(): StvorError;
34
+ clientNotReady(): StvorError;
35
+ deliveryFailed(recipientId: string): StvorError;
36
+ receiveTimeout(): StvorError;
37
+ receiveInProgress(): StvorError;
38
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * STVOR DX Facade - Error Handling
3
+ */
4
+ export const ErrorCode = {
5
+ AUTH_FAILED: 'AUTH_FAILED',
6
+ INVALID_APP_TOKEN: 'INVALID_APP_TOKEN',
7
+ RELAY_UNAVAILABLE: 'RELAY_UNAVAILABLE',
8
+ RECIPIENT_NOT_FOUND: 'RECIPIENT_NOT_FOUND',
9
+ MESSAGE_INTEGRITY_FAILED: 'MESSAGE_INTEGRITY_FAILED',
10
+ KEYSTORE_CORRUPTED: 'KEYSTORE_CORRUPTED',
11
+ DEVICE_COMPROMISED: 'DEVICE_COMPROMISED',
12
+ PROTOCOL_VERSION_MISMATCH: 'PROTOCOL_VERSION_MISMATCH',
13
+ CLIENT_NOT_READY: 'CLIENT_NOT_READY',
14
+ DELIVERY_FAILED: 'DELIVERY_FAILED',
15
+ RECEIVE_TIMEOUT: 'RECEIVE_TIMEOUT',
16
+ RECEIVE_IN_PROGRESS: 'RECEIVE_IN_PROGRESS',
17
+ };
18
+ export class StvorError extends Error {
19
+ constructor(code, message, action, retryable) {
20
+ super(message);
21
+ this.name = 'StvorError';
22
+ this.code = code;
23
+ this.action = action;
24
+ this.retryable = retryable;
25
+ }
26
+ }
27
+ export const Errors = {
28
+ authFailed() {
29
+ return new StvorError(ErrorCode.AUTH_FAILED, 'The AppToken is invalid or has been revoked.', 'Check your dashboard and regenerate a new AppToken.', false);
30
+ },
31
+ invalidAppToken() {
32
+ return new StvorError(ErrorCode.INVALID_APP_TOKEN, 'Invalid AppToken format. AppToken must start with "stvor_".', 'Get your AppToken from the developer dashboard.', false);
33
+ },
34
+ relayUnavailable() {
35
+ return new StvorError(ErrorCode.RELAY_UNAVAILABLE, 'Cannot connect to STVOR relay server.', 'Check your internet connection.', true);
36
+ },
37
+ recipientNotFound(userId) {
38
+ return new StvorError(ErrorCode.RECIPIENT_NOT_FOUND, `User "${userId}" not found. They may not have registered with STVOR.`, 'Ask the recipient to initialize STVOR first, or verify the userId is correct.', false);
39
+ },
40
+ messageIntegrityFailed() {
41
+ return new StvorError(ErrorCode.MESSAGE_INTEGRITY_FAILED, 'Message integrity check failed. The message may be corrupted or tampered with.', 'Request the message again from the sender.', false);
42
+ },
43
+ keystoreCorrupted() {
44
+ return new StvorError(ErrorCode.KEYSTORE_CORRUPTED, 'Local keystore is corrupted. All local keys have been wiped.', 'The user will need to re-register this device.', false);
45
+ },
46
+ deviceCompromised() {
47
+ return new StvorError(ErrorCode.DEVICE_COMPROMISED, 'Security violation detected. This device has been flagged.', 'Immediately log out the user and investigate.', false);
48
+ },
49
+ protocolMismatch() {
50
+ return new StvorError(ErrorCode.PROTOCOL_VERSION_MISMATCH, 'This app version uses an older protocol version.', 'Update the SDK to the latest version.', false);
51
+ },
52
+ clientNotReady() {
53
+ return new StvorError(ErrorCode.CLIENT_NOT_READY, 'Client is not ready. Call connect() first and await it.', 'Make sure to await app.connect() before sending messages.', false);
54
+ },
55
+ deliveryFailed(recipientId) {
56
+ return new StvorError(ErrorCode.DELIVERY_FAILED, `Failed to deliver message to ${recipientId}.`, 'Check that the recipient exists and try again.', true);
57
+ },
58
+ receiveTimeout() {
59
+ return new StvorError(ErrorCode.RECEIVE_TIMEOUT, 'No messages received within 30 second timeout.', 'Use onMessage() subscription for real-time updates.', true);
60
+ },
61
+ receiveInProgress() {
62
+ return new StvorError(ErrorCode.RECEIVE_IN_PROGRESS, 'A receive() call is already in progress.', 'Wait for the current receive() to complete before calling again.', true);
63
+ },
64
+ };
@@ -1,21 +1,17 @@
1
1
  /**
2
2
  * STVOR DX Facade SDK
3
3
  * High-level developer experience layer for STVOR E2E encryption
4
- *
4
+ *
5
5
  * Design goals:
6
6
  * - Minimal API surface
7
7
  * - Zero crypto knowledge required
8
8
  * - Secure by default
9
9
  * - Opinionated (no configuration)
10
10
  */
11
-
12
- // Re-export types
13
11
  export type { DecryptedMessage, SealedPayload } from './app';
14
12
  export type { StvorAppConfig, AppToken, UserId, MessageContent } from './types';
15
- export type { ErrorCode, StvorError } from './errors';
16
-
17
- // Re-export classes and functions
13
+ export type { ErrorCode } from './errors';
14
+ export { StvorError } from './errors';
18
15
  export { StvorApp, StvorFacadeClient } from './app';
19
-
20
16
  export { Stvor, init } from './app';
21
17
  export { ErrorCode as STVOR_ERRORS } from './errors';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * STVOR DX Facade SDK
3
+ * High-level developer experience layer for STVOR E2E encryption
4
+ *
5
+ * Design goals:
6
+ * - Minimal API surface
7
+ * - Zero crypto knowledge required
8
+ * - Secure by default
9
+ * - Opinionated (no configuration)
10
+ */
11
+ export { StvorError } from './errors';
12
+ // Re-export classes and functions
13
+ export { StvorApp, StvorFacadeClient } from './app';
14
+ export { Stvor, init } from './app';
15
+ export { ErrorCode as STVOR_ERRORS } from './errors';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * STVOR DX Facade - Relay Client
3
+ */
4
+ interface OutgoingMessage {
5
+ to: string;
6
+ from: string;
7
+ ciphertext: Uint8Array;
8
+ nonce: Uint8Array;
9
+ }
10
+ interface IncomingMessage {
11
+ id?: string;
12
+ from: string;
13
+ ciphertext: number[];
14
+ nonce: number[];
15
+ timestamp: string;
16
+ }
17
+ export declare class RelayClient {
18
+ private relayUrl;
19
+ private timeout;
20
+ private appToken;
21
+ private connected;
22
+ constructor(relayUrl: string, appToken: string, timeout?: number);
23
+ private getAuthHeaders;
24
+ healthCheck(): Promise<void>;
25
+ isConnected(): boolean;
26
+ register(userId: string, publicKey: JsonWebKey): Promise<void>;
27
+ getPublicKey(userId: string): Promise<CryptoKey | null>;
28
+ send(message: OutgoingMessage): Promise<void>;
29
+ fetchMessages(userId: string): Promise<IncomingMessage[]>;
30
+ disconnect(): void;
31
+ }
32
+ export {};
@@ -0,0 +1,128 @@
1
+ /**
2
+ * STVOR DX Facade - Relay Client
3
+ */
4
+ import { Errors } from './errors';
5
+ export class RelayClient {
6
+ constructor(relayUrl, appToken, timeout = 10000) {
7
+ this.connected = false;
8
+ this.relayUrl = relayUrl;
9
+ this.appToken = appToken;
10
+ this.timeout = timeout;
11
+ }
12
+ getAuthHeaders() {
13
+ return {
14
+ 'Authorization': `Bearer ${this.appToken}`,
15
+ 'Content-Type': 'application/json',
16
+ };
17
+ }
18
+ async healthCheck() {
19
+ const controller = new AbortController();
20
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
21
+ try {
22
+ const res = await fetch(`${this.relayUrl}/health`, {
23
+ method: 'GET',
24
+ signal: controller.signal,
25
+ });
26
+ if (!res.ok) {
27
+ throw Errors.relayUnavailable();
28
+ }
29
+ }
30
+ finally {
31
+ clearTimeout(timeoutId);
32
+ }
33
+ }
34
+ isConnected() {
35
+ return this.connected;
36
+ }
37
+ async register(userId, publicKey) {
38
+ const controller = new AbortController();
39
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
40
+ try {
41
+ const res = await fetch(`${this.relayUrl}/register`, {
42
+ method: 'POST',
43
+ headers: this.getAuthHeaders(),
44
+ body: JSON.stringify({ user_id: userId, publicKey }),
45
+ signal: controller.signal,
46
+ });
47
+ if (!res.ok) {
48
+ const error = await res.json().catch(() => ({}));
49
+ if (error.code === 'AUTH_FAILED') {
50
+ throw Errors.authFailed();
51
+ }
52
+ throw Errors.relayUnavailable();
53
+ }
54
+ this.connected = true;
55
+ }
56
+ finally {
57
+ clearTimeout(timeoutId);
58
+ }
59
+ }
60
+ async getPublicKey(userId) {
61
+ const controller = new AbortController();
62
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
63
+ try {
64
+ const res = await fetch(`${this.relayUrl}/public-key/${userId}`, {
65
+ method: 'GET',
66
+ headers: this.getAuthHeaders(),
67
+ signal: controller.signal,
68
+ });
69
+ if (res.status === 404) {
70
+ return null;
71
+ }
72
+ if (!res.ok) {
73
+ throw Errors.relayUnavailable();
74
+ }
75
+ const data = await res.json();
76
+ const jwk = data.publicKey;
77
+ return await crypto.subtle.importKey('jwk', jwk, { name: 'ECDH', namedCurve: 'X25519' }, false, []);
78
+ }
79
+ finally {
80
+ clearTimeout(timeoutId);
81
+ }
82
+ }
83
+ async send(message) {
84
+ const controller = new AbortController();
85
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
86
+ try {
87
+ const res = await fetch(`${this.relayUrl}/message`, {
88
+ method: 'POST',
89
+ headers: this.getAuthHeaders(),
90
+ body: JSON.stringify({
91
+ to: message.to,
92
+ from: message.from,
93
+ ciphertext: Array.from(message.ciphertext),
94
+ nonce: Array.from(message.nonce),
95
+ }),
96
+ signal: controller.signal,
97
+ });
98
+ if (!res.ok) {
99
+ throw Errors.deliveryFailed(message.to);
100
+ }
101
+ }
102
+ finally {
103
+ clearTimeout(timeoutId);
104
+ }
105
+ }
106
+ async fetchMessages(userId) {
107
+ const controller = new AbortController();
108
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
109
+ try {
110
+ const res = await fetch(`${this.relayUrl}/messages/${userId}`, {
111
+ method: 'GET',
112
+ headers: this.getAuthHeaders(),
113
+ signal: controller.signal,
114
+ });
115
+ if (!res.ok) {
116
+ throw Errors.relayUnavailable();
117
+ }
118
+ const data = await res.json();
119
+ return data.messages || [];
120
+ }
121
+ finally {
122
+ clearTimeout(timeoutId);
123
+ }
124
+ }
125
+ disconnect() {
126
+ this.connected = false;
127
+ }
128
+ }