@dxos/client-services 0.5.1-main.ff7d242 → 0.5.1-next.0ef769d

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.
Files changed (71) hide show
  1. package/dist/lib/browser/{chunk-7AHPYPLI.mjs → chunk-EZDHZKXT.mjs} +1296 -940
  2. package/dist/lib/browser/chunk-EZDHZKXT.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +31 -2
  4. package/dist/lib/browser/index.mjs.map +3 -3
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/packlets/testing/index.mjs +29 -9
  7. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-HBT5F5OI.cjs → chunk-4E7K7B6U.cjs} +1459 -1111
  9. package/dist/lib/node/chunk-4E7K7B6U.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +73 -44
  11. package/dist/lib/node/index.cjs.map +3 -3
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/packlets/testing/index.cjs +35 -15
  14. package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
  15. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +2 -1
  16. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  17. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +39 -0
  18. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -0
  19. package/dist/types/src/packlets/invitations/{invitation-extension.d.ts → invitation-host-extension.d.ts} +17 -31
  20. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -0
  21. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +6 -1
  22. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
  23. package/dist/types/src/packlets/invitations/invitation-topology.d.ts +37 -0
  24. package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +1 -0
  25. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +19 -10
  26. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  27. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +2 -0
  28. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +1 -0
  29. package/dist/types/src/packlets/invitations/invitations-manager.d.ts +2 -1
  30. package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
  31. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +1 -0
  32. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  33. package/dist/types/src/packlets/invitations/utils.d.ts +6 -0
  34. package/dist/types/src/packlets/invitations/utils.d.ts.map +1 -0
  35. package/dist/types/src/packlets/services/service-context.d.ts +8 -5
  36. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  37. package/dist/types/src/packlets/services/service-host.d.ts +1 -1
  38. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  39. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  40. package/dist/types/src/packlets/storage/level.d.ts +1 -2
  41. package/dist/types/src/packlets/storage/level.d.ts.map +1 -1
  42. package/dist/types/src/packlets/testing/invitation-utils.d.ts +2 -1
  43. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  44. package/dist/types/src/packlets/testing/test-builder.d.ts +2 -1
  45. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  46. package/dist/types/src/packlets/vault/shell-runtime.d.ts +10 -2
  47. package/dist/types/src/packlets/vault/shell-runtime.d.ts.map +1 -1
  48. package/dist/types/src/version.d.ts +1 -1
  49. package/package.json +36 -35
  50. package/src/packlets/invitations/device-invitation-protocol.ts +5 -1
  51. package/src/packlets/invitations/invitation-guest-extenstion.ts +126 -0
  52. package/src/packlets/invitations/{invitation-extension.ts → invitation-host-extension.ts} +99 -105
  53. package/src/packlets/invitations/invitation-protocol.ts +7 -1
  54. package/src/packlets/invitations/invitation-topology.ts +87 -0
  55. package/src/packlets/invitations/invitations-handler.test.ts +361 -0
  56. package/src/packlets/invitations/invitations-handler.ts +246 -149
  57. package/src/packlets/invitations/invitations-manager.ts +42 -3
  58. package/src/packlets/invitations/space-invitation-protocol.ts +19 -1
  59. package/src/packlets/invitations/utils.ts +27 -0
  60. package/src/packlets/services/automerge-host.test.ts +3 -1
  61. package/src/packlets/services/service-context.ts +7 -6
  62. package/src/packlets/services/service-host.ts +3 -4
  63. package/src/packlets/spaces/data-space.ts +2 -1
  64. package/src/packlets/storage/level.ts +2 -2
  65. package/src/packlets/testing/invitation-utils.ts +23 -3
  66. package/src/packlets/testing/test-builder.ts +6 -3
  67. package/src/packlets/vault/shell-runtime.ts +40 -2
  68. package/src/version.ts +1 -1
  69. package/dist/lib/browser/chunk-7AHPYPLI.mjs.map +0 -7
  70. package/dist/lib/node/chunk-HBT5F5OI.cjs.map +0 -7
  71. package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +0 -1
@@ -2,10 +2,10 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { Trigger } from '@dxos/async';
5
+ import { type Mutex, type MutexGuard, Trigger, scheduleTask } from '@dxos/async';
6
6
  import { cancelWithContext, Context } from '@dxos/context';
7
7
  import { randomBytes, verify } from '@dxos/crypto';
8
- import { invariant } from '@dxos/invariant';
8
+ import { invariant, InvariantViolation } from '@dxos/invariant';
9
9
  import { PublicKey } from '@dxos/keys';
10
10
  import { log } from '@dxos/log';
11
11
  import { InvalidInvitationExtensionRoleError, schema, trace } from '@dxos/protocols';
@@ -15,25 +15,26 @@ import {
15
15
  type AdmissionRequest,
16
16
  type AdmissionResponse,
17
17
  AuthenticationResponse,
18
- type IntroductionRequest,
19
18
  type InvitationHostService,
20
19
  Options,
21
20
  } from '@dxos/protocols/proto/dxos/halo/invitations';
22
21
  import { type ExtensionContext, RpcExtension } from '@dxos/teleport';
23
22
 
23
+ import { stateToString, tryAcquireBeforeContextDisposed } from './utils';
24
+
24
25
  /// Timeout for the options exchange.
25
26
  const OPTIONS_TIMEOUT = 10_000;
26
27
 
27
28
  export const MAX_OTP_ATTEMPTS = 3;
28
29
 
29
30
  type InvitationHostExtensionCallbacks = {
31
+ activeInvitation: Invitation | null;
32
+
30
33
  // Deliberately not async to not block the extensions opening.
31
- onOpen: () => void;
34
+ onOpen: (ctx: Context, extensionCtx: ExtensionContext) => void;
32
35
  onError: (error: Error) => void;
33
36
 
34
- onStateUpdate: (invitation: Invitation) => void;
35
-
36
- resolveInvitation: (request: IntroductionRequest) => Promise<Invitation | undefined>;
37
+ onStateUpdate: (newState: Invitation.State) => void;
37
38
 
38
39
  admit: (request: AdmissionRequest) => Promise<AdmissionResponse>;
39
40
  };
@@ -54,8 +55,6 @@ export class InvitationHostExtension extends RpcExtension<
54
55
 
55
56
  private _challenge?: Buffer = undefined;
56
57
 
57
- public invitation?: Invitation = undefined;
58
-
59
58
  public guestProfile?: ProfileDocument = undefined;
60
59
 
61
60
  public authenticationPassed = false;
@@ -70,7 +69,15 @@ export class InvitationHostExtension extends RpcExtension<
70
69
  */
71
70
  public completedTrigger = new Trigger<PublicKey>();
72
71
 
73
- constructor(private readonly _callbacks: InvitationHostExtensionCallbacks) {
72
+ /**
73
+ * Held to allow only one invitation flow at a time to be active.
74
+ */
75
+ private _invitationFlowLock: MutexGuard | null = null;
76
+
77
+ constructor(
78
+ private readonly _invitationFlowMutex: Mutex,
79
+ private readonly _callbacks: InvitationHostExtensionCallbacks,
80
+ ) {
74
81
  super({
75
82
  requested: {
76
83
  InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService'),
@@ -81,6 +88,10 @@ export class InvitationHostExtension extends RpcExtension<
81
88
  });
82
89
  }
83
90
 
91
+ public hasFlowLock(): boolean {
92
+ return this._invitationFlowLock != null;
93
+ }
94
+
84
95
  protected override async getHandlers(): Promise<{ InvitationHostService: InvitationHostService }> {
85
96
  return {
86
97
  // TODO(dmaretskyi): For now this is just forwarding the data to callbacks since we don't have session-specific logic.
@@ -98,30 +109,29 @@ export class InvitationHostExtension extends RpcExtension<
98
109
  const traceId = PublicKey.random().toHex();
99
110
  log.trace('dxos.sdk.invitation-handler.host.introduce', trace.begin({ id: traceId }));
100
111
 
101
- const invitation = await this._callbacks.resolveInvitation(request);
102
- if (!invitation) {
103
- log.warn('invitation not found', { invitationId });
104
- this._callbacks.onError(new Error('Invitation not found.'));
112
+ const invitation = this._requireActiveInvitation();
113
+ this._assertInvitationState(Invitation.State.CONNECTED);
114
+
115
+ if (invitationId !== invitation?.invitationId) {
116
+ log.warn('incorrect invitationId', { expected: invitation.invitationId, actual: invitationId });
117
+ this._callbacks.onError(new Error('Incorrect invitationId.'));
118
+ scheduleTask(this._ctx, () => this.close());
105
119
  // TODO(dmaretskyi): Better error handling.
106
120
  return {
107
121
  authMethod: Invitation.AuthMethod.NONE,
108
122
  };
109
123
  }
110
- this.invitation = invitation;
111
124
 
112
- log('guest introduced itself', {
113
- guestProfile: profile,
114
- });
125
+ log('guest introduced themselves', { guestProfile: profile });
115
126
  this.guestProfile = profile;
116
-
117
- this._callbacks.onStateUpdate({ ...this.invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
127
+ this._callbacks.onStateUpdate(Invitation.State.READY_FOR_AUTHENTICATION);
118
128
 
119
129
  this._challenge =
120
- this.invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
130
+ invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
121
131
 
122
132
  log.trace('dxos.sdk.invitation-handler.host.introduce', trace.end({ id: traceId }));
123
133
  return {
124
- authMethod: this.invitation.authMethod,
134
+ authMethod: invitation.authMethod,
125
135
  challenge: this._challenge,
126
136
  };
127
137
  },
@@ -129,21 +139,25 @@ export class InvitationHostExtension extends RpcExtension<
129
139
  authenticate: async ({ authCode: code, signedChallenge }) => {
130
140
  const traceId = PublicKey.random().toHex();
131
141
  log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.begin({ id: traceId }));
142
+
143
+ const invitation = this._requireActiveInvitation();
132
144
  log('received authentication request', { authCode: code });
133
145
  let status = AuthenticationResponse.Status.OK;
134
146
 
135
- invariant(this.invitation, 'Invitation is not set.');
136
- switch (this.invitation.authMethod) {
147
+ this._assertInvitationState([Invitation.State.AUTHENTICATING, Invitation.State.READY_FOR_AUTHENTICATION]);
148
+ this._callbacks.onStateUpdate(Invitation.State.AUTHENTICATING);
149
+
150
+ switch (invitation.authMethod) {
137
151
  case Invitation.AuthMethod.NONE: {
138
152
  log('authentication not required');
139
153
  return { status: AuthenticationResponse.Status.OK };
140
154
  }
141
155
 
142
156
  case Invitation.AuthMethod.SHARED_SECRET: {
143
- if (this.invitation.authCode) {
157
+ if (invitation.authCode) {
144
158
  if (this.authenticationRetry++ > MAX_OTP_ATTEMPTS) {
145
159
  status = AuthenticationResponse.Status.INVALID_OPT_ATTEMPTS;
146
- } else if (code !== this.invitation.authCode) {
160
+ } else if (code !== invitation.authCode) {
147
161
  status = AuthenticationResponse.Status.INVALID_OTP;
148
162
  } else {
149
163
  this.authenticationPassed = true;
@@ -153,7 +167,7 @@ export class InvitationHostExtension extends RpcExtension<
153
167
  }
154
168
 
155
169
  case Invitation.AuthMethod.KNOWN_PUBLIC_KEY: {
156
- if (!this.invitation.guestKeypair) {
170
+ if (!invitation.guestKeypair) {
157
171
  status = AuthenticationResponse.Status.INTERNAL_ERROR;
158
172
  break;
159
173
  }
@@ -162,7 +176,7 @@ export class InvitationHostExtension extends RpcExtension<
162
176
  verify(
163
177
  this._challenge,
164
178
  Buffer.from(signedChallenge ?? []),
165
- this.invitation.guestKeypair.publicKey.asBuffer(),
179
+ invitation.guestKeypair.publicKey.asBuffer(),
166
180
  );
167
181
  if (isSignatureValid) {
168
182
  this.authenticationPassed = true;
@@ -173,12 +187,18 @@ export class InvitationHostExtension extends RpcExtension<
173
187
  }
174
188
 
175
189
  default: {
176
- log.error('invalid authentication method', { authMethod: this.invitation.authMethod });
190
+ log.error('invalid authentication method', { authMethod: invitation.authMethod });
177
191
  status = AuthenticationResponse.Status.INTERNAL_ERROR;
178
192
  break;
179
193
  }
180
194
  }
181
195
 
196
+ if (![AuthenticationResponse.Status.OK, AuthenticationResponse.Status.INVALID_OTP].includes(status)) {
197
+ this._callbacks.onError(new Error(`Authentication failed, with status=${status}`));
198
+ scheduleTask(this._ctx, () => this.close());
199
+ return { status };
200
+ }
201
+
182
202
  log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.end({ id: traceId, data: { status } }));
183
203
  return { status };
184
204
  },
@@ -186,12 +206,15 @@ export class InvitationHostExtension extends RpcExtension<
186
206
  admit: async (request) => {
187
207
  const traceId = PublicKey.random().toHex();
188
208
  log.trace('dxos.sdk.invitation-handler.host.admit', trace.begin({ id: traceId }));
209
+ const invitation = this._requireActiveInvitation();
189
210
 
190
211
  try {
191
- invariant(this.invitation, 'Invitation is not set.');
192
212
  // Check authenticated.
193
- if (isAuthenticationRequired(this.invitation) && !this.authenticationPassed) {
194
- throw new Error('Not authenticated');
213
+ if (isAuthenticationRequired(invitation)) {
214
+ this._assertInvitationState(Invitation.State.AUTHENTICATING);
215
+ if (!this.authenticationPassed) {
216
+ throw new Error('Not authenticated');
217
+ }
195
218
  }
196
219
 
197
220
  const response = await this._callbacks.admit(request);
@@ -211,100 +234,71 @@ export class InvitationHostExtension extends RpcExtension<
211
234
  await super.onOpen(context);
212
235
 
213
236
  try {
237
+ log('host acquire lock');
238
+ this._invitationFlowLock = await tryAcquireBeforeContextDisposed(this._ctx, this._invitationFlowMutex);
239
+ log('host lock acquired');
240
+ const lastState = this._requireActiveInvitation().state;
241
+ this._callbacks.onStateUpdate(Invitation.State.CONNECTING);
214
242
  await this.rpc.InvitationHostService.options({ role: Options.Role.HOST });
243
+ log('options sent');
215
244
  await cancelWithContext(this._ctx, this._remoteOptionsTrigger.wait({ timeout: OPTIONS_TIMEOUT }));
245
+ log('options received');
216
246
  if (this._remoteOptions?.role !== Options.Role.GUEST) {
247
+ // we connected with another host, restore previous real invitation flow status
248
+ this._callbacks.onStateUpdate(lastState);
217
249
  throw new InvalidInvitationExtensionRoleError(undefined, {
218
250
  expected: Options.Role.GUEST,
219
251
  remoteOptions: this._remoteOptions,
252
+ remotePeerId: context.remotePeerId,
220
253
  });
221
254
  }
222
-
223
- this._callbacks.onOpen();
255
+ this._callbacks.onStateUpdate(Invitation.State.CONNECTED);
256
+ this._callbacks.onOpen(this._ctx, context);
224
257
  } catch (err: any) {
225
- this._callbacks.onError(err);
258
+ if (this._invitationFlowLock != null) {
259
+ this._callbacks.onError(err);
260
+ }
261
+ if (!this._ctx.disposed) {
262
+ context.close(err);
263
+ }
226
264
  }
227
265
  }
228
266
 
229
- override async onClose() {
230
- await this._ctx.dispose();
267
+ private _requireActiveInvitation(): Invitation {
268
+ const invitation = this._callbacks.activeInvitation;
269
+ if (invitation == null) {
270
+ scheduleTask(this._ctx, () => this.close());
271
+ throw new Error('Active invitation not found');
272
+ }
273
+ return invitation;
231
274
  }
232
- }
233
-
234
- type InvitationGuestExtensionCallbacks = {
235
- // Deliberately not async to not block the extensions opening.
236
- onOpen: (ctx: Context) => void;
237
- onError: (error: Error) => void;
238
- };
239
-
240
- /**
241
- * Guest's side for a connection to a concrete peer in p2p network during invitation.
242
- */
243
- export class InvitationGuestExtension extends RpcExtension<
244
- { InvitationHostService: InvitationHostService },
245
- { InvitationHostService: InvitationHostService }
246
- > {
247
- private _ctx = new Context();
248
- private _remoteOptions?: Options;
249
- private _remoteOptionsTrigger = new Trigger();
250
275
 
251
- constructor(private readonly _callbacks: InvitationGuestExtensionCallbacks) {
252
- super({
253
- requested: {
254
- InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService'),
255
- },
256
- exposed: {
257
- InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService'),
258
- },
259
- });
276
+ private _assertInvitationState(stateOrMany: Invitation.State | Invitation.State[]) {
277
+ const invitation = this._requireActiveInvitation();
278
+ const validStates = Array.isArray(stateOrMany) ? stateOrMany : [stateOrMany];
279
+ if (!validStates.includes(invitation.state)) {
280
+ scheduleTask(this._ctx, () => this.close());
281
+ throw new InvariantViolation(
282
+ `Expected ${stateToString(invitation.state)} to be one of [${validStates.map(stateToString).join(', ')}]`,
283
+ );
284
+ }
260
285
  }
261
286
 
262
- protected override async getHandlers(): Promise<{ InvitationHostService: InvitationHostService }> {
263
- return {
264
- InvitationHostService: {
265
- options: async (options) => {
266
- invariant(!this._remoteOptions, 'Remote options already set.');
267
- this._remoteOptions = options;
268
- this._remoteOptionsTrigger.wake();
269
- },
270
- introduce: () => {
271
- throw new Error('Method not allowed.');
272
- },
273
- authenticate: () => {
274
- throw new Error('Method not allowed.');
275
- },
276
- admit: () => {
277
- throw new Error('Method not allowed.');
278
- },
279
- },
280
- };
287
+ override async onClose() {
288
+ await this._destroy();
281
289
  }
282
290
 
283
- override async onOpen(context: ExtensionContext) {
284
- await super.onOpen(context);
285
-
286
- try {
287
- log('begin options');
288
- await cancelWithContext(this._ctx, this.rpc.InvitationHostService.options({ role: Options.Role.GUEST }));
289
- await cancelWithContext(this._ctx, this._remoteOptionsTrigger.wait({ timeout: OPTIONS_TIMEOUT }));
290
- log('end options');
291
- if (this._remoteOptions?.role !== Options.Role.HOST) {
292
- throw new InvalidInvitationExtensionRoleError(undefined, {
293
- expected: Options.Role.HOST,
294
- remoteOptions: this._remoteOptions,
295
- });
296
- }
297
-
298
- this._callbacks.onOpen(this._ctx);
299
- } catch (err: any) {
300
- log('openError', err);
301
- this._callbacks.onError(err);
302
- }
291
+ override async onAbort() {
292
+ await this._destroy();
303
293
  }
304
294
 
305
- override async onClose() {
306
- log('onClose');
295
+ private async _destroy() {
307
296
  await this._ctx.dispose();
297
+ if (this._invitationFlowLock != null) {
298
+ this._invitationFlowLock?.release();
299
+ this._invitationFlowLock = null;
300
+ log('invitation flow lock released');
301
+ }
308
302
  }
309
303
  }
310
304
 
@@ -32,10 +32,16 @@ export interface InvitationProtocol {
32
32
  getInvitationContext(): Partial<Invitation> & Pick<Invitation, 'kind'>;
33
33
 
34
34
  /**
35
- * Once authentication is successful, the host can admit the guest to the requested resource.
35
+ * Allow authorized peers to handle this invitation behalf of invitation creator.
36
+ * @return id of the delegation credential written to subject control-feed.
36
37
  */
37
38
  delegate(invitation: Invitation): Promise<PublicKey>;
38
39
 
40
+ /**
41
+ * Notify other peers that a delegated invitation was cancelled;
42
+ */
43
+ cancelDelegation(invitation: Invitation): Promise<void>;
44
+
39
45
  /**
40
46
  * Once authentication is successful, the host can admit the guest to the requested resource.
41
47
  */
@@ -0,0 +1,87 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { invariant } from '@dxos/invariant';
6
+ import { PublicKey } from '@dxos/keys';
7
+ import { log } from '@dxos/log';
8
+ import type { SwarmController, Topology } from '@dxos/network-manager';
9
+ import { Options } from '@dxos/protocols/proto/dxos/halo/invitations';
10
+ import { ComplexSet } from '@dxos/util';
11
+
12
+ /**
13
+ * Hosts are listening on an invitation topic.
14
+ * They initiate a connection with any new peer if they are not currently in the invitation flow
15
+ * with another peer (connected.length > 0).
16
+ * When the invitation flow ends guest leaves the swarm and topology is updated once again,
17
+ * so we can connect to the next peer we haven't tried yet.
18
+ * If the peer turns out to be a host or a malicious guest their ID is remembered so that we don't try
19
+ * to establish a connection with them again.
20
+ *
21
+ * Guests don't initiate connections. They accept all connections because if we reject,
22
+ * the host won't retry their offer.
23
+ * Even if we started an invitation flow with one host we might want to try other hosts in case
24
+ * the first one failed due to a network error, so multiple connections are accepted.
25
+ */
26
+ export class InvitationTopology implements Topology {
27
+ private _controller?: SwarmController;
28
+
29
+ /**
30
+ * Peers we tried to establish a connection with.
31
+ * In invitation flow peers are assigned random ids when they join the swarm, so we'll retry
32
+ * a peer if they reload an invitation.
33
+ *
34
+ * Consider keeping a separate set for peers we know are hosts and have some retry timeout
35
+ * for guests we failed an invitation flow with (potentially due to a network error).
36
+ */
37
+ private _seenPeers = new ComplexSet<PublicKey>(PublicKey.hash);
38
+
39
+ constructor(private readonly _role: Options.Role) {}
40
+
41
+ init(controller: SwarmController): void {
42
+ invariant(!this._controller, 'Already initialized.');
43
+ this._controller = controller;
44
+ }
45
+
46
+ update(): void {
47
+ invariant(this._controller, 'Not initialized.');
48
+ const { ownPeerId, candidates, connected, allPeers } = this._controller.getState();
49
+
50
+ // guests don't initiate connections
51
+ if (this._role === Options.Role.GUEST) {
52
+ return;
53
+ }
54
+
55
+ // don't start a connection while we have an active invitation flow
56
+ if (connected.length > 0) {
57
+ // update seenPeers here as well in case another host initiated a connection with us
58
+ connected.forEach((c) => this._seenPeers.add(c));
59
+ return;
60
+ }
61
+
62
+ const firstUnknownPeer = candidates.find((peerId) => !this._seenPeers.has(peerId));
63
+ // cleanup
64
+ this._seenPeers = new ComplexSet<PublicKey>(
65
+ PublicKey.hash,
66
+ allPeers.filter((peerId) => this._seenPeers.has(peerId)),
67
+ );
68
+ if (firstUnknownPeer != null) {
69
+ log('invitation connect', { ownPeerId, remotePeerId: firstUnknownPeer });
70
+ this._controller.connect(firstUnknownPeer);
71
+ this._seenPeers.add(firstUnknownPeer);
72
+ }
73
+ }
74
+
75
+ async onOffer(peer: PublicKey): Promise<boolean> {
76
+ invariant(this._controller, 'Not initialized.');
77
+ return !this._seenPeers.has(peer);
78
+ }
79
+
80
+ async destroy(): Promise<void> {
81
+ this._seenPeers.clear();
82
+ }
83
+
84
+ toString() {
85
+ return `InvitationTopology(${this._role === Options.Role.GUEST ? 'guest' : 'host'})`;
86
+ }
87
+ }