@dxos/client-services 0.5.1-main.39e0ee2 → 0.5.1-main.3c7169d

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 (61) hide show
  1. package/dist/lib/browser/{chunk-S3SB2RSX.mjs → chunk-FRVAAUY2.mjs} +908 -1264
  2. package/dist/lib/browser/chunk-FRVAAUY2.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +2 -31
  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 +8 -28
  7. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-TQ5236LO.cjs → chunk-Y2YAZWVU.cjs} +1037 -1385
  9. package/dist/lib/node/chunk-Y2YAZWVU.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +44 -73
  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 +14 -34
  14. package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
  15. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +1 -2
  16. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  17. package/dist/types/src/packlets/invitations/{invitation-host-extension.d.ts → invitation-extension.d.ts} +31 -17
  18. package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -0
  19. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +1 -6
  20. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
  21. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +10 -19
  22. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  23. package/dist/types/src/packlets/invitations/invitations-manager.d.ts +1 -2
  24. package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
  25. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +0 -1
  26. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  27. package/dist/types/src/packlets/services/service-context.d.ts +3 -6
  28. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  29. package/dist/types/src/packlets/testing/invitation-utils.d.ts +1 -2
  30. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  31. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  32. package/dist/types/src/packlets/vault/shell-runtime.d.ts +2 -10
  33. package/dist/types/src/packlets/vault/shell-runtime.d.ts.map +1 -1
  34. package/dist/types/src/version.d.ts +1 -1
  35. package/package.json +36 -36
  36. package/src/packlets/invitations/device-invitation-protocol.ts +1 -5
  37. package/src/packlets/invitations/{invitation-host-extension.ts → invitation-extension.ts} +105 -99
  38. package/src/packlets/invitations/invitation-protocol.ts +1 -7
  39. package/src/packlets/invitations/invitations-handler.ts +149 -246
  40. package/src/packlets/invitations/invitations-manager.ts +3 -42
  41. package/src/packlets/invitations/space-invitation-protocol.ts +1 -19
  42. package/src/packlets/services/service-context.ts +3 -5
  43. package/src/packlets/testing/invitation-utils.ts +3 -23
  44. package/src/packlets/testing/test-builder.ts +1 -3
  45. package/src/packlets/vault/shell-runtime.ts +2 -40
  46. package/src/version.ts +1 -1
  47. package/dist/lib/browser/chunk-S3SB2RSX.mjs.map +0 -7
  48. package/dist/lib/node/chunk-TQ5236LO.cjs.map +0 -7
  49. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +0 -39
  50. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +0 -1
  51. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +0 -1
  52. package/dist/types/src/packlets/invitations/invitation-topology.d.ts +0 -37
  53. package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +0 -1
  54. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +0 -2
  55. package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +0 -1
  56. package/dist/types/src/packlets/invitations/utils.d.ts +0 -6
  57. package/dist/types/src/packlets/invitations/utils.d.ts.map +0 -1
  58. package/src/packlets/invitations/invitation-guest-extenstion.ts +0 -126
  59. package/src/packlets/invitations/invitation-topology.ts +0 -87
  60. package/src/packlets/invitations/invitations-handler.test.ts +0 -361
  61. package/src/packlets/invitations/utils.ts +0 -27
@@ -2,31 +2,31 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
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';
5
+ import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
6
+ import { AuthenticatingInvitation, INVITATION_TIMEOUT } from '@dxos/client-protocol';
7
+ import { Context } 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 { createTeleportProtocolFactory, type NetworkManager, type SwarmConnection } from '@dxos/network-manager';
12
+ import {
13
+ createTeleportProtocolFactory,
14
+ type NetworkManager,
15
+ StarTopology,
16
+ type SwarmConnection,
17
+ } from '@dxos/network-manager';
13
18
  import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
14
19
  import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
15
20
  import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
16
21
  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';
20
22
 
21
- import { InvitationGuestExtension } from './invitation-guest-extenstion';
22
- import { InvitationHostExtension, isAuthenticationRequired, MAX_OTP_ATTEMPTS } from './invitation-host-extension';
23
+ import {
24
+ InvitationGuestExtension,
25
+ InvitationHostExtension,
26
+ isAuthenticationRequired,
27
+ MAX_OTP_ATTEMPTS,
28
+ } from './invitation-extension';
23
29
  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,35 +35,31 @@ type InvitationExtension = InvitationHostExtension | InvitationGuestExtension;
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 challenge specified in the invitation.
38
+ * - Wait for guest to authenticate with OTP.
39
39
  * - Waits for guest to present credentials (containing local device and feed keys).
40
- * - Writes credentials to control feed then exits or waits for more guests (multi use invitations).
40
+ * - Writes credentials to control feed then exits.
41
41
  *
42
42
  * Guest
43
43
  * - Joins the swarm with the topic.
44
44
  * - Sends an introduction.
45
- * - Submits the challenge.
45
+ * - Sends authentication OTP.
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
+ *
48
51
  * ```
49
52
  * [Guest] [Host]
50
53
  * |------------------------------------Introduce-->|
51
54
  * |-------------------------------[Authenticate]-->|
52
55
  * |----------------------------------------Admit-->|
53
56
  * ```
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
58
57
  */
59
58
  export class InvitationsHandler {
60
59
  /**
61
60
  * @internal
62
61
  */
63
- constructor(
64
- private readonly _networkManager: NetworkManager,
65
- private readonly _defaultTeleportParams?: Partial<TeleportParams>,
66
- ) {}
62
+ constructor(private readonly _networkManager: NetworkManager) {}
67
63
 
68
64
  handleInvitationFlow(
69
65
  ctx: Context,
@@ -71,17 +67,18 @@ export class InvitationsHandler {
71
67
  protocol: InvitationProtocol,
72
68
  invitation: Invitation,
73
69
  ): void {
74
- const guardedState = this._createGuardedState(ctx, invitation, stream);
75
70
  // Called for every connecting peer.
76
71
  const createExtension = (): InvitationHostExtension => {
77
- const extension = new InvitationHostExtension(guardedState.mutex, {
78
- get activeInvitation() {
79
- return ctx.disposed ? null : guardedState.current;
72
+ const extension = new InvitationHostExtension({
73
+ onStateUpdate: (invitation) => {
74
+ stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
80
75
  },
81
76
 
82
- onStateUpdate: (newState: Invitation.State): Invitation => {
83
- guardedState.set(extension, newState);
84
- return guardedState.current;
77
+ resolveInvitation: async ({ invitationId }) => {
78
+ if (invitationId && invitationId !== invitation.invitationId) {
79
+ return undefined;
80
+ }
81
+ return invitation;
85
82
  },
86
83
 
87
84
  admit: async (admissionRequest) => {
@@ -96,62 +93,50 @@ export class InvitationsHandler {
96
93
  return admissionResponse;
97
94
  } catch (err: any) {
98
95
  // TODO(burdon): Generic RPC callback to report error to client.
99
- guardedState.error(extension, err);
96
+ stream.error(err);
100
97
  throw err; // Propagate error to guest.
101
98
  }
102
99
  },
103
100
 
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 () => {
101
+ onOpen: () => {
102
+ scheduleTask(ctx, async () => {
113
103
  const traceId = PublicKey.random().toHex();
114
104
  try {
115
105
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
116
106
  log('connected', { ...protocol.toJSON() });
107
+ stream.next({ ...invitation, state: Invitation.State.CONNECTED });
117
108
  const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
118
109
  log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
119
- guardedState.set(extension, Invitation.State.SUCCESS);
110
+ stream.next({ ...invitation, state: Invitation.State.SUCCESS });
120
111
  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
- }
126
112
  } catch (err: any) {
127
113
  if (err instanceof TimeoutError) {
128
- if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
129
- log('timeout', { ...protocol.toJSON() });
130
- }
114
+ log('timeout', { ...protocol.toJSON() });
115
+ stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
131
116
  } else {
132
- if (guardedState.error(extension, err)) {
133
- log.error('failed', err);
134
- }
117
+ log.error('failed', err);
118
+ stream.error(err);
135
119
  }
136
120
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
137
- // Close connection
138
- extensionsCtx.close(err);
121
+ } finally {
122
+ if (!invitation.multiUse) {
123
+ // Wait for graceful close before disposing.
124
+ await swarmConnection.close();
125
+ await ctx.dispose();
126
+ }
139
127
  }
140
128
  });
141
129
  },
142
130
  onError: (err) => {
143
131
  if (err instanceof InvalidInvitationExtensionRoleError) {
144
- log('invalid role', { ...err.context });
145
132
  return;
146
133
  }
147
134
  if (err instanceof TimeoutError) {
148
- if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
149
- log('timeout', { err });
150
- }
135
+ log('timeout', { ...protocol.toJSON() });
136
+ stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
151
137
  } else {
152
- if (guardedState.error(extension, err)) {
153
- log.error('failed', err);
154
- }
138
+ log.error('failed', err);
139
+ stream.error(err);
155
140
  }
156
141
  },
157
142
  });
@@ -168,7 +153,7 @@ export class InvitationsHandler {
168
153
  async () => {
169
154
  // ensure the swarm is closed before changing state and closing the stream.
170
155
  await swarmConnection.close();
171
- guardedState.set(null, Invitation.State.EXPIRED);
156
+ stream.next({ ...invitation, state: Invitation.State.EXPIRED });
172
157
  await ctx.dispose();
173
158
  },
174
159
  invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
@@ -177,91 +162,100 @@ export class InvitationsHandler {
177
162
  }
178
163
 
179
164
  let swarmConnection: SwarmConnection;
165
+ const invitationLabel =
166
+ 'invitation host for ' +
167
+ (invitation.kind === Invitation.Kind.DEVICE ? 'device' : `space ${invitation.spaceKey?.truncate()}`);
180
168
  scheduleTask(ctx, async () => {
181
- swarmConnection = await this._joinSwarm(ctx, invitation, Options.Role.HOST, createExtension);
182
- guardedState.set(null, Invitation.State.CONNECTING);
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 });
183
182
  });
184
183
  }
185
184
 
186
185
  acceptInvitation(
187
- ctx: Context,
188
- stream: PushStream<Invitation>,
189
186
  protocol: InvitationProtocol,
190
187
  invitation: Invitation,
191
- otpEnteredTrigger: Trigger<string>,
192
188
  deviceProfile?: DeviceProfileDocument,
193
- ): void {
189
+ ): AuthenticatingInvitation {
194
190
  const { timeout = INVITATION_TIMEOUT } = invitation;
191
+ invariant(protocol);
195
192
 
196
193
  if (deviceProfile) {
197
194
  invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
198
195
  }
196
+ const authenticated = new Trigger<string>();
199
197
 
200
- const triedPeersIds = new ComplexSet(PublicKey.hash);
201
- const guardedState = this._createGuardedState(ctx, invitation, stream);
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;
202
201
 
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;
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 });
216
208
  };
217
209
 
218
- let admitted = false;
219
- const createExtension = (): InvitationGuestExtension => {
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);
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
+ });
226
222
 
227
- if (admitted) {
228
- extensionCtx.close();
229
- return;
230
- }
223
+ ctx.onDispose(() => {
224
+ log('complete', { ...protocol.toJSON() });
225
+ stream.complete();
226
+ });
227
+
228
+ const createExtension = (): InvitationGuestExtension => {
229
+ let connectionCount = 0;
231
230
 
232
- connectionCtx.onDispose(async () => {
233
- log('extension disposed', { admitted, currentState: guardedState.current.state });
231
+ const extension = new InvitationGuestExtension({
232
+ onOpen: (extensionCtx) => {
233
+ extensionCtx.onDispose(async () => {
234
+ log('extension disposed', { currentState });
234
235
  if (!admitted) {
235
- guardedState.error(extension, new ContextDisposedError());
236
- if (shouldCancelInvitationFlow(extension)) {
237
- await ctx.dispose();
238
- }
236
+ stream.error(new Error('Remote peer disconnected.'));
239
237
  }
240
238
  });
241
239
 
242
- scheduleTask(connectionCtx, async () => {
240
+ scheduleTask(ctx, async () => {
243
241
  const traceId = PublicKey.random().toHex();
244
242
  try {
245
243
  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
+ }
246
248
 
247
- scheduleTask(
248
- connectionCtx,
249
- () => {
250
- guardedState.set(extension, Invitation.State.TIMEOUT);
251
- extensionCtx.close();
252
- },
253
- timeout,
254
- );
249
+ scheduleTask(ctx, () => ctx.raise(new TimeoutError(timeout)), timeout);
255
250
 
256
251
  log('connected', { ...protocol.toJSON() });
257
- guardedState.set(extension, Invitation.State.CONNECTED);
252
+ setState({ state: Invitation.State.CONNECTED });
258
253
 
259
254
  // 1. Introduce guest to host.
260
255
  log('introduce', { ...protocol.toJSON() });
261
- const introductionResponse = await extension.rpc.InvitationHostService.introduce({
262
- invitationId: invitation.invitationId,
263
- ...protocol.createIntroduction(),
264
- });
256
+ const introductionResponse = await extension.rpc.InvitationHostService.introduce(
257
+ protocol.createIntroduction(),
258
+ );
265
259
  log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
266
260
  invitation.authMethod = introductionResponse.authMethod;
267
261
 
@@ -269,20 +263,10 @@ export class InvitationsHandler {
269
263
  if (isAuthenticationRequired(invitation)) {
270
264
  switch (invitation.authMethod) {
271
265
  case Invitation.AuthMethod.SHARED_SECRET:
272
- await this._handleGuestOtpAuth(
273
- extension,
274
- (state) => guardedState.set(extension, state),
275
- otpEnteredTrigger,
276
- { timeout },
277
- );
266
+ await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
278
267
  break;
279
268
  case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
280
- await this._handleGuestKpkAuth(
281
- extension,
282
- (state) => guardedState.set(extension, state),
283
- invitation,
284
- introductionResponse,
285
- );
269
+ await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
286
270
  break;
287
271
  }
288
272
  }
@@ -300,22 +284,19 @@ export class InvitationsHandler {
300
284
 
301
285
  // 5. Success.
302
286
  log('admitted by host', { ...protocol.toJSON() });
303
- await guardedState.complete({
304
- ...guardedState.current,
305
- ...result,
306
- state: Invitation.State.SUCCESS,
307
- });
287
+ setState({ ...result, target: invitation.target, state: Invitation.State.SUCCESS });
308
288
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.end({ id: traceId }));
309
289
  } catch (err: any) {
310
290
  if (err instanceof TimeoutError) {
311
291
  log('timeout', { ...protocol.toJSON() });
312
- guardedState.set(extension, Invitation.State.TIMEOUT);
292
+ setState({ state: Invitation.State.TIMEOUT });
313
293
  } else {
314
294
  log('auth failed', err);
315
- guardedState.error(extension, err);
295
+ stream.error(err);
316
296
  }
317
- extensionCtx.close(err);
318
297
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.error({ id: traceId, error: err }));
298
+ } finally {
299
+ await ctx.dispose();
319
300
  }
320
301
  });
321
302
  },
@@ -325,10 +306,10 @@ export class InvitationsHandler {
325
306
  }
326
307
  if (err instanceof TimeoutError) {
327
308
  log('timeout', { ...protocol.toJSON() });
328
- guardedState.set(extension, Invitation.State.TIMEOUT);
309
+ setState({ state: Invitation.State.TIMEOUT });
329
310
  } else {
330
311
  log('auth failed', err);
331
- guardedState.error(extension, err);
312
+ stream.error(err);
332
313
  }
333
314
  },
334
315
  });
@@ -340,131 +321,53 @@ export class InvitationsHandler {
340
321
  const error = protocol.checkInvitation(invitation);
341
322
  if (error) {
342
323
  stream.error(error);
343
- await ctx.dispose();
344
324
  } else {
345
325
  invariant(invitation.swarmKey);
346
- await this._joinSwarm(ctx, invitation, Options.Role.GUEST, createExtension);
347
- guardedState.set(null, Invitation.State.CONNECTING);
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 });
348
339
  }
349
340
  });
350
- }
351
341
 
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;
406
- },
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();
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;
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();
422
348
  },
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;
349
+ onAuthenticate: async (code: string) => {
350
+ // TODO(burdon): Reset creates a race condition? Event?
351
+ authenticated.wake(code);
433
352
  },
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),
442
353
  });
443
- }
444
354
 
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);
355
+ return observable;
453
356
  }
454
357
 
455
358
  private async _handleGuestOtpAuth(
456
359
  extension: InvitationGuestExtension,
457
- setState: (newState: Invitation.State) => void,
360
+ setState: (newState: Partial<Invitation>) => void,
458
361
  authenticated: Trigger<string>,
459
362
  options: { timeout: number },
460
363
  ) {
461
364
  for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
462
365
  log('guest waiting for authentication code...');
463
- setState(Invitation.State.READY_FOR_AUTHENTICATION);
366
+ setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
464
367
  const authCode = await authenticated.wait(options);
465
368
 
466
369
  log('sending authentication request');
467
- setState(Invitation.State.AUTHENTICATING);
370
+ setState({ state: Invitation.State.AUTHENTICATING });
468
371
  const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
469
372
  if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
470
373
  break;
@@ -483,7 +386,7 @@ export class InvitationsHandler {
483
386
 
484
387
  private async _handleGuestKpkAuth(
485
388
  extension: InvitationGuestExtension,
486
- setState: (newState: Invitation.State) => void,
389
+ setState: (newState: Partial<Invitation>) => void,
487
390
  invitation: Invitation,
488
391
  introductionResponse: IntroductionResponse,
489
392
  ) {
@@ -494,7 +397,7 @@ export class InvitationsHandler {
494
397
  throw new Error('challenge missing in the introduction');
495
398
  }
496
399
  log('sending authentication request');
497
- setState(Invitation.State.AUTHENTICATING);
400
+ setState({ state: Invitation.State.AUTHENTICATING });
498
401
  const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
499
402
  const response = await extension.rpc.InvitationHostService.authenticate({
500
403
  signedChallenge: signature,
@@ -2,9 +2,9 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Event, PushStream, TimeoutError, Trigger } from '@dxos/async';
5
+ import { Event, PushStream } from '@dxos/async';
6
6
  import {
7
- AuthenticatingInvitation,
7
+ type AuthenticatingInvitation,
8
8
  AUTHENTICATION_CODE_LENGTH,
9
9
  CancellableInvitation,
10
10
  INVITATION_TIMEOUT,
@@ -117,8 +117,7 @@ export class InvitationsManager {
117
117
  }
118
118
 
119
119
  const handler = this._getHandler(options);
120
- const { ctx, invitation, stream, otpEnteredTrigger } = this._createObservableAcceptingInvitation(handler, options);
121
- this._invitationsHandler.acceptInvitation(ctx, stream, handler, options, otpEnteredTrigger, request.deviceProfile);
120
+ const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
122
121
  this._acceptInvitations.set(invitation.get().invitationId, invitation);
123
122
  this.invitationAccepted.emit(invitation.get());
124
123
 
@@ -150,10 +149,6 @@ export class InvitationsManager {
150
149
  if (created.get().persistent) {
151
150
  await this._metadataStore.removeInvitation(invitationId);
152
151
  }
153
- if (created.get().type === Invitation.Type.DELEGATED) {
154
- const handler = this._getHandler(created.get());
155
- await handler.cancelDelegation(created.get());
156
- }
157
152
  await created.cancel();
158
153
  this._createInvitations.delete(invitationId);
159
154
  this.removedCreated.emit(created.get());
@@ -244,40 +239,6 @@ export class InvitationsManager {
244
239
  return { ctx, stream, observableInvitation };
245
240
  }
246
241
 
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
-
281
242
  private async _persistIfRequired(
282
243
  handler: InvitationProtocol,
283
244
  changeStream: PushStream<Invitation>,