@stvor/sdk 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/docs/index.mdx +649 -0
- package/example.ts +64 -0
- package/facade/app.ts +331 -0
- package/facade/errors.ts +149 -0
- package/facade/index.ts +21 -0
- package/facade/relay-client.ts +173 -0
- package/facade/types.ts +65 -0
- package/index.ts +9 -0
- package/legacy.ts +158 -0
- package/package.json +34 -0
package/facade/app.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR DX Facade - Main Application Classes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { StvorAppConfig, UserId, MessageContent } from './types';
|
|
6
|
+
import { DecryptedMessage, SealedPayload } from './types';
|
|
7
|
+
import { Errors, StvorError } from './errors';
|
|
8
|
+
import { RelayClient } from './relay-client';
|
|
9
|
+
|
|
10
|
+
export class StvorApp {
|
|
11
|
+
private relay: RelayClient;
|
|
12
|
+
private config: Required<StvorAppConfig>;
|
|
13
|
+
private connectedClients: Map<UserId, StvorFacadeClient> = new Map();
|
|
14
|
+
|
|
15
|
+
constructor(config: Required<StvorAppConfig>) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.relay = new RelayClient(config.relayUrl, config.appToken, config.timeout);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
isReady(): boolean {
|
|
21
|
+
return this.relay.isConnected();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connect(userId: UserId): Promise<StvorFacadeClient> {
|
|
25
|
+
const existingClient = this.connectedClients.get(userId);
|
|
26
|
+
if (existingClient) {
|
|
27
|
+
console.warn(`[STVOR] Warning: User "${userId}" is already connected. Returning cached client.`);
|
|
28
|
+
return existingClient;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const client = new StvorFacadeClient(userId, this.relay);
|
|
32
|
+
await this.initClient(client);
|
|
33
|
+
this.connectedClients.set(userId, client);
|
|
34
|
+
return client;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async disconnect(userId?: UserId): Promise<void> {
|
|
38
|
+
if (userId) {
|
|
39
|
+
const client = this.connectedClients.get(userId);
|
|
40
|
+
if (client) {
|
|
41
|
+
await client.disconnect();
|
|
42
|
+
this.connectedClients.delete(userId);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
for (const client of this.connectedClients.values()) {
|
|
46
|
+
await client.disconnect();
|
|
47
|
+
}
|
|
48
|
+
this.connectedClients.clear();
|
|
49
|
+
this.relay.disconnect();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async initClient(client: StvorFacadeClient): Promise<void> {
|
|
54
|
+
await client.internalInitialize();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class StvorFacadeClient {
|
|
59
|
+
private userId: UserId;
|
|
60
|
+
private relay: RelayClient;
|
|
61
|
+
private initialized: boolean = false;
|
|
62
|
+
private sessionKeyPair: CryptoKeyPair | null = null;
|
|
63
|
+
private messageQueue: DecryptedMessage[] = [];
|
|
64
|
+
private messageHandlers: Map<string, (msg: DecryptedMessage) => void> = new Map();
|
|
65
|
+
private isReceiving: boolean = false;
|
|
66
|
+
|
|
67
|
+
constructor(userId: UserId, relay: RelayClient) {
|
|
68
|
+
this.userId = userId;
|
|
69
|
+
this.relay = relay;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async internalInitialize(): Promise<void> {
|
|
73
|
+
await this.initialize();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async initialize(): Promise<void> {
|
|
77
|
+
if (this.initialized) return;
|
|
78
|
+
|
|
79
|
+
this.sessionKeyPair = await crypto.subtle.generateKey(
|
|
80
|
+
{ name: 'ECDH', namedCurve: 'X25519' },
|
|
81
|
+
true,
|
|
82
|
+
['deriveKey', 'deriveBits']
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const publicKeyJwk = await crypto.subtle.exportKey('jwk', this.sessionKeyPair.publicKey);
|
|
86
|
+
await this.relay.register(this.userId, publicKeyJwk);
|
|
87
|
+
|
|
88
|
+
this.initialized = true;
|
|
89
|
+
this.startMessagePolling();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async send(recipientId: UserId, content: MessageContent): Promise<void> {
|
|
93
|
+
if (!this.initialized) {
|
|
94
|
+
throw Errors.clientNotReady();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const contentBytes: Uint8Array = typeof content === 'string'
|
|
98
|
+
? new TextEncoder().encode(content)
|
|
99
|
+
: content;
|
|
100
|
+
|
|
101
|
+
const recipientKey = await this.relay.getPublicKey(recipientId);
|
|
102
|
+
if (!recipientKey) {
|
|
103
|
+
throw Errors.recipientNotFound(recipientId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sharedKey = await this.deriveSharedKey(recipientKey);
|
|
107
|
+
|
|
108
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
109
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
110
|
+
{ name: 'AES-GCM', iv } as AesGcmParams,
|
|
111
|
+
sharedKey,
|
|
112
|
+
contentBytes.buffer as ArrayBuffer
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await this.relay.send({
|
|
116
|
+
to: recipientId,
|
|
117
|
+
from: this.userId,
|
|
118
|
+
ciphertext: new Uint8Array(encrypted),
|
|
119
|
+
nonce: iv,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async receive(): Promise<DecryptedMessage> {
|
|
124
|
+
if (!this.initialized) {
|
|
125
|
+
throw Errors.clientNotReady();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (this.isReceiving) {
|
|
129
|
+
throw Errors.receiveInProgress();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.messageQueue.length > 0) {
|
|
133
|
+
return this.messageQueue.shift()!;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.isReceiving = true;
|
|
137
|
+
try {
|
|
138
|
+
return await this.fetchAndDecryptMessageWithTimeout(30000);
|
|
139
|
+
} finally {
|
|
140
|
+
this.isReceiving = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async seal(data: MessageContent, recipientId: UserId): Promise<SealedPayload> {
|
|
145
|
+
if (!this.initialized) {
|
|
146
|
+
throw Errors.clientNotReady();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const contentBytes: Uint8Array = typeof data === 'string'
|
|
150
|
+
? new TextEncoder().encode(data)
|
|
151
|
+
: data;
|
|
152
|
+
|
|
153
|
+
const recipientKey = await this.relay.getPublicKey(recipientId);
|
|
154
|
+
if (!recipientKey) {
|
|
155
|
+
throw Errors.recipientNotFound(recipientId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sharedKey = await this.deriveSharedKey(recipientKey);
|
|
159
|
+
|
|
160
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
161
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
162
|
+
{ name: 'AES-GCM', iv } as AesGcmParams,
|
|
163
|
+
sharedKey,
|
|
164
|
+
contentBytes.buffer as ArrayBuffer
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ciphertext: new Uint8Array(encrypted),
|
|
169
|
+
nonce: iv,
|
|
170
|
+
recipientId,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async open(sealed: SealedPayload): Promise<Uint8Array> {
|
|
175
|
+
if (!this.initialized) {
|
|
176
|
+
throw Errors.clientNotReady();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const senderKey = await this.relay.getPublicKey(sealed.recipientId);
|
|
180
|
+
if (!senderKey) {
|
|
181
|
+
throw Errors.recipientNotFound(sealed.recipientId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const sharedKey = await this.deriveSharedKey(senderKey);
|
|
185
|
+
|
|
186
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
187
|
+
{ name: 'AES-GCM', iv: sealed.nonce } as AesGcmParams,
|
|
188
|
+
sharedKey,
|
|
189
|
+
sealed.ciphertext.buffer as ArrayBuffer
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return new Uint8Array(decrypted);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onMessage(handler: (msg: DecryptedMessage) => void): () => void {
|
|
196
|
+
const id = crypto.randomUUID();
|
|
197
|
+
this.messageHandlers.set(id, handler);
|
|
198
|
+
return () => {
|
|
199
|
+
this.messageHandlers.delete(id);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
getUserId(): UserId {
|
|
204
|
+
return this.userId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async disconnect(): Promise<void> {
|
|
208
|
+
this.messageHandlers.clear();
|
|
209
|
+
this.initialized = false;
|
|
210
|
+
this.sessionKeyPair = null;
|
|
211
|
+
this.messageQueue = [];
|
|
212
|
+
this.isReceiving = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async deriveSharedKey(peerPublicKey: CryptoKey): Promise<CryptoKey> {
|
|
216
|
+
return await crypto.subtle.deriveKey(
|
|
217
|
+
{ name: 'ECDH', public: peerPublicKey },
|
|
218
|
+
this.sessionKeyPair!.privateKey,
|
|
219
|
+
{ name: 'AES-GCM', length: 256 },
|
|
220
|
+
false,
|
|
221
|
+
['encrypt', 'decrypt']
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async fetchAndDecryptMessageWithTimeout(timeoutMs: number): Promise<DecryptedMessage> {
|
|
226
|
+
const pollInterval = 1000;
|
|
227
|
+
let waited = 0;
|
|
228
|
+
|
|
229
|
+
while (waited < timeoutMs) {
|
|
230
|
+
try {
|
|
231
|
+
const messages = await this.relay.fetchMessages(this.userId);
|
|
232
|
+
if (messages.length > 0) {
|
|
233
|
+
return await this.decryptMessage(messages[0]);
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Silent error on poll
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
waited += pollInterval;
|
|
240
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw Errors.receiveTimeout();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async decryptMessage(msg: { from: string; ciphertext: number[]; nonce: number[]; timestamp: string; id?: string }): Promise<DecryptedMessage> {
|
|
247
|
+
const senderKey = await this.relay.getPublicKey(msg.from);
|
|
248
|
+
if (!senderKey) {
|
|
249
|
+
throw Errors.recipientNotFound(msg.from);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sharedKey = await this.deriveSharedKey(senderKey);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
256
|
+
{ name: 'AES-GCM', iv: new Uint8Array(msg.nonce) } as AesGcmParams,
|
|
257
|
+
sharedKey,
|
|
258
|
+
new Uint8Array(msg.ciphertext).buffer as ArrayBuffer
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
id: msg.id || crypto.randomUUID(),
|
|
263
|
+
senderId: msg.from,
|
|
264
|
+
content: new TextDecoder().decode(decrypted),
|
|
265
|
+
timestamp: new Date(msg.timestamp),
|
|
266
|
+
};
|
|
267
|
+
} catch {
|
|
268
|
+
throw Errors.messageIntegrityFailed();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private startMessagePolling(): void {
|
|
273
|
+
const poll = async () => {
|
|
274
|
+
try {
|
|
275
|
+
const messages = await this.relay.fetchMessages(this.userId);
|
|
276
|
+
|
|
277
|
+
if (messages.length > 0) {
|
|
278
|
+
const msg = await this.decryptMessage(messages[0]);
|
|
279
|
+
|
|
280
|
+
this.messageQueue.push(msg);
|
|
281
|
+
|
|
282
|
+
for (const handler of this.messageHandlers.values()) {
|
|
283
|
+
try {
|
|
284
|
+
handler(msg);
|
|
285
|
+
} catch {
|
|
286
|
+
// Handler error does not break other handlers
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
// Silent error on poll
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.initialized) {
|
|
295
|
+
setTimeout(poll, 1000);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
poll();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function init(config: StvorAppConfig): Promise<StvorApp> {
|
|
304
|
+
const relayUrl = config.relayUrl || 'https://relay.stvor.io';
|
|
305
|
+
const timeout = config.timeout || 10000;
|
|
306
|
+
|
|
307
|
+
if (!config.appToken || !config.appToken.startsWith('stvor_')) {
|
|
308
|
+
throw Errors.invalidAppToken();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const appConfig: Required<StvorAppConfig> = {
|
|
312
|
+
appToken: config.appToken,
|
|
313
|
+
relayUrl,
|
|
314
|
+
timeout,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const app = new StvorApp(appConfig);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const relay = new RelayClient(relayUrl, config.appToken, timeout);
|
|
321
|
+
await relay.healthCheck();
|
|
322
|
+
} catch {
|
|
323
|
+
throw Errors.relayUnavailable();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return app;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const Stvor = {
|
|
330
|
+
init,
|
|
331
|
+
};
|
package/facade/errors.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR DX Facade - Error Handling
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const ErrorCode = {
|
|
6
|
+
AUTH_FAILED: 'AUTH_FAILED',
|
|
7
|
+
INVALID_APP_TOKEN: 'INVALID_APP_TOKEN',
|
|
8
|
+
RELAY_UNAVAILABLE: 'RELAY_UNAVAILABLE',
|
|
9
|
+
RECIPIENT_NOT_FOUND: 'RECIPIENT_NOT_FOUND',
|
|
10
|
+
MESSAGE_INTEGRITY_FAILED: 'MESSAGE_INTEGRITY_FAILED',
|
|
11
|
+
KEYSTORE_CORRUPTED: 'KEYSTORE_CORRUPTED',
|
|
12
|
+
DEVICE_COMPROMISED: 'DEVICE_COMPROMISED',
|
|
13
|
+
PROTOCOL_VERSION_MISMATCH: 'PROTOCOL_VERSION_MISMATCH',
|
|
14
|
+
CLIENT_NOT_READY: 'CLIENT_NOT_READY',
|
|
15
|
+
DELIVERY_FAILED: 'DELIVERY_FAILED',
|
|
16
|
+
RECEIVE_TIMEOUT: 'RECEIVE_TIMEOUT',
|
|
17
|
+
RECEIVE_IN_PROGRESS: 'RECEIVE_IN_PROGRESS',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
|
|
21
|
+
|
|
22
|
+
export class StvorError extends Error {
|
|
23
|
+
readonly code: ErrorCode;
|
|
24
|
+
readonly action: string;
|
|
25
|
+
readonly retryable: boolean;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
code: ErrorCode,
|
|
29
|
+
message: string,
|
|
30
|
+
action: string,
|
|
31
|
+
retryable: boolean = false
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'StvorError';
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.action = action;
|
|
37
|
+
this.retryable = retryable;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Errors = {
|
|
42
|
+
authFailed(): StvorError {
|
|
43
|
+
return new StvorError(
|
|
44
|
+
ErrorCode.AUTH_FAILED,
|
|
45
|
+
'The AppToken is invalid or has been revoked.',
|
|
46
|
+
'Check your dashboard and regenerate a new AppToken.',
|
|
47
|
+
false
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
invalidAppToken(): StvorError {
|
|
52
|
+
return new StvorError(
|
|
53
|
+
ErrorCode.INVALID_APP_TOKEN,
|
|
54
|
+
'Invalid AppToken format. AppToken must start with "stvor_".',
|
|
55
|
+
'Get your AppToken from the developer dashboard.',
|
|
56
|
+
false
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
relayUnavailable(): StvorError {
|
|
61
|
+
return new StvorError(
|
|
62
|
+
ErrorCode.RELAY_UNAVAILABLE,
|
|
63
|
+
'Cannot connect to STVOR relay server.',
|
|
64
|
+
'Check your internet connection.',
|
|
65
|
+
true
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
recipientNotFound(userId: string): StvorError {
|
|
70
|
+
return new StvorError(
|
|
71
|
+
ErrorCode.RECIPIENT_NOT_FOUND,
|
|
72
|
+
`User "${userId}" not found. They may not have registered with STVOR.`,
|
|
73
|
+
'Ask the recipient to initialize STVOR first, or verify the userId is correct.',
|
|
74
|
+
false
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
messageIntegrityFailed(): StvorError {
|
|
79
|
+
return new StvorError(
|
|
80
|
+
ErrorCode.MESSAGE_INTEGRITY_FAILED,
|
|
81
|
+
'Message integrity check failed. The message may be corrupted or tampered with.',
|
|
82
|
+
'Request the message again from the sender.',
|
|
83
|
+
false
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
keystoreCorrupted(): StvorError {
|
|
88
|
+
return new StvorError(
|
|
89
|
+
ErrorCode.KEYSTORE_CORRUPTED,
|
|
90
|
+
'Local keystore is corrupted. All local keys have been wiped.',
|
|
91
|
+
'The user will need to re-register this device.',
|
|
92
|
+
false
|
|
93
|
+
);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
deviceCompromised(): StvorError {
|
|
97
|
+
return new StvorError(
|
|
98
|
+
ErrorCode.DEVICE_COMPROMISED,
|
|
99
|
+
'Security violation detected. This device has been flagged.',
|
|
100
|
+
'Immediately log out the user and investigate.',
|
|
101
|
+
false
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
protocolMismatch(): StvorError {
|
|
106
|
+
return new StvorError(
|
|
107
|
+
ErrorCode.PROTOCOL_VERSION_MISMATCH,
|
|
108
|
+
'This app version uses an older protocol version.',
|
|
109
|
+
'Update the SDK to the latest version.',
|
|
110
|
+
false
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
clientNotReady(): StvorError {
|
|
115
|
+
return new StvorError(
|
|
116
|
+
ErrorCode.CLIENT_NOT_READY,
|
|
117
|
+
'Client is not ready. Call connect() first and await it.',
|
|
118
|
+
'Make sure to await app.connect() before sending messages.',
|
|
119
|
+
false
|
|
120
|
+
);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
deliveryFailed(recipientId: string): StvorError {
|
|
124
|
+
return new StvorError(
|
|
125
|
+
ErrorCode.DELIVERY_FAILED,
|
|
126
|
+
`Failed to deliver message to ${recipientId}.`,
|
|
127
|
+
'Check that the recipient exists and try again.',
|
|
128
|
+
true
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
receiveTimeout(): StvorError {
|
|
133
|
+
return new StvorError(
|
|
134
|
+
ErrorCode.RECEIVE_TIMEOUT,
|
|
135
|
+
'No messages received within 30 second timeout.',
|
|
136
|
+
'Use onMessage() subscription for real-time updates.',
|
|
137
|
+
true
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
receiveInProgress(): StvorError {
|
|
142
|
+
return new StvorError(
|
|
143
|
+
ErrorCode.RECEIVE_IN_PROGRESS,
|
|
144
|
+
'A receive() call is already in progress.',
|
|
145
|
+
'Wait for the current receive() to complete before calling again.',
|
|
146
|
+
true
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
};
|
package/facade/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
|
|
12
|
+
// Re-export types
|
|
13
|
+
export type { DecryptedMessage, SealedPayload } from './app';
|
|
14
|
+
export type { StvorAppConfig, AppToken, UserId, MessageContent } from './types';
|
|
15
|
+
export type { ErrorCode, StvorError } from './errors';
|
|
16
|
+
|
|
17
|
+
// Re-export classes and functions
|
|
18
|
+
export { StvorApp, StvorFacadeClient } from './app';
|
|
19
|
+
|
|
20
|
+
export { Stvor, init } from './app';
|
|
21
|
+
export { ErrorCode as STVOR_ERRORS } from './errors';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STVOR DX Facade - Relay Client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Errors } from './errors';
|
|
6
|
+
|
|
7
|
+
interface OutgoingMessage {
|
|
8
|
+
to: string;
|
|
9
|
+
from: string;
|
|
10
|
+
ciphertext: Uint8Array;
|
|
11
|
+
nonce: Uint8Array;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface IncomingMessage {
|
|
15
|
+
id?: string;
|
|
16
|
+
from: string;
|
|
17
|
+
ciphertext: number[];
|
|
18
|
+
nonce: number[];
|
|
19
|
+
timestamp: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RelayClient {
|
|
23
|
+
private relayUrl: string;
|
|
24
|
+
private timeout: number;
|
|
25
|
+
private appToken: string;
|
|
26
|
+
private connected: boolean = false;
|
|
27
|
+
|
|
28
|
+
constructor(relayUrl: string, appToken: string, timeout: number = 10000) {
|
|
29
|
+
this.relayUrl = relayUrl;
|
|
30
|
+
this.appToken = appToken;
|
|
31
|
+
this.timeout = timeout;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private getAuthHeaders(): Record<string, string> {
|
|
35
|
+
return {
|
|
36
|
+
'Authorization': `Bearer ${this.appToken}`,
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async healthCheck(): Promise<void> {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(`${this.relayUrl}/health`, {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw Errors.relayUnavailable();
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timeoutId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isConnected(): boolean {
|
|
60
|
+
return this.connected;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async register(userId: string, publicKey: JsonWebKey): Promise<void> {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${this.relayUrl}/register`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: this.getAuthHeaders(),
|
|
71
|
+
body: JSON.stringify({ user_id: userId, publicKey }),
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const error = await res.json().catch(() => ({}));
|
|
77
|
+
if (error.code === 'AUTH_FAILED') {
|
|
78
|
+
throw Errors.authFailed();
|
|
79
|
+
}
|
|
80
|
+
throw Errors.relayUnavailable();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.connected = true;
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timeoutId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getPublicKey(userId: string): Promise<CryptoKey | null> {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(`${this.relayUrl}/public-key/${userId}`, {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
headers: this.getAuthHeaders(),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (res.status === 404) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
throw Errors.relayUnavailable();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await res.json();
|
|
109
|
+
const jwk = data.publicKey;
|
|
110
|
+
|
|
111
|
+
return await crypto.subtle.importKey(
|
|
112
|
+
'jwk',
|
|
113
|
+
jwk,
|
|
114
|
+
{ name: 'ECDH', namedCurve: 'X25519' },
|
|
115
|
+
false,
|
|
116
|
+
[]
|
|
117
|
+
);
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timeoutId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async send(message: OutgoingMessage): Promise<void> {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch(`${this.relayUrl}/message`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: this.getAuthHeaders(),
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
to: message.to,
|
|
133
|
+
from: message.from,
|
|
134
|
+
ciphertext: Array.from(message.ciphertext),
|
|
135
|
+
nonce: Array.from(message.nonce),
|
|
136
|
+
}),
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw Errors.deliveryFailed(message.to);
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timeoutId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async fetchMessages(userId: string): Promise<IncomingMessage[]> {
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${this.relayUrl}/messages/${userId}`, {
|
|
154
|
+
method: 'GET',
|
|
155
|
+
headers: this.getAuthHeaders(),
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw Errors.relayUnavailable();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await res.json();
|
|
164
|
+
return data.messages || [];
|
|
165
|
+
} finally {
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
disconnect(): void {
|
|
171
|
+
this.connected = false;
|
|
172
|
+
}
|
|
173
|
+
}
|