@stvor/sdk 2.4.1 → 3.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.
Files changed (81) hide show
  1. package/dist/facade/app.d.ts +83 -76
  2. package/dist/facade/app.js +330 -195
  3. package/dist/facade/crypto-session.cjs +29 -0
  4. package/dist/facade/crypto-session.d.ts +71 -0
  5. package/dist/facade/crypto-session.js +152 -0
  6. package/dist/facade/errors.d.ts +29 -12
  7. package/dist/facade/errors.js +49 -8
  8. package/dist/facade/index.d.ts +27 -8
  9. package/dist/facade/index.js +23 -3
  10. package/dist/facade/local-storage-identity-store.cjs +29 -0
  11. package/dist/facade/local-storage-identity-store.d.ts +50 -0
  12. package/dist/facade/local-storage-identity-store.js +100 -0
  13. package/dist/facade/metrics-attestation.cjs +29 -0
  14. package/dist/facade/metrics-attestation.d.ts +209 -0
  15. package/dist/facade/metrics-attestation.js +333 -0
  16. package/dist/facade/metrics-engine.cjs +29 -0
  17. package/dist/facade/metrics-engine.d.ts +91 -0
  18. package/dist/facade/metrics-engine.js +170 -0
  19. package/dist/facade/redis-replay-cache.cjs +29 -0
  20. package/dist/facade/redis-replay-cache.d.ts +88 -0
  21. package/dist/facade/redis-replay-cache.js +60 -0
  22. package/dist/facade/relay-client.d.ts +22 -23
  23. package/dist/facade/relay-client.js +107 -128
  24. package/dist/facade/replay-manager.cjs +29 -0
  25. package/dist/facade/replay-manager.d.ts +51 -0
  26. package/dist/facade/replay-manager.js +150 -0
  27. package/dist/facade/sodium-singleton.cjs +29 -0
  28. package/dist/facade/sodium-singleton.d.ts +20 -0
  29. package/dist/facade/sodium-singleton.js +44 -0
  30. package/dist/facade/tofu-manager.cjs +29 -0
  31. package/dist/facade/tofu-manager.d.ts +82 -0
  32. package/dist/facade/tofu-manager.js +166 -0
  33. package/dist/facade/types.d.ts +2 -0
  34. package/dist/index.d.cts +4 -0
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.js +7 -0
  37. package/dist/legacy.d.ts +31 -1
  38. package/dist/legacy.js +90 -2
  39. package/dist/ratchet/core-production.cjs +29 -0
  40. package/dist/ratchet/core-production.d.ts +95 -0
  41. package/dist/ratchet/core-production.js +286 -0
  42. package/dist/{facade/crypto.cjs → ratchet/index.cjs} +1 -1
  43. package/dist/ratchet/index.d.ts +59 -0
  44. package/dist/ratchet/index.js +343 -0
  45. package/dist/ratchet/key-recovery.cjs +29 -0
  46. package/dist/ratchet/key-recovery.d.ts +45 -0
  47. package/dist/ratchet/key-recovery.js +148 -0
  48. package/dist/ratchet/replay-protection.cjs +29 -0
  49. package/dist/ratchet/replay-protection.d.ts +21 -0
  50. package/dist/ratchet/replay-protection.js +50 -0
  51. package/dist/{mock-relay-server.cjs → ratchet/tofu.cjs} +1 -1
  52. package/dist/ratchet/tofu.d.ts +27 -0
  53. package/dist/ratchet/tofu.js +62 -0
  54. package/dist/src/facade/app.cjs +29 -0
  55. package/dist/src/facade/app.d.ts +105 -0
  56. package/dist/src/facade/app.js +245 -0
  57. package/dist/src/facade/crypto.cjs +29 -0
  58. package/dist/src/facade/errors.cjs +29 -0
  59. package/dist/src/facade/errors.d.ts +19 -0
  60. package/dist/src/facade/errors.js +21 -0
  61. package/dist/src/facade/index.cjs +29 -0
  62. package/dist/src/facade/index.d.ts +8 -0
  63. package/dist/src/facade/index.js +5 -0
  64. package/dist/src/facade/relay-client.cjs +29 -0
  65. package/dist/src/facade/relay-client.d.ts +36 -0
  66. package/dist/src/facade/relay-client.js +154 -0
  67. package/dist/src/facade/types.cjs +29 -0
  68. package/dist/src/facade/types.d.ts +50 -0
  69. package/dist/src/facade/types.js +4 -0
  70. package/dist/src/index.cjs +29 -0
  71. package/dist/src/index.d.ts +2 -0
  72. package/dist/src/index.js +2 -0
  73. package/dist/src/legacy.cjs +29 -0
  74. package/dist/src/legacy.d.ts +0 -0
  75. package/dist/src/legacy.js +1 -0
  76. package/dist/src/mock-relay-server.cjs +29 -0
  77. package/package.json +16 -5
  78. /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
  79. /package/dist/{facade → src/facade}/crypto.js +0 -0
  80. /package/dist/{mock-relay-server.d.ts → src/mock-relay-server.d.ts} +0 -0
  81. /package/dist/{mock-relay-server.js → src/mock-relay-server.js} +0 -0
@@ -1,241 +1,376 @@
1
+ /**
2
+ * STVOR DX Facade - Main Application Classes
3
+ *
4
+ * Security Guarantees:
5
+ * - X3DH + Double Ratchet (Signal Protocol)
6
+ * - Forward Secrecy via automatic DH ratchet rotation
7
+ * - Post-Compromise Security via forced ratchet steps
8
+ * - TOFU (Trust On First Use) for identity verification
9
+ * - Replay protection via nonce validation
10
+ * - Cryptographically verified metrics (HMAC-SHA256)
11
+ * - Node.js crypto for all cryptographic operations
12
+ */
1
13
  import { Errors, StvorError } from './errors.js';
14
+ export { StvorError, Errors };
2
15
  import { RelayClient } from './relay-client.js';
3
- import { CryptoSession } from './crypto.js';
4
- /** Default timeout for waiting for recipient keys (ms) */
5
- const DEFAULT_RECIPIENT_TIMEOUT = 10000;
6
- /** Polling interval for key resolution (ms) */
7
- const KEY_POLL_INTERVAL = 100;
8
- export class StvorFacadeClient {
9
- constructor(userId, relay, defaultTimeout = DEFAULT_RECIPIENT_TIMEOUT) {
10
- this.userId = userId;
11
- this.relay = relay;
12
- this.defaultTimeout = defaultTimeout;
13
- this.handlers = [];
14
- this.userAvailableHandlers = [];
15
- this.knownPubKeys = new Map();
16
- this.pendingKeyResolvers = new Map();
17
- this.crypto = new CryptoSession();
18
- // listen relay messages
19
- this.relay.onMessage((m) => this.handleRelayMessage(m));
20
- // announce our public key
21
- this.relay.send({ type: 'announce', user: this.userId, pub: this.crypto.exportPublic() });
22
- }
23
- async handleRelayMessage(m) {
24
- if (!m || typeof m !== 'object')
25
- return;
26
- if (m.type === 'announce' && m.user && m.pub) {
27
- const wasKnown = this.knownPubKeys.has(m.user);
28
- this.knownPubKeys.set(m.user, m.pub);
29
- // Notify pending resolvers
30
- const resolvers = this.pendingKeyResolvers.get(m.user);
31
- if (resolvers) {
32
- resolvers.forEach(resolve => resolve());
33
- this.pendingKeyResolvers.delete(m.user);
34
- }
35
- // Notify user available handlers (only for new users)
36
- if (!wasKnown) {
37
- for (const h of this.userAvailableHandlers) {
38
- try {
39
- h(m.user);
40
- }
41
- catch { }
42
- }
43
- }
44
- return;
45
- }
46
- if (m.type === 'message' && m.to === this.userId && m.payload) {
47
- const payload = m.payload;
48
- const sender = m.from;
49
- try {
50
- const plain = this.crypto.decrypt(payload, payload.senderPub);
51
- const text = new TextDecoder().decode(plain);
52
- for (const h of this.handlers)
53
- h(sender, text);
54
- }
55
- catch (e) {
56
- // ignore decryption errors
57
- }
58
- }
16
+ import { Counter, Gauge, register } from 'prom-client';
17
+ import { CryptoSessionManager } from './crypto-session.js';
18
+ import { verifyFingerprint } from './tofu-manager.js';
19
+ import { validateMessageWithNonce } from './replay-manager.js';
20
+ import { MetricsAttestationEngine } from './metrics-attestation.js';
21
+ // Define Prometheus metrics
22
+ const messagesDeliveredTotal = new Counter({
23
+ name: 'messages_delivered_total',
24
+ help: 'Total number of messages successfully delivered',
25
+ });
26
+ const quotaExceededTotal = new Counter({
27
+ name: 'quota_exceeded_total',
28
+ help: 'Total number of quota exceeded events',
29
+ });
30
+ const rateLimitedTotal = new Counter({
31
+ name: 'rate_limited_total',
32
+ help: 'Total number of rate-limited events',
33
+ });
34
+ const activeTokens = new Gauge({
35
+ name: 'active_tokens',
36
+ help: 'Number of currently active tokens',
37
+ });
38
+ // Export metrics for use in the application
39
+ export { messagesDeliveredTotal, quotaExceededTotal, rateLimitedTotal, activeTokens, register };
40
+ export class StvorApp {
41
+ constructor(config) {
42
+ this.connectedClients = new Map();
43
+ this.config = config;
44
+ this.relay = new RelayClient(config.relayUrl, config.appToken, config.timeout);
45
+ this.metricsAttestation = new MetricsAttestationEngine(config.appToken);
46
+ this.appToken = config.appToken;
47
+ this.backendUrl = config.backendUrl || 'http://localhost:3000';
59
48
  }
60
- async internalInitialize() {
61
- // nothing for now; announce already sent in constructor
49
+ isReady() {
50
+ return this.relay.isConnected();
62
51
  }
63
52
  /**
64
- * Check if a user's public key is available locally
53
+ * Get attestation engine for recording metrics
65
54
  */
66
- isUserAvailable(userId) {
67
- return this.knownPubKeys.has(userId);
55
+ getMetricsAttestationEngine() {
56
+ return this.metricsAttestation;
68
57
  }
69
58
  /**
70
- * Get list of all known users (whose public keys we have)
59
+ * Periodically send metrics attestations to backend
60
+ * Backend verifies and stores only valid attestations
71
61
  */
72
- getAvailableUsers() {
73
- return Array.from(this.knownPubKeys.keys()).filter(id => id !== this.userId);
62
+ async sendMetricsAttestation() {
63
+ const attestation = this.metricsAttestation.createAttestation();
64
+ try {
65
+ const response = await fetch(`${this.backendUrl}/api/metrics/attest`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({
69
+ appToken: this.appToken,
70
+ attestation,
71
+ }),
72
+ });
73
+ if (!response.ok) {
74
+ const error = await response.json();
75
+ console.error('[STVOR] Metrics attestation rejected:', error.reason);
76
+ return;
77
+ }
78
+ console.debug('[STVOR] Metrics attestation verified and stored');
79
+ }
80
+ catch (error) {
81
+ console.error('[STVOR] Failed to send metrics attestation:', error);
82
+ }
74
83
  }
75
84
  /**
76
- * Wait until a specific user's public key becomes available.
77
- * This is the recommended way to ensure you can send messages.
78
- *
79
- * @param userId - The user to wait for
80
- * @param timeoutMs - Maximum time to wait (default: 10000ms)
81
- * @throws StvorError with RECIPIENT_TIMEOUT if timeout expires
82
- *
83
- * @example
84
- * ```typescript
85
- * await alice.waitForUser('bob@example.com');
86
- * await alice.send('bob@example.com', 'Hello!');
87
- * ```
85
+ * Flush metrics to backend
86
+ * Sends current metrics attestation (if there is any activity)
87
+ * Called explicitly by user or on disconnect
88
88
  */
89
- async waitForUser(userId, timeoutMs = this.defaultTimeout) {
90
- // Already available
91
- if (this.knownPubKeys.has(userId)) {
92
- return;
89
+ async flushMetrics() {
90
+ await this.sendMetricsAttestation().catch(err => console.debug('[STVOR] Metrics flush failed:', err));
91
+ }
92
+ async connect(userId) {
93
+ const existingClient = this.connectedClients.get(userId);
94
+ if (existingClient) {
95
+ console.warn(`[STVOR] Warning: User "${userId}" is already connected. Returning cached client.`);
96
+ return existingClient;
93
97
  }
94
- return new Promise((resolve, reject) => {
95
- const timeout = setTimeout(() => {
96
- // Remove from pending
97
- const resolvers = this.pendingKeyResolvers.get(userId);
98
- if (resolvers) {
99
- const idx = resolvers.indexOf(resolveHandler);
100
- if (idx >= 0)
101
- resolvers.splice(idx, 1);
102
- if (resolvers.length === 0)
103
- this.pendingKeyResolvers.delete(userId);
104
- }
105
- reject(new StvorError(Errors.RECIPIENT_TIMEOUT, `Timed out waiting for user "${userId}" after ${timeoutMs}ms. ` +
106
- `The user may not be connected to the relay. ` +
107
- `Ensure both parties are online before sending messages.`, 'Verify the recipient is connected, or increase timeout', true));
108
- }, timeoutMs);
109
- const resolveHandler = () => {
110
- clearTimeout(timeout);
111
- resolve();
112
- };
113
- // Add to pending resolvers
114
- if (!this.pendingKeyResolvers.has(userId)) {
115
- this.pendingKeyResolvers.set(userId, []);
98
+ const client = new StvorFacadeClient(userId, this.relay, this.metricsAttestation);
99
+ await this.initClient(client);
100
+ this.connectedClients.set(userId, client);
101
+ return client;
102
+ }
103
+ async disconnect(userId) {
104
+ if (userId) {
105
+ const client = this.connectedClients.get(userId);
106
+ if (client) {
107
+ await client.disconnect();
108
+ this.connectedClients.delete(userId);
109
+ }
110
+ }
111
+ else {
112
+ for (const client of this.connectedClients.values()) {
113
+ await client.disconnect();
116
114
  }
117
- this.pendingKeyResolvers.get(userId).push(resolveHandler);
118
- });
115
+ this.connectedClients.clear();
116
+ this.relay.disconnect();
117
+ // Flush any pending metrics before disconnect
118
+ await this.flushMetrics();
119
+ }
120
+ }
121
+ async initClient(client) {
122
+ await client.internalInitialize();
123
+ }
124
+ }
125
+ export class StvorFacadeClient {
126
+ constructor(userId, relay, metricsAttestation) {
127
+ this.initialized = false;
128
+ this.messageHandlers = new Map();
129
+ this.messageQueue = [];
130
+ this.isReceiving = false;
131
+ this.userId = userId;
132
+ this.relay = relay;
133
+ this.metricsAttestation = metricsAttestation;
134
+ this.cryptoSession = new CryptoSessionManager(userId);
135
+ }
136
+ async internalInitialize() {
137
+ await this.initialize();
138
+ }
139
+ async initialize() {
140
+ if (this.initialized)
141
+ return;
142
+ // Initialize libsodium and generate identity keys
143
+ await this.cryptoSession.initialize();
144
+ // Get serialized public keys for relay registration
145
+ const publicKeys = this.cryptoSession.getPublicKeys();
146
+ // Register with relay server
147
+ await this.relay.register(this.userId, publicKeys);
148
+ this.initialized = true;
149
+ this.startMessagePolling();
119
150
  }
120
151
  /**
121
152
  * Send an encrypted message to a recipient.
122
153
  *
123
- * If the recipient's public key is not yet available, this method will
124
- * automatically wait up to `timeoutMs` for the key to arrive via the relay.
125
- *
126
- * @param recipientId - The recipient's user ID
127
- * @param content - Message content (string or Uint8Array)
128
- * @param options - Optional: { timeout: number, waitForRecipient: boolean }
129
- * @throws StvorError with RECIPIENT_TIMEOUT if recipient key doesn't arrive in time
154
+ * By default, if the recipient is not yet registered, the method will
155
+ * poll up to `options.timeout` ms for their keys to appear on the relay.
156
+ * Set `options.waitForRecipient: false` to throw immediately instead.
130
157
  *
131
- * @example
132
- * ```typescript
133
- * // Auto-waits for recipient (recommended)
134
- * await alice.send('bob@example.com', 'Hello!');
135
- *
136
- * // Skip waiting (throws immediately if not available)
137
- * await alice.send('bob@example.com', 'Hello!', { waitForRecipient: false });
138
- * ```
158
+ * @param recipientId - The recipient's user ID
159
+ * @param content - Message content (string or Uint8Array)
160
+ * @param options - Optional settings:
161
+ * - `timeout` — Max wait time in ms (default: 10 000)
162
+ * - `waitForRecipient` — Auto-wait for recipient keys (default: true)
139
163
  */
140
164
  async send(recipientId, content, options) {
141
- const { timeout = this.defaultTimeout, waitForRecipient = true } = options ?? {};
142
- // Try to resolve recipient key
143
- let recipientPub = this.knownPubKeys.get(recipientId);
144
- if (!recipientPub) {
145
- if (!waitForRecipient) {
146
- throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" is not available. ` +
147
- `Their public key has not been announced to the relay. ` +
148
- `Use waitForUser() or enable waitForRecipient option.`, 'Call waitForUser(recipientId) before sending, or ensure recipient is connected', false);
165
+ if (!this.initialized) {
166
+ throw Errors.clientNotReady();
167
+ }
168
+ const { timeout = 10000, waitForRecipient = true } = options ?? {};
169
+ // Check quota for production tokens (skip for local dev tokens)
170
+ const appToken = this.relay.getAppToken();
171
+ if (!appToken.startsWith('stvor_local_') && !appToken.startsWith('stvor_dev_')) {
172
+ const quota = await this.checkQuota();
173
+ if (quota && quota.used >= quota.limit && quota.limit !== -1) {
174
+ quotaExceededTotal.inc();
175
+ throw Errors.quotaExceeded();
176
+ }
177
+ }
178
+ const contentBytes = typeof content === 'string'
179
+ ? new TextEncoder().encode(content)
180
+ : content;
181
+ // Ensure session with recipient exists
182
+ if (!this.cryptoSession.hasSession(recipientId)) {
183
+ // Fetch recipient's public keys (with optional polling)
184
+ let recipientPublicKeys = await this.relay.getPublicKeys(recipientId);
185
+ // If not found and waitForRecipient is enabled, poll until timeout
186
+ if (!recipientPublicKeys && waitForRecipient) {
187
+ recipientPublicKeys = await this.waitForRecipientKeys(recipientId, timeout);
188
+ }
189
+ if (!recipientPublicKeys) {
190
+ throw Errors.recipientNotFound(recipientId);
149
191
  }
150
- // Wait for recipient key with timeout
151
- await this.waitForUser(recipientId, timeout);
152
- recipientPub = this.knownPubKeys.get(recipientId);
153
- if (!recipientPub) {
154
- // Should not happen, but safety check
155
- throw new StvorError(Errors.RECIPIENT_NOT_FOUND, `Recipient "${recipientId}" key resolution failed unexpectedly.`, 'This is an internal error, please report it', false);
192
+ // TOFU: Verify fingerprint (throws on mismatch)
193
+ const recipientIdentityKey = Buffer.from(recipientPublicKeys.identityKey, 'base64url');
194
+ await verifyFingerprint(recipientId, recipientIdentityKey);
195
+ // Establish X3DH session
196
+ await this.cryptoSession.establishSessionWithPeer(recipientId, recipientPublicKeys);
197
+ }
198
+ // Encrypt using Double Ratchet
199
+ const plaintext = new TextDecoder().decode(contentBytes);
200
+ const { ciphertext, header } = this.cryptoSession.encryptForPeer(recipientId, plaintext);
201
+ // METRIC: Record successful encryption (AFTER AEAD completes)
202
+ this.metricsAttestation.recordMessageEncrypted();
203
+ try {
204
+ await this.relay.send({
205
+ to: recipientId,
206
+ from: this.userId,
207
+ ciphertext,
208
+ header,
209
+ });
210
+ messagesDeliveredTotal.inc();
211
+ }
212
+ catch (e) {
213
+ if (e.code === 'QUOTA_EXCEEDED') {
214
+ quotaExceededTotal.inc();
215
+ throw Errors.quotaExceeded();
156
216
  }
217
+ throw e;
157
218
  }
158
- const plain = typeof content === 'string' ? new TextEncoder().encode(content) : content;
159
- const payload = this.crypto.encrypt(plain, recipientPub);
160
- const msg = { type: 'message', to: recipientId, from: this.userId, payload };
161
- this.relay.send(msg);
162
219
  }
163
220
  /**
164
- * Register a handler for incoming messages
221
+ * Check current quota usage from the relay server
165
222
  */
166
- onMessage(handler) {
167
- this.handlers.push(handler);
168
- return () => {
169
- const i = this.handlers.indexOf(handler);
170
- if (i >= 0)
171
- this.handlers.splice(i, 1);
172
- };
223
+ async checkQuota() {
224
+ try {
225
+ const response = await fetch(`${this.relay.getBaseUrl()}/usage`, {
226
+ headers: {
227
+ 'Authorization': `Bearer ${this.relay.getAppToken()}`
228
+ },
229
+ });
230
+ if (!response.ok)
231
+ return null;
232
+ return await response.json();
233
+ }
234
+ catch {
235
+ // If quota check fails, allow the request (fail open for availability)
236
+ return null;
237
+ }
173
238
  }
174
239
  /**
175
- * Register a handler that fires when a new user becomes available.
176
- * This is triggered when we receive a user's public key announcement.
240
+ * Wait for a specific recipient's public keys to become available on the relay.
241
+ * Polls the relay at 500ms intervals until the keys appear or timeout expires.
177
242
  *
178
- * **Edge-triggered**: Fires only ONCE per user, on first key discovery.
179
- * Will NOT fire again if user reconnects with same identity.
180
- *
181
- * @example
182
- * ```typescript
183
- * client.onUserAvailable((userId) => {
184
- * console.log(`${userId} is now available for messaging`);
185
- * });
186
- * ```
243
+ * @param recipientId - The user ID of the recipient
244
+ * @param timeoutMs - Max time to wait in milliseconds (default: 10000)
245
+ * @returns The recipient's serialized public keys, or null if timeout
187
246
  */
188
- onUserAvailable(handler) {
189
- this.userAvailableHandlers.push(handler);
247
+ async waitForUser(recipientId, timeoutMs = 10000) {
248
+ const keys = await this.waitForRecipientKeys(recipientId, timeoutMs);
249
+ return keys !== null;
250
+ }
251
+ async waitForRecipientKeys(recipientId, timeoutMs) {
252
+ const start = Date.now();
253
+ const pollInterval = 500;
254
+ while (Date.now() - start < timeoutMs) {
255
+ const keys = await this.relay.getPublicKeys(recipientId);
256
+ if (keys)
257
+ return keys;
258
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
259
+ }
260
+ return null;
261
+ }
262
+ // Note: blocking receive()/seal()/open() APIs are NOT part of SDK v0.1 facade.
263
+ // Use onMessage() for incoming messages and send() to transmit messages.
264
+ onMessage(handler) {
265
+ const id = crypto.randomUUID();
266
+ this.messageHandlers.set(id, handler);
190
267
  return () => {
191
- const i = this.userAvailableHandlers.indexOf(handler);
192
- if (i >= 0)
193
- this.userAvailableHandlers.splice(i, 1);
268
+ this.messageHandlers.delete(id);
194
269
  };
195
270
  }
196
- }
197
- export class StvorApp {
198
- constructor(config) {
199
- this.config = config;
200
- this.clients = new Map();
271
+ getUserId() {
272
+ return this.userId;
201
273
  }
202
- async connect(userId) {
203
- const existing = this.clients.get(userId);
204
- if (existing)
205
- return existing;
206
- const relay = new RelayClient(this.config.relayUrl ?? 'wss://stvor.xyz/relay', this.config.appToken, this.config.timeout ?? 10000);
207
- // Wait for relay handshake - throws if API key is invalid
208
- await relay.init();
209
- const client = new StvorFacadeClient(userId, relay, this.config.timeout ?? 10000);
210
- await client.internalInitialize();
211
- this.clients.set(userId, client);
212
- return client;
274
+ async decryptMessage(msg) {
275
+ // Ensure session with sender exists
276
+ if (!this.cryptoSession.hasSession(msg.from)) {
277
+ // Fetch sender's public keys
278
+ const senderPublicKeys = await this.relay.getPublicKeys(msg.from);
279
+ if (!senderPublicKeys) {
280
+ throw Errors.recipientNotFound(msg.from);
281
+ }
282
+ // TOFU: Verify fingerprint (throws on mismatch)
283
+ const senderIdentityKey = Buffer.from(senderPublicKeys.identityKey, 'base64url');
284
+ await verifyFingerprint(msg.from, senderIdentityKey);
285
+ // Establish X3DH session
286
+ await this.cryptoSession.establishSessionWithPeer(msg.from, senderPublicKeys);
287
+ }
288
+ // Replay protection: extract nonce from header (bytes 73-84)
289
+ const headerBuf = Buffer.from(msg.header, 'base64url');
290
+ const nonce = headerBuf.subarray(73, 85);
291
+ const timestamp = Math.floor(new Date(msg.timestamp).getTime() / 1000);
292
+ try {
293
+ await validateMessageWithNonce(msg.from, nonce, timestamp);
294
+ }
295
+ catch (e) {
296
+ // METRIC: Record replay attempt
297
+ this.metricsAttestation.recordReplayAttempt();
298
+ throw e; // Re-throw to prevent decryption
299
+ }
300
+ // Decrypt using Double Ratchet
301
+ try {
302
+ const plaintext = this.cryptoSession.decryptFromPeer(msg.from, msg.ciphertext, msg.header);
303
+ // METRIC: Record successful decryption (AFTER AAD verification)
304
+ this.metricsAttestation.recordMessageDecrypted();
305
+ return {
306
+ id: msg.id || crypto.randomUUID(),
307
+ senderId: msg.from,
308
+ content: plaintext,
309
+ timestamp: new Date(msg.timestamp),
310
+ };
311
+ }
312
+ catch (e) {
313
+ // METRIC: Record failed decryption (auth failure)
314
+ this.metricsAttestation.recordMessageRejected();
315
+ // Decryption failed — surface as delivery failure
316
+ throw Errors.deliveryFailed(msg.from);
317
+ }
213
318
  }
214
- /**
215
- * Get a connected client by user ID
216
- */
217
- getClient(userId) {
218
- return this.clients.get(userId);
319
+ startMessagePolling() {
320
+ const poll = async () => {
321
+ try {
322
+ const messages = await this.relay.fetchMessages(this.userId);
323
+ if (messages.length > 0) {
324
+ const msg = await this.decryptMessage(messages[0]);
325
+ for (const handler of this.messageHandlers.values()) {
326
+ try {
327
+ handler(msg);
328
+ }
329
+ catch (err) {
330
+ console.error('[StvorApp] Handler error:', err);
331
+ }
332
+ }
333
+ }
334
+ }
335
+ catch (err) {
336
+ console.error('[StvorApp] Poll error:', err);
337
+ }
338
+ if (this.initialized) {
339
+ setTimeout(poll, 1000);
340
+ }
341
+ };
342
+ poll();
219
343
  }
220
344
  /**
221
- * Check if a user is connected locally
345
+ * Disconnect the client from the relay server.
222
346
  */
223
- isConnected(userId) {
224
- return this.clients.has(userId);
225
- }
226
- async disconnect(userId) {
227
- if (userId) {
228
- this.clients.delete(userId);
229
- return;
230
- }
231
- this.clients.clear();
347
+ async disconnect() {
348
+ this.initialized = false;
349
+ // Stop message polling
350
+ // Note: The polling interval will naturally stop checking once initialized is false
232
351
  }
233
352
  }
234
353
  export async function init(config) {
235
- if (!config.appToken.startsWith('stvor_')) {
236
- throw new StvorError(Errors.INVALID_APP_TOKEN, 'Invalid app token');
354
+ const relayUrl = config.relayUrl || 'https://relay.stvor.io';
355
+ const timeout = config.timeout || 10000;
356
+ if (!config.appToken || !config.appToken.startsWith('stvor_')) {
357
+ throw Errors.invalidAppToken();
358
+ }
359
+ const appConfig = {
360
+ appToken: config.appToken,
361
+ relayUrl,
362
+ timeout,
363
+ };
364
+ const app = new StvorApp(appConfig);
365
+ try {
366
+ const relay = new RelayClient(relayUrl, config.appToken, timeout);
367
+ await relay.healthCheck();
368
+ }
369
+ catch (err) {
370
+ console.error('[StvorApp] Relay health check failed:', err);
371
+ throw Errors.relayUnavailable();
237
372
  }
238
- return new StvorApp(config);
373
+ return app;
239
374
  }
240
375
  // Alias for createApp
241
376
  export const createApp = init;
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Auto-generated CommonJS wrapper for facade/crypto-session.js
4
+ // This allows `require('@stvor/sdk')` to work alongside ESM `import`.
5
+
6
+ const mod = require('module');
7
+ const url = require('url');
8
+
9
+ // Use dynamic import to load the ESM module
10
+ let _cached;
11
+ async function _load() {
12
+ if (!_cached) {
13
+ _cached = await import(url.pathToFileURL(__filename.replace(/\.cjs$/, '.js')).href);
14
+ }
15
+ return _cached;
16
+ }
17
+
18
+ // For simple CJS usage, expose a promise-based loader
19
+ module.exports = new Proxy({ load: _load }, {
20
+ get(target, prop) {
21
+ if (prop === '__esModule') return true;
22
+ if (prop === 'then') return undefined; // prevent treating as thenable
23
+ if (prop === 'load') return _load;
24
+ if (prop === 'default') {
25
+ return _load().then(m => m.default);
26
+ }
27
+ return _load().then(m => m[prop]);
28
+ }
29
+ });