@dxos/client-services 0.6.12-staging.e11e696 → 0.6.13-main.548ca8d

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 (57) hide show
  1. package/dist/lib/browser/{chunk-67FEJJ6J.mjs → chunk-UEQIHAL2.mjs} +868 -619
  2. package/dist/lib/browser/chunk-UEQIHAL2.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/testing/index.mjs +4 -2
  6. package/dist/lib/browser/testing/index.mjs.map +3 -3
  7. package/dist/lib/node/{chunk-2KIDYJ7O.cjs → chunk-MA5EWTRH.cjs} +900 -655
  8. package/dist/lib/node/chunk-MA5EWTRH.cjs.map +7 -0
  9. package/dist/lib/node/index.cjs +45 -45
  10. package/dist/lib/node/meta.json +1 -1
  11. package/dist/lib/node/testing/index.cjs +11 -9
  12. package/dist/lib/node/testing/index.cjs.map +3 -3
  13. package/dist/lib/node-esm/{chunk-36ZRRDQI.mjs → chunk-AIBLDI4U.mjs} +859 -610
  14. package/dist/lib/node-esm/chunk-AIBLDI4U.mjs.map +7 -0
  15. package/dist/lib/node-esm/index.mjs +1 -1
  16. package/dist/lib/node-esm/meta.json +1 -1
  17. package/dist/lib/node-esm/testing/index.mjs +4 -2
  18. package/dist/lib/node-esm/testing/index.mjs.map +3 -3
  19. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts +30 -0
  20. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts.map +1 -0
  21. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +2 -1
  22. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -1
  23. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts +2 -1
  24. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -1
  25. package/dist/types/src/packlets/invitations/invitation-state.d.ts +19 -0
  26. package/dist/types/src/packlets/invitations/invitation-state.d.ts.map +1 -0
  27. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -8
  28. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  29. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  30. package/dist/types/src/packlets/services/service-context.d.ts +2 -3
  31. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  32. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +2 -0
  33. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  34. package/dist/types/src/packlets/spaces/data-space.d.ts +1 -0
  35. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  36. package/dist/types/src/packlets/spaces/spaces-service.d.ts +1 -1
  37. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  38. package/dist/types/src/version.d.ts +1 -1
  39. package/dist/types/src/version.d.ts.map +1 -1
  40. package/package.json +38 -38
  41. package/src/packlets/invitations/edge-invitation-handler.ts +184 -0
  42. package/src/packlets/invitations/invitation-guest-extenstion.ts +8 -4
  43. package/src/packlets/invitations/invitation-host-extension.ts +8 -4
  44. package/src/packlets/invitations/invitation-state.ts +111 -0
  45. package/src/packlets/invitations/invitations-handler.test.ts +2 -2
  46. package/src/packlets/invitations/invitations-handler.ts +23 -92
  47. package/src/packlets/invitations/space-invitation-protocol.ts +4 -0
  48. package/src/packlets/services/service-context.ts +4 -3
  49. package/src/packlets/spaces/data-space-manager.ts +31 -3
  50. package/src/packlets/spaces/data-space.ts +4 -0
  51. package/src/packlets/spaces/edge-feed-replicator.test.ts +2 -0
  52. package/src/packlets/spaces/spaces-service.ts +6 -1
  53. package/src/packlets/testing/test-builder.ts +1 -1
  54. package/src/version.ts +1 -1
  55. package/dist/lib/browser/chunk-67FEJJ6J.mjs.map +0 -7
  56. package/dist/lib/node/chunk-2KIDYJ7O.cjs.map +0 -7
  57. package/dist/lib/node-esm/chunk-36ZRRDQI.mjs.map +0 -7
@@ -0,0 +1,184 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type MutexGuard, scheduleMicroTask, scheduleTask } from '@dxos/async';
6
+ import { type Context } from '@dxos/context';
7
+ import { ed25519Signature } from '@dxos/crypto';
8
+ import { type EdgeHttpClient } from '@dxos/edge-client';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { SpaceId } from '@dxos/keys';
11
+ import { log } from '@dxos/log';
12
+ import {
13
+ EdgeAuthChallengeError,
14
+ EdgeCallFailedError,
15
+ type JoinSpaceRequest,
16
+ type JoinSpaceResponseBody,
17
+ } from '@dxos/protocols';
18
+ import { schema } from '@dxos/protocols/proto';
19
+ import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
20
+ import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
21
+ import {
22
+ type AdmissionResponse,
23
+ type AdmissionRequest,
24
+ type SpaceAdmissionRequest,
25
+ } from '@dxos/protocols/proto/dxos/halo/invitations';
26
+
27
+ import { type InvitationProtocol } from './invitation-protocol';
28
+ import { type FlowLockHolder, type GuardedInvitationState } from './invitation-state';
29
+ import { tryAcquireBeforeContextDisposed } from './utils';
30
+
31
+ export interface EdgeInvitationHandlerCallbacks {
32
+ onInvitationSuccess(response: AdmissionResponse, request: AdmissionRequest): Promise<void>;
33
+ }
34
+
35
+ export const DEFAULT_REQUEST_RETRY_INTERVAL_MS = 3000;
36
+ export const DEFAULT_REQUEST_RETRY_JITTER_MS = 500;
37
+
38
+ export type EdgeInvitationConfig = {
39
+ retryInterval?: number;
40
+ retryJitter?: number;
41
+ };
42
+
43
+ export class EdgeInvitationHandler implements FlowLockHolder {
44
+ private _flowLock: MutexGuard | undefined;
45
+
46
+ private readonly _retryInterval: number;
47
+ private readonly _retryJitter: number;
48
+
49
+ constructor(
50
+ config: EdgeInvitationConfig | undefined,
51
+ private readonly _client: EdgeHttpClient | undefined,
52
+ private readonly _callbacks: EdgeInvitationHandlerCallbacks,
53
+ ) {
54
+ this._retryInterval = config?.retryInterval ?? DEFAULT_REQUEST_RETRY_INTERVAL_MS;
55
+ this._retryJitter = config?.retryJitter ?? DEFAULT_REQUEST_RETRY_JITTER_MS;
56
+ }
57
+
58
+ public handle(
59
+ ctx: Context,
60
+ guardedState: GuardedInvitationState,
61
+ protocol: InvitationProtocol,
62
+ deviceProfile?: DeviceProfileDocument,
63
+ ) {
64
+ if (!this._client) {
65
+ log('edge disabled');
66
+ return;
67
+ }
68
+
69
+ const invitation = guardedState.current;
70
+ const spaceId = invitation.spaceId;
71
+ const canBeHandledByEdge =
72
+ invitation.authMethod !== Invitation.AuthMethod.SHARED_SECRET &&
73
+ invitation.type === Invitation.Type.DELEGATED &&
74
+ invitation.kind === Invitation.Kind.SPACE &&
75
+ spaceId != null &&
76
+ SpaceId.isValid(spaceId);
77
+
78
+ if (!canBeHandledByEdge) {
79
+ log('invitation could not be handled by edge', { invitation });
80
+ return;
81
+ }
82
+
83
+ ctx.onDispose(() => {
84
+ this._flowLock?.release();
85
+ this._flowLock = undefined;
86
+ });
87
+
88
+ const tryHandleInvitation = async () => {
89
+ const admissionRequest = await protocol.createAdmissionRequest(deviceProfile);
90
+ if (admissionRequest.space) {
91
+ try {
92
+ await this._handleSpaceInvitationFlow(ctx, guardedState, admissionRequest.space, spaceId);
93
+ } catch (error) {
94
+ if (error instanceof EdgeCallFailedError) {
95
+ log.info('join space with edge unsuccessful', {
96
+ reason: error.message,
97
+ retryable: error.isRetryable,
98
+ after: error.retryAfterMs ?? this._calculateNextRetryMs(),
99
+ });
100
+ if (error.isRetryable) {
101
+ scheduleTask(ctx, tryHandleInvitation, error.retryAfterMs ?? this._calculateNextRetryMs());
102
+ }
103
+ } else {
104
+ log.info('failed to handle invitation with edge', { error });
105
+ scheduleTask(ctx, tryHandleInvitation, this._calculateNextRetryMs());
106
+ }
107
+ }
108
+ }
109
+ };
110
+ scheduleMicroTask(ctx, tryHandleInvitation);
111
+ }
112
+
113
+ private async _handleSpaceInvitationFlow(
114
+ ctx: Context,
115
+ guardedState: GuardedInvitationState,
116
+ admissionRequest: SpaceAdmissionRequest,
117
+ spaceId: SpaceId,
118
+ ) {
119
+ try {
120
+ log('edge invitation flow');
121
+ this._flowLock = await tryAcquireBeforeContextDisposed(ctx, guardedState.mutex);
122
+ log('edge invitation flow acquired the lock');
123
+
124
+ guardedState.set(this, Invitation.State.CONNECTING);
125
+
126
+ const response = await this._joinSpaceByInvitation(guardedState, spaceId, {
127
+ identityKey: admissionRequest.identityKey.toHex(),
128
+ invitationId: guardedState.current.invitationId,
129
+ });
130
+
131
+ const admissionResponse = await this._mapToAdmissionResponse(response);
132
+ await this._callbacks.onInvitationSuccess(admissionResponse, { space: admissionRequest });
133
+ } catch (error) {
134
+ guardedState.set(this, Invitation.State.ERROR);
135
+ throw error;
136
+ } finally {
137
+ this._flowLock?.release();
138
+ this._flowLock = undefined;
139
+ }
140
+ }
141
+
142
+ private async _mapToAdmissionResponse(edgeResponse: JoinSpaceResponseBody): Promise<AdmissionResponse> {
143
+ const credentialBytes = Buffer.from(edgeResponse.spaceMemberCredential, 'base64');
144
+ const codec = schema.getCodecForType('dxos.halo.credentials.Credential');
145
+ return {
146
+ space: {
147
+ credential: codec.decode(credentialBytes),
148
+ },
149
+ };
150
+ }
151
+
152
+ private async _joinSpaceByInvitation(
153
+ guardedState: GuardedInvitationState,
154
+ spaceId: SpaceId,
155
+ request: JoinSpaceRequest,
156
+ ): Promise<JoinSpaceResponseBody> {
157
+ invariant(this._client);
158
+ try {
159
+ return await this._client.joinSpaceByInvitation(spaceId, request);
160
+ } catch (error: any) {
161
+ if (error instanceof EdgeAuthChallengeError) {
162
+ guardedState.set(this, Invitation.State.AUTHENTICATING);
163
+ const publicKey = guardedState.current.guestKeypair?.publicKey;
164
+ const privateKey = guardedState.current.guestKeypair?.privateKey;
165
+ if (!privateKey || !publicKey) {
166
+ throw error;
167
+ }
168
+ const signature = await ed25519Signature(privateKey, Buffer.from(error.challenge, 'base64'));
169
+ request.signature = Buffer.from(signature).toString('base64');
170
+ return this._client.joinSpaceByInvitation(spaceId, request);
171
+ } else {
172
+ throw error;
173
+ }
174
+ }
175
+ }
176
+
177
+ public hasFlowLock(): boolean {
178
+ return this._flowLock != null;
179
+ }
180
+
181
+ private _calculateNextRetryMs() {
182
+ return this._retryInterval + Math.random() * this._retryJitter;
183
+ }
184
+ }
@@ -12,6 +12,7 @@ import { type Invitation } from '@dxos/protocols/proto/dxos/client/services';
12
12
  import { type InvitationHostService, InvitationOptions } from '@dxos/protocols/proto/dxos/halo/invitations';
13
13
  import { type ExtensionContext, RpcExtension } from '@dxos/teleport';
14
14
 
15
+ import { type FlowLockHolder } from './invitation-state';
15
16
  import { tryAcquireBeforeContextDisposed } from './utils';
16
17
 
17
18
  const OPTIONS_TIMEOUT = 10_000;
@@ -27,10 +28,13 @@ type InvitationGuestExtensionCallbacks = {
27
28
  /**
28
29
  * Guest's side for a connection to a concrete peer in p2p network during invitation.
29
30
  */
30
- export class InvitationGuestExtension extends RpcExtension<
31
- { InvitationHostService: InvitationHostService },
32
- { InvitationHostService: InvitationHostService }
33
- > {
31
+ export class InvitationGuestExtension
32
+ extends RpcExtension<
33
+ { InvitationHostService: InvitationHostService },
34
+ { InvitationHostService: InvitationHostService }
35
+ >
36
+ implements FlowLockHolder
37
+ {
34
38
  private _ctx = new Context();
35
39
  private _remoteOptions?: InvitationOptions;
36
40
  private _remoteOptionsTrigger = new Trigger();
@@ -21,6 +21,7 @@ import {
21
21
  } from '@dxos/protocols/proto/dxos/halo/invitations';
22
22
  import { type ExtensionContext, RpcExtension } from '@dxos/teleport';
23
23
 
24
+ import type { FlowLockHolder } from './invitation-state';
24
25
  import { stateToString, tryAcquireBeforeContextDisposed } from './utils';
25
26
 
26
27
  /// Timeout for the options exchange.
@@ -43,10 +44,13 @@ type InvitationHostExtensionCallbacks = {
43
44
  /**
44
45
  * Host's side for a connection to a concrete peer in p2p network during invitation.
45
46
  */
46
- export class InvitationHostExtension extends RpcExtension<
47
- { InvitationHostService: InvitationHostService },
48
- { InvitationHostService: InvitationHostService }
49
- > {
47
+ export class InvitationHostExtension
48
+ extends RpcExtension<
49
+ { InvitationHostService: InvitationHostService },
50
+ { InvitationHostService: InvitationHostService }
51
+ >
52
+ implements FlowLockHolder
53
+ {
50
54
  /**
51
55
  * @internal
52
56
  */
@@ -0,0 +1,111 @@
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
+ currentInvitation = { ...currentInvitation, ...newState };
60
+ stream.next(currentInvitation);
61
+ return ctx.dispose();
62
+ },
63
+ set: (lockHolder: FlowLockHolder | null, newState: Invitation.State): boolean => {
64
+ if (isStateChangeAllowed(lockHolder)) {
65
+ logStateUpdate(currentInvitation, lockHolder, newState);
66
+ currentInvitation = { ...currentInvitation, state: newState };
67
+ stream.next(currentInvitation);
68
+ lastActiveLockHolder = lockHolder;
69
+ return true;
70
+ }
71
+ return false;
72
+ },
73
+ error: (lockHolder: FlowLockHolder | null, error: any): boolean => {
74
+ if (isStateChangeAllowed(lockHolder)) {
75
+ logStateUpdate(currentInvitation, lockHolder, Invitation.State.ERROR);
76
+ currentInvitation = { ...currentInvitation, state: Invitation.State.ERROR };
77
+ stream.next(currentInvitation);
78
+ stream.error(error);
79
+ lastActiveLockHolder = lockHolder;
80
+ return true;
81
+ }
82
+ return false;
83
+ },
84
+ };
85
+ };
86
+
87
+ const logStateUpdate = (invitation: Invitation, actor: any, newState: Invitation.State) => {
88
+ if (isNonTerminalState(newState)) {
89
+ log('invitation state update', {
90
+ actor: actor?.constructor.name,
91
+ newState: stateToString(newState),
92
+ oldState: stateToString(invitation.state),
93
+ });
94
+ } else {
95
+ log.info('invitation state update', {
96
+ actor: actor?.constructor.name,
97
+ newState: stateToString(newState),
98
+ oldState: stateToString(invitation.state),
99
+ });
100
+ }
101
+ };
102
+
103
+ const isNonTerminalState = (currentState: Invitation.State): boolean => {
104
+ return ![
105
+ Invitation.State.SUCCESS,
106
+ Invitation.State.ERROR,
107
+ Invitation.State.CANCELLED,
108
+ Invitation.State.TIMEOUT,
109
+ Invitation.State.EXPIRED,
110
+ ].includes(currentState);
111
+ };
@@ -257,8 +257,8 @@ describe('InvitationHandler', () => {
257
257
  const space = await peer.dataSpaceManager.createSpace();
258
258
  spaceKey = space.key;
259
259
  }
260
- const invitationHandler = new InvitationsHandler(peer.networkManager, {
261
- controlHeartbeatInterval: 250, // faster peer failure detection
260
+ const invitationHandler = new InvitationsHandler(peer.networkManager, undefined, {
261
+ teleport: { controlHeartbeatInterval: 250 }, // faster peer failure detection
262
262
  });
263
263
  const protocol = new SpaceInvitationProtocol(peer.dataSpaceManager, peer.identity, peer.keyring, spaceKey);
264
264
  const ctx = new Context();
@@ -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(
@@ -75,7 +81,7 @@ export class InvitationsHandler {
75
81
  invitation: Invitation,
76
82
  ): void {
77
83
  metrics.increment('dxos.invitation.created');
78
- const guardedState = this._createGuardedState(ctx, invitation, stream);
84
+ const guardedState = createGuardedInvitationState(ctx, invitation, stream);
79
85
  // Called for every connecting peer.
80
86
  const createExtension = (): InvitationHostExtension => {
81
87
  const extension = new InvitationHostExtension(guardedState.mutex, {
@@ -208,7 +214,7 @@ export class InvitationsHandler {
208
214
  }
209
215
 
210
216
  const triedPeersIds = new ComplexSet(PublicKey.hash);
211
- const guardedState = this._createGuardedState(ctx, invitation, stream);
217
+ const guardedState = createGuardedInvitationState(ctx, invitation, stream);
212
218
 
213
219
  const shouldCancelInvitationFlow = (extension: InvitationGuestExtension) => {
214
220
  const isLockedByAnotherConnection = guardedState.mutex.isLocked() && !extension.hasFlowLock();
@@ -310,7 +316,7 @@ export class InvitationsHandler {
310
316
 
311
317
  // 5. Success.
312
318
  log('admitted by host', { ...protocol.toJSON() });
313
- await guardedState.complete({
319
+ guardedState.complete({
314
320
  ...guardedState.current,
315
321
  ...result,
316
322
  state: Invitation.State.SUCCESS,
@@ -346,6 +352,15 @@ export class InvitationsHandler {
346
352
  return extension;
347
353
  };
348
354
 
355
+ const edgeInvitationHandler = new EdgeInvitationHandler(this._connectionParams?.edgeInvitations, this._edgeClient, {
356
+ onInvitationSuccess: async (admissionResponse, admissionRequest) => {
357
+ const result = await protocol.accept(admissionResponse, admissionRequest);
358
+ log('admitted by edge', { ...protocol.toJSON() });
359
+ guardedState.complete({ ...guardedState.current, ...result, state: Invitation.State.SUCCESS });
360
+ },
361
+ });
362
+ edgeInvitationHandler.handle(ctx, guardedState, protocol, deviceProfile);
363
+
349
364
  scheduleTask(ctx, async () => {
350
365
  const error = protocol.checkInvitation(invitation);
351
366
  if (error) {
@@ -389,7 +404,7 @@ export class InvitationsHandler {
389
404
  topic: invitation.swarmKey,
390
405
  protocolProvider: createTeleportProtocolFactory(async (teleport) => {
391
406
  teleport.addExtension('dxos.halo.invitations', extensionFactory());
392
- }, this._defaultTeleportParams),
407
+ }, this._connectionParams?.teleport),
393
408
  topology: new InvitationTopology(role),
394
409
  label,
395
410
  });
@@ -397,90 +412,6 @@ export class InvitationsHandler {
397
412
  return swarmConnection;
398
413
  }
399
414
 
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('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
415
  private async _handleGuestOtpAuth(
485
416
  extension: InvitationGuestExtension,
486
417
  setState: (newState: Invitation.State) => void,
@@ -60,9 +60,13 @@ export class SpaceInvitationProtocol implements InvitationProtocol {
60
60
  }
61
61
 
62
62
  getInvitationContext(): Partial<Invitation> & Pick<Invitation, 'kind'> {
63
+ invariant(this._spaceKey);
64
+ const space = this._spaceManager.spaces.get(this._spaceKey);
65
+ invariant(space);
63
66
  return {
64
67
  kind: Invitation.Kind.SPACE,
65
68
  spaceKey: this._spaceKey,
69
+ spaceId: space.id,
66
70
  };
67
71
  }
68
72
 
@@ -30,7 +30,6 @@ import { type Runtime } from '@dxos/protocols/proto/dxos/config';
30
30
  import type { FeedMessage } from '@dxos/protocols/proto/dxos/echo/feed';
31
31
  import { type Credential, type ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
32
32
  import { type Storage } from '@dxos/random-access-storage';
33
- import type { TeleportParams } from '@dxos/teleport';
34
33
  import { BlobStore } from '@dxos/teleport-extension-object-sync';
35
34
  import { trace as Trace } from '@dxos/tracing';
36
35
  import { safeInstanceof } from '@dxos/util';
@@ -47,6 +46,7 @@ import {
47
46
  InvitationsManager,
48
47
  SpaceInvitationProtocol,
49
48
  type InvitationProtocol,
49
+ type InvitationConnectionParams,
50
50
  } from '../invitations';
51
51
  import { DataSpaceManager, type DataSpaceManagerRuntimeParams, type SigningContext } from '../spaces';
52
52
 
@@ -55,7 +55,7 @@ export type ServiceContextRuntimeParams = Pick<
55
55
  'devicePresenceOfflineTimeout' | 'devicePresenceAnnounceInterval'
56
56
  > &
57
57
  DataSpaceManagerRuntimeParams & {
58
- invitationConnectionDefaultParams?: Partial<TeleportParams>;
58
+ invitationConnectionDefaultParams?: InvitationConnectionParams;
59
59
  disableP2pReplication?: boolean;
60
60
  };
61
61
  /**
@@ -179,7 +179,8 @@ export class ServiceContext extends Resource {
179
179
  this._meshReplicator = new MeshEchoReplicator();
180
180
 
181
181
  this.invitations = new InvitationsHandler(
182
- this.networkManager, //
182
+ this.networkManager,
183
+ this._edgeHttpClient,
183
184
  _runtimeParams?.invitationConnectionDefaultParams,
184
185
  );
185
186
  this.invitationsManager = new InvitationsManager(
@@ -46,7 +46,7 @@ import { AlreadyJoinedError, trace as Trace } from '@dxos/protocols';
46
46
  import { Invitation, SpaceState } from '@dxos/protocols/proto/dxos/client/services';
47
47
  import { type Runtime } from '@dxos/protocols/proto/dxos/config';
48
48
  import { type FeedMessage } from '@dxos/protocols/proto/dxos/echo/feed';
49
- import { type SpaceMetadata } from '@dxos/protocols/proto/dxos/echo/metadata';
49
+ import { type SpaceMetadata, EdgeReplicationSetting } from '@dxos/protocols/proto/dxos/echo/metadata';
50
50
  import { SpaceMember, type Credential, type ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
51
51
  import { type DelegateSpaceInvitation } from '@dxos/protocols/proto/dxos/halo/invitations';
52
52
  import { type PeerState } from '@dxos/protocols/proto/dxos/mesh/presence';
@@ -394,6 +394,26 @@ export class DataSpaceManager extends Resource {
394
394
  });
395
395
  }
396
396
 
397
+ async setSpaceEdgeReplicationSetting(spaceKey: PublicKey, setting: EdgeReplicationSetting) {
398
+ const space = this._spaces.get(spaceKey);
399
+ invariant(space, 'Space not found.');
400
+
401
+ await this._metadataStore.setSpaceEdgeReplicationSetting(spaceKey, setting);
402
+
403
+ if (space.isOpen) {
404
+ switch (setting) {
405
+ case EdgeReplicationSetting.DISABLED:
406
+ await this._echoEdgeReplicator?.disconnectFromSpace(space.id);
407
+ break;
408
+ case EdgeReplicationSetting.ENABLED:
409
+ await this._echoEdgeReplicator?.connectToSpace(space.id);
410
+ break;
411
+ }
412
+ }
413
+
414
+ space.stateUpdate.emit();
415
+ }
416
+
397
417
  private async _constructSpace(metadata: SpaceMetadata) {
398
418
  log('construct space', { metadata });
399
419
  const gossip = new Gossip({
@@ -489,10 +509,18 @@ export class DataSpaceManager extends Resource {
489
509
  edgeFeatures: this._edgeFeatures,
490
510
  });
491
511
  dataSpace.postOpen.append(async () => {
492
- await this._echoEdgeReplicator?.connectToSpace(dataSpace.id);
512
+ const setting = dataSpace.getEdgeReplicationSetting();
513
+ if (setting === EdgeReplicationSetting.ENABLED) {
514
+ await this._echoEdgeReplicator?.connectToSpace(dataSpace.id);
515
+ } else if (this._echoEdgeReplicator) {
516
+ log('not connecting EchoEdgeReplicator because of EdgeReplicationSetting', { spaceId: dataSpace.id });
517
+ }
493
518
  });
494
519
  dataSpace.preClose.append(async () => {
495
- await this._echoEdgeReplicator?.disconnectFromSpace(dataSpace.id);
520
+ const setting = dataSpace.getEdgeReplicationSetting();
521
+ if (setting === EdgeReplicationSetting.ENABLED) {
522
+ await this._echoEdgeReplicator?.disconnectFromSpace(dataSpace.id);
523
+ }
496
524
  });
497
525
 
498
526
  presence.newPeer.on((peerState) => {
@@ -570,6 +570,10 @@ export class DataSpace {
570
570
  this.stateUpdate.emit();
571
571
  }
572
572
 
573
+ getEdgeReplicationSetting() {
574
+ return this._metadataStore.getSpaceEdgeReplicationSetting(this.key);
575
+ }
576
+
573
577
  private _onFeedAdded = async (feed: FeedWrapper<any>) => {
574
578
  await this._edgeFeedReplicator!.addFeed(feed);
575
579
  };
@@ -116,9 +116,11 @@ describe('EdgeFeedReplicator', () => {
116
116
  admitConnection.wake();
117
117
 
118
118
  await expect.poll(() => sendSpy.mock.calls.length).toEqual(2);
119
+ sendSpy.mockRestore();
119
120
  expect(messageSink.length).toEqual(0);
120
121
  await updateIdentity(messenger);
121
122
 
123
+ await messenger.reconnect.waitForCount(1);
122
124
  await expect.poll(() => messageSink.find((msg) => msg.type === 'data')).toBeDefined();
123
125
  });
124
126