@stvor/sdk 2.4.0 → 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 (82) hide show
  1. package/dist/facade/app.cjs +29 -0
  2. package/dist/facade/app.d.ts +83 -76
  3. package/dist/facade/app.js +330 -195
  4. package/dist/facade/crypto-session.cjs +29 -0
  5. package/dist/facade/crypto-session.d.ts +49 -54
  6. package/dist/facade/crypto-session.js +117 -140
  7. package/dist/facade/errors.cjs +29 -0
  8. package/dist/facade/errors.d.ts +29 -12
  9. package/dist/facade/errors.js +49 -8
  10. package/dist/facade/index.cjs +29 -0
  11. package/dist/facade/index.d.ts +27 -8
  12. package/dist/facade/index.js +23 -3
  13. package/dist/facade/local-storage-identity-store.cjs +29 -0
  14. package/dist/facade/local-storage-identity-store.d.ts +50 -0
  15. package/dist/facade/local-storage-identity-store.js +100 -0
  16. package/dist/facade/metrics-attestation.cjs +29 -0
  17. package/dist/facade/metrics-attestation.d.ts +209 -0
  18. package/dist/facade/metrics-attestation.js +333 -0
  19. package/dist/facade/metrics-engine.cjs +29 -0
  20. package/dist/facade/metrics-engine.d.ts +91 -0
  21. package/dist/facade/metrics-engine.js +170 -0
  22. package/dist/facade/redis-replay-cache.cjs +29 -0
  23. package/dist/facade/redis-replay-cache.d.ts +88 -0
  24. package/dist/facade/redis-replay-cache.js +60 -0
  25. package/dist/facade/relay-client.cjs +29 -0
  26. package/dist/facade/relay-client.d.ts +22 -23
  27. package/dist/facade/relay-client.js +107 -128
  28. package/dist/facade/replay-manager.cjs +29 -0
  29. package/dist/facade/replay-manager.d.ts +28 -35
  30. package/dist/facade/replay-manager.js +102 -69
  31. package/dist/facade/sodium-singleton.cjs +29 -0
  32. package/dist/facade/tofu-manager.cjs +29 -0
  33. package/dist/facade/tofu-manager.d.ts +38 -36
  34. package/dist/facade/tofu-manager.js +109 -77
  35. package/dist/facade/types.cjs +29 -0
  36. package/dist/facade/types.d.ts +2 -0
  37. package/dist/index.cjs +29 -0
  38. package/dist/index.d.cts +6 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +7 -0
  41. package/dist/legacy.cjs +29 -0
  42. package/dist/legacy.d.ts +31 -1
  43. package/dist/legacy.js +90 -2
  44. package/dist/ratchet/core-production.cjs +29 -0
  45. package/dist/ratchet/core-production.d.ts +95 -0
  46. package/dist/ratchet/core-production.js +286 -0
  47. package/dist/ratchet/index.cjs +29 -0
  48. package/dist/ratchet/index.d.ts +49 -78
  49. package/dist/ratchet/index.js +313 -288
  50. package/dist/ratchet/key-recovery.cjs +29 -0
  51. package/dist/ratchet/replay-protection.cjs +29 -0
  52. package/dist/ratchet/tofu.cjs +29 -0
  53. package/dist/src/facade/app.cjs +29 -0
  54. package/dist/src/facade/app.d.ts +105 -0
  55. package/dist/src/facade/app.js +245 -0
  56. package/dist/src/facade/crypto.cjs +29 -0
  57. package/dist/src/facade/errors.cjs +29 -0
  58. package/dist/src/facade/errors.d.ts +19 -0
  59. package/dist/src/facade/errors.js +21 -0
  60. package/dist/src/facade/index.cjs +29 -0
  61. package/dist/src/facade/index.d.ts +8 -0
  62. package/dist/src/facade/index.js +5 -0
  63. package/dist/src/facade/relay-client.cjs +29 -0
  64. package/dist/src/facade/relay-client.d.ts +36 -0
  65. package/dist/src/facade/relay-client.js +154 -0
  66. package/dist/src/facade/types.cjs +29 -0
  67. package/dist/src/facade/types.d.ts +50 -0
  68. package/dist/src/facade/types.js +4 -0
  69. package/dist/src/index.cjs +29 -0
  70. package/dist/src/index.d.ts +2 -0
  71. package/dist/src/index.js +2 -0
  72. package/dist/src/legacy.cjs +29 -0
  73. package/dist/src/legacy.d.ts +0 -0
  74. package/dist/src/legacy.js +1 -0
  75. package/dist/src/mock-relay-server.cjs +29 -0
  76. package/dist/src/mock-relay-server.d.ts +30 -0
  77. package/dist/src/mock-relay-server.js +236 -0
  78. package/package.json +37 -11
  79. package/dist/ratchet/tests/ratchet.test.d.ts +0 -1
  80. package/dist/ratchet/tests/ratchet.test.js +0 -160
  81. /package/dist/{facade → src/facade}/crypto.d.ts +0 -0
  82. /package/dist/{facade → src/facade}/crypto.js +0 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * STVOR DX Facade - Relay Client
3
+ */
4
+ import { Errors, StvorError } from './errors.js';
5
+ import * as WS from 'ws';
6
+ export class RelayClient {
7
+ constructor(relayUrl, appToken, timeout = 10000) {
8
+ this.connected = false;
9
+ this.handshakeComplete = false;
10
+ this.backoff = 1000;
11
+ this.queue = [];
12
+ this.handlers = [];
13
+ this.reconnecting = false;
14
+ this.authFailed = false;
15
+ this.relayUrl = relayUrl.replace(/^http/, 'ws');
16
+ this.appToken = appToken;
17
+ this.timeout = timeout;
18
+ }
19
+ /**
20
+ * Initialize the connection and wait for handshake.
21
+ * Throws StvorError if API key is rejected.
22
+ */
23
+ async init() {
24
+ if (this.authFailed) {
25
+ throw new StvorError(Errors.INVALID_API_KEY, 'Relay rejected connection: invalid API key');
26
+ }
27
+ if (this.handshakeComplete)
28
+ return;
29
+ await this.connect();
30
+ }
31
+ getAuthHeaders() {
32
+ return {
33
+ Authorization: `Bearer ${this.appToken}`,
34
+ };
35
+ }
36
+ connect() {
37
+ if (this.connectPromise)
38
+ return this.connectPromise;
39
+ if (this.ws)
40
+ return Promise.resolve();
41
+ this.connectPromise = new Promise((resolve, reject) => {
42
+ this.connectResolve = resolve;
43
+ this.connectReject = reject;
44
+ const WSClass = WS.default ?? WS;
45
+ this.ws = new WSClass(this.relayUrl, { headers: this.getAuthHeaders() });
46
+ // Timeout for handshake
47
+ const handshakeTimeout = setTimeout(() => {
48
+ if (!this.handshakeComplete) {
49
+ this.ws?.close();
50
+ reject(new StvorError(Errors.RELAY_UNAVAILABLE, 'Relay handshake timeout'));
51
+ }
52
+ }, this.timeout);
53
+ this.ws.on('open', () => {
54
+ this.connected = true;
55
+ this.backoff = 1000;
56
+ // Don't flush queue yet - wait for handshake
57
+ });
58
+ this.ws.on('message', (data) => {
59
+ try {
60
+ const json = JSON.parse(data.toString());
61
+ // Handle handshake response
62
+ if (json.type === 'handshake') {
63
+ clearTimeout(handshakeTimeout);
64
+ if (json.status === 'ok') {
65
+ this.handshakeComplete = true;
66
+ // Now flush the queue
67
+ while (this.queue.length) {
68
+ const m = this.queue.shift();
69
+ this.doSend(m);
70
+ }
71
+ this.connectResolve?.();
72
+ }
73
+ else {
74
+ // Handshake rejected
75
+ this.authFailed = true;
76
+ this.ws?.close();
77
+ const err = new StvorError(Errors.INVALID_API_KEY, `Relay rejected connection: ${json.reason || 'invalid API key'}`);
78
+ this.connectReject?.(err);
79
+ }
80
+ return;
81
+ }
82
+ // Regular message
83
+ for (const h of this.handlers)
84
+ h(json);
85
+ }
86
+ catch (e) {
87
+ // ignore parse errors
88
+ }
89
+ });
90
+ this.ws.on('close', (code) => {
91
+ this.connected = false;
92
+ this.handshakeComplete = false;
93
+ this.ws = undefined;
94
+ this.connectPromise = undefined;
95
+ // If auth failed, don't reconnect
96
+ if (this.authFailed) {
97
+ return;
98
+ }
99
+ // 401/403 close codes mean auth failure
100
+ if (code === 4001 || code === 4003) {
101
+ this.authFailed = true;
102
+ this.connectReject?.(new StvorError(Errors.INVALID_API_KEY, 'Relay rejected connection: invalid API key'));
103
+ return;
104
+ }
105
+ this.scheduleReconnect();
106
+ });
107
+ this.ws.on('error', (err) => {
108
+ this.connected = false;
109
+ this.handshakeComplete = false;
110
+ this.ws = undefined;
111
+ this.connectPromise = undefined;
112
+ if (this.authFailed) {
113
+ return;
114
+ }
115
+ this.scheduleReconnect();
116
+ });
117
+ });
118
+ return this.connectPromise;
119
+ }
120
+ scheduleReconnect() {
121
+ if (this.reconnecting)
122
+ return;
123
+ this.reconnecting = true;
124
+ setTimeout(() => {
125
+ this.reconnecting = false;
126
+ this.connect();
127
+ this.backoff = Math.min(this.backoff * 2, 30000);
128
+ }, this.backoff);
129
+ }
130
+ doSend(obj) {
131
+ const data = JSON.stringify(obj);
132
+ if (this.connected && this.ws && this.handshakeComplete) {
133
+ this.ws.send(data);
134
+ }
135
+ else {
136
+ this.queue.push(obj);
137
+ }
138
+ }
139
+ send(obj) {
140
+ if (this.authFailed) {
141
+ throw new StvorError(Errors.INVALID_API_KEY, 'Cannot send: relay rejected connection due to invalid API key');
142
+ }
143
+ this.doSend(obj);
144
+ }
145
+ onMessage(h) {
146
+ this.handlers.push(h);
147
+ }
148
+ isConnected() {
149
+ return this.connected && this.handshakeComplete;
150
+ }
151
+ isAuthenticated() {
152
+ return this.handshakeComplete && !this.authFailed;
153
+ }
154
+ }
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Auto-generated CommonJS wrapper for src/facade/types.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
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * STVOR DX Facade - Type Definitions
3
+ */
4
+ /**
5
+ * AppToken from developer dashboard
6
+ * Replaces "API key" terminology for better DX
7
+ */
8
+ export type AppToken = string;
9
+ /**
10
+ * Configuration for SDK initialization
11
+ */
12
+ export interface StvorAppConfig {
13
+ /** AppToken from STVOR developer dashboard */
14
+ appToken: string;
15
+ /** Relay server URL (optional, defaults to stvor.io) */
16
+ relayUrl?: string;
17
+ /** Connection timeout in ms (optional, default 10000) */
18
+ timeout?: number;
19
+ }
20
+ /**
21
+ * User identifier in your application
22
+ * Can be email, username, or UUID
23
+ */
24
+ export type UserId = string;
25
+ /**
26
+ * Message content - text or binary data
27
+ */
28
+ export type MessageContent = string | Uint8Array;
29
+ /**
30
+ * Result of receiving a decrypted message
31
+ */
32
+ export interface DecryptedMessage {
33
+ /** Unique message identifier */
34
+ id: string;
35
+ /** Sender's user ID */
36
+ senderId: UserId;
37
+ /** Decrypted content */
38
+ content: MessageContent;
39
+ /** Timestamp when message was sent */
40
+ timestamp: Date;
41
+ }
42
+ /**
43
+ * Sealed payload for encrypting files/binary data
44
+ */
45
+ export interface SealedPayload {
46
+ /** Encrypted ciphertext */
47
+ ciphertext: Uint8Array;
48
+ /** Nonce used for encryption */
49
+ nonce: Uint8Array;
50
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * STVOR DX Facade - Type Definitions
3
+ */
4
+ export {};
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Auto-generated CommonJS wrapper for src/index.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
+ });
@@ -0,0 +1,2 @@
1
+ export * from '../legacy.js';
2
+ export * from '../facade/index.js';
@@ -0,0 +1,2 @@
1
+ export * from '../legacy.js';
2
+ export * from '../facade/index.js';
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Auto-generated CommonJS wrapper for src/legacy.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
+ });
File without changes
@@ -0,0 +1 @@
1
+ // ...existing code from packages/sdk/legacy.ts...
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ // Auto-generated CommonJS wrapper for src/mock-relay-server.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
+ });
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * STVOR Mock Relay Server
4
+ *
5
+ * A lightweight local development server that emulates the production
6
+ * STVOR relay over WebSocket. Run it locally to develop and test
7
+ * without internet access or a production relay.
8
+ *
9
+ * Usage:
10
+ * npx @stvor/sdk mock-relay # via npx
11
+ * npm run mock-relay # from SDK root
12
+ * node dist/mock-relay-server.js # direct
13
+ * PORT=9000 node dist/mock-relay-server.js # custom port
14
+ * STVOR_MOCK_VERBOSE=1 node dist/mock-relay-server.js # verbose
15
+ *
16
+ * Accepts any AppToken starting with "stvor_" for easy local testing.
17
+ *
18
+ * Protocol:
19
+ * Connection: ws://localhost:PORT with Authorization header
20
+ * Handshake: Server sends { type: 'handshake', status: 'ok' }
21
+ * Announce: { type: 'announce', user: string, pub: string }
22
+ * Message: { type: 'message', to: string, from: string, payload: any }
23
+ * Ack: { type: 'ack', id: string }
24
+ * Error: { type: 'error', code: string, message: string }
25
+ */
26
+ import http from 'node:http';
27
+ declare const PORT: number;
28
+ declare const httpServer: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
29
+ declare const wss: any;
30
+ export { PORT, wss, httpServer };
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * STVOR Mock Relay Server
4
+ *
5
+ * A lightweight local development server that emulates the production
6
+ * STVOR relay over WebSocket. Run it locally to develop and test
7
+ * without internet access or a production relay.
8
+ *
9
+ * Usage:
10
+ * npx @stvor/sdk mock-relay # via npx
11
+ * npm run mock-relay # from SDK root
12
+ * node dist/mock-relay-server.js # direct
13
+ * PORT=9000 node dist/mock-relay-server.js # custom port
14
+ * STVOR_MOCK_VERBOSE=1 node dist/mock-relay-server.js # verbose
15
+ *
16
+ * Accepts any AppToken starting with "stvor_" for easy local testing.
17
+ *
18
+ * Protocol:
19
+ * Connection: ws://localhost:PORT with Authorization header
20
+ * Handshake: Server sends { type: 'handshake', status: 'ok' }
21
+ * Announce: { type: 'announce', user: string, pub: string }
22
+ * Message: { type: 'message', to: string, from: string, payload: any }
23
+ * Ack: { type: 'ack', id: string }
24
+ * Error: { type: 'error', code: string, message: string }
25
+ */
26
+ import * as WS from 'ws';
27
+ import http from 'node:http';
28
+ const { WebSocketServer } = WS;
29
+ // ── Configuration ────────────────────────────────────────────────────
30
+ const PORT = parseInt(process.env.STVOR_MOCK_PORT || process.env.PORT || '4444', 10);
31
+ const VERBOSE = process.env.STVOR_MOCK_VERBOSE === '1';
32
+ // ── In-memory state ──────────────────────────────────────────────────
33
+ /** userId → WebSocket */
34
+ const clients = new Map();
35
+ /** userId → public key (base64) */
36
+ const pubkeys = new Map();
37
+ /** userId → pending messages (for offline delivery) */
38
+ const mailboxes = new Map();
39
+ let totalConnections = 0;
40
+ let totalMessages = 0;
41
+ // ── Helpers ──────────────────────────────────────────────────────────
42
+ function log(...args) {
43
+ if (VERBOSE)
44
+ console.log('[mock-relay]', new Date().toISOString(), ...args);
45
+ }
46
+ function validateAuth(req) {
47
+ const auth = req.headers.authorization;
48
+ if (!auth)
49
+ return false;
50
+ const token = auth.replace(/^Bearer\s+/i, '');
51
+ return token.startsWith('stvor_');
52
+ }
53
+ function broadcast(obj, exceptWs) {
54
+ const data = JSON.stringify(obj);
55
+ for (const ws of clients.values()) {
56
+ if (ws !== exceptWs && ws.readyState === 1 /* OPEN */) {
57
+ ws.send(data);
58
+ }
59
+ }
60
+ }
61
+ // ── HTTP server for health check (and future REST endpoints) ─────────
62
+ const httpServer = http.createServer((req, res) => {
63
+ const url = new URL(req.url || '/', `http://localhost:${PORT}`);
64
+ // CORS
65
+ res.setHeader('Access-Control-Allow-Origin', '*');
66
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
67
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
68
+ if (req.method === 'OPTIONS') {
69
+ res.writeHead(204);
70
+ return res.end();
71
+ }
72
+ if (url.pathname === '/health') {
73
+ res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ return res.end(JSON.stringify({
75
+ status: 'ok',
76
+ server: 'stvor-mock-relay',
77
+ version: '1.0.0',
78
+ uptime: process.uptime(),
79
+ connectedUsers: clients.size,
80
+ totalConnections,
81
+ totalMessages,
82
+ }));
83
+ }
84
+ // GET /status/:userId - check if user is online
85
+ const statusMatch = url.pathname.match(/^\/status\/(.+)$/);
86
+ if (statusMatch) {
87
+ const userId = decodeURIComponent(statusMatch[1]);
88
+ res.writeHead(200, { 'Content-Type': 'application/json' });
89
+ return res.end(JSON.stringify({
90
+ userId,
91
+ online: clients.has(userId),
92
+ hasPublicKey: pubkeys.has(userId),
93
+ }));
94
+ }
95
+ // GET /usage - mock unlimited usage
96
+ if (url.pathname === '/usage') {
97
+ res.writeHead(200, { 'Content-Type': 'application/json' });
98
+ return res.end(JSON.stringify({ used: 0, limit: -1 }));
99
+ }
100
+ res.writeHead(404, { 'Content-Type': 'application/json' });
101
+ res.end(JSON.stringify({ error: 'Not Found' }));
102
+ });
103
+ // ── WebSocket server ─────────────────────────────────────────────────
104
+ const wss = new WebSocketServer({ server: httpServer });
105
+ wss.on('connection', (ws, req) => {
106
+ totalConnections++;
107
+ // ── Auth check ───────────────────────────────────────────────────
108
+ if (!validateAuth(req)) {
109
+ log('Auth failed for connection');
110
+ ws.send(JSON.stringify({
111
+ type: 'handshake',
112
+ status: 'error',
113
+ reason: 'Invalid AppToken. Token must start with "stvor_".',
114
+ }));
115
+ ws.close(4001, 'Unauthorized');
116
+ return;
117
+ }
118
+ // ── Successful handshake ─────────────────────────────────────────
119
+ ws.send(JSON.stringify({ type: 'handshake', status: 'ok' }));
120
+ log('Client connected (auth OK)');
121
+ // Send all known user announcements to the new client
122
+ for (const [user, pub] of pubkeys.entries()) {
123
+ try {
124
+ ws.send(JSON.stringify({ type: 'announce', user, pub }));
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ }
130
+ // ── Message handler ──────────────────────────────────────────────
131
+ ws.on('message', (data) => {
132
+ let msg;
133
+ try {
134
+ msg = JSON.parse(data.toString());
135
+ }
136
+ catch {
137
+ return;
138
+ }
139
+ // ── Announce: register user + broadcast public key ────────────
140
+ if (msg.type === 'announce' && msg.user) {
141
+ const oldWs = clients.get(msg.user);
142
+ clients.set(msg.user, ws);
143
+ if (msg.pub) {
144
+ pubkeys.set(msg.user, msg.pub);
145
+ }
146
+ log(`User announced: ${msg.user}`);
147
+ // Broadcast announce to all other clients
148
+ broadcast({ type: 'announce', user: msg.user, pub: msg.pub }, ws);
149
+ // Deliver any pending messages
150
+ const pending = mailboxes.get(msg.user) || [];
151
+ if (pending.length > 0) {
152
+ log(`Delivering ${pending.length} pending messages to ${msg.user}`);
153
+ for (const m of pending) {
154
+ try {
155
+ ws.send(JSON.stringify(m));
156
+ }
157
+ catch {
158
+ // ignore
159
+ }
160
+ }
161
+ mailboxes.set(msg.user, []);
162
+ }
163
+ return;
164
+ }
165
+ // ── Message: route to recipient ──────────────────────────────
166
+ if (msg.type === 'message' && msg.to) {
167
+ totalMessages++;
168
+ const target = clients.get(msg.to);
169
+ if (target && target.readyState === 1 /* OPEN */) {
170
+ // Deliver immediately
171
+ target.send(JSON.stringify(msg));
172
+ log(`Message delivered: ${msg.from} → ${msg.to}`);
173
+ // ACK back to sender
174
+ if (msg.from && ws.readyState === 1 /* OPEN */) {
175
+ ws.send(JSON.stringify({ type: 'ack', id: msg.id ?? null }));
176
+ }
177
+ }
178
+ else {
179
+ // Store for later delivery
180
+ if (!mailboxes.has(msg.to)) {
181
+ mailboxes.set(msg.to, []);
182
+ }
183
+ mailboxes.get(msg.to).push(msg);
184
+ log(`Message queued: ${msg.from} → ${msg.to} (recipient offline)`);
185
+ // Notify sender that message is queued
186
+ if (ws.readyState === 1 /* OPEN */) {
187
+ ws.send(JSON.stringify({
188
+ type: 'queued',
189
+ id: msg.id ?? null,
190
+ to: msg.to,
191
+ message: 'Recipient is offline. Message queued for delivery.',
192
+ }));
193
+ }
194
+ }
195
+ return;
196
+ }
197
+ log('Unknown message type:', msg.type);
198
+ });
199
+ // ── Disconnect ───────────────────────────────────────────────────
200
+ ws.on('close', () => {
201
+ for (const [user, sock] of clients.entries()) {
202
+ if (sock === ws) {
203
+ log(`User disconnected: ${user}`);
204
+ clients.delete(user);
205
+ // Keep public key so reconnecting users can still be found
206
+ // pubkeys.delete(user); // intentionally NOT deleted
207
+ }
208
+ }
209
+ });
210
+ ws.on('error', (err) => {
211
+ log('WebSocket error:', err.message);
212
+ });
213
+ });
214
+ // ── Start server ─────────────────────────────────────────────────────
215
+ httpServer.listen(PORT, () => {
216
+ console.log('');
217
+ console.log(' ╔══════════════════════════════════════════════════════╗');
218
+ console.log(' ║ STVOR Mock Relay Server v1.0.0 ║');
219
+ console.log(' ╠══════════════════════════════════════════════════════╣');
220
+ console.log(` ║ WebSocket: ws://localhost:${String(PORT).padEnd(27)}║`);
221
+ console.log(` ║ Health: http://localhost:${String(PORT).padEnd(22)}║`);
222
+ console.log(' ║ Auth: Any token starting with "stvor_" ║');
223
+ console.log(' ║ Data: In-memory (resets on restart) ║');
224
+ console.log(' ╚══════════════════════════════════════════════════════╝');
225
+ console.log('');
226
+ console.log(' Usage in your app:');
227
+ console.log('');
228
+ console.log(" const app = await Stvor.init({");
229
+ console.log(" appToken: 'stvor_dev_test123',");
230
+ console.log(` relayUrl: 'ws://localhost:${PORT}'`);
231
+ console.log(" });");
232
+ console.log('');
233
+ console.log(' Set STVOR_MOCK_VERBOSE=1 for detailed logging.');
234
+ console.log('');
235
+ });
236
+ export { PORT, wss, httpServer };