@stvor/sdk 2.3.0 → 2.3.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.
@@ -69,6 +69,9 @@ export declare class StvorFacadeClient {
69
69
  * Register a handler that fires when a new user becomes available.
70
70
  * This is triggered when we receive a user's public key announcement.
71
71
  *
72
+ * **Edge-triggered**: Fires only ONCE per user, on first key discovery.
73
+ * Will NOT fire again if user reconnects with same identity.
74
+ *
72
75
  * @example
73
76
  * ```typescript
74
77
  * client.onUserAvailable((userId) => {
@@ -175,6 +175,9 @@ export class StvorFacadeClient {
175
175
  * Register a handler that fires when a new user becomes available.
176
176
  * This is triggered when we receive a user's public key announcement.
177
177
  *
178
+ * **Edge-triggered**: Fires only ONCE per user, on first key discovery.
179
+ * Will NOT fire again if user reconnects with same identity.
180
+ *
178
181
  * @example
179
182
  * ```typescript
180
183
  * client.onUserAvailable((userId) => {
@@ -201,6 +204,8 @@ export class StvorApp {
201
204
  if (existing)
202
205
  return existing;
203
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();
204
209
  const client = new StvorFacadeClient(userId, relay, this.config.timeout ?? 10000);
205
210
  await client.internalInitialize();
206
211
  this.clients.set(userId, client);
@@ -1,5 +1,6 @@
1
1
  export declare const Errors: {
2
2
  readonly INVALID_APP_TOKEN: "INVALID_APP_TOKEN";
3
+ readonly INVALID_API_KEY: "INVALID_API_KEY";
3
4
  readonly RELAY_UNAVAILABLE: "RELAY_UNAVAILABLE";
4
5
  readonly DELIVERY_FAILED: "DELIVERY_FAILED";
5
6
  readonly RECIPIENT_NOT_FOUND: "RECIPIENT_NOT_FOUND";
@@ -1,5 +1,6 @@
1
1
  export const Errors = {
2
2
  INVALID_APP_TOKEN: 'INVALID_APP_TOKEN',
3
+ INVALID_API_KEY: 'INVALID_API_KEY',
3
4
  RELAY_UNAVAILABLE: 'RELAY_UNAVAILABLE',
4
5
  DELIVERY_FAILED: 'DELIVERY_FAILED',
5
6
  RECIPIENT_NOT_FOUND: 'RECIPIENT_NOT_FOUND',
@@ -9,16 +9,28 @@ export declare class RelayClient {
9
9
  private appToken;
10
10
  private ws?;
11
11
  private connected;
12
+ private handshakeComplete;
12
13
  private backoff;
13
14
  private queue;
14
15
  private handlers;
15
16
  private reconnecting;
17
+ private connectPromise?;
18
+ private connectResolve?;
19
+ private connectReject?;
20
+ private authFailed;
16
21
  constructor(relayUrl: string, appToken: string, timeout?: number);
22
+ /**
23
+ * Initialize the connection and wait for handshake.
24
+ * Throws StvorError if API key is rejected.
25
+ */
26
+ init(): Promise<void>;
17
27
  private getAuthHeaders;
18
28
  private connect;
19
29
  private scheduleReconnect;
30
+ private doSend;
20
31
  send(obj: JSONable): void;
21
32
  onMessage(h: RelayHandler): void;
22
33
  isConnected(): boolean;
34
+ isAuthenticated(): boolean;
23
35
  }
24
36
  export {};
@@ -1,18 +1,32 @@
1
1
  /**
2
2
  * STVOR DX Facade - Relay Client
3
3
  */
4
+ import { Errors, StvorError } from './errors.js';
4
5
  import * as WS from 'ws';
5
6
  export class RelayClient {
6
7
  constructor(relayUrl, appToken, timeout = 10000) {
7
8
  this.connected = false;
9
+ this.handshakeComplete = false;
8
10
  this.backoff = 1000;
9
11
  this.queue = [];
10
12
  this.handlers = [];
11
13
  this.reconnecting = false;
14
+ this.authFailed = false;
12
15
  this.relayUrl = relayUrl.replace(/^http/, 'ws');
13
16
  this.appToken = appToken;
14
17
  this.timeout = timeout;
15
- this.connect();
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();
16
30
  }
17
31
  getAuthHeaders() {
18
32
  return {
@@ -20,39 +34,88 @@ export class RelayClient {
20
34
  };
21
35
  }
22
36
  connect() {
37
+ if (this.connectPromise)
38
+ return this.connectPromise;
23
39
  if (this.ws)
24
- return;
25
- const WSClass = WS.default ?? WS;
26
- this.ws = new WSClass(this.relayUrl, { headers: this.getAuthHeaders() });
27
- this.ws.on('open', () => {
28
- this.connected = true;
29
- this.backoff = 1000;
30
- // flush queue
31
- while (this.queue.length) {
32
- const m = this.queue.shift();
33
- this.send(m);
34
- }
35
- });
36
- this.ws.on('message', (data) => {
37
- try {
38
- const json = JSON.parse(data.toString());
39
- for (const h of this.handlers)
40
- h(json);
41
- }
42
- catch (e) {
43
- // ignore
44
- }
45
- });
46
- this.ws.on('close', () => {
47
- this.connected = false;
48
- this.ws = undefined;
49
- this.scheduleReconnect();
50
- });
51
- this.ws.on('error', () => {
52
- this.connected = false;
53
- this.ws = undefined;
54
- this.scheduleReconnect();
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
+ });
55
117
  });
118
+ return this.connectPromise;
56
119
  }
57
120
  scheduleReconnect() {
58
121
  if (this.reconnecting)
@@ -64,19 +127,28 @@ export class RelayClient {
64
127
  this.backoff = Math.min(this.backoff * 2, 30000);
65
128
  }, this.backoff);
66
129
  }
67
- send(obj) {
130
+ doSend(obj) {
68
131
  const data = JSON.stringify(obj);
69
- if (this.connected && this.ws) {
132
+ if (this.connected && this.ws && this.handshakeComplete) {
70
133
  this.ws.send(data);
71
134
  }
72
135
  else {
73
136
  this.queue.push(obj);
74
137
  }
75
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
+ }
76
145
  onMessage(h) {
77
146
  this.handlers.push(h);
78
147
  }
79
148
  isConnected() {
80
- return this.connected;
149
+ return this.connected && this.handshakeComplete;
150
+ }
151
+ isAuthenticated() {
152
+ return this.handshakeComplete && !this.authFailed;
81
153
  }
82
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stvor/sdk",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "description": "Stvor DX Facade - Simple E2EE SDK for client-side encryption",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",