@dxos/client-services 0.6.13 → 0.6.14-main.69511f5

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 (138) hide show
  1. package/dist/lib/browser/{chunk-CRXXOI45.mjs → chunk-PK5RMXEO.mjs} +6462 -5230
  2. package/dist/lib/browser/chunk-PK5RMXEO.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 +12 -8
  7. package/dist/lib/browser/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-PZ3JJJ3K.cjs → chunk-XDE6WELX.cjs} +6287 -5057
  9. package/dist/lib/node/chunk-XDE6WELX.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 +18 -13
  14. package/dist/lib/node/testing/index.cjs.map +3 -3
  15. package/dist/lib/node-esm/chunk-S5DTGWTU.mjs +8956 -0
  16. package/dist/lib/node-esm/chunk-S5DTGWTU.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 +424 -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/diagnostics/diagnostics-broadcast.d.ts.map +1 -1
  31. package/dist/types/src/packlets/identity/authenticator.d.ts.map +1 -1
  32. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts +2 -0
  33. package/dist/types/src/packlets/identity/authenticator.node.test.d.ts.map +1 -0
  34. package/dist/types/src/packlets/identity/contacts-service.d.ts +1 -1
  35. package/dist/types/src/packlets/identity/contacts-service.d.ts.map +1 -1
  36. package/dist/types/src/packlets/identity/identity-manager.d.ts +28 -9
  37. package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
  38. package/dist/types/src/packlets/identity/identity-recovery-manager.d.ts +18 -0
  39. package/dist/types/src/packlets/identity/identity-recovery-manager.d.ts.map +1 -0
  40. package/dist/types/src/packlets/identity/identity-service.d.ts +7 -2
  41. package/dist/types/src/packlets/identity/identity-service.d.ts.map +1 -1
  42. package/dist/types/src/packlets/identity/identity.d.ts +12 -3
  43. package/dist/types/src/packlets/identity/identity.d.ts.map +1 -1
  44. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  45. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts +30 -0
  46. package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts.map +1 -0
  47. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +2 -1
  48. package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -1
  49. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts +2 -1
  50. package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -1
  51. package/dist/types/src/packlets/invitations/invitation-state.d.ts +19 -0
  52. package/dist/types/src/packlets/invitations/invitation-state.d.ts.map +1 -0
  53. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -8
  54. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  55. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  56. package/dist/types/src/packlets/services/service-context.d.ts +14 -9
  57. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  58. package/dist/types/src/packlets/services/service-host.d.ts +2 -0
  59. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  60. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +7 -3
  61. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  62. package/dist/types/src/packlets/spaces/data-space.d.ts +5 -3
  63. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  64. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts +3 -0
  65. package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts.map +1 -1
  66. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts +2 -0
  67. package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts.map +1 -0
  68. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +1 -1
  69. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -1
  70. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts +35 -6
  71. package/dist/types/src/packlets/spaces/notarization-plugin.d.ts.map +1 -1
  72. package/dist/types/src/packlets/spaces/spaces-service.d.ts +1 -1
  73. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  74. package/dist/types/src/packlets/storage/storage.d.ts.map +1 -1
  75. package/dist/types/src/packlets/testing/test-builder.d.ts +1 -2
  76. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  77. package/dist/types/src/packlets/worker/worker-runtime.d.ts.map +1 -1
  78. package/dist/types/src/testing/setup.d.ts +3 -0
  79. package/dist/types/src/testing/setup.d.ts.map +1 -0
  80. package/dist/types/src/version.d.ts +1 -1
  81. package/dist/types/src/version.d.ts.map +1 -1
  82. package/package.json +44 -45
  83. package/src/index.ts +1 -0
  84. package/src/packlets/agents/edge-agent-manager.ts +163 -0
  85. package/src/packlets/agents/edge-agent-service.ts +42 -0
  86. package/src/packlets/agents/index.ts +6 -0
  87. package/src/packlets/devices/devices-service.test.ts +4 -5
  88. package/src/packlets/diagnostics/diagnostics-broadcast.ts +1 -0
  89. package/src/packlets/identity/{authenticator.test.ts → authenticator.node.test.ts} +2 -3
  90. package/src/packlets/identity/authenticator.ts +5 -2
  91. package/src/packlets/identity/contacts-service.ts +1 -1
  92. package/src/packlets/identity/identity-manager.test.ts +31 -16
  93. package/src/packlets/identity/identity-manager.ts +76 -32
  94. package/src/packlets/identity/identity-recovery-manager.ts +95 -0
  95. package/src/packlets/identity/identity-service.test.ts +5 -8
  96. package/src/packlets/identity/identity-service.ts +11 -5
  97. package/src/packlets/identity/identity.test.ts +130 -239
  98. package/src/packlets/identity/identity.ts +60 -17
  99. package/src/packlets/invitations/device-invitation-protocol.test.ts +7 -4
  100. package/src/packlets/invitations/device-invitation-protocol.ts +8 -2
  101. package/src/packlets/invitations/edge-invitation-handler.ts +185 -0
  102. package/src/packlets/invitations/invitation-guest-extenstion.ts +8 -4
  103. package/src/packlets/invitations/invitation-host-extension.ts +8 -7
  104. package/src/packlets/invitations/invitation-state.ts +112 -0
  105. package/src/packlets/invitations/invitations-handler.test.ts +16 -9
  106. package/src/packlets/invitations/invitations-handler.ts +57 -98
  107. package/src/packlets/invitations/space-invitation-protocol.test.ts +4 -3
  108. package/src/packlets/invitations/space-invitation-protocol.ts +5 -0
  109. package/src/packlets/logging/logging.test.ts +1 -2
  110. package/src/packlets/network/network-service.test.ts +2 -3
  111. package/src/packlets/services/service-context.test.ts +3 -1
  112. package/src/packlets/services/service-context.ts +113 -35
  113. package/src/packlets/services/service-host.test.ts +8 -12
  114. package/src/packlets/services/service-host.ts +25 -7
  115. package/src/packlets/services/service-registry.test.ts +1 -2
  116. package/src/packlets/spaces/data-space-manager.test.ts +2 -2
  117. package/src/packlets/spaces/data-space-manager.ts +44 -7
  118. package/src/packlets/spaces/data-space.ts +37 -6
  119. package/src/packlets/spaces/edge-feed-replicator.test.ts +252 -0
  120. package/src/packlets/spaces/edge-feed-replicator.ts +80 -22
  121. package/src/packlets/spaces/epoch-migrations.ts +2 -2
  122. package/src/packlets/spaces/notarization-plugin.test.ts +10 -7
  123. package/src/packlets/spaces/notarization-plugin.ts +196 -29
  124. package/src/packlets/spaces/spaces-service.test.ts +5 -9
  125. package/src/packlets/spaces/spaces-service.ts +6 -1
  126. package/src/packlets/storage/storage.ts +0 -1
  127. package/src/packlets/system/system-service.test.ts +1 -2
  128. package/src/packlets/testing/test-builder.ts +7 -4
  129. package/src/packlets/worker/worker-runtime.ts +2 -2
  130. package/src/testing/setup.ts +11 -0
  131. package/src/version.ts +1 -5
  132. package/dist/lib/browser/chunk-CRXXOI45.mjs.map +0 -7
  133. package/dist/lib/node/chunk-PZ3JJJ3K.cjs.map +0 -7
  134. package/dist/types/src/packlets/identity/authenticator.test.d.ts +0 -2
  135. package/dist/types/src/packlets/identity/authenticator.test.d.ts.map +0 -1
  136. package/dist/types/src/packlets/services/automerge-host.test.d.ts +0 -2
  137. package/dist/types/src/packlets/services/automerge-host.test.d.ts.map +0 -1
  138. package/src/packlets/services/automerge-host.test.ts +0 -60
@@ -0,0 +1,185 @@
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 { sign } 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
+ const publicKey = guardedState.current.guestKeypair?.publicKey;
163
+ const privateKey = guardedState.current.guestKeypair?.privateKey;
164
+ if (!privateKey || !publicKey) {
165
+ throw error;
166
+ }
167
+ const signature = sign(Buffer.from(error.challenge, 'base64'), privateKey);
168
+ return this._client.joinSpaceByInvitation(spaceId, {
169
+ ...request,
170
+ signature: Buffer.from(signature).toString('base64'),
171
+ });
172
+ } else {
173
+ throw error;
174
+ }
175
+ }
176
+ }
177
+
178
+ public hasFlowLock(): boolean {
179
+ return this._flowLock != null;
180
+ }
181
+
182
+ private _calculateNextRetryMs() {
183
+ return this._retryInterval + Math.random() * this._retryJitter;
184
+ }
185
+ }
@@ -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,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
  });
@@ -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;