@dxos/client-services 0.5.1-next.5e6b3f6 → 0.5.1-next.65aaa36
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-5AW3X677.mjs → chunk-2MPSJHTS.mjs} +1263 -907
- package/dist/lib/browser/chunk-2MPSJHTS.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +31 -2
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/packlets/testing/index.mjs +28 -8
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-6ABI276Y.cjs → chunk-ITFB7UCR.cjs} +1417 -1069
- package/dist/lib/node/chunk-ITFB7UCR.cjs.map +7 -0
- package/dist/lib/node/index.cjs +73 -44
- package/dist/lib/node/index.cjs.map +3 -3
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/packlets/testing/index.cjs +34 -14
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +2 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts +39 -0
- package/dist/types/src/packlets/invitations/invitation-guest-extenstion.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/{invitation-extension.d.ts → invitation-host-extension.d.ts} +17 -31
- package/dist/types/src/packlets/invitations/invitation-host-extension.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +6 -1
- package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-topology.d.ts +37 -0
- package/dist/types/src/packlets/invitations/invitation-topology.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts +19 -10
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts +2 -0
- package/dist/types/src/packlets/invitations/invitations-handler.test.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts +2 -1
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +1 -0
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/utils.d.ts +6 -0
- package/dist/types/src/packlets/invitations/utils.d.ts.map +1 -0
- package/dist/types/src/packlets/services/service-context.d.ts +6 -3
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts +2 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/packlets/vault/shell-runtime.d.ts +10 -2
- package/dist/types/src/packlets/vault/shell-runtime.d.ts.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/package.json +36 -36
- package/src/packlets/invitations/device-invitation-protocol.ts +5 -1
- package/src/packlets/invitations/invitation-guest-extenstion.ts +126 -0
- package/src/packlets/invitations/{invitation-extension.ts → invitation-host-extension.ts} +99 -105
- package/src/packlets/invitations/invitation-protocol.ts +7 -1
- package/src/packlets/invitations/invitation-topology.ts +87 -0
- package/src/packlets/invitations/invitations-handler.test.ts +361 -0
- package/src/packlets/invitations/invitations-handler.ts +246 -149
- package/src/packlets/invitations/invitations-manager.ts +42 -3
- package/src/packlets/invitations/space-invitation-protocol.ts +19 -1
- package/src/packlets/invitations/utils.ts +27 -0
- package/src/packlets/services/service-context.ts +5 -3
- package/src/packlets/testing/invitation-utils.ts +23 -3
- package/src/packlets/testing/test-builder.ts +3 -1
- package/src/packlets/vault/shell-runtime.ts +40 -2
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-5AW3X677.mjs.map +0 -7
- package/dist/lib/node/chunk-6ABI276Y.cjs.map +0 -7
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +0 -1
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// Copyright 2022 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Trigger } from '@dxos/async';
|
|
5
|
+
import { type Mutex, type MutexGuard, Trigger, scheduleTask } from '@dxos/async';
|
|
6
6
|
import { cancelWithContext, Context } from '@dxos/context';
|
|
7
7
|
import { randomBytes, verify } from '@dxos/crypto';
|
|
8
|
-
import { invariant } from '@dxos/invariant';
|
|
8
|
+
import { invariant, InvariantViolation } from '@dxos/invariant';
|
|
9
9
|
import { PublicKey } from '@dxos/keys';
|
|
10
10
|
import { log } from '@dxos/log';
|
|
11
11
|
import { InvalidInvitationExtensionRoleError, schema, trace } from '@dxos/protocols';
|
|
@@ -15,25 +15,26 @@ import {
|
|
|
15
15
|
type AdmissionRequest,
|
|
16
16
|
type AdmissionResponse,
|
|
17
17
|
AuthenticationResponse,
|
|
18
|
-
type IntroductionRequest,
|
|
19
18
|
type InvitationHostService,
|
|
20
19
|
Options,
|
|
21
20
|
} from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
22
21
|
import { type ExtensionContext, RpcExtension } from '@dxos/teleport';
|
|
23
22
|
|
|
23
|
+
import { stateToString, tryAcquireBeforeContextDisposed } from './utils';
|
|
24
|
+
|
|
24
25
|
/// Timeout for the options exchange.
|
|
25
26
|
const OPTIONS_TIMEOUT = 10_000;
|
|
26
27
|
|
|
27
28
|
export const MAX_OTP_ATTEMPTS = 3;
|
|
28
29
|
|
|
29
30
|
type InvitationHostExtensionCallbacks = {
|
|
31
|
+
activeInvitation: Invitation | null;
|
|
32
|
+
|
|
30
33
|
// Deliberately not async to not block the extensions opening.
|
|
31
|
-
onOpen: () => void;
|
|
34
|
+
onOpen: (ctx: Context, extensionCtx: ExtensionContext) => void;
|
|
32
35
|
onError: (error: Error) => void;
|
|
33
36
|
|
|
34
|
-
onStateUpdate: (
|
|
35
|
-
|
|
36
|
-
resolveInvitation: (request: IntroductionRequest) => Promise<Invitation | undefined>;
|
|
37
|
+
onStateUpdate: (newState: Invitation.State) => void;
|
|
37
38
|
|
|
38
39
|
admit: (request: AdmissionRequest) => Promise<AdmissionResponse>;
|
|
39
40
|
};
|
|
@@ -54,8 +55,6 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
54
55
|
|
|
55
56
|
private _challenge?: Buffer = undefined;
|
|
56
57
|
|
|
57
|
-
public invitation?: Invitation = undefined;
|
|
58
|
-
|
|
59
58
|
public guestProfile?: ProfileDocument = undefined;
|
|
60
59
|
|
|
61
60
|
public authenticationPassed = false;
|
|
@@ -70,7 +69,15 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
70
69
|
*/
|
|
71
70
|
public completedTrigger = new Trigger<PublicKey>();
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Held to allow only one invitation flow at a time to be active.
|
|
74
|
+
*/
|
|
75
|
+
private _invitationFlowLock: MutexGuard | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly _invitationFlowMutex: Mutex,
|
|
79
|
+
private readonly _callbacks: InvitationHostExtensionCallbacks,
|
|
80
|
+
) {
|
|
74
81
|
super({
|
|
75
82
|
requested: {
|
|
76
83
|
InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService'),
|
|
@@ -81,6 +88,10 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
81
88
|
});
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
public hasFlowLock(): boolean {
|
|
92
|
+
return this._invitationFlowLock != null;
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
protected override async getHandlers(): Promise<{ InvitationHostService: InvitationHostService }> {
|
|
85
96
|
return {
|
|
86
97
|
// TODO(dmaretskyi): For now this is just forwarding the data to callbacks since we don't have session-specific logic.
|
|
@@ -98,30 +109,29 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
98
109
|
const traceId = PublicKey.random().toHex();
|
|
99
110
|
log.trace('dxos.sdk.invitation-handler.host.introduce', trace.begin({ id: traceId }));
|
|
100
111
|
|
|
101
|
-
const invitation =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
const invitation = this._requireActiveInvitation();
|
|
113
|
+
this._assertInvitationState(Invitation.State.CONNECTED);
|
|
114
|
+
|
|
115
|
+
if (invitationId !== invitation?.invitationId) {
|
|
116
|
+
log.warn('incorrect invitationId', { expected: invitation.invitationId, actual: invitationId });
|
|
117
|
+
this._callbacks.onError(new Error('Incorrect invitationId.'));
|
|
118
|
+
scheduleTask(this._ctx, () => this.close());
|
|
105
119
|
// TODO(dmaretskyi): Better error handling.
|
|
106
120
|
return {
|
|
107
121
|
authMethod: Invitation.AuthMethod.NONE,
|
|
108
122
|
};
|
|
109
123
|
}
|
|
110
|
-
this.invitation = invitation;
|
|
111
124
|
|
|
112
|
-
log('guest introduced
|
|
113
|
-
guestProfile: profile,
|
|
114
|
-
});
|
|
125
|
+
log('guest introduced themselves', { guestProfile: profile });
|
|
115
126
|
this.guestProfile = profile;
|
|
116
|
-
|
|
117
|
-
this._callbacks.onStateUpdate({ ...this.invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
127
|
+
this._callbacks.onStateUpdate(Invitation.State.READY_FOR_AUTHENTICATION);
|
|
118
128
|
|
|
119
129
|
this._challenge =
|
|
120
|
-
|
|
130
|
+
invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
|
|
121
131
|
|
|
122
132
|
log.trace('dxos.sdk.invitation-handler.host.introduce', trace.end({ id: traceId }));
|
|
123
133
|
return {
|
|
124
|
-
authMethod:
|
|
134
|
+
authMethod: invitation.authMethod,
|
|
125
135
|
challenge: this._challenge,
|
|
126
136
|
};
|
|
127
137
|
},
|
|
@@ -129,21 +139,25 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
129
139
|
authenticate: async ({ authCode: code, signedChallenge }) => {
|
|
130
140
|
const traceId = PublicKey.random().toHex();
|
|
131
141
|
log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.begin({ id: traceId }));
|
|
142
|
+
|
|
143
|
+
const invitation = this._requireActiveInvitation();
|
|
132
144
|
log('received authentication request', { authCode: code });
|
|
133
145
|
let status = AuthenticationResponse.Status.OK;
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
this._assertInvitationState([Invitation.State.AUTHENTICATING, Invitation.State.READY_FOR_AUTHENTICATION]);
|
|
148
|
+
this._callbacks.onStateUpdate(Invitation.State.AUTHENTICATING);
|
|
149
|
+
|
|
150
|
+
switch (invitation.authMethod) {
|
|
137
151
|
case Invitation.AuthMethod.NONE: {
|
|
138
152
|
log('authentication not required');
|
|
139
153
|
return { status: AuthenticationResponse.Status.OK };
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
case Invitation.AuthMethod.SHARED_SECRET: {
|
|
143
|
-
if (
|
|
157
|
+
if (invitation.authCode) {
|
|
144
158
|
if (this.authenticationRetry++ > MAX_OTP_ATTEMPTS) {
|
|
145
159
|
status = AuthenticationResponse.Status.INVALID_OPT_ATTEMPTS;
|
|
146
|
-
} else if (code !==
|
|
160
|
+
} else if (code !== invitation.authCode) {
|
|
147
161
|
status = AuthenticationResponse.Status.INVALID_OTP;
|
|
148
162
|
} else {
|
|
149
163
|
this.authenticationPassed = true;
|
|
@@ -153,7 +167,7 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
153
167
|
}
|
|
154
168
|
|
|
155
169
|
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY: {
|
|
156
|
-
if (!
|
|
170
|
+
if (!invitation.guestKeypair) {
|
|
157
171
|
status = AuthenticationResponse.Status.INTERNAL_ERROR;
|
|
158
172
|
break;
|
|
159
173
|
}
|
|
@@ -162,7 +176,7 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
162
176
|
verify(
|
|
163
177
|
this._challenge,
|
|
164
178
|
Buffer.from(signedChallenge ?? []),
|
|
165
|
-
|
|
179
|
+
invitation.guestKeypair.publicKey.asBuffer(),
|
|
166
180
|
);
|
|
167
181
|
if (isSignatureValid) {
|
|
168
182
|
this.authenticationPassed = true;
|
|
@@ -173,12 +187,18 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
173
187
|
}
|
|
174
188
|
|
|
175
189
|
default: {
|
|
176
|
-
log.error('invalid authentication method', { authMethod:
|
|
190
|
+
log.error('invalid authentication method', { authMethod: invitation.authMethod });
|
|
177
191
|
status = AuthenticationResponse.Status.INTERNAL_ERROR;
|
|
178
192
|
break;
|
|
179
193
|
}
|
|
180
194
|
}
|
|
181
195
|
|
|
196
|
+
if (![AuthenticationResponse.Status.OK, AuthenticationResponse.Status.INVALID_OTP].includes(status)) {
|
|
197
|
+
this._callbacks.onError(new Error(`Authentication failed, with status=${status}`));
|
|
198
|
+
scheduleTask(this._ctx, () => this.close());
|
|
199
|
+
return { status };
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.end({ id: traceId, data: { status } }));
|
|
183
203
|
return { status };
|
|
184
204
|
},
|
|
@@ -186,12 +206,15 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
186
206
|
admit: async (request) => {
|
|
187
207
|
const traceId = PublicKey.random().toHex();
|
|
188
208
|
log.trace('dxos.sdk.invitation-handler.host.admit', trace.begin({ id: traceId }));
|
|
209
|
+
const invitation = this._requireActiveInvitation();
|
|
189
210
|
|
|
190
211
|
try {
|
|
191
|
-
invariant(this.invitation, 'Invitation is not set.');
|
|
192
212
|
// Check authenticated.
|
|
193
|
-
if (isAuthenticationRequired(
|
|
194
|
-
|
|
213
|
+
if (isAuthenticationRequired(invitation)) {
|
|
214
|
+
this._assertInvitationState(Invitation.State.AUTHENTICATING);
|
|
215
|
+
if (!this.authenticationPassed) {
|
|
216
|
+
throw new Error('Not authenticated');
|
|
217
|
+
}
|
|
195
218
|
}
|
|
196
219
|
|
|
197
220
|
const response = await this._callbacks.admit(request);
|
|
@@ -211,100 +234,71 @@ export class InvitationHostExtension extends RpcExtension<
|
|
|
211
234
|
await super.onOpen(context);
|
|
212
235
|
|
|
213
236
|
try {
|
|
237
|
+
log('host acquire lock');
|
|
238
|
+
this._invitationFlowLock = await tryAcquireBeforeContextDisposed(this._ctx, this._invitationFlowMutex);
|
|
239
|
+
log('host lock acquired');
|
|
240
|
+
const lastState = this._requireActiveInvitation().state;
|
|
241
|
+
this._callbacks.onStateUpdate(Invitation.State.CONNECTING);
|
|
214
242
|
await this.rpc.InvitationHostService.options({ role: Options.Role.HOST });
|
|
243
|
+
log('options sent');
|
|
215
244
|
await cancelWithContext(this._ctx, this._remoteOptionsTrigger.wait({ timeout: OPTIONS_TIMEOUT }));
|
|
245
|
+
log('options received');
|
|
216
246
|
if (this._remoteOptions?.role !== Options.Role.GUEST) {
|
|
247
|
+
// we connected with another host, restore previous real invitation flow status
|
|
248
|
+
this._callbacks.onStateUpdate(lastState);
|
|
217
249
|
throw new InvalidInvitationExtensionRoleError(undefined, {
|
|
218
250
|
expected: Options.Role.GUEST,
|
|
219
251
|
remoteOptions: this._remoteOptions,
|
|
252
|
+
remotePeerId: context.remotePeerId,
|
|
220
253
|
});
|
|
221
254
|
}
|
|
222
|
-
|
|
223
|
-
this._callbacks.onOpen();
|
|
255
|
+
this._callbacks.onStateUpdate(Invitation.State.CONNECTED);
|
|
256
|
+
this._callbacks.onOpen(this._ctx, context);
|
|
224
257
|
} catch (err: any) {
|
|
225
|
-
this.
|
|
258
|
+
if (this._invitationFlowLock != null) {
|
|
259
|
+
this._callbacks.onError(err);
|
|
260
|
+
}
|
|
261
|
+
if (!this._ctx.disposed) {
|
|
262
|
+
context.close(err);
|
|
263
|
+
}
|
|
226
264
|
}
|
|
227
265
|
}
|
|
228
266
|
|
|
229
|
-
|
|
230
|
-
|
|
267
|
+
private _requireActiveInvitation(): Invitation {
|
|
268
|
+
const invitation = this._callbacks.activeInvitation;
|
|
269
|
+
if (invitation == null) {
|
|
270
|
+
scheduleTask(this._ctx, () => this.close());
|
|
271
|
+
throw new Error('Active invitation not found');
|
|
272
|
+
}
|
|
273
|
+
return invitation;
|
|
231
274
|
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
type InvitationGuestExtensionCallbacks = {
|
|
235
|
-
// Deliberately not async to not block the extensions opening.
|
|
236
|
-
onOpen: (ctx: Context) => void;
|
|
237
|
-
onError: (error: Error) => void;
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Guest's side for a connection to a concrete peer in p2p network during invitation.
|
|
242
|
-
*/
|
|
243
|
-
export class InvitationGuestExtension extends RpcExtension<
|
|
244
|
-
{ InvitationHostService: InvitationHostService },
|
|
245
|
-
{ InvitationHostService: InvitationHostService }
|
|
246
|
-
> {
|
|
247
|
-
private _ctx = new Context();
|
|
248
|
-
private _remoteOptions?: Options;
|
|
249
|
-
private _remoteOptionsTrigger = new Trigger();
|
|
250
275
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
276
|
+
private _assertInvitationState(stateOrMany: Invitation.State | Invitation.State[]) {
|
|
277
|
+
const invitation = this._requireActiveInvitation();
|
|
278
|
+
const validStates = Array.isArray(stateOrMany) ? stateOrMany : [stateOrMany];
|
|
279
|
+
if (!validStates.includes(invitation.state)) {
|
|
280
|
+
scheduleTask(this._ctx, () => this.close());
|
|
281
|
+
throw new InvariantViolation(
|
|
282
|
+
`Expected ${stateToString(invitation.state)} to be one of [${validStates.map(stateToString).join(', ')}]`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
260
285
|
}
|
|
261
286
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
InvitationHostService: {
|
|
265
|
-
options: async (options) => {
|
|
266
|
-
invariant(!this._remoteOptions, 'Remote options already set.');
|
|
267
|
-
this._remoteOptions = options;
|
|
268
|
-
this._remoteOptionsTrigger.wake();
|
|
269
|
-
},
|
|
270
|
-
introduce: () => {
|
|
271
|
-
throw new Error('Method not allowed.');
|
|
272
|
-
},
|
|
273
|
-
authenticate: () => {
|
|
274
|
-
throw new Error('Method not allowed.');
|
|
275
|
-
},
|
|
276
|
-
admit: () => {
|
|
277
|
-
throw new Error('Method not allowed.');
|
|
278
|
-
},
|
|
279
|
-
},
|
|
280
|
-
};
|
|
287
|
+
override async onClose() {
|
|
288
|
+
await this._destroy();
|
|
281
289
|
}
|
|
282
290
|
|
|
283
|
-
override async
|
|
284
|
-
await
|
|
285
|
-
|
|
286
|
-
try {
|
|
287
|
-
log('begin options');
|
|
288
|
-
await cancelWithContext(this._ctx, this.rpc.InvitationHostService.options({ role: Options.Role.GUEST }));
|
|
289
|
-
await cancelWithContext(this._ctx, this._remoteOptionsTrigger.wait({ timeout: OPTIONS_TIMEOUT }));
|
|
290
|
-
log('end options');
|
|
291
|
-
if (this._remoteOptions?.role !== Options.Role.HOST) {
|
|
292
|
-
throw new InvalidInvitationExtensionRoleError(undefined, {
|
|
293
|
-
expected: Options.Role.HOST,
|
|
294
|
-
remoteOptions: this._remoteOptions,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
this._callbacks.onOpen(this._ctx);
|
|
299
|
-
} catch (err: any) {
|
|
300
|
-
log('openError', err);
|
|
301
|
-
this._callbacks.onError(err);
|
|
302
|
-
}
|
|
291
|
+
override async onAbort() {
|
|
292
|
+
await this._destroy();
|
|
303
293
|
}
|
|
304
294
|
|
|
305
|
-
|
|
306
|
-
log('onClose');
|
|
295
|
+
private async _destroy() {
|
|
307
296
|
await this._ctx.dispose();
|
|
297
|
+
if (this._invitationFlowLock != null) {
|
|
298
|
+
this._invitationFlowLock?.release();
|
|
299
|
+
this._invitationFlowLock = null;
|
|
300
|
+
log('invitation flow lock released');
|
|
301
|
+
}
|
|
308
302
|
}
|
|
309
303
|
}
|
|
310
304
|
|
|
@@ -32,10 +32,16 @@ export interface InvitationProtocol {
|
|
|
32
32
|
getInvitationContext(): Partial<Invitation> & Pick<Invitation, 'kind'>;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* Allow authorized peers to handle this invitation behalf of invitation creator.
|
|
36
|
+
* @return id of the delegation credential written to subject control-feed.
|
|
36
37
|
*/
|
|
37
38
|
delegate(invitation: Invitation): Promise<PublicKey>;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Notify other peers that a delegated invitation was cancelled;
|
|
42
|
+
*/
|
|
43
|
+
cancelDelegation(invitation: Invitation): Promise<void>;
|
|
44
|
+
|
|
39
45
|
/**
|
|
40
46
|
* Once authentication is successful, the host can admit the guest to the requested resource.
|
|
41
47
|
*/
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { invariant } from '@dxos/invariant';
|
|
6
|
+
import { PublicKey } from '@dxos/keys';
|
|
7
|
+
import { log } from '@dxos/log';
|
|
8
|
+
import type { SwarmController, Topology } from '@dxos/network-manager';
|
|
9
|
+
import { Options } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
10
|
+
import { ComplexSet } from '@dxos/util';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hosts are listening on an invitation topic.
|
|
14
|
+
* They initiate a connection with any new peer if they are not currently in the invitation flow
|
|
15
|
+
* with another peer (connected.length > 0).
|
|
16
|
+
* When the invitation flow ends guest leaves the swarm and topology is updated once again,
|
|
17
|
+
* so we can connect to the next peer we haven't tried yet.
|
|
18
|
+
* If the peer turns out to be a host or a malicious guest their ID is remembered so that we don't try
|
|
19
|
+
* to establish a connection with them again.
|
|
20
|
+
*
|
|
21
|
+
* Guests don't initiate connections. They accept all connections because if we reject,
|
|
22
|
+
* the host won't retry their offer.
|
|
23
|
+
* Even if we started an invitation flow with one host we might want to try other hosts in case
|
|
24
|
+
* the first one failed due to a network error, so multiple connections are accepted.
|
|
25
|
+
*/
|
|
26
|
+
export class InvitationTopology implements Topology {
|
|
27
|
+
private _controller?: SwarmController;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Peers we tried to establish a connection with.
|
|
31
|
+
* In invitation flow peers are assigned random ids when they join the swarm, so we'll retry
|
|
32
|
+
* a peer if they reload an invitation.
|
|
33
|
+
*
|
|
34
|
+
* Consider keeping a separate set for peers we know are hosts and have some retry timeout
|
|
35
|
+
* for guests we failed an invitation flow with (potentially due to a network error).
|
|
36
|
+
*/
|
|
37
|
+
private _seenPeers = new ComplexSet<PublicKey>(PublicKey.hash);
|
|
38
|
+
|
|
39
|
+
constructor(private readonly _role: Options.Role) {}
|
|
40
|
+
|
|
41
|
+
init(controller: SwarmController): void {
|
|
42
|
+
invariant(!this._controller, 'Already initialized.');
|
|
43
|
+
this._controller = controller;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
update(): void {
|
|
47
|
+
invariant(this._controller, 'Not initialized.');
|
|
48
|
+
const { ownPeerId, candidates, connected, allPeers } = this._controller.getState();
|
|
49
|
+
|
|
50
|
+
// guests don't initiate connections
|
|
51
|
+
if (this._role === Options.Role.GUEST) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// don't start a connection while we have an active invitation flow
|
|
56
|
+
if (connected.length > 0) {
|
|
57
|
+
// update seenPeers here as well in case another host initiated a connection with us
|
|
58
|
+
connected.forEach((c) => this._seenPeers.add(c));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const firstUnknownPeer = candidates.find((peerId) => !this._seenPeers.has(peerId));
|
|
63
|
+
// cleanup
|
|
64
|
+
this._seenPeers = new ComplexSet<PublicKey>(
|
|
65
|
+
PublicKey.hash,
|
|
66
|
+
allPeers.filter((peerId) => this._seenPeers.has(peerId)),
|
|
67
|
+
);
|
|
68
|
+
if (firstUnknownPeer != null) {
|
|
69
|
+
log('invitation connect', { ownPeerId, remotePeerId: firstUnknownPeer });
|
|
70
|
+
this._controller.connect(firstUnknownPeer);
|
|
71
|
+
this._seenPeers.add(firstUnknownPeer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async onOffer(peer: PublicKey): Promise<boolean> {
|
|
76
|
+
invariant(this._controller, 'Not initialized.');
|
|
77
|
+
return !this._seenPeers.has(peer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async destroy(): Promise<void> {
|
|
81
|
+
this._seenPeers.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
toString() {
|
|
85
|
+
return `InvitationTopology(${this._role === Options.Role.GUEST ? 'guest' : 'host'})`;
|
|
86
|
+
}
|
|
87
|
+
}
|