@abraca/dabra 1.0.22 → 1.0.23

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/dist/index.d.ts CHANGED
@@ -340,6 +340,11 @@ declare class AbracadabraClient {
340
340
  publicKey: string;
341
341
  };
342
342
  }>;
343
+ /**
344
+ * Fetch a short-lived anonymous pairing token for WebRTC signaling.
345
+ * No authentication required. The token only grants access to `__pairing_*` rooms.
346
+ */
347
+ static getPairingToken(serverUrl: string): Promise<string>;
343
348
  /** Get encryption info for a document. */
344
349
  getDocEncryption(docId: string): Promise<DocEncryptionInfo>;
345
350
  /** Set the encryption mode for a document (no downgrade). */
@@ -2248,12 +2253,22 @@ declare class ManualSignaling extends EventEmitter {
2248
2253
  interface DevicePairingConfig {
2249
2254
  /** Server base URL (http/https). */
2250
2255
  serverUrl: string;
2251
- /** JWT token or async token factory for signaling auth. */
2252
- token: string | (() => string) | (() => Promise<string>);
2256
+ /**
2257
+ * JWT token or async token factory for signaling auth.
2258
+ * When omitted, a short-lived anonymous pairing token is fetched automatically
2259
+ * from `POST /auth/pairing-token`.
2260
+ */
2261
+ token?: string | (() => string) | (() => Promise<string>);
2253
2262
  /** E2EE identity (Ed25519 public key + X25519 private key). */
2254
2263
  e2ee: E2EEIdentity;
2255
2264
  /** ICE servers. Defaults to Google STUN. */
2256
2265
  iceServers?: RTCIceServer[];
2266
+ /**
2267
+ * Fallback signaling server URL. If the primary server is unreachable,
2268
+ * signaling will retry through this server. The actual pairing data is
2269
+ * E2EE-encrypted, so the relay server cannot read it.
2270
+ */
2271
+ fallbackSignalingUrl?: string;
2257
2272
  /** WebSocket polyfill (for Node.js). */
2258
2273
  WebSocketPolyfill?: any;
2259
2274
  }
@@ -2277,6 +2292,8 @@ declare class DevicePairingChannel extends EventEmitter {
2277
2292
  private _destroyed;
2278
2293
  private _pendingRequest;
2279
2294
  private _connectedPeerId;
2295
+ private _usingFallback;
2296
+ private _resolvedIceServers;
2280
2297
  readonly role: PairingRole;
2281
2298
  readonly pairingCode: string;
2282
2299
  private constructor();
@@ -2315,7 +2332,11 @@ declare class DevicePairingChannel extends EventEmitter {
2315
2332
  requestPairing(request: PairingRequest): void;
2316
2333
  get isDestroyed(): boolean;
2317
2334
  destroy(): void;
2335
+ private resolveToken;
2318
2336
  private start;
2337
+ private connectToServer;
2338
+ /** Whether the connection fell back to the fallback signaling server. */
2339
+ get usingFallback(): boolean;
2319
2340
  private sendMessage;
2320
2341
  private handleMessage;
2321
2342
  }
@@ -2406,6 +2427,16 @@ interface IdentityDocConfiguration {
2406
2427
  token?: string | (() => string) | (() => Promise<string>);
2407
2428
  /** Per-server token factories keyed by base URL. */
2408
2429
  tokens?: Record<string, string | (() => string) | (() => Promise<string>)>;
2430
+ /**
2431
+ * Crypto identity for Ed25519 challenge-response auth.
2432
+ * When provided, used instead of JWT tokens for authenticating with servers.
2433
+ */
2434
+ cryptoIdentity?: CryptoIdentity | (() => Promise<CryptoIdentity>);
2435
+ /**
2436
+ * Signs a base64url challenge and returns a base64url signature.
2437
+ * Required when cryptoIdentity is set.
2438
+ */
2439
+ signChallenge?: (challenge: string) => Promise<string>;
2409
2440
  /**
2410
2441
  * WebRTC configuration for P2P identity sync.
2411
2442
  * When provided, enables E2EE peer-to-peer sync using signaling from
@@ -2444,7 +2475,7 @@ declare class IdentityDocProvider extends EventEmitter {
2444
2475
  private _destroyed;
2445
2476
  constructor(configuration: IdentityDocConfiguration);
2446
2477
  connect(): void;
2447
- private _connectToServer;
2478
+ connectToServer(key: string, serverUrl: string): void;
2448
2479
  private _connectWebRTC;
2449
2480
  get profileMap(): Y.Map<string>;
2450
2481
  get serversMap(): Y.Map<Y.Map<any>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -227,6 +227,23 @@ export class AbracadabraClient {
227
227
  });
228
228
  }
229
229
 
230
+ // ── Pairing ─────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Fetch a short-lived anonymous pairing token for WebRTC signaling.
234
+ * No authentication required. The token only grants access to `__pairing_*` rooms.
235
+ */
236
+ static async getPairingToken(serverUrl: string): Promise<string> {
237
+ let base = serverUrl;
238
+ while (base.endsWith("/")) base = base.slice(0, -1);
239
+ const resp = await fetch(`${base}/auth/pairing-token`, { method: "POST" });
240
+ if (!resp.ok) {
241
+ throw new Error(`Failed to fetch pairing token: ${resp.status}`);
242
+ }
243
+ const { token } = (await resp.json()) as { token: string };
244
+ return token;
245
+ }
246
+
230
247
  // ── Encryption ───────────────────────────────────────────────────────────
231
248
 
232
249
  /** Get encryption info for a document. */
@@ -5,6 +5,7 @@ import { AbracadabraProvider } from "./AbracadabraProvider.ts";
5
5
  import type { AbracadabraProviderConfiguration } from "./AbracadabraProvider.ts";
6
6
  import { AbracadabraWS } from "./AbracadabraWS.ts";
7
7
  import { AbracadabraWebRTC } from "./webrtc/AbracadabraWebRTC.ts";
8
+ import type { CryptoIdentity } from "./types.ts";
8
9
  import type { E2EEIdentity } from "./webrtc/E2EEChannel.ts";
9
10
 
10
11
  // ── Identity Doc ID Derivation ─────────────────────────────────────────────
@@ -89,6 +90,18 @@ export interface IdentityDocConfiguration {
89
90
  /** Per-server token factories keyed by base URL. */
90
91
  tokens?: Record<string, string | (() => string) | (() => Promise<string>)>;
91
92
 
93
+ /**
94
+ * Crypto identity for Ed25519 challenge-response auth.
95
+ * When provided, used instead of JWT tokens for authenticating with servers.
96
+ */
97
+ cryptoIdentity?: CryptoIdentity | (() => Promise<CryptoIdentity>);
98
+
99
+ /**
100
+ * Signs a base64url challenge and returns a base64url signature.
101
+ * Required when cryptoIdentity is set.
102
+ */
103
+ signChallenge?: (challenge: string) => Promise<string>;
104
+
92
105
  /**
93
106
  * WebRTC configuration for P2P identity sync.
94
107
  * When provided, enables E2EE peer-to-peer sync using signaling from
@@ -164,7 +177,7 @@ export class IdentityDocProvider extends EventEmitter {
164
177
 
165
178
  for (const { url, key } of targets) {
166
179
  if (this.providers.has(key)) continue;
167
- this._connectToServer(key, url);
180
+ this.connectToServer(key, url);
168
181
  }
169
182
 
170
183
  if (this.config.webrtc && !this.webrtc) {
@@ -172,7 +185,21 @@ export class IdentityDocProvider extends EventEmitter {
172
185
  }
173
186
  }
174
187
 
175
- private _connectToServer(key: string, serverUrl: string): void {
188
+ connectToServer(key: string, serverUrl: string): void {
189
+ if (this._destroyed) return;
190
+
191
+ // Tear down existing connection for this key (idempotent reconnect)
192
+ const existingProvider = this.providers.get(key);
193
+ if (existingProvider) {
194
+ existingProvider.destroy();
195
+ this.providers.delete(key);
196
+ }
197
+ const existingWs = this.websockets.get(key);
198
+ if (existingWs) {
199
+ existingWs.destroy();
200
+ this.websockets.delete(key);
201
+ }
202
+
176
203
  const token =
177
204
  this.config.tokens?.[serverUrl] ?? this.config.token ?? "";
178
205
 
@@ -184,17 +211,26 @@ export class IdentityDocProvider extends EventEmitter {
184
211
  const ws = new AbracadabraWS({ url: wsUrl, WebSocketPolyfill: undefined as any });
185
212
  this.websockets.set(key, ws);
186
213
 
187
- const provider = new AbracadabraProvider({
214
+ const providerConfig: AbracadabraProviderConfiguration = {
188
215
  name: this.docId,
189
216
  document: this.document,
190
217
  websocketProvider: ws,
191
- token,
192
218
  serverAgnostic: true,
193
219
  disableOfflineStore: key !== "local" && key !== "sync"
194
220
  ? true
195
221
  : this.config.disableOfflineStore ?? false,
196
222
  ...this.config.providerDefaults,
197
- });
223
+ };
224
+
225
+ // Use crypto identity auth if available, otherwise JWT token
226
+ if (this.config.cryptoIdentity && this.config.signChallenge) {
227
+ providerConfig.cryptoIdentity = this.config.cryptoIdentity;
228
+ providerConfig.signChallenge = this.config.signChallenge;
229
+ } else {
230
+ providerConfig.token = token;
231
+ }
232
+
233
+ const provider = new AbracadabraProvider(providerConfig);
198
234
 
199
235
  provider.on("synced", () => this.emit("synced", { server: key }));
200
236
  provider.on("status", (data: any) =>
@@ -467,7 +503,7 @@ export class IdentityDocProvider extends EventEmitter {
467
503
 
468
504
  if (url) {
469
505
  this.config = { ...this.config, syncServerUrl: url };
470
- this._connectToServer("sync", url);
506
+ this.connectToServer("sync", url);
471
507
  }
472
508
  }
473
509
 
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { sha256 } from "@noble/hashes/sha256";
14
14
  import EventEmitter from "../EventEmitter.ts";
15
- import type { AbracadabraClient } from "../AbracadabraClient.ts";
15
+ import { AbracadabraClient } from "../AbracadabraClient.ts";
16
16
  import { AbracadabraWebRTC } from "./AbracadabraWebRTC.ts";
17
17
  import type { E2EEIdentity } from "./E2EEChannel.ts";
18
18
 
@@ -22,6 +22,7 @@ import type { E2EEIdentity } from "./E2EEChannel.ts";
22
22
  const CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
23
23
  const CODE_LENGTH = 6;
24
24
  const PAIRING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
25
+ const SIGNALING_CONNECT_TIMEOUT_MS = 5_000; // 5 seconds before trying fallback
25
26
  const PAIRING_CHANNEL = "device-pairing";
26
27
 
27
28
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -29,12 +30,22 @@ const PAIRING_CHANNEL = "device-pairing";
29
30
  export interface DevicePairingConfig {
30
31
  /** Server base URL (http/https). */
31
32
  serverUrl: string;
32
- /** JWT token or async token factory for signaling auth. */
33
- token: string | (() => string) | (() => Promise<string>);
33
+ /**
34
+ * JWT token or async token factory for signaling auth.
35
+ * When omitted, a short-lived anonymous pairing token is fetched automatically
36
+ * from `POST /auth/pairing-token`.
37
+ */
38
+ token?: string | (() => string) | (() => Promise<string>);
34
39
  /** E2EE identity (Ed25519 public key + X25519 private key). */
35
40
  e2ee: E2EEIdentity;
36
41
  /** ICE servers. Defaults to Google STUN. */
37
42
  iceServers?: RTCIceServer[];
43
+ /**
44
+ * Fallback signaling server URL. If the primary server is unreachable,
45
+ * signaling will retry through this server. The actual pairing data is
46
+ * E2EE-encrypted, so the relay server cannot read it.
47
+ */
48
+ fallbackSignalingUrl?: string;
38
49
  /** WebSocket polyfill (for Node.js). */
39
50
  WebSocketPolyfill?: any;
40
51
  }
@@ -106,6 +117,8 @@ export class DevicePairingChannel extends EventEmitter {
106
117
  private _destroyed = false;
107
118
  private _pendingRequest: PairingRequest | null = null;
108
119
  private _connectedPeerId: string | null = null;
120
+ private _usingFallback = false;
121
+ private _resolvedIceServers: RTCIceServer[] | undefined;
109
122
 
110
123
  readonly role: PairingRole;
111
124
  readonly pairingCode: string;
@@ -279,14 +292,66 @@ export class DevicePairingChannel extends EventEmitter {
279
292
 
280
293
  // ── Private ─────────────────────────────────────────────────────────
281
294
 
295
+ private async resolveToken(
296
+ serverUrl?: string,
297
+ ): Promise<string | (() => string) | (() => Promise<string>)> {
298
+ // Use provided token if connecting to the primary server.
299
+ if (this.config.token && serverUrl === this.config.serverUrl) {
300
+ return this.config.token;
301
+ }
302
+
303
+ // Fetch a short-lived anonymous pairing token from the target server.
304
+ let base = serverUrl ?? this.config.serverUrl;
305
+ while (base.endsWith("/")) base = base.slice(0, -1);
306
+ const resp = await fetch(`${base}/auth/pairing-token`, {
307
+ method: "POST",
308
+ });
309
+ if (!resp.ok) {
310
+ throw new Error(
311
+ `Failed to fetch pairing token: ${resp.status} ${resp.statusText}`,
312
+ );
313
+ }
314
+ const { token } = (await resp.json()) as { token: string };
315
+ return token;
316
+ }
317
+
282
318
  private start(): void {
319
+ this.connectToServer(this.config.serverUrl);
320
+
321
+ // Auto-destroy after timeout.
322
+ this.timeoutHandle = setTimeout(() => {
323
+ if (!this._destroyed) {
324
+ this.emit("error", new Error("Pairing timed out"));
325
+ this.destroy();
326
+ }
327
+ }, PAIRING_TIMEOUT_MS);
328
+ }
329
+
330
+ private connectToServer(serverUrl: string, signalingUrl?: string): void {
283
331
  const roomId = codeToRoomId(this.pairingCode);
284
332
 
333
+ // Resolve token (possibly async) — uses a factory so SignalingSocket can await it.
334
+ const tokenPromise = this.resolveToken(serverUrl);
335
+ const tokenFactory = async (): Promise<string> => {
336
+ const t = await tokenPromise;
337
+ if (typeof t === "function") return await t();
338
+ return t;
339
+ };
340
+
341
+ // Fetch ICE servers from the target server if none configured.
342
+ if (!this.config.iceServers) {
343
+ const client = new AbracadabraClient({ url: serverUrl });
344
+ client.getIceServers().then((servers) => {
345
+ if (servers.length > 0) this._resolvedIceServers = servers;
346
+ });
347
+ }
348
+
285
349
  this.webrtc = new AbracadabraWebRTC({
286
350
  docId: roomId,
287
- url: this.config.serverUrl,
288
- token: this.config.token,
289
- iceServers: this.config.iceServers,
351
+ url: serverUrl,
352
+ signalingUrl: signalingUrl ?? undefined,
353
+ token: tokenFactory,
354
+ iceServers: this.config.iceServers ?? this._resolvedIceServers,
290
355
  e2ee: this.config.e2ee,
291
356
  enableDocSync: false,
292
357
  enableAwarenessSync: false,
@@ -295,6 +360,12 @@ export class DevicePairingChannel extends EventEmitter {
295
360
  WebSocketPolyfill: this.config.WebSocketPolyfill,
296
361
  });
297
362
 
363
+ let connected = false;
364
+
365
+ this.webrtc.on("connected", () => {
366
+ connected = true;
367
+ });
368
+
298
369
  this.webrtc.on("e2eeEstablished", ({ peerId }: { peerId: string }) => {
299
370
  this._connectedPeerId = peerId;
300
371
  this.emit("connected");
@@ -320,17 +391,34 @@ export class DevicePairingChannel extends EventEmitter {
320
391
  },
321
392
  );
322
393
 
323
- // Auto-destroy after timeout.
324
- this.timeoutHandle = setTimeout(() => {
325
- if (!this._destroyed) {
326
- this.emit("error", new Error("Pairing timed out"));
327
- this.destroy();
328
- }
329
- }, PAIRING_TIMEOUT_MS);
394
+ // If a fallback signaling server is configured, try it after a timeout.
395
+ if (this.config.fallbackSignalingUrl && !signalingUrl) {
396
+ const fallbackTimer = setTimeout(() => {
397
+ if (this._destroyed || connected) return;
398
+ // Primary server didn't connect in time — try fallback.
399
+ if (this.webrtc) {
400
+ this.webrtc.destroy();
401
+ this.webrtc = null;
402
+ }
403
+ this._usingFallback = true;
404
+ this.emit("fallback", { url: this.config.fallbackSignalingUrl });
405
+ this.connectToServer(
406
+ this.config.fallbackSignalingUrl!,
407
+ );
408
+ }, SIGNALING_CONNECT_TIMEOUT_MS);
409
+
410
+ // Clear fallback timer if primary succeeds.
411
+ this.webrtc.on("connected", () => clearTimeout(fallbackTimer));
412
+ }
330
413
 
331
414
  this.webrtc.connect();
332
415
  }
333
416
 
417
+ /** Whether the connection fell back to the fallback signaling server. */
418
+ get usingFallback(): boolean {
419
+ return this._usingFallback;
420
+ }
421
+
334
422
  private sendMessage(msg: PairingMsg): void {
335
423
  if (!this.webrtc || !this._connectedPeerId) return;
336
424
  // Send via the custom message path — the E2EE layer on the data channel
@@ -238,8 +238,8 @@ export class SignalingSocket extends EventEmitter {
238
238
 
239
239
  case "joined":
240
240
  this.emit("joined", {
241
- peerId: msg.peer_id,
242
- userId: msg.user_id,
241
+ peer_id: msg.peer_id,
242
+ user_id: msg.user_id,
243
243
  muted: msg.muted,
244
244
  video: msg.video,
245
245
  screen: msg.screen,