@abraca/dabra 1.0.23 → 1.0.25

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
@@ -2396,6 +2396,23 @@ interface IdentityServerEntry {
2396
2396
  spacesEnabled?: boolean;
2397
2397
  addedAt: number;
2398
2398
  }
2399
+ type DeviceTier = "master" | "paired";
2400
+ interface DeviceServerStatus {
2401
+ registered: boolean;
2402
+ registeredAt: number | null;
2403
+ keyId: string | null;
2404
+ lastVerifiedAt: number | null;
2405
+ error: string | null;
2406
+ }
2407
+ interface IdentityDeviceEntry {
2408
+ deviceName: string;
2409
+ tier: DeviceTier;
2410
+ x25519Key: string | null;
2411
+ addedAt: number;
2412
+ revokedAt: number | null;
2413
+ revokedBy: string | null;
2414
+ servers: Map<string, DeviceServerStatus>;
2415
+ }
2399
2416
  interface IdentitySpaceEntry {
2400
2417
  id: string;
2401
2418
  name: string;
@@ -2481,6 +2498,7 @@ declare class IdentityDocProvider extends EventEmitter {
2481
2498
  get serversMap(): Y.Map<Y.Map<any>>;
2482
2499
  get spacesArray(): Y.Array<Y.Map<any>>;
2483
2500
  get pluginsMap(): Y.Map<any>;
2501
+ get devicesMap(): Y.Map<Y.Map<any>>;
2484
2502
  get preferencesMap(): Y.Map<any>;
2485
2503
  getProfile(): IdentityProfile;
2486
2504
  setProfile(profile: Partial<IdentityProfile>): void;
@@ -2496,11 +2514,22 @@ declare class IdentityDocProvider extends EventEmitter {
2496
2514
  getDisabledBuiltins(): Y.Array<string>;
2497
2515
  getPreference<T = any>(key: string): T | undefined;
2498
2516
  setPreference(key: string, value: any): void;
2517
+ getDevice(publicKey: string): IdentityDeviceEntry | undefined;
2518
+ getDevices(): Map<string, IdentityDeviceEntry>;
2519
+ addDevice(publicKey: string, opts: {
2520
+ deviceName: string;
2521
+ tier: DeviceTier;
2522
+ x25519Key?: string;
2523
+ serverUrl?: string;
2524
+ keyId?: string;
2525
+ }): void;
2526
+ revokeDevice(publicKey: string, revokedBy: string): void;
2527
+ setDeviceServerStatus(devicePubKey: string, serverUrl: string, status: Partial<DeviceServerStatus>): void;
2499
2528
  /**
2500
2529
  * Observe deep changes on a specific top-level map.
2501
2530
  * Returns an unsubscribe function.
2502
2531
  */
2503
- observe(mapName: "profile" | "servers" | "plugins" | "preferences", callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void): () => void;
2532
+ observe(mapName: "profile" | "servers" | "plugins" | "preferences" | "devices", callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void): () => void;
2504
2533
  /**
2505
2534
  * Observe changes to the spaces array.
2506
2535
  * Returns an unsubscribe function.
@@ -2520,4 +2549,60 @@ declare class IdentityDocProvider extends EventEmitter {
2520
2549
  destroy(): void;
2521
2550
  }
2522
2551
  //#endregion
2523
- 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, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, 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, deriveIdentityDocId, encryptField, makeEncryptedYMap, makeEncryptedYText, onAuthenticatedParameters, onAuthenticationFailedParameters, onAwarenessChangeParameters, onAwarenessUpdateParameters, onCloseParameters, onDisconnectParameters, onMessageParameters, onOpenParameters, onOutgoingMessageParameters, onStatelessParameters, onStatusParameters, onSubdocLoadedParameters, onSubdocRegisteredParameters, onSyncedParameters, onUnsyncedChangesParameters, readAuthMessage, writeAuthenticated, writeAuthentication, writePermissionDenied, writeTokenSyncRequest };
2552
+ //#region packages/provider/src/DeviceRegistrationService.d.ts
2553
+ interface FanOutResult {
2554
+ serverUrl: string;
2555
+ success: boolean;
2556
+ error?: string;
2557
+ }
2558
+ /**
2559
+ * Handles cross-server device key registration and revocation propagation.
2560
+ *
2561
+ * All methods operate on the identity Y.Doc's `devicesMap` and authenticate
2562
+ * independently on each server using the master key's `signChallenge`.
2563
+ */
2564
+ declare class DeviceRegistrationService {
2565
+ /**
2566
+ * Fan out a device key to all servers in `serversMap` where it isn't registered.
2567
+ *
2568
+ * Must be called from a master device that can sign challenges with the
2569
+ * account-level private key.
2570
+ */
2571
+ static fanOutDeviceKey(opts: {
2572
+ devicePubKey: string;
2573
+ x25519Key?: string;
2574
+ deviceName?: string;
2575
+ identityDoc: IdentityDocProvider;
2576
+ masterPubKey: string;
2577
+ signChallenge: (challenge: string) => Promise<string>;
2578
+ skipServerUrl?: string;
2579
+ }): Promise<FanOutResult[]>;
2580
+ /**
2581
+ * Propagate pending revocations from the identity Y.Doc to all servers.
2582
+ *
2583
+ * Scans `devicesMap` for entries with `revokedAt` set and revokes them
2584
+ * on every server where they're still registered. Must be called from
2585
+ * a master device.
2586
+ */
2587
+ static propagateRevocations(opts: {
2588
+ identityDoc: IdentityDocProvider;
2589
+ masterPubKey: string;
2590
+ signChallenge: (challenge: string) => Promise<string>;
2591
+ }): Promise<FanOutResult[]>;
2592
+ /**
2593
+ * Reconcile the identity Y.Doc's `devicesMap` with a server's actual
2594
+ * device key list. Call this on every server connection.
2595
+ *
2596
+ * - Updates registration status in Y.Doc to match server reality
2597
+ * - Imports unknown server keys as legacy entries
2598
+ * - Executes pending revocations if the caller is a master device
2599
+ */
2600
+ static reconcile(opts: {
2601
+ identityDoc: IdentityDocProvider;
2602
+ serverUrl: string;
2603
+ client: AbracadabraClient;
2604
+ isMaster: boolean;
2605
+ }): Promise<void>;
2606
+ }
2607
+ //#endregion
2608
+ 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, DeviceRegistrationService, type DeviceServerStatus, type DeviceTier, 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, type IdentityDeviceEntry, type IdentityDocConfiguration, IdentityDocProvider, type IdentityProfile, type IdentityServerEntry, type IdentitySpaceEntry, 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, deriveIdentityDocId, 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.23",
3
+ "version": "1.0.25",
4
4
  "description": "abracadabra provider",
5
5
  "keywords": [
6
6
  "abracadabra",
@@ -0,0 +1,243 @@
1
+ import type * as Y from "yjs";
2
+ import { AbracadabraClient } from "./AbracadabraClient.ts";
3
+ import type { IdentityDocProvider } from "./IdentityDoc.ts";
4
+ import type { PublicKeyInfo } from "./types.ts";
5
+
6
+ // ── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ export interface FanOutResult {
9
+ serverUrl: string;
10
+ success: boolean;
11
+ error?: string;
12
+ }
13
+
14
+ // ── DeviceRegistrationService ────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Handles cross-server device key registration and revocation propagation.
18
+ *
19
+ * All methods operate on the identity Y.Doc's `devicesMap` and authenticate
20
+ * independently on each server using the master key's `signChallenge`.
21
+ */
22
+ export class DeviceRegistrationService {
23
+ /**
24
+ * Fan out a device key to all servers in `serversMap` where it isn't registered.
25
+ *
26
+ * Must be called from a master device that can sign challenges with the
27
+ * account-level private key.
28
+ */
29
+ static async fanOutDeviceKey(opts: {
30
+ devicePubKey: string;
31
+ x25519Key?: string;
32
+ deviceName?: string;
33
+ identityDoc: IdentityDocProvider;
34
+ masterPubKey: string;
35
+ signChallenge: (challenge: string) => Promise<string>;
36
+ skipServerUrl?: string;
37
+ }): Promise<FanOutResult[]> {
38
+ const {
39
+ devicePubKey,
40
+ x25519Key,
41
+ deviceName,
42
+ identityDoc,
43
+ masterPubKey,
44
+ signChallenge,
45
+ skipServerUrl,
46
+ } = opts;
47
+
48
+ const servers = identityDoc.getServers();
49
+ const results: FanOutResult[] = [];
50
+
51
+ // Ensure the device entry exists in devicesMap
52
+ if (!identityDoc.getDevice(devicePubKey)) {
53
+ identityDoc.addDevice(devicePubKey, {
54
+ deviceName: deviceName ?? "Unknown",
55
+ tier: "paired",
56
+ x25519Key,
57
+ });
58
+ }
59
+
60
+ for (const [serverUrl] of servers) {
61
+ if (skipServerUrl && serverUrl === skipServerUrl) continue;
62
+
63
+ // Check if already registered on this server
64
+ const device = identityDoc.getDevice(devicePubKey);
65
+ const serverStatus = device?.servers.get(serverUrl);
66
+ if (serverStatus?.registered) continue;
67
+
68
+ try {
69
+ const client = new AbracadabraClient({ url: serverUrl });
70
+ await client.loginWithKey(masterPubKey, signChallenge);
71
+ await client.addKey({
72
+ publicKey: devicePubKey,
73
+ x25519Key,
74
+ deviceName,
75
+ });
76
+
77
+ // Get the keyId from the server for future revocation
78
+ const keys = await client.listKeys();
79
+ const match = keys.find(
80
+ (k) => k.publicKey === devicePubKey && !k.revoked,
81
+ );
82
+
83
+ identityDoc.setDeviceServerStatus(devicePubKey, serverUrl, {
84
+ registered: true,
85
+ registeredAt: Date.now(),
86
+ keyId: match?.id ?? null,
87
+ lastVerifiedAt: Date.now(),
88
+ error: null,
89
+ });
90
+
91
+ results.push({ serverUrl, success: true });
92
+ } catch (e: any) {
93
+ identityDoc.setDeviceServerStatus(devicePubKey, serverUrl, {
94
+ error: e?.message ?? "Registration failed",
95
+ });
96
+ results.push({
97
+ serverUrl,
98
+ success: false,
99
+ error: e?.message ?? "Registration failed",
100
+ });
101
+ }
102
+ }
103
+
104
+ return results;
105
+ }
106
+
107
+ /**
108
+ * Propagate pending revocations from the identity Y.Doc to all servers.
109
+ *
110
+ * Scans `devicesMap` for entries with `revokedAt` set and revokes them
111
+ * on every server where they're still registered. Must be called from
112
+ * a master device.
113
+ */
114
+ static async propagateRevocations(opts: {
115
+ identityDoc: IdentityDocProvider;
116
+ masterPubKey: string;
117
+ signChallenge: (challenge: string) => Promise<string>;
118
+ }): Promise<FanOutResult[]> {
119
+ const { identityDoc, masterPubKey, signChallenge } = opts;
120
+ const devices = identityDoc.getDevices();
121
+ const results: FanOutResult[] = [];
122
+
123
+ for (const [devicePubKey, device] of devices) {
124
+ if (!device.revokedAt) continue;
125
+
126
+ for (const [serverUrl, serverStatus] of device.servers) {
127
+ if (!serverStatus.registered || !serverStatus.keyId) continue;
128
+
129
+ try {
130
+ const client = new AbracadabraClient({ url: serverUrl });
131
+ await client.loginWithKey(masterPubKey, signChallenge);
132
+ await client.revokeKey(serverStatus.keyId);
133
+
134
+ identityDoc.setDeviceServerStatus(
135
+ devicePubKey,
136
+ serverUrl,
137
+ {
138
+ registered: false,
139
+ error: null,
140
+ },
141
+ );
142
+ results.push({ serverUrl, success: true });
143
+ } catch (e: any) {
144
+ results.push({
145
+ serverUrl,
146
+ success: false,
147
+ error: e?.message ?? "Revocation failed",
148
+ });
149
+ }
150
+ }
151
+ }
152
+
153
+ return results;
154
+ }
155
+
156
+ /**
157
+ * Reconcile the identity Y.Doc's `devicesMap` with a server's actual
158
+ * device key list. Call this on every server connection.
159
+ *
160
+ * - Updates registration status in Y.Doc to match server reality
161
+ * - Imports unknown server keys as legacy entries
162
+ * - Executes pending revocations if the caller is a master device
163
+ */
164
+ static async reconcile(opts: {
165
+ identityDoc: IdentityDocProvider;
166
+ serverUrl: string;
167
+ client: AbracadabraClient;
168
+ isMaster: boolean;
169
+ }): Promise<void> {
170
+ const { identityDoc, serverUrl, client, isMaster } = opts;
171
+
172
+ let serverKeys: PublicKeyInfo[];
173
+ try {
174
+ serverKeys = await client.listKeys();
175
+ } catch {
176
+ return; // Server unreachable or auth issue — skip reconciliation
177
+ }
178
+
179
+ const devicesMap = identityDoc.devicesMap;
180
+
181
+ // 1. Verify Y.Doc matches server reality
182
+ devicesMap.forEach((yEntry, devicePub) => {
183
+ const serversYMap = yEntry.get("servers") as
184
+ | Y.Map<Y.Map<any>>
185
+ | undefined;
186
+ const serverEntry = serversYMap?.get(serverUrl);
187
+ const serverKey = serverKeys.find((k) => k.publicKey === devicePub);
188
+ const revokedAt = yEntry.get("revokedAt");
189
+
190
+ if (serverEntry?.get("registered") && !serverKey) {
191
+ // Y.Doc thinks registered but server doesn't have it
192
+ identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
193
+ registered: false,
194
+ keyId: null,
195
+ });
196
+ }
197
+
198
+ if (serverKey && !serverKey.revoked) {
199
+ // Server has it — update Y.Doc if needed
200
+ if (!serverEntry?.get("registered")) {
201
+ identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
202
+ registered: true,
203
+ keyId: serverKey.id,
204
+ lastVerifiedAt: Date.now(),
205
+ });
206
+ } else {
207
+ // Just update verification timestamp + keyId
208
+ identityDoc.setDeviceServerStatus(devicePub, serverUrl, {
209
+ lastVerifiedAt: Date.now(),
210
+ keyId: serverKey.id,
211
+ });
212
+ }
213
+
214
+ // Execute pending revocation if we're a master device
215
+ if (revokedAt && isMaster) {
216
+ client
217
+ .revokeKey(serverKey.id)
218
+ .then(() => {
219
+ identityDoc.setDeviceServerStatus(
220
+ devicePub,
221
+ serverUrl,
222
+ { registered: false },
223
+ );
224
+ })
225
+ .catch(() => {});
226
+ }
227
+ }
228
+ });
229
+
230
+ // 2. Import unknown server keys into Y.Doc (migration)
231
+ for (const key of serverKeys) {
232
+ if (key.revoked) continue;
233
+ if (!devicesMap.has(key.publicKey)) {
234
+ identityDoc.addDevice(key.publicKey, {
235
+ deviceName: key.deviceName ?? "Unknown",
236
+ tier: "paired",
237
+ serverUrl,
238
+ keyId: key.id,
239
+ });
240
+ }
241
+ }
242
+ }
243
+ }
@@ -53,6 +53,26 @@ export interface IdentityServerEntry {
53
53
  addedAt: number;
54
54
  }
55
55
 
56
+ export type DeviceTier = "master" | "paired";
57
+
58
+ export interface DeviceServerStatus {
59
+ registered: boolean;
60
+ registeredAt: number | null;
61
+ keyId: string | null;
62
+ lastVerifiedAt: number | null;
63
+ error: string | null;
64
+ }
65
+
66
+ export interface IdentityDeviceEntry {
67
+ deviceName: string;
68
+ tier: DeviceTier;
69
+ x25519Key: string | null;
70
+ addedAt: number;
71
+ revokedAt: number | null;
72
+ revokedBy: string | null;
73
+ servers: Map<string, DeviceServerStatus>;
74
+ }
75
+
56
76
  export interface IdentitySpaceEntry {
57
77
  id: string;
58
78
  name: string;
@@ -275,6 +295,10 @@ export class IdentityDocProvider extends EventEmitter {
275
295
  return this.document.getMap("plugins");
276
296
  }
277
297
 
298
+ get devicesMap(): Y.Map<Y.Map<any>> {
299
+ return this.document.getMap("devices");
300
+ }
301
+
278
302
  get preferencesMap(): Y.Map<any> {
279
303
  return this.document.getMap("preferences");
280
304
  }
@@ -445,6 +469,124 @@ export class IdentityDocProvider extends EventEmitter {
445
469
  this.preferencesMap.set(key, value);
446
470
  }
447
471
 
472
+ // ── Devices ─────────────────────────────────────────────────────────────
473
+
474
+ getDevice(publicKey: string): IdentityDeviceEntry | undefined {
475
+ const entry = this.devicesMap.get(publicKey);
476
+ if (!entry) return undefined;
477
+ const servers = new Map<string, DeviceServerStatus>();
478
+ const serversYMap = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
479
+ if (serversYMap) {
480
+ serversYMap.forEach((sEntry, url) => {
481
+ servers.set(url, {
482
+ registered: sEntry.get("registered") ?? false,
483
+ registeredAt: sEntry.get("registeredAt") ?? null,
484
+ keyId: sEntry.get("keyId") ?? null,
485
+ lastVerifiedAt: sEntry.get("lastVerifiedAt") ?? null,
486
+ error: sEntry.get("error") ?? null,
487
+ });
488
+ });
489
+ }
490
+ return {
491
+ deviceName: entry.get("deviceName") ?? "Unknown",
492
+ tier: entry.get("tier") ?? "paired",
493
+ x25519Key: entry.get("x25519Key") ?? null,
494
+ addedAt: entry.get("addedAt") ?? 0,
495
+ revokedAt: entry.get("revokedAt") ?? null,
496
+ revokedBy: entry.get("revokedBy") ?? null,
497
+ servers,
498
+ };
499
+ }
500
+
501
+ getDevices(): Map<string, IdentityDeviceEntry> {
502
+ const result = new Map<string, IdentityDeviceEntry>();
503
+ this.devicesMap.forEach((_entry, pubKey) => {
504
+ const device = this.getDevice(pubKey);
505
+ if (device) result.set(pubKey, device);
506
+ });
507
+ return result;
508
+ }
509
+
510
+ addDevice(
511
+ publicKey: string,
512
+ opts: {
513
+ deviceName: string;
514
+ tier: DeviceTier;
515
+ x25519Key?: string;
516
+ serverUrl?: string;
517
+ keyId?: string;
518
+ },
519
+ ): void {
520
+ this.document.transact(() => {
521
+ let entry = this.devicesMap.get(publicKey);
522
+ if (!entry) {
523
+ entry = new Y.Map();
524
+ this.devicesMap.set(publicKey, entry);
525
+ }
526
+ entry.set("deviceName", opts.deviceName);
527
+ entry.set("tier", opts.tier);
528
+ if (opts.x25519Key) entry.set("x25519Key", opts.x25519Key);
529
+ entry.set("addedAt", Date.now());
530
+ entry.set("revokedAt", null);
531
+ entry.set("revokedBy", null);
532
+
533
+ if (opts.serverUrl) {
534
+ let servers = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
535
+ if (!servers) {
536
+ servers = new Y.Map();
537
+ entry.set("servers", servers);
538
+ }
539
+ const sEntry = new Y.Map();
540
+ sEntry.set("registered", true);
541
+ sEntry.set("registeredAt", Date.now());
542
+ sEntry.set("keyId", opts.keyId ?? null);
543
+ sEntry.set("lastVerifiedAt", Date.now());
544
+ sEntry.set("error", null);
545
+ servers.set(opts.serverUrl, sEntry);
546
+ }
547
+ });
548
+ }
549
+
550
+ revokeDevice(publicKey: string, revokedBy: string): void {
551
+ const entry = this.devicesMap.get(publicKey);
552
+ if (!entry) return;
553
+ this.document.transact(() => {
554
+ entry.set("revokedAt", Date.now());
555
+ entry.set("revokedBy", revokedBy);
556
+ });
557
+ }
558
+
559
+ setDeviceServerStatus(
560
+ devicePubKey: string,
561
+ serverUrl: string,
562
+ status: Partial<DeviceServerStatus>,
563
+ ): void {
564
+ const entry = this.devicesMap.get(devicePubKey);
565
+ if (!entry) return;
566
+ this.document.transact(() => {
567
+ let servers = entry.get("servers") as Y.Map<Y.Map<any>> | undefined;
568
+ if (!servers) {
569
+ servers = new Y.Map();
570
+ entry.set("servers", servers);
571
+ }
572
+ let sEntry = servers.get(serverUrl);
573
+ if (!sEntry) {
574
+ sEntry = new Y.Map();
575
+ sEntry.set("registered", false);
576
+ sEntry.set("registeredAt", null);
577
+ sEntry.set("keyId", null);
578
+ sEntry.set("lastVerifiedAt", null);
579
+ sEntry.set("error", null);
580
+ servers.set(serverUrl, sEntry);
581
+ }
582
+ for (const [key, value] of Object.entries(status)) {
583
+ if (value !== undefined) {
584
+ sEntry.set(key, value);
585
+ }
586
+ }
587
+ });
588
+ }
589
+
448
590
  // ── Observation ──────────────────────────────────────────────────────────
449
591
 
450
592
  /**
@@ -452,7 +594,7 @@ export class IdentityDocProvider extends EventEmitter {
452
594
  * Returns an unsubscribe function.
453
595
  */
454
596
  observe(
455
- mapName: "profile" | "servers" | "plugins" | "preferences",
597
+ mapName: "profile" | "servers" | "plugins" | "preferences" | "devices",
456
598
  callback: (events: Y.YEvent<any>[], transaction: Y.Transaction) => void,
457
599
  ): () => void {
458
600
  const map = this.document.getMap(mapName);
package/src/index.ts CHANGED
@@ -31,4 +31,8 @@ export type {
31
31
  IdentityProfile,
32
32
  IdentityServerEntry,
33
33
  IdentitySpaceEntry,
34
+ IdentityDeviceEntry,
35
+ DeviceServerStatus,
36
+ DeviceTier,
34
37
  } from "./IdentityDoc.ts";
38
+ export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
@@ -342,19 +342,25 @@ export class AbracadabraWebRTC extends EventEmitter {
342
342
  if (!pc) return;
343
343
 
344
344
  const channelName = "custom";
345
- let channel = pc.router.getChannel(channelName);
346
- if (!channel || channel.readyState !== "open") {
347
- // Create on-demand.
348
- channel = pc.router.createChannel(channelName, { ordered: true });
349
- // Wait for open before sending.
350
- channel.onopen = () => {
351
- const data = new TextEncoder().encode(payload);
352
- pc.router.send(channelName, data);
353
- };
345
+ const channel = pc.router.getChannel(channelName);
346
+ if (channel && channel.readyState === "open") {
347
+ const data = new TextEncoder().encode(payload);
348
+ pc.router.send(channelName, data).catch(() => {});
354
349
  return;
355
350
  }
356
- const data = new TextEncoder().encode(payload);
357
- pc.router.send(channelName, data);
351
+
352
+ // Channel not open yet — queue via the router's channelOpen event.
353
+ // Do NOT create a new channel here. The "custom" channel must be
354
+ // created during initiateConnection to be included in the SDP offer.
355
+ // If it doesn't exist yet, the peer hasn't finished negotiation.
356
+ const onOpen = ({ name }: { name: string }) => {
357
+ if (name === channelName) {
358
+ pc.router.off("channelOpen", onOpen);
359
+ const data = new TextEncoder().encode(payload);
360
+ pc.router.send(channelName, data).catch(() => {});
361
+ }
362
+ };
363
+ pc.router.on("channelOpen", onOpen);
358
364
  }
359
365
 
360
366
  /**
@@ -475,8 +481,12 @@ export class AbracadabraWebRTC extends EventEmitter {
475
481
  // Listen for key-exchange messages on the router.
476
482
  pc.router.on("channelMessage", async ({ name, data }: { name: string; data: any }) => {
477
483
  if (name === KEY_EXCHANGE_CHANNEL) {
478
- const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
479
- await e2ee.handleKeyExchange(buf);
484
+ try {
485
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
486
+ await e2ee.handleKeyExchange(buf);
487
+ } catch (err) {
488
+ this.emit("e2eeFailed", { peerId, error: err });
489
+ }
480
490
  }
481
491
  });
482
492
 
@@ -556,10 +566,16 @@ export class AbracadabraWebRTC extends EventEmitter {
556
566
  enableFileTransfer: this.config.enableFileTransfer,
557
567
  });
558
568
 
569
+ // Always create the "custom" channel for application messages
570
+ // (e.g. device pairing). Must be in the initial offer so the
571
+ // remote peer receives it via ondatachannel.
572
+ pc.router.createChannel("custom", { ordered: true });
573
+
559
574
  try {
560
575
  const sdp = await pc.createOffer();
561
576
  this.signaling?.sendOffer(peerId, sdp);
562
- } catch {
577
+ } catch (err) {
578
+ this.emit("error", { type: "offer-failed", peerId, error: err });
563
579
  this.removePeer(peerId);
564
580
  }
565
581
  }
@@ -581,7 +597,8 @@ export class AbracadabraWebRTC extends EventEmitter {
581
597
  try {
582
598
  const answerSdp = await pc.setRemoteOffer(sdp);
583
599
  this.signaling?.sendAnswer(from, answerSdp);
584
- } catch {
600
+ } catch (err) {
601
+ this.emit("error", { type: "answer-failed", peerId: from, error: err });
585
602
  this.removePeer(from);
586
603
  }
587
604
  }
@@ -378,6 +378,18 @@ export class DevicePairingChannel extends EventEmitter {
378
378
  },
379
379
  );
380
380
 
381
+ this.webrtc.on("e2eeFailed", ({ peerId, error }: { peerId: string; error: any }) => {
382
+ if (!this._destroyed) {
383
+ this.emit("error", new Error(`E2EE failed: ${error?.message ?? error}`));
384
+ }
385
+ });
386
+
387
+ this.webrtc.on("error", (err: any) => {
388
+ if (!this._destroyed) {
389
+ this.emit("error", new Error(`WebRTC: ${err?.type ?? err?.message ?? "unknown"}`));
390
+ }
391
+ });
392
+
381
393
  this.webrtc.on("peerLeft", () => {
382
394
  if (!this._destroyed) {
383
395
  this.emit("error", new Error("Peer disconnected"));