@dxos/client-services 0.6.13 → 0.6.14-main.1366248

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 (145) hide show
  1. package/dist/lib/browser/{chunk-CRXXOI45.mjs → chunk-KQURG5PQ.mjs} +6537 -5279
  2. package/dist/lib/browser/chunk-KQURG5PQ.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +7 -3
  4. package/dist/lib/browser/index.mjs.map +3 -3
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/testing/index.mjs +20 -9
  7. package/dist/lib/browser/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-PZ3JJJ3K.cjs → chunk-WNXJ7EP5.cjs} +6324 -5068
  9. package/dist/lib/node/chunk-WNXJ7EP5.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +50 -46
  11. package/dist/lib/node/index.cjs.map +3 -3
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/testing/index.cjs +26 -14
  14. package/dist/lib/node/testing/index.cjs.map +3 -3
  15. package/dist/lib/node-esm/chunk-OQSP5ADY.mjs +8982 -0
  16. package/dist/lib/node-esm/chunk-OQSP5ADY.mjs.map +7 -0
  17. package/dist/lib/node-esm/index.mjs +420 -0
  18. package/dist/lib/node-esm/index.mjs.map +7 -0
  19. package/dist/lib/node-esm/meta.json +1 -0
  20. package/dist/lib/node-esm/testing/index.mjs +431 -0
  21. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  22. package/dist/types/src/index.d.ts +1 -0
  23. package/dist/types/src/index.d.ts.map +1 -1
  24. package/dist/types/src/packlets/agents/edge-agent-manager.d.ts +35 -0
  25. package/dist/types/src/packlets/agents/edge-agent-manager.d.ts.map +1 -0
  26. package/dist/types/src/packlets/agents/edge-agent-service.d.ts +10 -0
  27. package/dist/types/src/packlets/agents/edge-agent-service.d.ts.map +1 -0
  28. package/dist/types/src/packlets/agents/index.d.ts +3 -0
  29. package/dist/types/src/packlets/agents/index.d.ts.map +1 -0
  30. package/dist/types/src/packlets/devices/devices-service.d.ts +3 -1
  31. package/dist/types/src/packlets/devices/devices-service.d.ts.map +1 -1
  32. package/dist/types/src/packlets/diagnostics/diagnostics-broadcast.d.ts.map +1 -1
  33. package/dist/types/src/packlets/identity/authenticator.d.ts.map +1 -1
  34. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts +2 -0
  35. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts.map +1 -0
  36. package/dist/types/src/packlets/identity/contacts-service.d.ts +1 -1
  37. package/dist/types/src/packlets/identity/contacts-service.d.ts.map +1 -1
  38. package/dist/types/src/packlets/identity/identity-manager.d.ts +28 -9
  39. package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
  40. package/dist/types/src/packlets/identity/identity-recovery-manager.d.ts +18 -0
  41. package/dist/types/src/packlets/identity/identity-recovery-manager.d.ts.map +1 -0
  42. package/dist/types/src/packlets/identity/identity-service.d.ts +7 -2
  43. package/dist/types/src/packlets/identity/identity-service.d.ts.map +1 -1
  44. package/dist/types/src/packlets/identity/identity.d.ts +12 -3
  45. package/dist/types/src/packlets/identity/identity.d.ts.map +1 -1
  46. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  47. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts +31 -0
  48. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts.map +1 -0
  49. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +2 -1
  50. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -1
  51. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts +2 -1
  52. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -1
  53. package/dist/types/src/packlets/invitations/invitation-state.d.ts +19 -0
  54. package/dist/types/src/packlets/invitations/invitation-state.d.ts.map +1 -0
  55. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -8
  56. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  57. package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
  58. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  59. package/dist/types/src/packlets/services/service-context.d.ts +14 -9
  60. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  61. package/dist/types/src/packlets/services/service-host.d.ts +2 -0
  62. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  63. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +7 -3
  64. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  65. package/dist/types/src/packlets/spaces/data-space.d.ts +5 -3
  66. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  67. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts +4 -0
  68. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts.map +1 -1
  69. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts +2 -0
  70. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts.map +1 -0
  71. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +1 -1
  72. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -1
  73. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts +35 -6
  74. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts.map +1 -1
  75. package/dist/types/src/packlets/spaces/spaces-service.d.ts +1 -1
  76. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  77. package/dist/types/src/packlets/storage/storage.d.ts.map +1 -1
  78. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  79. package/dist/types/src/packlets/testing/test-builder.d.ts +1 -2
  80. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  81. package/dist/types/src/packlets/worker/worker-runtime.d.ts.map +1 -1
  82. package/dist/types/src/testing/setup.d.ts +3 -0
  83. package/dist/types/src/testing/setup.d.ts.map +1 -0
  84. package/dist/types/src/version.d.ts +1 -1
  85. package/dist/types/src/version.d.ts.map +1 -1
  86. package/package.json +44 -45
  87. package/src/index.ts +1 -0
  88. package/src/packlets/agents/edge-agent-manager.ts +163 -0
  89. package/src/packlets/agents/edge-agent-service.ts +42 -0
  90. package/src/packlets/agents/index.ts +6 -0
  91. package/src/packlets/devices/devices-service.test.ts +4 -5
  92. package/src/packlets/devices/devices-service.ts +18 -7
  93. package/src/packlets/diagnostics/diagnostics-broadcast.ts +1 -0
  94. package/src/packlets/identity/{authenticator.test.ts → authenticator.node.test.ts} +2 -3
  95. package/src/packlets/identity/authenticator.ts +5 -2
  96. package/src/packlets/identity/contacts-service.ts +1 -1
  97. package/src/packlets/identity/identity-manager.test.ts +31 -16
  98. package/src/packlets/identity/identity-manager.ts +76 -32
  99. package/src/packlets/identity/identity-recovery-manager.ts +95 -0
  100. package/src/packlets/identity/identity-service.test.ts +5 -8
  101. package/src/packlets/identity/identity-service.ts +11 -5
  102. package/src/packlets/identity/identity.test.ts +141 -239
  103. package/src/packlets/identity/identity.ts +60 -17
  104. package/src/packlets/invitations/device-invitation-protocol.test.ts +7 -4
  105. package/src/packlets/invitations/device-invitation-protocol.ts +8 -2
  106. package/src/packlets/invitations/edge-invitation-handler.ts +188 -0
  107. package/src/packlets/invitations/invitation-guest-extenstion.ts +13 -9
  108. package/src/packlets/invitations/invitation-host-extension.ts +15 -17
  109. package/src/packlets/invitations/invitation-state.ts +112 -0
  110. package/src/packlets/invitations/invitations-handler.test.ts +19 -15
  111. package/src/packlets/invitations/invitations-handler.ts +91 -126
  112. package/src/packlets/invitations/invitations-service.ts +5 -1
  113. package/src/packlets/invitations/space-invitation-protocol.test.ts +4 -3
  114. package/src/packlets/invitations/space-invitation-protocol.ts +5 -0
  115. package/src/packlets/logging/logging.test.ts +1 -2
  116. package/src/packlets/network/network-service.test.ts +2 -3
  117. package/src/packlets/services/service-context.test.ts +3 -1
  118. package/src/packlets/services/service-context.ts +113 -35
  119. package/src/packlets/services/service-host.test.ts +8 -12
  120. package/src/packlets/services/service-host.ts +26 -8
  121. package/src/packlets/services/service-registry.test.ts +1 -2
  122. package/src/packlets/spaces/data-space-manager.test.ts +2 -2
  123. package/src/packlets/spaces/data-space-manager.ts +44 -7
  124. package/src/packlets/spaces/data-space.ts +37 -6
  125. package/src/packlets/spaces/edge-feed-replicator.test.ts +257 -0
  126. package/src/packlets/spaces/edge-feed-replicator.ts +84 -22
  127. package/src/packlets/spaces/epoch-migrations.ts +2 -2
  128. package/src/packlets/spaces/notarization-plugin.test.ts +10 -7
  129. package/src/packlets/spaces/notarization-plugin.ts +196 -29
  130. package/src/packlets/spaces/spaces-service.test.ts +5 -9
  131. package/src/packlets/spaces/spaces-service.ts +6 -1
  132. package/src/packlets/storage/storage.ts +0 -1
  133. package/src/packlets/system/system-service.test.ts +1 -2
  134. package/src/packlets/testing/invitation-utils.ts +7 -0
  135. package/src/packlets/testing/test-builder.ts +7 -4
  136. package/src/packlets/worker/worker-runtime.ts +2 -2
  137. package/src/testing/setup.ts +11 -0
  138. package/src/version.ts +1 -5
  139. package/dist/lib/browser/chunk-CRXXOI45.mjs.map +0 -7
  140. package/dist/lib/node/chunk-PZ3JJJ3K.cjs.map +0 -7
  141. package/dist/types/src/packlets/identity/authenticator.test.d.ts +0 -2
  142. package/dist/types/src/packlets/identity/authenticator.test.d.ts.map +0 -1
  143. package/dist/types/src/packlets/services/automerge-host.test.d.ts +0 -2
  144. package/dist/types/src/packlets/services/automerge-host.test.d.ts.map +0 -1
  145. package/src/packlets/services/automerge-host.test.ts +0 -60
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A utility object for serializing invitation state changes by multiple concurrent
3
+ * invitation flow connections.
4
+ */
5
+ //
6
+ // Copyright 2024 DXOS.org
7
+ //
8
+
9
+ import { Mutex, type PushStream } from '@dxos/async';
10
+ import { type Context } from '@dxos/context';
11
+ import { log } from '@dxos/log';
12
+ import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
13
+
14
+ import { stateToString } from './utils';
15
+
16
+ export interface FlowLockHolder {
17
+ hasFlowLock(): boolean;
18
+ }
19
+
20
+ export interface GuardedInvitationState {
21
+ mutex: Mutex;
22
+ current: Invitation;
23
+
24
+ complete(newState: Partial<Invitation>): void;
25
+ set(lockHolder: FlowLockHolder | null, newState: Invitation.State): boolean;
26
+ error(lockHolder: FlowLockHolder | null, error: any): boolean;
27
+ }
28
+
29
+ export const createGuardedInvitationState = (
30
+ ctx: Context,
31
+ invitation: Invitation,
32
+ stream: PushStream<Invitation>,
33
+ ): GuardedInvitationState => {
34
+ // the mutex guards invitation flow on host and guest side, making sure only one flow is currently active
35
+ // deadlocks seem very unlikely because hosts don't initiate multiple connections
36
+ // even if this somehow happens that there are 2 guests (A, B) and 2 hosts (1, 2) and:
37
+ // A has lock for flow with 1, B has lock for flow with 2
38
+ // 1 has lock for flow with B, 2 has lock for flow with A
39
+ // there'll be a 10-second introduction timeout after which connection will be closed and deadlock broken
40
+ const mutex = new Mutex();
41
+ let lastActiveLockHolder: FlowLockHolder | null = null;
42
+ let currentInvitation = { ...invitation };
43
+ const isStateChangeAllowed = (lockHolder: FlowLockHolder | null) => {
44
+ if (ctx.disposed || (lockHolder !== null && mutex.isLocked() && !lockHolder.hasFlowLock())) {
45
+ return false;
46
+ }
47
+ // don't allow transitions from a terminal state unless a new extension acquired mutex
48
+ // handles a case when error occurs (e.g. connection is closed) after we completed the flow
49
+ // successfully or already reported another error
50
+ return lockHolder == null || lastActiveLockHolder !== lockHolder || isNonTerminalState(currentInvitation.state);
51
+ };
52
+ return {
53
+ mutex,
54
+ get current() {
55
+ return currentInvitation;
56
+ },
57
+ // disposing context prevents any further state updates
58
+ complete: (newState: Partial<Invitation>) => {
59
+ logStateUpdate(currentInvitation, undefined, invitation.state);
60
+ currentInvitation = { ...currentInvitation, ...newState };
61
+ stream.next(currentInvitation);
62
+ return ctx.dispose();
63
+ },
64
+ set: (lockHolder: FlowLockHolder | null, newState: Invitation.State): boolean => {
65
+ if (isStateChangeAllowed(lockHolder)) {
66
+ logStateUpdate(currentInvitation, lockHolder, newState);
67
+ currentInvitation = { ...currentInvitation, state: newState };
68
+ stream.next(currentInvitation);
69
+ lastActiveLockHolder = lockHolder;
70
+ return true;
71
+ }
72
+ return false;
73
+ },
74
+ error: (lockHolder: FlowLockHolder | null, error: any): boolean => {
75
+ if (isStateChangeAllowed(lockHolder)) {
76
+ logStateUpdate(currentInvitation, lockHolder, Invitation.State.ERROR, error);
77
+ currentInvitation = { ...currentInvitation, state: Invitation.State.ERROR };
78
+ stream.next(currentInvitation);
79
+ stream.error(error);
80
+ lastActiveLockHolder = lockHolder;
81
+ return true;
82
+ }
83
+ return false;
84
+ },
85
+ };
86
+ };
87
+
88
+ const logStateUpdate = (invitation: Invitation, actor: any, newState: Invitation.State, error?: Error) => {
89
+ const logContext = {
90
+ invitationId: invitation.invitationId,
91
+ actor: actor?.constructor.name,
92
+ newState: stateToString(newState),
93
+ oldState: stateToString(invitation.state),
94
+ error: error?.message,
95
+ errorStack: error?.stack,
96
+ };
97
+ if (isNonTerminalState(newState)) {
98
+ log.verbose('dxos.sdk.invitations-handler.state.update', logContext);
99
+ } else {
100
+ log.info('dxos.sdk.invitations-handler.state.update', logContext);
101
+ }
102
+ };
103
+
104
+ const isNonTerminalState = (currentState: Invitation.State): boolean => {
105
+ return ![
106
+ Invitation.State.SUCCESS,
107
+ Invitation.State.ERROR,
108
+ Invitation.State.CANCELLED,
109
+ Invitation.State.TIMEOUT,
110
+ Invitation.State.EXPIRED,
111
+ ].includes(currentState);
112
+ };
@@ -2,13 +2,13 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { expect } from 'chai';
5
+ import { beforeEach, onTestFinished, describe, expect, test } from 'vitest';
6
6
 
7
7
  import { type PushStream, sleep, Trigger, waitForCondition } from '@dxos/async';
8
8
  import { Context } from '@dxos/context';
9
9
  import { PublicKey } from '@dxos/keys';
10
10
  import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
11
- import { afterTest, describe, openAndClose, test } from '@dxos/test';
11
+ import { openAndClose } from '@dxos/test-utils';
12
12
  import { range } from '@dxos/util';
13
13
 
14
14
  import { type InvitationProtocol } from './invitation-protocol';
@@ -34,6 +34,7 @@ type StateUpdateSink = PushStream<Invitation> & {
34
34
 
35
35
  describe('InvitationHandler', () => {
36
36
  let testBuilder: TestBuilder;
37
+
37
38
  beforeEach(() => {
38
39
  testBuilder = new TestBuilder();
39
40
  });
@@ -70,7 +71,7 @@ describe('InvitationHandler', () => {
70
71
 
71
72
  await guest.sink.waitFor(Invitation.State.READY_FOR_AUTHENTICATION);
72
73
  await sleep(200);
73
- await host.sink.waitFor(Invitation.State.TIMEOUT);
74
+ await host.sink.waitFor(Invitation.State.CONNECTING);
74
75
  await guest.sink.waitFor(Invitation.State.TIMEOUT);
75
76
 
76
77
  await sleep(10);
@@ -90,7 +91,7 @@ describe('InvitationHandler', () => {
90
91
 
91
92
  await guest.sink.waitFor(Invitation.State.READY_FOR_AUTHENTICATION);
92
93
  await guest.peer.networkManager.close();
93
- await host.sink.waitFor(Invitation.State.ERROR);
94
+ await host.sink.waitFor(Invitation.State.CONNECTING);
94
95
 
95
96
  await sleep(10);
96
97
  expect(host.ctx.disposed).to.be.false;
@@ -133,7 +134,7 @@ describe('InvitationHandler', () => {
133
134
  const host = await createPeer();
134
135
  const invitation = await createInvitation(host, { multiUse: true });
135
136
  await hostInvitation(host, invitation);
136
- const anotherHost = await createNewHost(invitation);
137
+ await createNewHost(invitation);
137
138
 
138
139
  const guest = await createPeer(host.spaceKey);
139
140
  const codeInput = await failAuth(guest, invitation);
@@ -142,9 +143,6 @@ describe('InvitationHandler', () => {
142
143
  await sleep(10);
143
144
  }
144
145
  await guest.sink.waitFor(Invitation.State.SUCCESS);
145
-
146
- const hostFailed = [host, anotherHost].map((h) => h.sink.hasState(0, Invitation.State.ERROR));
147
- expect(hostFailed.sort()).to.deep.eq([false, true]);
148
146
  });
149
147
 
150
148
  test('single guest - many hosts', async () => {
@@ -163,7 +161,7 @@ describe('InvitationHandler', () => {
163
161
  expect(guest.ctx.disposed).to.be.true;
164
162
  });
165
163
 
166
- test('guest gives up after trying with three hosts', async () => {
164
+ test('guest gives up after trying with three hosts', { timeout: 20_000 }, async () => {
167
165
  const hosts: PeerSetup[] = [await createPeer()];
168
166
  const [host] = hosts;
169
167
  const invitation = await createInvitation(host, { multiUse: true });
@@ -181,7 +179,7 @@ describe('InvitationHandler', () => {
181
179
 
182
180
  await sleep(10);
183
181
  expect(guest.sink.lastState).to.eq(Invitation.State.ERROR);
184
- }).timeout(20_000);
182
+ });
185
183
 
186
184
  test('single host - many guests', async () => {
187
185
  const hosts: PeerSetup[] = [await createPeer()];
@@ -256,12 +254,14 @@ describe('InvitationHandler', () => {
256
254
  const space = await peer.dataSpaceManager.createSpace();
257
255
  spaceKey = space.key;
258
256
  }
259
- const invitationHandler = new InvitationsHandler(peer.networkManager, {
260
- controlHeartbeatInterval: 250, // faster peer failure detection
257
+ const invitationHandler = new InvitationsHandler(peer.networkManager, undefined, {
258
+ teleport: { controlHeartbeatInterval: 250 }, // faster peer failure detection
261
259
  });
262
260
  const protocol = new SpaceInvitationProtocol(peer.dataSpaceManager, peer.identity, peer.keyring, spaceKey);
263
261
  const ctx = new Context();
264
- afterTest(() => ctx.dispose());
262
+ onTestFinished(async () => {
263
+ await ctx.dispose();
264
+ });
265
265
  const sink = newStateUpdateSink();
266
266
  return { ctx, sink, peer, protocol, handler: invitationHandler, spaceKey };
267
267
  };
@@ -269,14 +269,18 @@ describe('InvitationHandler', () => {
269
269
  const hostInvitation = async (setup: PeerSetup, invitation: Invitation) => {
270
270
  await setup.ctx.dispose();
271
271
  setup.ctx = new Context();
272
- afterTest(() => setup.ctx.dispose());
272
+ onTestFinished(async () => {
273
+ await setup.ctx.dispose();
274
+ });
273
275
  setup.handler.handleInvitationFlow(setup.ctx, setup.sink, setup.protocol, invitation);
274
276
  };
275
277
 
276
278
  const acceptInvitation = async (setup: PeerSetup, invitation: Invitation): Promise<Trigger<string>> => {
277
279
  await setup.ctx.dispose();
278
280
  setup.ctx = new Context();
279
- afterTest(() => setup.ctx.dispose());
281
+ onTestFinished(async () => {
282
+ await setup.ctx.dispose();
283
+ });
280
284
  const authCodeInput = new Trigger<string>();
281
285
  setup.handler.acceptInvitation(setup.ctx, setup.sink, setup.protocol, invitation, authCodeInput);
282
286
  return authCodeInput;
@@ -2,10 +2,11 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { Mutex, type PushStream, scheduleTask, TimeoutError, type Trigger } from '@dxos/async';
5
+ import { type PushStream, scheduleTask, TimeoutError, type Trigger } from '@dxos/async';
6
6
  import { INVITATION_TIMEOUT } from '@dxos/client-protocol';
7
7
  import { type Context, ContextDisposedError } from '@dxos/context';
8
8
  import { createKeyPair, sign } from '@dxos/crypto';
9
+ import { type EdgeHttpClient } from '@dxos/edge-client';
9
10
  import { invariant } from '@dxos/invariant';
10
11
  import { PublicKey } from '@dxos/keys';
11
12
  import { log } from '@dxos/log';
@@ -19,17 +20,21 @@ import { type ExtensionContext, type TeleportExtension, type TeleportParams } fr
19
20
  import { trace as _trace } from '@dxos/tracing';
20
21
  import { ComplexSet } from '@dxos/util';
21
22
 
23
+ import { type EdgeInvitationConfig, EdgeInvitationHandler } from './edge-invitation-handler';
22
24
  import { InvitationGuestExtension } from './invitation-guest-extenstion';
23
25
  import { InvitationHostExtension, isAuthenticationRequired, MAX_OTP_ATTEMPTS } from './invitation-host-extension';
24
26
  import { type InvitationProtocol } from './invitation-protocol';
27
+ import { createGuardedInvitationState } from './invitation-state';
25
28
  import { InvitationTopology } from './invitation-topology';
26
- import { stateToString } from './utils';
27
29
 
28
30
  const metrics = _trace.metrics;
29
31
 
30
32
  const MAX_DELEGATED_INVITATION_HOST_TRIES = 3;
31
33
 
32
- type InvitationExtension = InvitationHostExtension | InvitationGuestExtension;
34
+ export type InvitationConnectionParams = {
35
+ teleport: Partial<TeleportParams>;
36
+ edgeInvitations?: EdgeInvitationConfig;
37
+ };
33
38
 
34
39
  /**
35
40
  * Generic handler for Halo and Space invitations.
@@ -65,7 +70,8 @@ export class InvitationsHandler {
65
70
  */
66
71
  constructor(
67
72
  private readonly _networkManager: SwarmNetworkManager,
68
- private readonly _defaultTeleportParams?: Partial<TeleportParams>,
73
+ private readonly _edgeClient?: EdgeHttpClient,
74
+ private readonly _connectionParams?: InvitationConnectionParams,
69
75
  ) {}
70
76
 
71
77
  handleInvitationFlow(
@@ -74,8 +80,14 @@ export class InvitationsHandler {
74
80
  protocol: InvitationProtocol,
75
81
  invitation: Invitation,
76
82
  ): void {
77
- metrics.increment('dxos.invitation.created');
78
- const guardedState = this._createGuardedState(ctx, invitation, stream);
83
+ log.verbose('dxos.sdk.invitations-handler.handleInvitationFlow', {
84
+ state: invitation.state,
85
+ invitationId: invitation.invitationId,
86
+ kind: invitation.kind,
87
+ type: invitation.type,
88
+ });
89
+ metrics.increment('dxos.invitation.host');
90
+ const guardedState = createGuardedInvitationState(ctx, invitation, stream);
79
91
  // Called for every connecting peer.
80
92
  const createExtension = (): InvitationHostExtension => {
81
93
  const extension = new InvitationHostExtension(guardedState.mutex, {
@@ -84,12 +96,18 @@ export class InvitationsHandler {
84
96
  },
85
97
 
86
98
  onStateUpdate: (newState: Invitation.State): Invitation => {
87
- guardedState.set(extension, newState);
99
+ if (newState !== Invitation.State.ERROR && newState !== Invitation.State.TIMEOUT) {
100
+ guardedState.set(extension, newState);
101
+ }
88
102
  return guardedState.current;
89
103
  },
90
104
 
91
105
  admit: async (admissionRequest) => {
92
106
  try {
107
+ log.verbose('dxos.sdk.invitations-handler.host.admit', {
108
+ invitationId: invitation.invitationId,
109
+ ...protocol.toJSON(),
110
+ });
93
111
  const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
94
112
  invariant(deviceKey);
95
113
  const admissionResponse = await protocol.admit(invitation, admissionRequest, extension.guestProfile);
@@ -109,7 +127,7 @@ export class InvitationsHandler {
109
127
  let admitted = false;
110
128
  connectionCtx.onDispose(() => {
111
129
  if (!admitted) {
112
- guardedState.error(extension, new ContextDisposedError());
130
+ guardedState.set(extension, Invitation.State.CONNECTING);
113
131
  }
114
132
  });
115
133
 
@@ -117,9 +135,9 @@ export class InvitationsHandler {
117
135
  const traceId = PublicKey.random().toHex();
118
136
  try {
119
137
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
120
- log('connected', { ...protocol.toJSON() });
138
+ log.verbose('connected', { ...protocol.toJSON() });
121
139
  const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
122
- log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
140
+ log.verbose('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
123
141
  guardedState.set(extension, Invitation.State.SUCCESS);
124
142
  metrics.increment('dxos.invitation.success');
125
143
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
@@ -129,13 +147,14 @@ export class InvitationsHandler {
129
147
  await ctx.dispose();
130
148
  }
131
149
  } catch (err: any) {
150
+ const stateChanged = guardedState.set(extension, Invitation.State.CONNECTING);
132
151
  if (err instanceof TimeoutError) {
133
- if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
152
+ if (stateChanged) {
134
153
  metrics.increment('dxos.invitation.timeout');
135
- log('timeout', { ...protocol.toJSON() });
154
+ log.verbose('timeout', { ...protocol.toJSON() });
136
155
  }
137
156
  } else {
138
- if (guardedState.error(extension, err)) {
157
+ if (stateChanged) {
139
158
  metrics.increment('dxos.invitation.failed');
140
159
  log.error('failed', err);
141
160
  }
@@ -147,17 +166,18 @@ export class InvitationsHandler {
147
166
  });
148
167
  },
149
168
  onError: (err) => {
169
+ const stateChanged = guardedState.set(extension, Invitation.State.CONNECTING);
150
170
  if (err instanceof InvalidInvitationExtensionRoleError) {
151
171
  log('invalid role', { ...err.context });
152
172
  return;
153
173
  }
154
174
  if (err instanceof TimeoutError) {
155
- if (guardedState.set(extension, Invitation.State.TIMEOUT)) {
175
+ if (stateChanged) {
156
176
  metrics.increment('dxos.invitation.timeout');
157
- log('timeout', { err });
177
+ log.verbose('timeout', { err });
158
178
  }
159
179
  } else {
160
- if (guardedState.error(extension, err)) {
180
+ if (stateChanged) {
161
181
  metrics.increment('dxos.invitation.failed');
162
182
  log.error('failed', err);
163
183
  }
@@ -171,19 +191,21 @@ export class InvitationsHandler {
171
191
  if (invitation.lifetime && invitation.created) {
172
192
  if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
173
193
  log.warn('invitation has already expired');
174
- } else {
175
- scheduleTask(
176
- ctx,
177
- async () => {
178
- // ensure the swarm is closed before changing state and closing the stream.
179
- await swarmConnection.close();
180
- guardedState.set(null, Invitation.State.EXPIRED);
181
- metrics.increment('dxos.invitation.expired');
182
- await ctx.dispose();
183
- },
184
- invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
185
- );
194
+ guardedState.set(null, Invitation.State.EXPIRED);
195
+ void ctx.dispose().catch((err) => log.catch(err));
196
+ return;
186
197
  }
198
+ scheduleTask(
199
+ ctx,
200
+ async () => {
201
+ // ensure the swarm is closed before changing state and closing the stream.
202
+ await swarmConnection.close();
203
+ guardedState.set(null, Invitation.State.EXPIRED);
204
+ metrics.increment('dxos.invitation.expired');
205
+ await ctx.dispose();
206
+ },
207
+ invitation.created.getTime() + invitation.lifetime * 1000 - Date.now(),
208
+ );
187
209
  }
188
210
 
189
211
  let swarmConnection: SwarmConnection;
@@ -201,6 +223,12 @@ export class InvitationsHandler {
201
223
  otpEnteredTrigger: Trigger<string>,
202
224
  deviceProfile?: DeviceProfileDocument,
203
225
  ): void {
226
+ log.verbose('dxos.sdk.invitations-handler.acceptInvitation', {
227
+ state: invitation.state,
228
+ invitationId: invitation.invitationId,
229
+ kind: invitation.kind,
230
+ type: invitation.type,
231
+ });
204
232
  const { timeout = INVITATION_TIMEOUT } = invitation;
205
233
 
206
234
  if (deviceProfile) {
@@ -208,7 +236,7 @@ export class InvitationsHandler {
208
236
  }
209
237
 
210
238
  const triedPeersIds = new ComplexSet(PublicKey.hash);
211
- const guardedState = this._createGuardedState(ctx, invitation, stream);
239
+ const guardedState = createGuardedInvitationState(ctx, invitation, stream);
212
240
 
213
241
  const shouldCancelInvitationFlow = (extension: InvitationGuestExtension) => {
214
242
  const isLockedByAnotherConnection = guardedState.mutex.isLocked() && !extension.hasFlowLock();
@@ -240,7 +268,7 @@ export class InvitationsHandler {
240
268
  }
241
269
 
242
270
  connectionCtx.onDispose(async () => {
243
- log('extension disposed', { admitted, currentState: guardedState.current.state });
271
+ log.verbose('extension disposed', { admitted, currentState: guardedState.current.state });
244
272
  if (!admitted) {
245
273
  guardedState.error(extension, new ContextDisposedError());
246
274
  if (shouldCancelInvitationFlow(extension)) {
@@ -263,16 +291,23 @@ export class InvitationsHandler {
263
291
  timeout,
264
292
  );
265
293
 
266
- log('connected', { ...protocol.toJSON() });
294
+ log.verbose('dxos.sdk.invitations-handler.guest.connected', { ...protocol.toJSON() });
267
295
  guardedState.set(extension, Invitation.State.CONNECTED);
268
296
 
269
297
  // 1. Introduce guest to host.
270
- log('introduce', { ...protocol.toJSON() });
298
+ log.verbose('dxos.sdk.invitations-handler.guest.introduce', {
299
+ invitationId: invitation.invitationId,
300
+ ...protocol.toJSON(),
301
+ });
271
302
  const introductionResponse = await extension.rpc.InvitationHostService.introduce({
272
303
  invitationId: invitation.invitationId,
273
304
  ...protocol.createIntroduction(),
274
305
  });
275
- log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
306
+ log.verbose('dxos.sdk.invitations-handler.guest.introduce-response', {
307
+ invitationId: invitation.invitationId,
308
+ ...protocol.toJSON(),
309
+ authMethod: introductionResponse.authMethod,
310
+ });
276
311
  invitation.authMethod = introductionResponse.authMethod;
277
312
 
278
313
  // 2. Get authentication code.
@@ -298,7 +333,10 @@ export class InvitationsHandler {
298
333
  }
299
334
 
300
335
  // 3. Send admission credentials to host (with local space keys).
301
- log('request admission', { ...protocol.toJSON() });
336
+ log.verbose('dxos.sdk.invitations-handler.guest.request-admission', {
337
+ invitationId: invitation.invitationId,
338
+ ...protocol.toJSON(),
339
+ });
302
340
  const admissionRequest = await protocol.createAdmissionRequest(deviceProfile);
303
341
  const admissionResponse = await extension.rpc.InvitationHostService.admit(admissionRequest);
304
342
 
@@ -309,8 +347,11 @@ export class InvitationsHandler {
309
347
  const result = await protocol.accept(admissionResponse, admissionRequest);
310
348
 
311
349
  // 5. Success.
312
- log('admitted by host', { ...protocol.toJSON() });
313
- await guardedState.complete({
350
+ log.verbose('dxos.sdk.invitations-handler.guest.admitted-by-host', {
351
+ invitationId: invitation.invitationId,
352
+ ...protocol.toJSON(),
353
+ });
354
+ guardedState.complete({
314
355
  ...guardedState.current,
315
356
  ...result,
316
357
  state: Invitation.State.SUCCESS,
@@ -318,10 +359,10 @@ export class InvitationsHandler {
318
359
  log.trace('dxos.sdk.invitations-handler.guest.onOpen', trace.end({ id: traceId }));
319
360
  } catch (err: any) {
320
361
  if (err instanceof TimeoutError) {
321
- log('timeout', { ...protocol.toJSON() });
362
+ log.verbose('timeout', { ...protocol.toJSON() });
322
363
  guardedState.set(extension, Invitation.State.TIMEOUT);
323
364
  } else {
324
- log('auth failed', err);
365
+ log.verbose('auth failed', err);
325
366
  guardedState.error(extension, err);
326
367
  }
327
368
  extensionCtx.close(err);
@@ -334,10 +375,10 @@ export class InvitationsHandler {
334
375
  return;
335
376
  }
336
377
  if (err instanceof TimeoutError) {
337
- log('timeout', { ...protocol.toJSON() });
378
+ log.verbose('timeout', { ...protocol.toJSON() });
338
379
  guardedState.set(extension, Invitation.State.TIMEOUT);
339
380
  } else {
340
- log('auth failed', err);
381
+ log.verbose('auth failed', err);
341
382
  guardedState.error(extension, err);
342
383
  }
343
384
  },
@@ -346,6 +387,15 @@ export class InvitationsHandler {
346
387
  return extension;
347
388
  };
348
389
 
390
+ const edgeInvitationHandler = new EdgeInvitationHandler(this._connectionParams?.edgeInvitations, this._edgeClient, {
391
+ onInvitationSuccess: async (admissionResponse, admissionRequest) => {
392
+ const result = await protocol.accept(admissionResponse, admissionRequest);
393
+ log.info('admitted by edge', { ...protocol.toJSON() });
394
+ guardedState.complete({ ...guardedState.current, ...result, state: Invitation.State.SUCCESS });
395
+ },
396
+ });
397
+ edgeInvitationHandler.handle(ctx, guardedState, protocol, deviceProfile);
398
+
349
399
  scheduleTask(ctx, async () => {
350
400
  const error = protocol.checkInvitation(invitation);
351
401
  if (error) {
@@ -389,7 +439,7 @@ export class InvitationsHandler {
389
439
  topic: invitation.swarmKey,
390
440
  protocolProvider: createTeleportProtocolFactory(async (teleport) => {
391
441
  teleport.addExtension('dxos.halo.invitations', extensionFactory());
392
- }, this._defaultTeleportParams),
442
+ }, this._connectionParams?.teleport),
393
443
  topology: new InvitationTopology(role),
394
444
  label,
395
445
  });
@@ -397,90 +447,6 @@ export class InvitationsHandler {
397
447
  return swarmConnection;
398
448
  }
399
449
 
400
- /**
401
- * A utility object for serializing invitation state changes by multiple concurrent
402
- * invitation flow connections.
403
- */
404
- private _createGuardedState(ctx: Context, invitation: Invitation, stream: PushStream<Invitation>) {
405
- // the mutex guards invitation flow on host and guest side, making sure only one flow is currently active
406
- // deadlocks seem very unlikely because hosts don't initiate multiple connections
407
- // even if this somehow happens that there are 2 guests (A, B) and 2 hosts (1, 2) and:
408
- // A has lock for flow with 1, B has lock for flow with 2
409
- // 1 has lock for flow with B, 2 has lock for flow with A
410
- // there'll be a 10-second introduction timeout after which connection will be closed and deadlock broken
411
- const mutex = new Mutex();
412
- let lastActiveExtension: any = null;
413
- let currentInvitation = { ...invitation };
414
- const isStateChangeAllowed = (extension: InvitationExtension | null) => {
415
- if (ctx.disposed || (extension !== null && mutex.isLocked() && !extension.hasFlowLock())) {
416
- return false;
417
- }
418
- // don't allow transitions from a terminal state unless a new extension acquired mutex
419
- // handles a case when error occurs (e.g. connection is closed) after we completed the flow
420
- // successfully or already reported another error
421
- return extension == null || lastActiveExtension !== extension || this._isNotTerminal(currentInvitation.state);
422
- };
423
- return {
424
- mutex,
425
- get current() {
426
- return currentInvitation;
427
- },
428
- // disposing context prevents any further state updates
429
- complete: (newState: Partial<Invitation>) => {
430
- currentInvitation = { ...currentInvitation, ...newState };
431
- stream.next(currentInvitation);
432
- return ctx.dispose();
433
- },
434
- set: (extension: InvitationExtension | null, newState: Invitation.State): boolean => {
435
- if (isStateChangeAllowed(extension)) {
436
- this._logStateUpdate(currentInvitation, extension, newState);
437
- currentInvitation = { ...currentInvitation, state: newState };
438
- stream.next(currentInvitation);
439
- lastActiveExtension = extension;
440
- return true;
441
- }
442
- return false;
443
- },
444
- error: (extension: InvitationExtension | null, error: any): boolean => {
445
- if (isStateChangeAllowed(extension)) {
446
- this._logStateUpdate(currentInvitation, extension, Invitation.State.ERROR);
447
- currentInvitation = { ...currentInvitation, state: Invitation.State.ERROR };
448
- stream.next(currentInvitation);
449
- stream.error(error);
450
- lastActiveExtension = extension;
451
- return true;
452
- }
453
- return false;
454
- },
455
- };
456
- }
457
-
458
- private _logStateUpdate(invitation: Invitation, actor: any, newState: Invitation.State) {
459
- if (this._isNotTerminal(newState)) {
460
- log('invitation state update', {
461
- actor: actor?.constructor.name,
462
- newState: stateToString(newState),
463
- oldState: stateToString(invitation.state),
464
- });
465
- } else {
466
- log.info('invitation state update', {
467
- actor: actor?.constructor.name,
468
- newState: stateToString(newState),
469
- oldState: stateToString(invitation.state),
470
- });
471
- }
472
- }
473
-
474
- private _isNotTerminal(currentState: Invitation.State): boolean {
475
- return ![
476
- Invitation.State.SUCCESS,
477
- Invitation.State.ERROR,
478
- Invitation.State.CANCELLED,
479
- Invitation.State.TIMEOUT,
480
- Invitation.State.EXPIRED,
481
- ].includes(currentState);
482
- }
483
-
484
450
  private async _handleGuestOtpAuth(
485
451
  extension: InvitationGuestExtension,
486
452
  setState: (newState: Invitation.State) => void,
@@ -523,7 +489,6 @@ export class InvitationsHandler {
523
489
  throw new Error('challenge missing in the introduction');
524
490
  }
525
491
  log('sending authentication request');
526
- setState(Invitation.State.AUTHENTICATING);
527
492
  const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
528
493
  const response = await extension.rpc.InvitationHostService.authenticate({
529
494
  signedChallenge: signature,
@@ -10,6 +10,7 @@ import {
10
10
  type InvitationsService,
11
11
  QueryInvitationsResponse,
12
12
  } from '@dxos/protocols/proto/dxos/client/services';
13
+ import { trace } from '@dxos/tracing';
13
14
 
14
15
  import { type InvitationsManager } from './invitations-manager';
15
16
 
@@ -30,7 +31,10 @@ export class InvitationsServiceImpl implements InvitationsService {
30
31
  return new Stream<Invitation>(({ next, close }) => {
31
32
  void this._invitationsManager
32
33
  .createInvitation(options)
33
- .then((invitation) => invitation.subscribe(next, close, close))
34
+ .then((invitation) => {
35
+ trace.metrics.increment('dxos.invitation.created');
36
+ invitation.subscribe(next, close, close);
37
+ })
34
38
  .catch(close);
35
39
  });
36
40
  }
@@ -2,20 +2,21 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { expect } from 'chai';
5
+ import { onTestFinished, describe, expect, test } from 'vitest';
6
6
 
7
7
  import { asyncChain, Trigger } from '@dxos/async';
8
8
  import { raise } from '@dxos/debug';
9
9
  import { AlreadyJoinedError } from '@dxos/protocols';
10
10
  import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
11
- import { afterTest, describe, test } from '@dxos/test';
12
11
 
13
12
  import { type ServiceContext } from '../services';
14
13
  import { createIdentity, createPeers } from '../testing';
15
14
  import { acceptInvitation, createInvitation, performInvitation } from '../testing/invitation-utils';
16
15
 
17
16
  const closeAfterTest = async (peer: ServiceContext) => {
18
- afterTest(() => peer.close());
17
+ onTestFinished(async () => {
18
+ await peer.close();
19
+ });
19
20
  return peer;
20
21
  };
21
22