@dxos/client-services 0.5.1-main.f02b2c7 → 0.5.1-main.f81ddc4

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 (66) hide show
  1. package/dist/lib/browser/{chunk-T3ZIANWR.mjs → chunk-OE6XNPWD.mjs} +1296 -940
  2. package/dist/lib/browser/chunk-OE6XNPWD.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +1 -1
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/packlets/testing/index.mjs +29 -9
  6. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  7. package/dist/lib/node/{chunk-425TJE5X.cjs → chunk-PQ6V45LX.cjs} +1459 -1111
  8. package/dist/lib/node/chunk-PQ6V45LX.cjs.map +7 -0
  9. package/dist/lib/node/index.cjs +43 -43
  10. package/dist/lib/node/meta.json +1 -1
  11. package/dist/lib/node/packlets/testing/index.cjs +35 -15
  12. package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
  13. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +2 -1
  14. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  15. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +39 -0
  16. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -0
  17. package/dist/types/src/packlets/invitations/{invitation-extension.d.ts → invitation-host-extension.d.ts} +17 -31
  18. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -0
  19. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +6 -1
  20. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
  21. package/dist/types/src/packlets/invitations/invitation-topology.d.ts +37 -0
  22. package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +1 -0
  23. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +19 -10
  24. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  25. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +2 -0
  26. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +1 -0
  27. package/dist/types/src/packlets/invitations/invitations-manager.d.ts +2 -1
  28. package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
  29. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +1 -0
  30. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  31. package/dist/types/src/packlets/invitations/utils.d.ts +6 -0
  32. package/dist/types/src/packlets/invitations/utils.d.ts.map +1 -0
  33. package/dist/types/src/packlets/services/service-context.d.ts +8 -5
  34. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  35. package/dist/types/src/packlets/services/service-host.d.ts +1 -1
  36. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  37. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  38. package/dist/types/src/packlets/storage/level.d.ts +1 -2
  39. package/dist/types/src/packlets/storage/level.d.ts.map +1 -1
  40. package/dist/types/src/packlets/testing/invitation-utils.d.ts +2 -1
  41. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  42. package/dist/types/src/packlets/testing/test-builder.d.ts +2 -1
  43. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  44. package/dist/types/src/version.d.ts +1 -1
  45. package/package.json +36 -35
  46. package/src/packlets/invitations/device-invitation-protocol.ts +5 -1
  47. package/src/packlets/invitations/invitation-guest-extenstion.ts +126 -0
  48. package/src/packlets/invitations/{invitation-extension.ts → invitation-host-extension.ts} +99 -105
  49. package/src/packlets/invitations/invitation-protocol.ts +7 -1
  50. package/src/packlets/invitations/invitation-topology.ts +87 -0
  51. package/src/packlets/invitations/invitations-handler.test.ts +361 -0
  52. package/src/packlets/invitations/invitations-handler.ts +246 -149
  53. package/src/packlets/invitations/invitations-manager.ts +42 -3
  54. package/src/packlets/invitations/space-invitation-protocol.ts +19 -1
  55. package/src/packlets/invitations/utils.ts +27 -0
  56. package/src/packlets/services/automerge-host.test.ts +3 -1
  57. package/src/packlets/services/service-context.ts +7 -6
  58. package/src/packlets/services/service-host.ts +3 -4
  59. package/src/packlets/spaces/data-space.ts +2 -1
  60. package/src/packlets/storage/level.ts +2 -2
  61. package/src/packlets/testing/invitation-utils.ts +23 -3
  62. package/src/packlets/testing/test-builder.ts +6 -3
  63. package/src/version.ts +1 -1
  64. package/dist/lib/browser/chunk-T3ZIANWR.mjs.map +0 -7
  65. package/dist/lib/node/chunk-425TJE5X.cjs.map +0 -7
  66. package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +0 -1
@@ -2,31 +2,31 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
6
- import { AuthenticatingInvitation, INVITATION_TIMEOUT } from '@dxos/client-protocol';
7
- import { Context } from '@dxos/context';
5
+ import { Mutex, type PushStream, scheduleTask, TimeoutError, type Trigger } from '@dxos/async';
6
+ import { INVITATION_TIMEOUT } from '@dxos/client-protocol';
7
+ import { type Context, ContextDisposedError } from '@dxos/context';
8
8
  import { createKeyPair, sign } from '@dxos/crypto';
9
9
  import { invariant } from '@dxos/invariant';
10
10
  import { PublicKey } from '@dxos/keys';
11
11
  import { log } from '@dxos/log';
12
- import {
13
- createTeleportProtocolFactory,
14
- type NetworkManager,
15
- StarTopology,
16
- type SwarmConnection,
17
- } from '@dxos/network-manager';
12
+ import { createTeleportProtocolFactory, type NetworkManager, type SwarmConnection } from '@dxos/network-manager';
18
13
  import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
19
14
  import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
20
15
  import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
21
16
  import { AuthenticationResponse, type IntroductionResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
17
+ import { Options } from '@dxos/protocols/proto/dxos/halo/invitations';
18
+ import { type ExtensionContext, type TeleportExtension, type TeleportParams } from '@dxos/teleport';
19
+ import { ComplexSet } from '@dxos/util';
22
20
 
23
- import {
24
- InvitationGuestExtension,
25
- InvitationHostExtension,
26
- isAuthenticationRequired,
27
- MAX_OTP_ATTEMPTS,
28
- } from './invitation-extension';
21
+ import { InvitationGuestExtension } from './invitation-guest-extenstion';
22
+ import { InvitationHostExtension, isAuthenticationRequired, MAX_OTP_ATTEMPTS } from './invitation-host-extension';
29
23
  import { type InvitationProtocol } from './invitation-protocol';
24
+ import { InvitationTopology } from './invitation-topology';
25
+ import { stateToString } from './utils';
26
+
27
+ const MAX_DELEGATED_INVITATION_HOST_TRIES = 3;
28
+
29
+ type InvitationExtension = InvitationHostExtension | InvitationGuestExtension;
30
30
 
31
31
  /**
32
32
  * Generic handler for Halo and Space invitations.
@@ -35,31 +35,35 @@ import { type InvitationProtocol } from './invitation-protocol';
35
35
  * Host
36
36
  * - Creates an invitation containing a swarm topic (which can be shared via a URL, QR code, or direct message).
37
37
  * - Joins the swarm with the topic and waits for guest's introduction.
38
- * - Wait for guest to authenticate with OTP.
38
+ * - Wait for guest to authenticate with challenge specified in the invitation.
39
39
  * - Waits for guest to present credentials (containing local device and feed keys).
40
- * - Writes credentials to control feed then exits.
40
+ * - Writes credentials to control feed then exits or waits for more guests (multi use invitations).
41
41
  *
42
42
  * Guest
43
43
  * - Joins the swarm with the topic.
44
44
  * - Sends an introduction.
45
- * - Sends authentication OTP.
45
+ * - Submits the challenge.
46
46
  * - If Space handler then creates a local cloned space (with genesis block).
47
47
  * - Sends admission credentials.
48
- *
49
- * TODO(burdon): Show proxy/service relationship and reference design doc/diagram.
50
- *
51
48
  * ```
52
49
  * [Guest] [Host]
53
50
  * |------------------------------------Introduce-->|
54
51
  * |-------------------------------[Authenticate]-->|
55
52
  * |----------------------------------------Admit-->|
56
53
  * ```
54
+ *
55
+ * TODO: consider refactoring using xstate making the logic separation more explicit:
56
+ * TODO: the flow logic should either be contained in invitations-handler or in extensions, not be split across
57
+ * TODO: potentially re-evaluate host-side API to allow multiple concurrent connection, so that mutex can be removed
57
58
  */
58
59
  export class InvitationsHandler {
59
60
  /**
60
61
  * @internal
61
62
  */
62
- constructor(private readonly _networkManager: NetworkManager) {}
63
+ constructor(
64
+ private readonly _networkManager: NetworkManager,
65
+ private readonly _defaultTeleportParams?: Partial<TeleportParams>,
66
+ ) {}
63
67
 
64
68
  handleInvitationFlow(
65
69
  ctx: Context,
@@ -67,18 +71,17 @@ export class InvitationsHandler {
67
71
  protocol: InvitationProtocol,
68
72
  invitation: Invitation,
69
73
  ): void {
74
+ const guardedState = this._createGuardedState(ctx, invitation, stream);
70
75
  // Called for every connecting peer.
71
76
  const createExtension = (): InvitationHostExtension => {
72
- const extension = new InvitationHostExtension({
73
- onStateUpdate: (invitation) => {
74
- stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
77
+ const extension = new InvitationHostExtension(guardedState.mutex, {
78
+ get activeInvitation() {
79
+ return ctx.disposed ? null : guardedState.current;
75
80
  },
76
81
 
77
- resolveInvitation: async ({ invitationId }) => {
78
- if (invitationId && invitationId !== invitation.invitationId) {
79
- return undefined;
80
- }
81
- return invitation;
82
+ onStateUpdate: (newState: Invitation.State): Invitation => {
83
+ guardedState.set(extension, newState);
84
+ return guardedState.current;
82
85
  },
83
86
 
84
87
  admit: async (admissionRequest) => {
@@ -93,50 +96,62 @@ export class InvitationsHandler {
93
96
  return admissionResponse;
94
97
  } catch (err: any) {
95
98
  // TODO(burdon): Generic RPC callback to report error to client.
96
- stream.error(err);
99
+ guardedState.error(extension, err);
97
100
  throw err; // Propagate error to guest.
98
101
  }
99
102
  },
100
103
 
101
- onOpen: () => {
102
- scheduleTask(ctx, async () => {
104
+ onOpen: (connectionCtx: Context, extensionsCtx: ExtensionContext) => {
105
+ let admitted = false;
106
+ connectionCtx.onDispose(() => {
107
+ if (!admitted) {
108
+ guardedState.error(extension, new ContextDisposedError());
109
+ }
110
+ });
111
+
112
+ scheduleTask(connectionCtx, async () => {
103
113
  const traceId = PublicKey.random().toHex();
104
114
  try {
105
115
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
106
116
  log('connected', { ...protocol.toJSON() });
107
- stream.next({ ...invitation, state: Invitation.State.CONNECTED });
108
117
  const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
109
118
  log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
110
- stream.next({ ...invitation, state: Invitation.State.SUCCESS });
119
+ guardedState.set(extension, Invitation.State.SUCCESS);
111
120
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
121
+ admitted = true;
122
+
123
+ if (!invitation.multiUse) {
124
+ await ctx.dispose();
125
+ }
112
126
  } catch (err: any) {
113
127
  if (err instanceof TimeoutError) {
114
- log('timeout', { ...protocol.toJSON() });
115
- stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
128
+ if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
129
+ log('timeout', { ...protocol.toJSON() });
130
+ }
116
131
  } else {
117
- log.error('failed', err);
118
- stream.error(err);
132
+ if (guardedState.error(extension, err)) {
133
+ log.error('failed', err);
134
+ }
119
135
  }
120
136
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
121
- } finally {
122
- if (!invitation.multiUse) {
123
- // Wait for graceful close before disposing.
124
- await swarmConnection.close();
125
- await ctx.dispose();
126
- }
137
+ // Close connection
138
+ extensionsCtx.close(err);
127
139
  }
128
140
  });
129
141
  },
130
142
  onError: (err) => {
131
143
  if (err instanceof InvalidInvitationExtensionRoleError) {
144
+ log('invalid role', { ...err.context });
132
145
  return;
133
146
  }
134
147
  if (err instanceof TimeoutError) {
135
- log('timeout', { ...protocol.toJSON() });
136
- stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
148
+ if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
149
+ log('timeout', { err });
150
+ }
137
151
  } else {
138
- log.error('failed', err);
139
- stream.error(err);
152
+ if (guardedState.error(extension, err)) {
153
+ log.error('failed', err);
154
+ }
140
155
  }
141
156
  },
142
157
  });
@@ -153,7 +168,7 @@ export class InvitationsHandler {
153
168
  async () => {
154
169
  // ensure the swarm is closed before changing state and closing the stream.
155
170
  await swarmConnection.close();
156
- stream.next({ ...invitation, state: Invitation.State.EXPIRED });
171
+ guardedState.set(null, Invitation.State.EXPIRED);
157
172
  await ctx.dispose();
158
173
  },
159
174
  invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
@@ -162,100 +177,91 @@ export class InvitationsHandler {
162
177
  }
163
178
 
164
179
  let swarmConnection: SwarmConnection;
165
- const invitationLabel =
166
- 'invitation host for ' +
167
- (invitation.kind === Invitation.Kind.DEVICE ? 'device' : `space ${invitation.spaceKey?.truncate()}`);
168
180
  scheduleTask(ctx, async () => {
169
- const topic = invitation.swarmKey!;
170
- swarmConnection = await this._networkManager.joinSwarm({
171
- topic,
172
- peerId: topic,
173
- protocolProvider: createTeleportProtocolFactory(async (teleport) => {
174
- teleport.addExtension('dxos.halo.invitations', createExtension());
175
- }),
176
- topology: new StarTopology(topic),
177
- label: invitationLabel,
178
- });
179
- ctx.onDispose(() => swarmConnection.close());
180
-
181
- stream.next({ ...invitation, state: Invitation.State.CONNECTING });
181
+ swarmConnection = await this._joinSwarm(ctx, invitation, Options.Role.HOST, createExtension);
182
+ guardedState.set(null, Invitation.State.CONNECTING);
182
183
  });
183
184
  }
184
185
 
185
186
  acceptInvitation(
187
+ ctx: Context,
188
+ stream: PushStream<Invitation>,
186
189
  protocol: InvitationProtocol,
187
190
  invitation: Invitation,
191
+ otpEnteredTrigger: Trigger<string>,
188
192
  deviceProfile?: DeviceProfileDocument,
189
- ): AuthenticatingInvitation {
193
+ ): void {
190
194
  const { timeout = INVITATION_TIMEOUT } = invitation;
191
- invariant(protocol);
192
195
 
193
196
  if (deviceProfile) {
194
197
  invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
195
198
  }
196
- const authenticated = new Trigger<string>();
197
199
 
198
- // TODO(dmaretskyi): Turn into state?
199
- // Whether the Host has already admitted us and the remote connection is no longer needed.
200
- let admitted = false;
200
+ const triedPeersIds = new ComplexSet(PublicKey.hash);
201
+ const guardedState = this._createGuardedState(ctx, invitation, stream);
201
202
 
202
- let currentState: Invitation.State;
203
- const stream = new PushStream<Invitation>();
204
- const setState = (newData: Partial<Invitation>) => {
205
- invariant(newData.state !== undefined);
206
- currentState = newData.state;
207
- stream.next({ ...invitation, ...newData });
203
+ const shouldCancelInvitationFlow = (extension: InvitationGuestExtension) => {
204
+ const isLockedByAnotherConnection = guardedState.mutex.isLocked() && !extension.hasFlowLock();
205
+ log('should cancel invitation flow', {
206
+ isLockedByAnotherConnection,
207
+ invitationType: Invitation.Type.DELEGATED,
208
+ triedPeers: triedPeersIds.size,
209
+ });
210
+ if (isLockedByAnotherConnection) {
211
+ return false;
212
+ }
213
+ // for delegated invitations we might try with other hosts and will dispose either after
214
+ // a timeout or when the number of tries was exceeded
215
+ return invitation.type !== Invitation.Type.DELEGATED || triedPeersIds.size >= MAX_DELEGATED_INVITATION_HOST_TRIES;
208
216
  };
209
217
 
210
- const ctx = new Context({
211
- onError: (err) => {
212
- if (err instanceof TimeoutError) {
213
- log('timeout', { ...protocol.toJSON() });
214
- setState({ state: Invitation.State.TIMEOUT });
215
- } else {
216
- log.warn('auth failed', err);
217
- stream.error(err);
218
- }
219
- void ctx.dispose();
220
- },
221
- });
222
-
223
- ctx.onDispose(() => {
224
- log('complete', { ...protocol.toJSON() });
225
- stream.complete();
226
- });
227
-
218
+ let admitted = false;
228
219
  const createExtension = (): InvitationGuestExtension => {
229
- let connectionCount = 0;
220
+ const extension = new InvitationGuestExtension(guardedState.mutex, {
221
+ onStateUpdate: (newState: Invitation.State) => {
222
+ guardedState.set(extension, newState);
223
+ },
224
+ onOpen: (connectionCtx: Context, extensionCtx: ExtensionContext) => {
225
+ triedPeersIds.add(extensionCtx.remotePeerId);
226
+
227
+ if (admitted) {
228
+ extensionCtx.close();
229
+ return;
230
+ }
230
231
 
231
- const extension = new InvitationGuestExtension({
232
- onOpen: (extensionCtx) => {
233
- extensionCtx.onDispose(async () => {
234
- log('extension disposed', { currentState });
232
+ connectionCtx.onDispose(async () => {
233
+ log('extension disposed', { admitted, currentState: guardedState.current.state });
235
234
  if (!admitted) {
236
- stream.error(new Error('Remote peer disconnected.'));
235
+ guardedState.error(extension, new ContextDisposedError());
236
+ if (shouldCancelInvitationFlow(extension)) {
237
+ await ctx.dispose();
238
+ }
237
239
  }
238
240
  });
239
241
 
240
- scheduleTask(ctx, async () => {
242
+ scheduleTask(connectionCtx, async () => {
241
243
  const traceId = PublicKey.random().toHex();
242
244
  try {
243
245
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.begin({ id: traceId }));
244
- // TODO(burdon): Bug where guest may create multiple connections.
245
- if (++connectionCount > 1) {
246
- throw new Error(`multiple connections detected: ${connectionCount}`);
247
- }
248
246
 
249
- scheduleTask(ctx, () => ctx.raise(new TimeoutError(timeout)), timeout);
247
+ scheduleTask(
248
+ connectionCtx,
249
+ () => {
250
+ guardedState.set(extension, Invitation.State.TIMEOUT);
251
+ extensionCtx.close();
252
+ },
253
+ timeout,
254
+ );
250
255
 
251
256
  log('connected', { ...protocol.toJSON() });
252
- setState({ state: Invitation.State.CONNECTED });
257
+ guardedState.set(extension, Invitation.State.CONNECTED);
253
258
 
254
259
  // 1. Introduce guest to host.
255
260
  log('introduce', { ...protocol.toJSON() });
256
- const introductionResponse = await extension.rpc.InvitationHostService.introduce(
257
- protocol.createIntroduction(),
258
- );
261
+ const introductionResponse = await extension.rpc.InvitationHostService.introduce({
262
+ invitationId: invitation.invitationId,
263
+ ...protocol.createIntroduction(),
264
+ });
259
265
  log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
260
266
  invitation.authMethod = introductionResponse.authMethod;
261
267
 
@@ -263,10 +269,20 @@ export class InvitationsHandler {
263
269
  if (isAuthenticationRequired(invitation)) {
264
270
  switch (invitation.authMethod) {
265
271
  case Invitation.AuthMethod.SHARED_SECRET:
266
- await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
272
+ await this._handleGuestOtpAuth(
273
+ extension,
274
+ (state) => guardedState.set(extension, state),
275
+ otpEnteredTrigger,
276
+ { timeout },
277
+ );
267
278
  break;
268
279
  case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
269
- await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
280
+ await this._handleGuestKpkAuth(
281
+ extension,
282
+ (state) => guardedState.set(extension, state),
283
+ invitation,
284
+ introductionResponse,
285
+ );
270
286
  break;
271
287
  }
272
288
  }
@@ -284,19 +300,22 @@ export class InvitationsHandler {
284
300
 
285
301
  // 5. Success.
286
302
  log('admitted by host', { ...protocol.toJSON() });
287
- setState({ ...result, target: invitation.target, state: Invitation.State.SUCCESS });
303
+ await guardedState.complete({
304
+ ...guardedState.current,
305
+ ...result,
306
+ state: Invitation.State.SUCCESS,
307
+ });
288
308
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.end({ id: traceId }));
289
309
  } catch (err: any) {
290
310
  if (err instanceof TimeoutError) {
291
311
  log('timeout', { ...protocol.toJSON() });
292
- setState({ state: Invitation.State.TIMEOUT });
312
+ guardedState.set(extension, Invitation.State.TIMEOUT);
293
313
  } else {
294
314
  log('auth failed', err);
295
- stream.error(err);
315
+ guardedState.error(extension, err);
296
316
  }
317
+ extensionCtx.close(err);
297
318
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.error({ id: traceId, error: err }));
298
- } finally {
299
- await ctx.dispose();
300
319
  }
301
320
  });
302
321
  },
@@ -306,10 +325,10 @@ export class InvitationsHandler {
306
325
  }
307
326
  if (err instanceof TimeoutError) {
308
327
  log('timeout', { ...protocol.toJSON() });
309
- setState({ state: Invitation.State.TIMEOUT });
328
+ guardedState.set(extension, Invitation.State.TIMEOUT);
310
329
  } else {
311
330
  log('auth failed', err);
312
- stream.error(err);
331
+ guardedState.error(extension, err);
313
332
  }
314
333
  },
315
334
  });
@@ -321,53 +340,131 @@ export class InvitationsHandler {
321
340
  const error = protocol.checkInvitation(invitation);
322
341
  if (error) {
323
342
  stream.error(error);
343
+ await ctx.dispose();
324
344
  } else {
325
345
  invariant(invitation.swarmKey);
326
- const topic = invitation.swarmKey;
327
- const swarmConnection = await this._networkManager.joinSwarm({
328
- topic,
329
- peerId: PublicKey.random(),
330
- protocolProvider: createTeleportProtocolFactory(async (teleport) => {
331
- teleport.addExtension('dxos.halo.invitations', createExtension());
332
- }),
333
- topology: new StarTopology(topic),
334
- label: 'invitation guest',
335
- });
336
- ctx.onDispose(() => swarmConnection.close());
337
-
338
- setState({ state: Invitation.State.CONNECTING });
346
+ await this._joinSwarm(ctx, invitation, Options.Role.GUEST, createExtension);
347
+ guardedState.set(null, Invitation.State.CONNECTING);
339
348
  }
340
349
  });
350
+ }
341
351
 
342
- const observable = new AuthenticatingInvitation({
343
- initialInvitation: invitation,
344
- subscriber: stream.observable,
345
- onCancel: async () => {
346
- setState({ state: Invitation.State.CANCELLED });
347
- await ctx.dispose();
352
+ private async _joinSwarm(
353
+ ctx: Context,
354
+ invitation: Invitation,
355
+ role: Options.Role,
356
+ extensionFactory: () => TeleportExtension,
357
+ ): Promise<SwarmConnection> {
358
+ let label: string;
359
+ if (role === Options.Role.GUEST) {
360
+ label = 'invitation guest';
361
+ } else if (invitation.kind === Invitation.Kind.DEVICE) {
362
+ label = 'invitation host for device';
363
+ } else {
364
+ label = `invitation host for space ${invitation.spaceKey?.truncate()}`;
365
+ }
366
+ const swarmConnection = await this._networkManager.joinSwarm({
367
+ topic: invitation.swarmKey,
368
+ peerId: PublicKey.random(),
369
+ protocolProvider: createTeleportProtocolFactory(async (teleport) => {
370
+ teleport.addExtension('dxos.halo.invitations', extensionFactory());
371
+ }, this._defaultTeleportParams),
372
+ topology: new InvitationTopology(role),
373
+ label,
374
+ });
375
+ ctx.onDispose(() => swarmConnection.close());
376
+ return swarmConnection;
377
+ }
378
+
379
+ /**
380
+ * A utility object for serializing invitation state changes by multiple concurrent
381
+ * invitation flow connections.
382
+ */
383
+ private _createGuardedState(ctx: Context, invitation: Invitation, stream: PushStream<Invitation>) {
384
+ // the mutex guards invitation flow on host and guest side, making sure only one flow is currently active
385
+ // deadlocks seem very unlikely because hosts don't initiate multiple connections
386
+ // even if this somehow happens that there are 2 guests (A, B) and 2 hosts (1, 2) and:
387
+ // A has lock for flow with 1, B has lock for flow with 2
388
+ // 1 has lock for flow with B, 2 has lock for flow with A
389
+ // there'll be a 10-second introduction timeout after which connection will be closed and deadlock broken
390
+ const mutex = new Mutex();
391
+ let lastActiveExtension: any = null;
392
+ let currentInvitation = { ...invitation };
393
+ const isStateChangeAllowed = (extension: InvitationExtension | null) => {
394
+ if (ctx.disposed || (extension !== null && mutex.isLocked() && !extension.hasFlowLock())) {
395
+ return false;
396
+ }
397
+ // don't allow transitions from a terminal state unless a new extension acquired mutex
398
+ // handles a case when error occurs (e.g. connection is closed) after we completed the flow
399
+ // successfully or already reported another error
400
+ return extension == null || lastActiveExtension !== extension || this._isNotTerminal(currentInvitation.state);
401
+ };
402
+ return {
403
+ mutex,
404
+ get current() {
405
+ return currentInvitation;
348
406
  },
349
- onAuthenticate: async (code: string) => {
350
- // TODO(burdon): Reset creates a race condition? Event?
351
- authenticated.wake(code);
407
+ // disposing context prevents any further state updates
408
+ complete: (newState: Partial<Invitation>) => {
409
+ currentInvitation = { ...currentInvitation, ...newState };
410
+ stream.next(currentInvitation);
411
+ return ctx.dispose();
352
412
  },
413
+ set: (extension: InvitationExtension | null, newState: Invitation.State): boolean => {
414
+ if (isStateChangeAllowed(extension)) {
415
+ this._logStateUpdate(currentInvitation, extension, newState);
416
+ currentInvitation = { ...currentInvitation, state: newState };
417
+ stream.next(currentInvitation);
418
+ lastActiveExtension = extension;
419
+ return true;
420
+ }
421
+ return false;
422
+ },
423
+ error: (extension: InvitationExtension | null, error: any): boolean => {
424
+ if (isStateChangeAllowed(extension)) {
425
+ this._logStateUpdate(currentInvitation, extension, Invitation.State.ERROR);
426
+ currentInvitation = { ...currentInvitation, state: Invitation.State.ERROR };
427
+ stream.next(currentInvitation);
428
+ stream.error(error);
429
+ lastActiveExtension = extension;
430
+ return true;
431
+ }
432
+ return false;
433
+ },
434
+ };
435
+ }
436
+
437
+ private _logStateUpdate(invitation: Invitation, actor: any, newState: Invitation.State) {
438
+ log('invitation state update', {
439
+ actor: actor?.constructor.name,
440
+ newState: stateToString(newState),
441
+ oldState: stateToString(invitation.state),
353
442
  });
443
+ }
354
444
 
355
- return observable;
445
+ private _isNotTerminal(currentState: Invitation.State): boolean {
446
+ return ![
447
+ Invitation.State.SUCCESS,
448
+ Invitation.State.ERROR,
449
+ Invitation.State.CANCELLED,
450
+ Invitation.State.TIMEOUT,
451
+ Invitation.State.EXPIRED,
452
+ ].includes(currentState);
356
453
  }
357
454
 
358
455
  private async _handleGuestOtpAuth(
359
456
  extension: InvitationGuestExtension,
360
- setState: (newState: Partial<Invitation>) => void,
457
+ setState: (newState: Invitation.State) => void,
361
458
  authenticated: Trigger<string>,
362
459
  options: { timeout: number },
363
460
  ) {
364
461
  for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
365
462
  log('guest waiting for authentication code...');
366
- setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
463
+ setState(Invitation.State.READY_FOR_AUTHENTICATION);
367
464
  const authCode = await authenticated.wait(options);
368
465
 
369
466
  log('sending authentication request');
370
- setState({ state: Invitation.State.AUTHENTICATING });
467
+ setState(Invitation.State.AUTHENTICATING);
371
468
  const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
372
469
  if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
373
470
  break;
@@ -386,7 +483,7 @@ export class InvitationsHandler {
386
483
 
387
484
  private async _handleGuestKpkAuth(
388
485
  extension: InvitationGuestExtension,
389
- setState: (newState: Partial<Invitation>) => void,
486
+ setState: (newState: Invitation.State) => void,
390
487
  invitation: Invitation,
391
488
  introductionResponse: IntroductionResponse,
392
489
  ) {
@@ -397,7 +494,7 @@ export class InvitationsHandler {
397
494
  throw new Error('challenge missing in the introduction');
398
495
  }
399
496
  log('sending authentication request');
400
- setState({ state: Invitation.State.AUTHENTICATING });
497
+ setState(Invitation.State.AUTHENTICATING);
401
498
  const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
402
499
  const response = await extension.rpc.InvitationHostService.authenticate({
403
500
  signedChallenge: signature,
@@ -2,9 +2,9 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Event, PushStream } from '@dxos/async';
5
+ import { Event, PushStream, TimeoutError, Trigger } from '@dxos/async';
6
6
  import {
7
- type AuthenticatingInvitation,
7
+ AuthenticatingInvitation,
8
8
  AUTHENTICATION_CODE_LENGTH,
9
9
  CancellableInvitation,
10
10
  INVITATION_TIMEOUT,
@@ -117,7 +117,8 @@ export class InvitationsManager {
117
117
  }
118
118
 
119
119
  const handler = this._getHandler(options);
120
- const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
120
+ const { ctx, invitation, stream, otpEnteredTrigger } = this._createObservableAcceptingInvitation(handler, options);
121
+ this._invitationsHandler.acceptInvitation(ctx, stream, handler, options, otpEnteredTrigger, request.deviceProfile);
121
122
  this._acceptInvitations.set(invitation.get().invitationId, invitation);
122
123
  this.invitationAccepted.emit(invitation.get());
123
124
 
@@ -149,6 +150,10 @@ export class InvitationsManager {
149
150
  if (created.get().persistent) {
150
151
  await this._metadataStore.removeInvitation(invitationId);
151
152
  }
153
+ if (created.get().type === Invitation.Type.DELEGATED) {
154
+ const handler = this._getHandler(created.get());
155
+ await handler.cancelDelegation(created.get());
156
+ }
152
157
  await created.cancel();
153
158
  this._createInvitations.delete(invitationId);
154
159
  this.removedCreated.emit(created.get());
@@ -239,6 +244,40 @@ export class InvitationsManager {
239
244
  return { ctx, stream, observableInvitation };
240
245
  }
241
246
 
247
+ private _createObservableAcceptingInvitation(handler: InvitationProtocol, initialState: Invitation) {
248
+ const otpEnteredTrigger = new Trigger<string>();
249
+ const stream = new PushStream<Invitation>();
250
+ const ctx = new Context({
251
+ onError: (err) => {
252
+ if (err instanceof TimeoutError) {
253
+ log('timeout', { ...handler.toJSON() });
254
+ stream.next({ ...initialState, state: Invitation.State.TIMEOUT });
255
+ } else {
256
+ log.warn('auth failed', err);
257
+ stream.next({ ...initialState, state: Invitation.State.ERROR });
258
+ }
259
+ void ctx.dispose();
260
+ },
261
+ });
262
+ ctx.onDispose(() => {
263
+ log('complete', { ...handler.toJSON() });
264
+ stream.complete();
265
+ });
266
+ const invitation = new AuthenticatingInvitation({
267
+ initialInvitation: initialState,
268
+ subscriber: stream.observable,
269
+ onCancel: async () => {
270
+ stream.next({ ...initialState, state: Invitation.State.CANCELLED });
271
+ await ctx.dispose();
272
+ },
273
+ onAuthenticate: async (code: string) => {
274
+ // TODO(burdon): Reset creates a race condition? Event?
275
+ otpEnteredTrigger.wake(code);
276
+ },
277
+ });
278
+ return { ctx, invitation, stream, otpEnteredTrigger };
279
+ }
280
+
242
281
  private async _persistIfRequired(
243
282
  handler: InvitationProtocol,
244
283
  changeStream: PushStream<Invitation>,