@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.
- package/dist/lib/browser/{chunk-67FEJJ6J.mjs → chunk-UEQIHAL2.mjs} +868 -619
- package/dist/lib/browser/chunk-UEQIHAL2.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +1 -1
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +4 -2
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-2KIDYJ7O.cjs → chunk-MA5EWTRH.cjs} +900 -655
- package/dist/lib/node/chunk-MA5EWTRH.cjs.map +7 -0
- package/dist/lib/node/index.cjs +45 -45
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +11 -9
- package/dist/lib/node/testing/index.cjs.map +3 -3
- package/dist/lib/node-esm/{chunk-36ZRRDQI.mjs → chunk-AIBLDI4U.mjs} +859 -610
- package/dist/lib/node-esm/chunk-AIBLDI4U.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +1 -1
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +4 -2
- package/dist/lib/node-esm/testing/index.mjs.map +3 -3
- package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts +30 -0
- package/dist/types/src/packlets/invitations/edge-invitation-handler.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +2 -1
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts +2 -1
- package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-state.d.ts +19 -0
- package/dist/types/src/packlets/invitations/invitation-state.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -8
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-context.d.ts +2 -3
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts +2 -0
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space.d.ts +1 -0
- package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/spaces-service.d.ts +1 -1
- package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/dist/types/src/version.d.ts.map +1 -1
- package/package.json +38 -38
- package/src/packlets/invitations/edge-invitation-handler.ts +184 -0
- package/src/packlets/invitations/invitation-guest-extenstion.ts +8 -4
- package/src/packlets/invitations/invitation-host-extension.ts +8 -4
- package/src/packlets/invitations/invitation-state.ts +111 -0
- package/src/packlets/invitations/invitations-handler.test.ts +2 -2
- package/src/packlets/invitations/invitations-handler.ts +23 -92
- package/src/packlets/invitations/space-invitation-protocol.ts +4 -0
- package/src/packlets/services/service-context.ts +4 -3
- package/src/packlets/spaces/data-space-manager.ts +31 -3
- package/src/packlets/spaces/data-space.ts +4 -0
- package/src/packlets/spaces/edge-feed-replicator.test.ts +2 -0
- package/src/packlets/spaces/spaces-service.ts +6 -1
- package/src/packlets/testing/test-builder.ts +1 -1
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-67FEJJ6J.mjs.map +0 -7
- package/dist/lib/node/chunk-2KIDYJ7O.cjs.map +0 -7
- 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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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?:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|