@dxos/client-services 0.6.12 → 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 (118) hide show
  1. package/dist/lib/browser/{chunk-TOAILL4T.mjs → chunk-UEQIHAL2.mjs} +5838 -5151
  2. package/dist/lib/browser/chunk-UEQIHAL2.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +3 -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 +8 -7
  7. package/dist/lib/browser/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-H6C4XY6B.cjs → chunk-MA5EWTRH.cjs} +5858 -5175
  9. package/dist/lib/node/chunk-MA5EWTRH.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +46 -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 +14 -13
  14. package/dist/lib/node/testing/index.cjs.map +3 -3
  15. package/dist/lib/node-esm/chunk-AIBLDI4U.mjs +8403 -0
  16. package/dist/lib/node-esm/chunk-AIBLDI4U.mjs.map +7 -0
  17. package/dist/lib/node-esm/index.mjs +416 -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 +420 -0
  21. package/dist/lib/node-esm/testing/index.mjs.map +7 -0
  22. package/dist/types/src/packlets/diagnostics/diagnostics-broadcast.d.ts.map +1 -1
  23. package/dist/types/src/packlets/identity/authenticator.d.ts.map +1 -1
  24. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts +2 -0
  25. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts.map +1 -0
  26. package/dist/types/src/packlets/identity/contacts-service.d.ts +1 -1
  27. package/dist/types/src/packlets/identity/contacts-service.d.ts.map +1 -1
  28. package/dist/types/src/packlets/identity/identity-manager.d.ts +19 -7
  29. package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
  30. package/dist/types/src/packlets/identity/identity.d.ts +8 -1
  31. package/dist/types/src/packlets/identity/identity.d.ts.map +1 -1
  32. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts +30 -0
  33. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts.map +1 -0
  34. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +2 -1
  35. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -1
  36. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts +2 -1
  37. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -1
  38. package/dist/types/src/packlets/invitations/invitation-state.d.ts +19 -0
  39. package/dist/types/src/packlets/invitations/invitation-state.d.ts.map +1 -0
  40. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -8
  41. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  42. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  43. package/dist/types/src/packlets/services/service-context.d.ts +9 -9
  44. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  45. package/dist/types/src/packlets/services/service-host.d.ts +1 -0
  46. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  47. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +6 -3
  48. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  49. package/dist/types/src/packlets/spaces/data-space.d.ts +4 -3
  50. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  51. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts +3 -0
  52. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts.map +1 -1
  53. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts +2 -0
  54. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts.map +1 -0
  55. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +1 -1
  56. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -1
  57. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts +31 -6
  58. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts.map +1 -1
  59. package/dist/types/src/packlets/spaces/spaces-service.d.ts +1 -1
  60. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  61. package/dist/types/src/packlets/storage/storage.d.ts.map +1 -1
  62. package/dist/types/src/packlets/testing/test-builder.d.ts +1 -2
  63. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  64. package/dist/types/src/packlets/worker/worker-runtime.d.ts.map +1 -1
  65. package/dist/types/src/testing/setup.d.ts +3 -0
  66. package/dist/types/src/testing/setup.d.ts.map +1 -0
  67. package/dist/types/src/version.d.ts +1 -1
  68. package/dist/types/src/version.d.ts.map +1 -1
  69. package/package.json +43 -39
  70. package/src/packlets/devices/devices-service.test.ts +4 -5
  71. package/src/packlets/diagnostics/diagnostics-broadcast.ts +1 -0
  72. package/src/packlets/identity/{authenticator.test.ts → authenticator.node.test.ts} +2 -3
  73. package/src/packlets/identity/authenticator.ts +5 -2
  74. package/src/packlets/identity/contacts-service.ts +1 -1
  75. package/src/packlets/identity/identity-manager.test.ts +5 -6
  76. package/src/packlets/identity/identity-manager.ts +35 -19
  77. package/src/packlets/identity/identity-service.test.ts +4 -8
  78. package/src/packlets/identity/identity.test.ts +128 -239
  79. package/src/packlets/identity/identity.ts +42 -8
  80. package/src/packlets/invitations/device-invitation-protocol.test.ts +7 -4
  81. package/src/packlets/invitations/edge-invitation-handler.ts +184 -0
  82. package/src/packlets/invitations/invitation-guest-extenstion.ts +8 -4
  83. package/src/packlets/invitations/invitation-host-extension.ts +8 -7
  84. package/src/packlets/invitations/invitation-state.ts +111 -0
  85. package/src/packlets/invitations/invitations-handler.test.ts +16 -9
  86. package/src/packlets/invitations/invitations-handler.ts +23 -92
  87. package/src/packlets/invitations/space-invitation-protocol.test.ts +4 -3
  88. package/src/packlets/invitations/space-invitation-protocol.ts +4 -0
  89. package/src/packlets/logging/logging.test.ts +1 -2
  90. package/src/packlets/network/network-service.test.ts +2 -3
  91. package/src/packlets/services/service-context.test.ts +3 -1
  92. package/src/packlets/services/service-context.ts +68 -31
  93. package/src/packlets/services/service-host.test.ts +8 -12
  94. package/src/packlets/services/service-host.ts +8 -6
  95. package/src/packlets/services/service-registry.test.ts +1 -2
  96. package/src/packlets/spaces/data-space-manager.test.ts +2 -2
  97. package/src/packlets/spaces/data-space-manager.ts +40 -5
  98. package/src/packlets/spaces/data-space.ts +34 -6
  99. package/src/packlets/spaces/edge-feed-replicator.test.ts +253 -0
  100. package/src/packlets/spaces/edge-feed-replicator.ts +80 -22
  101. package/src/packlets/spaces/epoch-migrations.ts +2 -2
  102. package/src/packlets/spaces/notarization-plugin.test.ts +10 -7
  103. package/src/packlets/spaces/notarization-plugin.ts +169 -29
  104. package/src/packlets/spaces/spaces-service.test.ts +5 -9
  105. package/src/packlets/spaces/spaces-service.ts +6 -1
  106. package/src/packlets/storage/storage.ts +0 -1
  107. package/src/packlets/system/system-service.test.ts +1 -2
  108. package/src/packlets/testing/test-builder.ts +3 -4
  109. package/src/packlets/worker/worker-runtime.ts +2 -2
  110. package/src/testing/setup.ts +11 -0
  111. package/src/version.ts +1 -5
  112. package/dist/lib/browser/chunk-TOAILL4T.mjs.map +0 -7
  113. package/dist/lib/node/chunk-H6C4XY6B.cjs.map +0 -7
  114. package/dist/types/src/packlets/identity/authenticator.test.d.ts +0 -2
  115. package/dist/types/src/packlets/identity/authenticator.test.d.ts.map +0 -1
  116. package/dist/types/src/packlets/services/automerge-host.test.d.ts +0 -2
  117. package/dist/types/src/packlets/services/automerge-host.test.d.ts.map +0 -1
  118. package/src/packlets/services/automerge-host.test.ts +0 -60
@@ -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
  */
@@ -106,13 +110,11 @@ export class InvitationHostExtension extends RpcExtension<
106
110
 
107
111
  introduce: async (request) => {
108
112
  const { profile, invitationId } = request;
109
-
110
113
  const traceId = PublicKey.random().toHex();
111
114
  log.trace('dxos.sdk.invitation-handler.host.introduce', trace.begin({ id: traceId }));
112
115
 
113
116
  const invitation = this._requireActiveInvitation();
114
117
  this._assertInvitationState(Invitation.State.CONNECTED);
115
-
116
118
  if (invitationId !== invitation?.invitationId) {
117
119
  log.warn('incorrect invitationId', { expected: invitation.invitationId, actual: invitationId });
118
120
  this._callbacks.onError(new Error('Incorrect invitationId.'));
@@ -126,7 +128,6 @@ export class InvitationHostExtension extends RpcExtension<
126
128
  log('guest introduced themselves', { guestProfile: profile });
127
129
  this.guestProfile = profile;
128
130
  this._callbacks.onStateUpdate(Invitation.State.READY_FOR_AUTHENTICATION);
129
-
130
131
  this._challenge =
131
132
  invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
132
133
 
@@ -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
+ };
@@ -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
  });
@@ -163,7 +164,7 @@ describe('InvitationHandler', () => {
163
164
  expect(guest.ctx.disposed).to.be.true;
164
165
  });
165
166
 
166
- test('guest gives up after trying with three hosts', async () => {
167
+ test('guest gives up after trying with three hosts', { timeout: 20_000 }, async () => {
167
168
  const hosts: PeerSetup[] = [await createPeer()];
168
169
  const [host] = hosts;
169
170
  const invitation = await createInvitation(host, { multiUse: true });
@@ -181,7 +182,7 @@ describe('InvitationHandler', () => {
181
182
 
182
183
  await sleep(10);
183
184
  expect(guest.sink.lastState).to.eq(Invitation.State.ERROR);
184
- }).timeout(20_000);
185
+ });
185
186
 
186
187
  test('single host - many guests', async () => {
187
188
  const hosts: PeerSetup[] = [await createPeer()];
@@ -256,12 +257,14 @@ describe('InvitationHandler', () => {
256
257
  const space = await peer.dataSpaceManager.createSpace();
257
258
  spaceKey = space.key;
258
259
  }
259
- const invitationHandler = new InvitationsHandler(peer.networkManager, {
260
- controlHeartbeatInterval: 250, // faster peer failure detection
260
+ const invitationHandler = new InvitationsHandler(peer.networkManager, undefined, {
261
+ teleport: { controlHeartbeatInterval: 250 }, // faster peer failure detection
261
262
  });
262
263
  const protocol = new SpaceInvitationProtocol(peer.dataSpaceManager, peer.identity, peer.keyring, spaceKey);
263
264
  const ctx = new Context();
264
- afterTest(() => ctx.dispose());
265
+ onTestFinished(async () => {
266
+ await ctx.dispose();
267
+ });
265
268
  const sink = newStateUpdateSink();
266
269
  return { ctx, sink, peer, protocol, handler: invitationHandler, spaceKey };
267
270
  };
@@ -269,14 +272,18 @@ describe('InvitationHandler', () => {
269
272
  const hostInvitation = async (setup: PeerSetup, invitation: Invitation) => {
270
273
  await setup.ctx.dispose();
271
274
  setup.ctx = new Context();
272
- afterTest(() => setup.ctx.dispose());
275
+ onTestFinished(async () => {
276
+ await setup.ctx.dispose();
277
+ });
273
278
  setup.handler.handleInvitationFlow(setup.ctx, setup.sink, setup.protocol, invitation);
274
279
  };
275
280
 
276
281
  const acceptInvitation = async (setup: PeerSetup, invitation: Invitation): Promise<Trigger<string>> => {
277
282
  await setup.ctx.dispose();
278
283
  setup.ctx = new Context();
279
- afterTest(() => setup.ctx.dispose());
284
+ onTestFinished(async () => {
285
+ await setup.ctx.dispose();
286
+ });
280
287
  const authCodeInput = new Trigger<string>();
281
288
  setup.handler.acceptInvitation(setup.ctx, setup.sink, setup.protocol, invitation, authCodeInput);
282
289
  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(
@@ -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.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
415
  private async _handleGuestOtpAuth(
485
416
  extension: InvitationGuestExtension,
486
417
  setState: (newState: Invitation.State) => void,
@@ -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
 
@@ -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