@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.
- package/dist/lib/browser/{chunk-TOAILL4T.mjs → chunk-UEQIHAL2.mjs} +5838 -5151
- package/dist/lib/browser/chunk-UEQIHAL2.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +3 -3
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +8 -7
- package/dist/lib/browser/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-H6C4XY6B.cjs → chunk-MA5EWTRH.cjs} +5858 -5175
- package/dist/lib/node/chunk-MA5EWTRH.cjs.map +7 -0
- package/dist/lib/node/index.cjs +46 -46
- package/dist/lib/node/index.cjs.map +3 -3
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +14 -13
- package/dist/lib/node/testing/index.cjs.map +3 -3
- package/dist/lib/node-esm/chunk-AIBLDI4U.mjs +8403 -0
- package/dist/lib/node-esm/chunk-AIBLDI4U.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +416 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/lib/node-esm/testing/index.mjs +420 -0
- package/dist/lib/node-esm/testing/index.mjs.map +7 -0
- package/dist/types/src/packlets/diagnostics/diagnostics-broadcast.d.ts.map +1 -1
- package/dist/types/src/packlets/identity/authenticator.d.ts.map +1 -1
- package/dist/types/src/packlets/identity/authenticator.node.test.d.ts +2 -0
- package/dist/types/src/packlets/identity/authenticator.node.test.d.ts.map +1 -0
- package/dist/types/src/packlets/identity/contacts-service.d.ts +1 -1
- package/dist/types/src/packlets/identity/contacts-service.d.ts.map +1 -1
- package/dist/types/src/packlets/identity/identity-manager.d.ts +19 -7
- package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/identity/identity.d.ts +8 -1
- package/dist/types/src/packlets/identity/identity.d.ts.map +1 -1
- 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 +9 -9
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts +1 -0
- package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts +6 -3
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space.d.ts +4 -3
- package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts +3 -0
- package/dist/types/src/packlets/spaces/edge-feed-replicator.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts +2 -0
- package/dist/types/src/packlets/spaces/edge-feed-replicator.test.d.ts.map +1 -0
- package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +1 -1
- package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/notarization-plugin.d.ts +31 -6
- package/dist/types/src/packlets/spaces/notarization-plugin.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/packlets/storage/storage.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts +1 -2
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/packlets/worker/worker-runtime.d.ts.map +1 -1
- package/dist/types/src/testing/setup.d.ts +3 -0
- package/dist/types/src/testing/setup.d.ts.map +1 -0
- package/dist/types/src/version.d.ts +1 -1
- package/dist/types/src/version.d.ts.map +1 -1
- package/package.json +43 -39
- package/src/packlets/devices/devices-service.test.ts +4 -5
- package/src/packlets/diagnostics/diagnostics-broadcast.ts +1 -0
- package/src/packlets/identity/{authenticator.test.ts → authenticator.node.test.ts} +2 -3
- package/src/packlets/identity/authenticator.ts +5 -2
- package/src/packlets/identity/contacts-service.ts +1 -1
- package/src/packlets/identity/identity-manager.test.ts +5 -6
- package/src/packlets/identity/identity-manager.ts +35 -19
- package/src/packlets/identity/identity-service.test.ts +4 -8
- package/src/packlets/identity/identity.test.ts +128 -239
- package/src/packlets/identity/identity.ts +42 -8
- package/src/packlets/invitations/device-invitation-protocol.test.ts +7 -4
- 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 -7
- package/src/packlets/invitations/invitation-state.ts +111 -0
- package/src/packlets/invitations/invitations-handler.test.ts +16 -9
- package/src/packlets/invitations/invitations-handler.ts +23 -92
- package/src/packlets/invitations/space-invitation-protocol.test.ts +4 -3
- package/src/packlets/invitations/space-invitation-protocol.ts +4 -0
- package/src/packlets/logging/logging.test.ts +1 -2
- package/src/packlets/network/network-service.test.ts +2 -3
- package/src/packlets/services/service-context.test.ts +3 -1
- package/src/packlets/services/service-context.ts +68 -31
- package/src/packlets/services/service-host.test.ts +8 -12
- package/src/packlets/services/service-host.ts +8 -6
- package/src/packlets/services/service-registry.test.ts +1 -2
- package/src/packlets/spaces/data-space-manager.test.ts +2 -2
- package/src/packlets/spaces/data-space-manager.ts +40 -5
- package/src/packlets/spaces/data-space.ts +34 -6
- package/src/packlets/spaces/edge-feed-replicator.test.ts +253 -0
- package/src/packlets/spaces/edge-feed-replicator.ts +80 -22
- package/src/packlets/spaces/epoch-migrations.ts +2 -2
- package/src/packlets/spaces/notarization-plugin.test.ts +10 -7
- package/src/packlets/spaces/notarization-plugin.ts +169 -29
- package/src/packlets/spaces/spaces-service.test.ts +5 -9
- package/src/packlets/spaces/spaces-service.ts +6 -1
- package/src/packlets/storage/storage.ts +0 -1
- package/src/packlets/system/system-service.test.ts +1 -2
- package/src/packlets/testing/test-builder.ts +3 -4
- package/src/packlets/worker/worker-runtime.ts +2 -2
- package/src/testing/setup.ts +11 -0
- package/src/version.ts +1 -5
- package/dist/lib/browser/chunk-TOAILL4T.mjs.map +0 -7
- package/dist/lib/node/chunk-H6C4XY6B.cjs.map +0 -7
- package/dist/types/src/packlets/identity/authenticator.test.d.ts +0 -2
- package/dist/types/src/packlets/identity/authenticator.test.d.ts.map +0 -1
- package/dist/types/src/packlets/services/automerge-host.test.d.ts +0 -2
- package/dist/types/src/packlets/services/automerge-host.test.d.ts.map +0 -1
- 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
|
|
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
|
*/
|
|
@@ -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 '
|
|
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 {
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.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 '
|
|
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
|
-
|
|
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
|
|