@abraca/dabra 1.0.19 → 1.0.21

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
@@ -315,8 +315,31 @@ declare class AbracadabraClient {
315
315
  }): Promise<void>;
316
316
  /** List all registered public keys for the current user. */
317
317
  listKeys(): Promise<PublicKeyInfo[]>;
318
+ /** Rename a registered device key. */
319
+ renameKey(keyId: string, deviceName: string): Promise<void>;
318
320
  /** Revoke a public key by its ID. */
319
321
  revokeKey(keyId: string): Promise<void>;
322
+ /** Create a single-use device invite code for pairing a new device to this account. */
323
+ createDeviceInvite(opts?: {
324
+ expiresIn?: number;
325
+ }): Promise<{
326
+ code: string;
327
+ expiresAt: number;
328
+ }>;
329
+ /** Redeem a device invite code to register a new device key. Returns a JWT token. */
330
+ redeemDeviceInvite(opts: {
331
+ code: string;
332
+ publicKey: string;
333
+ x25519Key?: string;
334
+ deviceName?: string;
335
+ }): Promise<{
336
+ token: string;
337
+ user: {
338
+ id: string;
339
+ username: string;
340
+ publicKey: string;
341
+ };
342
+ }>;
320
343
  /** Get encryption info for a document. */
321
344
  getDocEncryption(docId: string): Promise<DocEncryptionInfo>;
322
345
  /** Set the encryption mode for a document (no downgrade). */
@@ -1008,6 +1031,9 @@ interface PublicKeyInfo {
1008
1031
  publicKey: string;
1009
1032
  deviceName: string | null;
1010
1033
  revoked: boolean;
1034
+ createdAt?: number | null;
1035
+ pairedBy?: string | null;
1036
+ pairedVia?: string | null;
1011
1037
  }
1012
1038
  interface PermissionEntry {
1013
1039
  user_id: string;
@@ -2040,6 +2066,7 @@ declare class AbracadabraWebRTC extends EventEmitter {
2040
2066
  broadcastFile(file: File | Blob, filename: string): Promise<FileTransferHandle[]>;
2041
2067
  /**
2042
2068
  * Send a custom string message to a specific peer via a data channel.
2069
+ * When E2EE is active, the message is encrypted through the router.
2043
2070
  */
2044
2071
  sendCustomMessage(peerId: string, payload: string): void;
2045
2072
  /**
@@ -2211,6 +2238,82 @@ declare class ManualSignaling extends EventEmitter {
2211
2238
  destroy(): void;
2212
2239
  }
2213
2240
  //#endregion
2241
+ //#region packages/provider/src/webrtc/DevicePairingChannel.d.ts
2242
+ interface DevicePairingConfig {
2243
+ /** Server base URL (http/https). */
2244
+ serverUrl: string;
2245
+ /** JWT token or async token factory for signaling auth. */
2246
+ token: string | (() => string) | (() => Promise<string>);
2247
+ /** E2EE identity (Ed25519 public key + X25519 private key). */
2248
+ e2ee: E2EEIdentity;
2249
+ /** ICE servers. Defaults to Google STUN. */
2250
+ iceServers?: RTCIceServer[];
2251
+ /** WebSocket polyfill (for Node.js). */
2252
+ WebSocketPolyfill?: any;
2253
+ }
2254
+ interface PairingRequest {
2255
+ /** Device B's Ed25519 public key (base64url). */
2256
+ publicKey: string;
2257
+ /** Device B's X25519 public key (base64url). */
2258
+ x25519Key: string;
2259
+ /** Human-readable device label. */
2260
+ deviceName: string;
2261
+ }
2262
+ interface PairingResult {
2263
+ success: boolean;
2264
+ error?: string;
2265
+ }
2266
+ type PairingRole = "approver" | "requester";
2267
+ declare class DevicePairingChannel extends EventEmitter {
2268
+ private readonly config;
2269
+ private webrtc;
2270
+ private timeoutHandle;
2271
+ private _destroyed;
2272
+ private _pendingRequest;
2273
+ private _connectedPeerId;
2274
+ readonly role: PairingRole;
2275
+ readonly pairingCode: string;
2276
+ private constructor();
2277
+ /**
2278
+ * Create an approver session (Device A). Returns the channel and a
2279
+ * 6-character pairing code to share with Device B.
2280
+ */
2281
+ static createApprover(config: DevicePairingConfig): {
2282
+ channel: DevicePairingChannel;
2283
+ pairingCode: string;
2284
+ };
2285
+ /**
2286
+ * Create a requester session (Device B). Joins with a pairing code
2287
+ * obtained from Device A.
2288
+ */
2289
+ static createRequester(config: DevicePairingConfig, pairingCode: string): DevicePairingChannel;
2290
+ /**
2291
+ * Approve the pending pairing request. Calls `client.addKey()` to
2292
+ * register Device B's public key, then notifies Device B.
2293
+ */
2294
+ approve(client: AbracadabraClient): Promise<PairingResult>;
2295
+ /**
2296
+ * Approve via server-side device invite. Creates a single-use invite code
2297
+ * and sends it to Device B over the E2EE channel. Device B redeems it
2298
+ * independently via HTTP — Device A can go offline after this.
2299
+ */
2300
+ approveWithInvite(client: AbracadabraClient): Promise<PairingResult>;
2301
+ /**
2302
+ * Reject the pending pairing request.
2303
+ */
2304
+ reject(reason?: string): void;
2305
+ /**
2306
+ * Send a pairing request to Device A. Call this after the "connected"
2307
+ * event fires.
2308
+ */
2309
+ requestPairing(request: PairingRequest): void;
2310
+ get isDestroyed(): boolean;
2311
+ destroy(): void;
2312
+ private start;
2313
+ private sendMessage;
2314
+ private handleMessage;
2315
+ }
2316
+ //#endregion
2214
2317
  //#region packages/provider/src/sync/BroadcastChannelSync.d.ts
2215
2318
  declare class BroadcastChannelSync extends EventEmitter {
2216
2319
  private readonly document;
@@ -2239,4 +2342,4 @@ declare class BroadcastChannelSync extends EventEmitter {
2239
2342
  private handleAwarenessMessage;
2240
2343
  }
2241
2344
  //#endregion
2242
- export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
2345
+ export { AbracadabraBaseProvider, AbracadabraBaseProviderConfiguration, AbracadabraClient, AbracadabraClientConfig, AbracadabraOutgoingMessageArguments, AbracadabraProvider, AbracadabraProviderConfiguration, AbracadabraWS, AbracadabraWSConfiguration, AbracadabraWebRTC, type AbracadabraWebRTCConfiguration, AbracadabraWebSocketConn, AuthMessageType, AuthorizedScope, AwarenessError, BackgroundSyncManager, type BackgroundSyncManagerOptions, BackgroundSyncPersistence, BroadcastChannelSync, CHANNEL_NAMES, CloseEvent, CompleteAbracadabraBaseProviderConfiguration, CompleteAbracadabraWSConfiguration, CompleteHocuspocusProviderConfiguration, CompleteHocuspocusProviderWebsocketConfiguration, ConnectionTimeout, Constructable, ConstructableOutgoingMessage, CryptoIdentity, CryptoIdentityKeystore, DEFAULT_FILE_CHUNK_SIZE, DEFAULT_ICE_SERVERS, DataChannelRouter, DevicePairingChannel, type DevicePairingConfig, type DocEncryptionInfo, DocKeyManager, type DocSyncState, DocumentCache, type DocumentCacheOptions, DocumentMeta, E2EAbracadabraProvider, E2EEChannel, type E2EEIdentity, E2EOfflineStore, EffectivePermissionEntry, EffectivePermissionsResponse, EffectiveRole, EncryptedYMap, EncryptedYText, FileBlobStore, FileTransferChannel, FileTransferHandle, type FileTransferMeta, type FileTransferStatus, Forbidden, HealthStatus, HocusPocusWebSocket, HocuspocusProvider, HocuspocusProviderConfiguration, HocuspocusProviderWebsocket, HocuspocusProviderWebsocketConfiguration, HocuspocusWebSocket, InviteRow, KEY_EXCHANGE_CHANNEL, ManualSignaling, type ManualSignalingBlob, MessageTooBig, MessageType, OfflineStore, OutgoingMessageArguments, OutgoingMessageInterface, type PairingRequest, type PairingResult, PeerConnection, type PeerInfo, type PeerState, PendingSubdoc, PermissionEntry, PublicKeyInfo, ResetConnection, SearchIndex, SearchResult, ServerInfo, type SignalingIncoming, type SignalingOutgoing, SignalingSocket, SpaceMeta, StatesArray, SubdocMessage, SubdocRegisteredEvent, Unauthorized, UploadInfo, UploadMeta, UploadQueueEntry, UploadQueueStatus, UserProfile, WebSocketStatus, WsReadyStates, YjsDataChannel, attachUpdatedAtObserver, awarenessStatesToArray, decryptField, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/dabra",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -195,11 +195,38 @@ export class AbracadabraClient {
195
195
  return res.keys;
196
196
  }
197
197
 
198
+ /** Rename a registered device key. */
199
+ async renameKey(keyId: string, deviceName: string): Promise<void> {
200
+ await this.request("PATCH", `/auth/keys/${encodeURIComponent(keyId)}`, { body: { deviceName } });
201
+ }
202
+
198
203
  /** Revoke a public key by its ID. */
199
204
  async revokeKey(keyId: string): Promise<void> {
200
205
  await this.request("DELETE", `/auth/keys/${encodeURIComponent(keyId)}`);
201
206
  }
202
207
 
208
+ // ── Device Invites ───────────────────────────────────────────────────────
209
+
210
+ /** Create a single-use device invite code for pairing a new device to this account. */
211
+ async createDeviceInvite(opts?: { expiresIn?: number }): Promise<{ code: string; expiresAt: number }> {
212
+ return this.request<{ code: string; expiresAt: number }>("POST", "/auth/device-invite", {
213
+ body: opts?.expiresIn != null ? { expiresIn: opts.expiresIn } : {},
214
+ });
215
+ }
216
+
217
+ /** Redeem a device invite code to register a new device key. Returns a JWT token. */
218
+ async redeemDeviceInvite(opts: {
219
+ code: string;
220
+ publicKey: string;
221
+ x25519Key?: string;
222
+ deviceName?: string;
223
+ }): Promise<{ token: string; user: { id: string; username: string; publicKey: string } }> {
224
+ return this.request("POST", "/auth/device-redeem", {
225
+ body: { code: opts.code, publicKey: opts.publicKey, x25519Key: opts.x25519Key, deviceName: opts.deviceName },
226
+ auth: false,
227
+ });
228
+ }
229
+
203
230
  // ── Encryption ───────────────────────────────────────────────────────────
204
231
 
205
232
  /** Get encryption info for a document. */
@@ -220,9 +220,8 @@ export class AbracadabraWS extends EventEmitter {
220
220
  attach(provider: AbracadabraBaseProvider) {
221
221
  const existing = this.configuration.providerMap.get(provider.configuration.name);
222
222
  if (existing && existing !== provider) {
223
- console.warn(
224
- `[AbracadabraWS] attach: overwriting provider for "${provider.configuration.name}". ` +
225
- `This may indicate a duplicate loadChild for the same document.`
223
+ console.debug(
224
+ `[AbracadabraWS] attach: replacing provider for "${provider.configuration.name}".`
226
225
  );
227
226
  }
228
227
  this.configuration.providerMap.set(provider.configuration.name, provider);
@@ -394,6 +394,25 @@ export class BackgroundSyncManager extends EventEmitter {
394
394
  // Check if the provider already exists (user is viewing it) before loading.
395
395
  const alreadyCached = this.rootProvider.children.has(docId);
396
396
 
397
+ // Another provider (e.g. a nested renderer) may have already loaded
398
+ // this doc through a different parent. The shared WS providerMap
399
+ // tracks all attached providers; if one exists, the doc is already
400
+ // syncing — skip to avoid creating a duplicate.
401
+ if (!alreadyCached) {
402
+ const wsProviderMap = (this.rootProvider as any).configuration
403
+ ?.websocketProvider?.configuration?.providerMap as
404
+ | Map<string, unknown>
405
+ | undefined;
406
+ if (wsProviderMap?.has(docId)) {
407
+ return {
408
+ docId,
409
+ status: "synced",
410
+ lastSynced: Date.now(),
411
+ isE2E: false,
412
+ };
413
+ }
414
+ }
415
+
397
416
  const childProvider = await this.rootProvider.loadChild(docId);
398
417
 
399
418
  // Wait for ready (offline snapshot loaded) then synced (server sync done)
package/src/types.ts CHANGED
@@ -195,6 +195,9 @@ export interface PublicKeyInfo {
195
195
  publicKey: string;
196
196
  deviceName: string | null;
197
197
  revoked: boolean;
198
+ createdAt?: number | null;
199
+ pairedBy?: string | null;
200
+ pairedVia?: string | null;
198
201
  }
199
202
 
200
203
  export interface PermissionEntry {
@@ -335,22 +335,26 @@ export class AbracadabraWebRTC extends EventEmitter {
335
335
 
336
336
  /**
337
337
  * Send a custom string message to a specific peer via a data channel.
338
+ * When E2EE is active, the message is encrypted through the router.
338
339
  */
339
340
  sendCustomMessage(peerId: string, payload: string): void {
340
341
  const pc = this.peerConnections.get(peerId);
341
342
  if (!pc) return;
342
343
 
343
- let channel = pc.router.getChannel("custom");
344
+ const channelName = "custom";
345
+ let channel = pc.router.getChannel(channelName);
344
346
  if (!channel || channel.readyState !== "open") {
345
347
  // Create on-demand.
346
- channel = pc.router.createChannel("custom", { ordered: true });
348
+ channel = pc.router.createChannel(channelName, { ordered: true });
347
349
  // Wait for open before sending.
348
350
  channel.onopen = () => {
349
- channel!.send(payload);
351
+ const data = new TextEncoder().encode(payload);
352
+ pc.router.send(channelName, data);
350
353
  };
351
354
  return;
352
355
  }
353
- channel.send(payload);
356
+ const data = new TextEncoder().encode(payload);
357
+ pc.router.send(channelName, data);
354
358
  }
355
359
 
356
360
  /**
@@ -0,0 +1,384 @@
1
+ /**
2
+ * DevicePairingChannel
3
+ *
4
+ * Enables cross-device identity pairing over an E2EE WebRTC data channel.
5
+ * Device A (approver) creates a pairing session with a short code. Device B
6
+ * (requester) joins with the code. After E2EE is established, Device B sends
7
+ * its public key and Device A registers it via `addKey()`.
8
+ *
9
+ * Reuses the existing WebRTC stack: SignalingSocket, PeerConnection,
10
+ * DataChannelRouter, and E2EEChannel (X25519 ECDH + AES-256-GCM).
11
+ */
12
+
13
+ import { sha256 } from "@noble/hashes/sha256";
14
+ import EventEmitter from "../EventEmitter.ts";
15
+ import type { AbracadabraClient } from "../AbracadabraClient.ts";
16
+ import { AbracadabraWebRTC } from "./AbracadabraWebRTC.ts";
17
+ import type { E2EEIdentity } from "./E2EEChannel.ts";
18
+
19
+ // ── Constants ───────────────────────────────────────────────────────────────
20
+
21
+ /** Ambiguity-free charset (no 0/O/1/I). */
22
+ const CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
23
+ const CODE_LENGTH = 6;
24
+ const PAIRING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
25
+ const PAIRING_CHANNEL = "device-pairing";
26
+
27
+ // ── Types ───────────────────────────────────────────────────────────────────
28
+
29
+ export interface DevicePairingConfig {
30
+ /** Server base URL (http/https). */
31
+ serverUrl: string;
32
+ /** JWT token or async token factory for signaling auth. */
33
+ token: string | (() => string) | (() => Promise<string>);
34
+ /** E2EE identity (Ed25519 public key + X25519 private key). */
35
+ e2ee: E2EEIdentity;
36
+ /** ICE servers. Defaults to Google STUN. */
37
+ iceServers?: RTCIceServer[];
38
+ /** WebSocket polyfill (for Node.js). */
39
+ WebSocketPolyfill?: any;
40
+ }
41
+
42
+ export interface PairingRequest {
43
+ /** Device B's Ed25519 public key (base64url). */
44
+ publicKey: string;
45
+ /** Device B's X25519 public key (base64url). */
46
+ x25519Key: string;
47
+ /** Human-readable device label. */
48
+ deviceName: string;
49
+ }
50
+
51
+ export interface PairingResult {
52
+ success: boolean;
53
+ error?: string;
54
+ }
55
+
56
+ type PairingRole = "approver" | "requester";
57
+
58
+ // ── Internal wire messages ──────────────────────────────────────────────────
59
+
60
+ interface PairRequestMsg {
61
+ type: "pair-request";
62
+ publicKey: string;
63
+ x25519Key: string;
64
+ deviceName: string;
65
+ }
66
+
67
+ interface PairApprovedMsg {
68
+ type: "pair-approved";
69
+ }
70
+
71
+ interface PairInviteCodeMsg {
72
+ type: "pair-invite-code";
73
+ code: string;
74
+ }
75
+
76
+ interface PairRejectedMsg {
77
+ type: "pair-rejected";
78
+ reason: string;
79
+ }
80
+
81
+ type PairingMsg = PairRequestMsg | PairApprovedMsg | PairRejectedMsg | PairInviteCodeMsg;
82
+
83
+ // ── Helpers ─────────────────────────────────────────────────────────────────
84
+
85
+ function generatePairingCode(): string {
86
+ const bytes = crypto.getRandomValues(new Uint8Array(CODE_LENGTH));
87
+ return Array.from(bytes)
88
+ .map((b) => CODE_CHARSET[b % CODE_CHARSET.length])
89
+ .join("");
90
+ }
91
+
92
+ function codeToRoomId(code: string): string {
93
+ const hash = sha256(new TextEncoder().encode(code.toUpperCase()));
94
+ // Take first 16 hex chars for the room ID.
95
+ const hex = Array.from(hash.slice(0, 8))
96
+ .map((b) => b.toString(16).padStart(2, "0"))
97
+ .join("");
98
+ return `__pairing_${hex}`;
99
+ }
100
+
101
+ // ── DevicePairingChannel ────────────────────────────────────────────────────
102
+
103
+ export class DevicePairingChannel extends EventEmitter {
104
+ private webrtc: AbracadabraWebRTC | null = null;
105
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
106
+ private _destroyed = false;
107
+ private _pendingRequest: PairingRequest | null = null;
108
+ private _connectedPeerId: string | null = null;
109
+
110
+ readonly role: PairingRole;
111
+ readonly pairingCode: string;
112
+
113
+ private constructor(
114
+ role: PairingRole,
115
+ pairingCode: string,
116
+ private readonly config: DevicePairingConfig,
117
+ ) {
118
+ super();
119
+ this.role = role;
120
+ this.pairingCode = pairingCode;
121
+ }
122
+
123
+ // ── Factory Methods ───────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Create an approver session (Device A). Returns the channel and a
127
+ * 6-character pairing code to share with Device B.
128
+ */
129
+ static createApprover(config: DevicePairingConfig): {
130
+ channel: DevicePairingChannel;
131
+ pairingCode: string;
132
+ } {
133
+ const code = generatePairingCode();
134
+ const channel = new DevicePairingChannel("approver", code, config);
135
+ channel.start();
136
+ return { channel, pairingCode: code };
137
+ }
138
+
139
+ /**
140
+ * Create a requester session (Device B). Joins with a pairing code
141
+ * obtained from Device A.
142
+ */
143
+ static createRequester(
144
+ config: DevicePairingConfig,
145
+ pairingCode: string,
146
+ ): DevicePairingChannel {
147
+ const channel = new DevicePairingChannel(
148
+ "requester",
149
+ pairingCode.toUpperCase().replace(/[^A-Z2-9]/g, ""),
150
+ config,
151
+ );
152
+ channel.start();
153
+ return channel;
154
+ }
155
+
156
+ // ── Approver API ──────────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Approve the pending pairing request. Calls `client.addKey()` to
160
+ * register Device B's public key, then notifies Device B.
161
+ */
162
+ async approve(client: AbracadabraClient): Promise<PairingResult> {
163
+ if (this.role !== "approver") {
164
+ return { success: false, error: "Only the approver can approve" };
165
+ }
166
+ if (!this._pendingRequest) {
167
+ return { success: false, error: "No pending pairing request" };
168
+ }
169
+ if (!this._connectedPeerId) {
170
+ return { success: false, error: "Peer not connected" };
171
+ }
172
+
173
+ const req = this._pendingRequest;
174
+
175
+ try {
176
+ await client.addKey({
177
+ publicKey: req.publicKey,
178
+ deviceName: req.deviceName,
179
+ x25519Key: req.x25519Key,
180
+ });
181
+
182
+ this.sendMessage({ type: "pair-approved" });
183
+ this._pendingRequest = null;
184
+ this.emit("pairingComplete", { success: true } as PairingResult);
185
+ return { success: true };
186
+ } catch (err: any) {
187
+ const error = err?.message ?? "Failed to register device key";
188
+ this.sendMessage({ type: "pair-rejected", reason: error });
189
+ this._pendingRequest = null;
190
+ const result: PairingResult = { success: false, error };
191
+ this.emit("pairingComplete", result);
192
+ return result;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Approve via server-side device invite. Creates a single-use invite code
198
+ * and sends it to Device B over the E2EE channel. Device B redeems it
199
+ * independently via HTTP — Device A can go offline after this.
200
+ */
201
+ async approveWithInvite(client: AbracadabraClient): Promise<PairingResult> {
202
+ if (this.role !== "approver") {
203
+ return { success: false, error: "Only the approver can approve" };
204
+ }
205
+ if (!this._pendingRequest) {
206
+ return { success: false, error: "No pending pairing request" };
207
+ }
208
+ if (!this._connectedPeerId) {
209
+ return { success: false, error: "Peer not connected" };
210
+ }
211
+
212
+ try {
213
+ const { code } = await client.createDeviceInvite();
214
+ this.sendMessage({ type: "pair-invite-code", code });
215
+ this._pendingRequest = null;
216
+ this.emit("pairingComplete", { success: true } as PairingResult);
217
+ return { success: true };
218
+ } catch (err: any) {
219
+ const error = err?.message ?? "Failed to create device invite";
220
+ this.sendMessage({ type: "pair-rejected", reason: error });
221
+ this._pendingRequest = null;
222
+ const result: PairingResult = { success: false, error };
223
+ this.emit("pairingComplete", result);
224
+ return result;
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Reject the pending pairing request.
230
+ */
231
+ reject(reason = "Rejected by user"): void {
232
+ if (this.role !== "approver" || !this._pendingRequest) return;
233
+ this.sendMessage({ type: "pair-rejected", reason });
234
+ this._pendingRequest = null;
235
+ this.emit("pairingComplete", {
236
+ success: false,
237
+ error: reason,
238
+ } as PairingResult);
239
+ }
240
+
241
+ // ── Requester API ─────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Send a pairing request to Device A. Call this after the "connected"
245
+ * event fires.
246
+ */
247
+ requestPairing(request: PairingRequest): void {
248
+ if (this.role !== "requester") return;
249
+ this.sendMessage({
250
+ type: "pair-request",
251
+ publicKey: request.publicKey,
252
+ x25519Key: request.x25519Key,
253
+ deviceName: request.deviceName,
254
+ });
255
+ }
256
+
257
+ // ── Lifecycle ─────────────────────────────────────────────────────────
258
+
259
+ get isDestroyed(): boolean {
260
+ return this._destroyed;
261
+ }
262
+
263
+ destroy(): void {
264
+ if (this._destroyed) return;
265
+ this._destroyed = true;
266
+
267
+ if (this.timeoutHandle) {
268
+ clearTimeout(this.timeoutHandle);
269
+ this.timeoutHandle = null;
270
+ }
271
+
272
+ if (this.webrtc) {
273
+ this.webrtc.destroy();
274
+ this.webrtc = null;
275
+ }
276
+
277
+ this.removeAllListeners();
278
+ }
279
+
280
+ // ── Private ─────────────────────────────────────────────────────────
281
+
282
+ private start(): void {
283
+ const roomId = codeToRoomId(this.pairingCode);
284
+
285
+ this.webrtc = new AbracadabraWebRTC({
286
+ docId: roomId,
287
+ url: this.config.serverUrl,
288
+ token: this.config.token,
289
+ iceServers: this.config.iceServers,
290
+ e2ee: this.config.e2ee,
291
+ enableDocSync: false,
292
+ enableAwarenessSync: false,
293
+ enableFileTransfer: false,
294
+ autoConnect: false,
295
+ WebSocketPolyfill: this.config.WebSocketPolyfill,
296
+ });
297
+
298
+ this.webrtc.on("e2eeEstablished", ({ peerId }: { peerId: string }) => {
299
+ this._connectedPeerId = peerId;
300
+ this.emit("connected");
301
+ });
302
+
303
+ this.webrtc.on(
304
+ "customMessage",
305
+ ({ peerId, payload }: { peerId: string; payload: string }) => {
306
+ this.handleMessage(peerId, payload);
307
+ },
308
+ );
309
+
310
+ this.webrtc.on("peerLeft", () => {
311
+ if (!this._destroyed) {
312
+ this.emit("error", new Error("Peer disconnected"));
313
+ }
314
+ });
315
+
316
+ this.webrtc.on(
317
+ "signalingError",
318
+ (err: { code: string; message: string }) => {
319
+ this.emit("error", new Error(`Signaling: ${err.message}`));
320
+ },
321
+ );
322
+
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);
330
+
331
+ this.webrtc.connect();
332
+ }
333
+
334
+ private sendMessage(msg: PairingMsg): void {
335
+ if (!this.webrtc || !this._connectedPeerId) return;
336
+ // Send via the custom message path — the E2EE layer on the data channel
337
+ // encrypts all non-key-exchange traffic via the DataChannelRouter.
338
+ this.webrtc.sendCustomMessage(
339
+ this._connectedPeerId,
340
+ JSON.stringify(msg),
341
+ );
342
+ }
343
+
344
+ private handleMessage(peerId: string, payload: string): void {
345
+ let msg: PairingMsg;
346
+ try {
347
+ msg = JSON.parse(payload);
348
+ } catch {
349
+ return;
350
+ }
351
+
352
+ switch (msg.type) {
353
+ case "pair-request":
354
+ if (this.role !== "approver") return;
355
+ this._pendingRequest = {
356
+ publicKey: msg.publicKey,
357
+ x25519Key: msg.x25519Key,
358
+ deviceName: msg.deviceName,
359
+ };
360
+ this.emit("pairingRequest", this._pendingRequest);
361
+ break;
362
+
363
+ case "pair-approved":
364
+ if (this.role !== "requester") return;
365
+ this.emit("approved");
366
+ this.emit("pairingComplete", { success: true } as PairingResult);
367
+ break;
368
+
369
+ case "pair-rejected":
370
+ if (this.role !== "requester") return;
371
+ this.emit("rejected", msg.reason);
372
+ this.emit("pairingComplete", {
373
+ success: false,
374
+ error: msg.reason,
375
+ } as PairingResult);
376
+ break;
377
+
378
+ case "pair-invite-code":
379
+ if (this.role !== "requester") return;
380
+ this.emit("inviteCode", msg.code);
381
+ break;
382
+ }
383
+ }
384
+ }
@@ -8,6 +8,8 @@ export { E2EEChannel } from "./E2EEChannel.ts";
8
8
  export type { E2EEIdentity } from "./E2EEChannel.ts";
9
9
  export { ManualSignaling } from "./ManualSignaling.ts";
10
10
  export type { ManualSignalingBlob } from "./ManualSignaling.ts";
11
+ export { DevicePairingChannel } from "./DevicePairingChannel.ts";
12
+ export type { DevicePairingConfig, PairingRequest, PairingResult } from "./DevicePairingChannel.ts";
11
13
  export type {
12
14
  AbracadabraWebRTCConfiguration,
13
15
  PeerInfo,