@dxos/echo-pipeline 0.3.11-main.cc2fe2c → 0.3.11-main.ccc0ca3

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.
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { Trigger } from '@dxos/async';
5
6
  import {
6
7
  Repo,
7
8
  NetworkAdapter,
@@ -15,12 +16,13 @@ import {
15
16
  import { IndexedDBStorageAdapter } from '@dxos/automerge/automerge-repo-storage-indexeddb';
16
17
  import { Stream } from '@dxos/codec-protobuf';
17
18
  import { invariant } from '@dxos/invariant';
19
+ import { PublicKey } from '@dxos/keys';
18
20
  import { log } from '@dxos/log';
19
21
  import { type HostInfo, type SyncRepoRequest, type SyncRepoResponse } from '@dxos/protocols/proto/dxos/echo/service';
20
22
  import { type PeerInfo } from '@dxos/protocols/proto/dxos/mesh/teleport/automerge';
21
23
  import { StorageType, type Directory } from '@dxos/random-access-storage';
22
24
  import { AutomergeReplicator } from '@dxos/teleport-extension-automerge-replicator';
23
- import { arrayToBuffer, bufferToArray } from '@dxos/util';
25
+ import { ComplexMap, ComplexSet, arrayToBuffer, bufferToArray, defaultMap } from '@dxos/util';
24
26
 
25
27
  export class AutomergeHost {
26
28
  private readonly _repo: Repo;
@@ -28,6 +30,11 @@ export class AutomergeHost {
28
30
  private readonly _clientNetwork: LocalHostNetworkAdapter;
29
31
  private readonly _storage: StorageAdapter;
30
32
 
33
+ /**
34
+ * spaceKey -> deviceKey[]
35
+ */
36
+ private readonly _authorizedDevices = new ComplexMap<PublicKey, ComplexSet<PublicKey>>(PublicKey.hash);
37
+
31
38
  constructor(storageDirectory: Directory) {
32
39
  this._meshNetwork = new MeshNetworkAdapter();
33
40
  this._clientNetwork = new LocalHostNetworkAdapter();
@@ -38,11 +45,52 @@ export class AutomergeHost {
38
45
  ? new IndexedDBStorageAdapter(storageDirectory.path, 'data')
39
46
  : new AutomergeStorageAdapter(storageDirectory);
40
47
  this._repo = new Repo({
48
+ peerId: `host-${PublicKey.random().toHex()}` as PeerId,
41
49
  network: [this._clientNetwork, this._meshNetwork],
42
50
  storage: this._storage,
43
51
 
44
52
  // TODO(dmaretskyi): Share based on HALO permissions and space affinity.
45
- sharePolicy: async (peerId, documentId) => true, // Share everything.
53
+ // Hosts, running in the worker, don't share documents unless requested by other peers.
54
+ sharePolicy: async (peerId /* device key */, documentId /* space key */) => {
55
+ if (peerId.startsWith('client-')) {
56
+ return true;
57
+ }
58
+
59
+ if (!documentId) {
60
+ return false;
61
+ }
62
+
63
+ const doc = this._repo.handles[documentId]?.docSync();
64
+ if (!doc) {
65
+ log('doc not found for share policy check', { peerId, documentId });
66
+ return false;
67
+ }
68
+
69
+ try {
70
+ if (!doc.experimental_spaceKey) {
71
+ log.warn('space key not found for share policy check', { peerId, documentId });
72
+ return false;
73
+ }
74
+
75
+ const spaceKey = PublicKey.from(doc.experimental_spaceKey);
76
+ const authorizedDevices = this._authorizedDevices.get(spaceKey);
77
+
78
+ // TODO(mykola): Hack, stop abusing `peerMetadata` field.
79
+ const deviceKeyHex = (this.repo.peerMetadataByPeerId[peerId] as any)?.dxos_deviceKey;
80
+ if (!deviceKeyHex) {
81
+ log.warn('device key not found for share policy check', { peerId, documentId });
82
+ return false;
83
+ }
84
+ const deviceKey = PublicKey.from(deviceKeyHex);
85
+
86
+ const isAuthorized = authorizedDevices?.has(deviceKey) ?? false;
87
+ log.info('share policy check', { peerId, documentId, deviceKey, spaceKey, isAuthorized });
88
+ return isAuthorized;
89
+ } catch (err) {
90
+ log.catch(err);
91
+ return false;
92
+ }
93
+ }, // Share everything.
46
94
  });
47
95
  this._clientNetwork.ready();
48
96
  this._meshNetwork.ready();
@@ -53,6 +101,7 @@ export class AutomergeHost {
53
101
  }
54
102
 
55
103
  async close() {
104
+ this._storage instanceof AutomergeStorageAdapter && (await this._storage.close());
56
105
  await this._clientNetwork.close();
57
106
  }
58
107
 
@@ -68,7 +117,7 @@ export class AutomergeHost {
68
117
  return this._clientNetwork.sendSyncMessage(request);
69
118
  }
70
119
 
71
- getHostInfo(): HostInfo {
120
+ async getHostInfo(): Promise<HostInfo> {
72
121
  return this._clientNetwork.getHostInfo();
73
122
  }
74
123
 
@@ -79,6 +128,10 @@ export class AutomergeHost {
79
128
  createExtension(): AutomergeReplicator {
80
129
  return this._meshNetwork.createExtension();
81
130
  }
131
+
132
+ authorizeDevice(spaceKey: PublicKey, deviceKey: PublicKey) {
133
+ defaultMap(this._authorizedDevices, spaceKey, () => new ComplexSet(PublicKey.hash)).add(deviceKey);
134
+ }
82
135
  }
83
136
 
84
137
  type ClientSyncState = {
@@ -104,8 +157,11 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
104
157
  });
105
158
  }
106
159
 
160
+ private _connected = new Trigger();
161
+
107
162
  override connect(peerId: PeerId): void {
108
163
  this.peerId = peerId;
164
+ this._connected.wake();
109
165
  // No-op. Client always connects first
110
166
  }
111
167
 
@@ -146,18 +202,26 @@ class LocalHostNetworkAdapter extends NetworkAdapter {
146
202
  },
147
203
  });
148
204
 
149
- this.emit('peer-candidate', {
150
- peerId,
151
- });
205
+ this._connected
206
+ .wait({ timeout: 1_000 })
207
+ .then(() => {
208
+ this.emit('peer-candidate', {
209
+ peerMetadata: {},
210
+ peerId,
211
+ });
212
+ })
213
+ .catch((err) => log.catch(err));
152
214
  });
153
215
  }
154
216
 
155
217
  async sendSyncMessage({ id, syncMessage }: SyncRepoRequest): Promise<void> {
218
+ await this._connected.wait({ timeout: 1_000 });
156
219
  const message = cbor.decode(syncMessage!) as Message;
157
220
  this.emit('message', message);
158
221
  }
159
222
 
160
- getHostInfo(): HostInfo {
223
+ async getHostInfo(): Promise<HostInfo> {
224
+ await this._connected.wait({ timeout: 1_000 });
161
225
  invariant(this.peerId, 'Peer id not set.');
162
226
  return {
163
227
  peerId: this.peerId,
@@ -210,7 +274,7 @@ export class MeshNetworkAdapter extends NetworkAdapter {
210
274
  peerId: this.peerId,
211
275
  },
212
276
  {
213
- onStartReplication: async (info) => {
277
+ onStartReplication: async (info, remotePeerId /** Teleport ID */) => {
214
278
  // Note: We store only one extension per peer.
215
279
  // There can be a case where two connected peers have more than one teleport connection between them
216
280
  // and each of them uses different teleport connections to send messages.
@@ -224,6 +288,10 @@ export class MeshNetworkAdapter extends NetworkAdapter {
224
288
  // TODO(mykola): Fix race condition?
225
289
  this._extensions.set(info.id, extension);
226
290
  this.emit('peer-candidate', {
291
+ // TODO(mykola): Hack, stop abusing `peerMetadata` field.
292
+ peerMetadata: {
293
+ dxos_deviceKey: remotePeerId.toHex(),
294
+ } as any,
227
295
  peerId: info.id as PeerId,
228
296
  });
229
297
  },
@@ -248,11 +316,18 @@ export class MeshNetworkAdapter extends NetworkAdapter {
248
316
  }
249
317
 
250
318
  export class AutomergeStorageAdapter extends StorageAdapter {
319
+ // TODO(mykola): Hack for restricting automerge Repo to access storage if Host is `closed`.
320
+ // Automerge Repo do not have any lifetime management.
321
+ private _state: 'opened' | 'closed' = 'opened';
322
+
251
323
  constructor(private readonly _directory: Directory) {
252
324
  super();
253
325
  }
254
326
 
255
327
  override async load(key: StorageKey): Promise<Uint8Array | undefined> {
328
+ if (this._state !== 'opened') {
329
+ return undefined;
330
+ }
256
331
  const filename = this._getFilename(key);
257
332
  const file = this._directory.getOrCreateFile(filename);
258
333
  const { size } = await file.stat();
@@ -264,6 +339,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
264
339
  }
265
340
 
266
341
  override async save(key: StorageKey, data: Uint8Array): Promise<void> {
342
+ if (this._state !== 'opened') {
343
+ return undefined;
344
+ }
267
345
  const filename = this._getFilename(key);
268
346
  const file = this._directory.getOrCreateFile(filename);
269
347
  await file.write(0, arrayToBuffer(data));
@@ -273,6 +351,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
273
351
  }
274
352
 
275
353
  override async remove(key: StorageKey): Promise<void> {
354
+ if (this._state !== 'opened') {
355
+ return undefined;
356
+ }
276
357
  // TODO(dmaretskyi): Better deletion.
277
358
  const filename = this._getFilename(key);
278
359
  const file = this._directory.getOrCreateFile(filename);
@@ -280,6 +361,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
280
361
  }
281
362
 
282
363
  override async loadRange(keyPrefix: StorageKey): Promise<Chunk[]> {
364
+ if (this._state !== 'opened') {
365
+ return [];
366
+ }
283
367
  const filename = this._getFilename(keyPrefix);
284
368
  const entries = await this._directory.list();
285
369
  return Promise.all(
@@ -298,6 +382,9 @@ export class AutomergeStorageAdapter extends StorageAdapter {
298
382
  }
299
383
 
300
384
  override async removeRange(keyPrefix: StorageKey): Promise<void> {
385
+ if (this._state !== 'opened') {
386
+ return undefined;
387
+ }
301
388
  const filename = this._getFilename(keyPrefix);
302
389
  const entries = await this._directory.list();
303
390
  await Promise.all(
@@ -310,6 +397,10 @@ export class AutomergeStorageAdapter extends StorageAdapter {
310
397
  );
311
398
  }
312
399
 
400
+ async close(): Promise<void> {
401
+ this._state = 'closed';
402
+ }
403
+
313
404
  private _getFilename(key: StorageKey): string {
314
405
  return key.map((k) => k.replaceAll('%', '%25').replaceAll('-', '%2D')).join('-');
315
406
  }
@@ -42,7 +42,7 @@ export type ConstructSpaceParams = {
42
42
  /**
43
43
  * Called when connection auth passed successful.
44
44
  */
45
- onNetworkConnection: (session: Teleport) => void;
45
+ onAuthorizedConnection: (session: Teleport) => void;
46
46
  onAuthFailure?: (session: Teleport) => void;
47
47
  };
48
48
 
@@ -93,7 +93,7 @@ export class SpaceManager {
93
93
  async constructSpace({
94
94
  metadata,
95
95
  swarmIdentity,
96
- onNetworkConnection,
96
+ onAuthorizedConnection,
97
97
  onAuthFailure,
98
98
  memberKey,
99
99
  }: ConstructSpaceParams) {
@@ -108,7 +108,7 @@ export class SpaceManager {
108
108
  topic: spaceKey,
109
109
  swarmIdentity,
110
110
  networkManager: this._networkManager,
111
- onSessionAuth: onNetworkConnection,
111
+ onSessionAuth: onAuthorizedConnection,
112
112
  onAuthFailure,
113
113
  blobStore: this._blobStore,
114
114
  });
@@ -189,7 +189,7 @@ export class TestAgent {
189
189
  credentialAuthenticator: MOCK_AUTH_VERIFIER,
190
190
  },
191
191
  memberKey: identityKey,
192
- onNetworkConnection: (session) => {
192
+ onAuthorizedConnection: (session) => {
193
193
  session.addExtension(
194
194
  'dxos.mesh.teleport.gossip',
195
195
  this.createGossip().createExtension({ remotePeerId: session.remotePeerId }),