@dxos/client-services 0.4.10-main.b3cf40d → 0.4.10-main.bb9f1bf
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-4ECD5R24.mjs → chunk-TM5QAJBN.mjs} +1439 -1073
- package/dist/lib/browser/chunk-TM5QAJBN.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +15 -3
- package/dist/lib/browser/index.mjs.map +1 -1
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/packlets/testing/index.mjs +136 -116
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-OYV4Y6T4.cjs → chunk-ZMWT7BZY.cjs} +1357 -1079
- package/dist/lib/node/chunk-ZMWT7BZY.cjs.map +7 -0
- package/dist/lib/node/index.cjs +50 -38
- package/dist/lib/node/index.cjs.map +1 -1
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/packlets/testing/index.cjs +135 -118
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/packlets/diagnostics/browser-diagnostics-broadcast.d.ts +5 -0
- package/dist/types/src/packlets/diagnostics/browser-diagnostics-broadcast.d.ts.map +1 -0
- package/dist/types/src/packlets/diagnostics/diagnostics-broadcast.d.ts +5 -0
- package/dist/types/src/packlets/diagnostics/diagnostics-broadcast.d.ts.map +1 -0
- package/dist/types/src/packlets/diagnostics/diagnostics-collector.d.ts +15 -0
- package/dist/types/src/packlets/diagnostics/diagnostics-collector.d.ts.map +1 -0
- package/dist/types/src/packlets/{services → diagnostics}/diagnostics.d.ts +1 -1
- package/dist/types/src/packlets/diagnostics/diagnostics.d.ts.map +1 -0
- package/dist/types/src/packlets/diagnostics/index.d.ts +4 -0
- package/dist/types/src/packlets/diagnostics/index.d.ts.map +1 -0
- package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/indexing/util.d.ts +2 -6
- package/dist/types/src/packlets/indexing/util.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +3 -1
- package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/index.d.ts +1 -0
- package/dist/types/src/packlets/invitations/index.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts +1 -0
- package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -1
- 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/invitations-handler.d.ts +8 -4
- package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts +44 -0
- package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -0
- package/dist/types/src/packlets/invitations/invitations-service.d.ts +7 -23
- package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +2 -1
- package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
- package/dist/types/src/packlets/services/index.d.ts +1 -1
- package/dist/types/src/packlets/services/index.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-context.d.ts +9 -5
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts +6 -1
- package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
- package/dist/types/src/packlets/services/util.d.ts +1 -0
- package/dist/types/src/packlets/services/util.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts +5 -1
- package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
- package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
- package/dist/types/src/packlets/storage/index.d.ts +1 -0
- package/dist/types/src/packlets/storage/index.d.ts.map +1 -1
- package/dist/types/src/packlets/storage/level.d.ts +4 -0
- package/dist/types/src/packlets/storage/level.d.ts.map +1 -0
- package/dist/types/src/packlets/storage/storage.d.ts.map +1 -1
- package/dist/types/src/packlets/storage/util.d.ts +4 -0
- package/dist/types/src/packlets/storage/util.d.ts.map +1 -0
- package/dist/types/src/packlets/system/system-service.d.ts +1 -1
- package/dist/types/src/packlets/system/system-service.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts +7 -2
- package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/package.json +36 -34
- package/src/index.ts +1 -0
- package/src/packlets/devices/devices-service.test.ts +1 -1
- package/src/packlets/diagnostics/browser-diagnostics-broadcast.ts +94 -0
- package/src/packlets/diagnostics/diagnostics-broadcast.ts +20 -0
- package/src/packlets/diagnostics/diagnostics-collector.ts +65 -0
- package/src/packlets/{services → diagnostics}/diagnostics.ts +2 -2
- package/src/packlets/diagnostics/index.ts +7 -0
- package/src/packlets/identity/identity-manager.ts +1 -0
- package/src/packlets/identity/identity-service.test.ts +1 -1
- package/src/packlets/identity/identity.test.ts +3 -0
- package/src/packlets/indexing/util.ts +9 -66
- package/src/packlets/invitations/device-invitation-protocol.test.ts +1 -1
- package/src/packlets/invitations/device-invitation-protocol.ts +6 -1
- package/src/packlets/invitations/index.ts +1 -0
- package/src/packlets/invitations/invitation-extension.ts +28 -1
- package/src/packlets/invitations/invitation-protocol.ts +7 -1
- package/src/packlets/invitations/invitations-handler.ts +75 -96
- package/src/packlets/invitations/invitations-manager.ts +271 -0
- package/src/packlets/invitations/invitations-service.ts +23 -168
- package/src/packlets/invitations/space-invitation-protocol.ts +45 -3
- package/src/packlets/network/network-service.test.ts +1 -1
- package/src/packlets/services/automerge-host.test.ts +10 -4
- package/src/packlets/services/index.ts +1 -1
- package/src/packlets/services/service-context.test.ts +9 -6
- package/src/packlets/services/service-context.ts +29 -10
- package/src/packlets/services/service-host.ts +61 -29
- package/src/packlets/services/service-registry.test.ts +1 -1
- package/src/packlets/services/util.ts +2 -0
- package/src/packlets/spaces/data-space-manager.test.ts +4 -4
- package/src/packlets/spaces/data-space-manager.ts +48 -2
- package/src/packlets/spaces/data-space.ts +1 -1
- package/src/packlets/spaces/spaces-service.test.ts +1 -1
- package/src/packlets/storage/index.ts +1 -0
- package/src/packlets/storage/level.ts +19 -0
- package/src/packlets/storage/storage.ts +3 -9
- package/src/packlets/storage/util.ts +19 -0
- package/src/packlets/system/system-service.ts +1 -1
- package/src/packlets/testing/invitation-utils.ts +100 -97
- package/src/packlets/testing/test-builder.ts +42 -6
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-4ECD5R24.mjs.map +0 -7
- package/dist/lib/node/chunk-OYV4Y6T4.cjs.map +0 -7
- package/dist/types/src/packlets/services/diagnostics.d.ts.map +0 -1
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { PushStream, scheduleTask, TimeoutError, Trigger } from '@dxos/async';
|
|
6
|
-
import {
|
|
7
|
-
AuthenticatingInvitation,
|
|
8
|
-
AUTHENTICATION_CODE_LENGTH,
|
|
9
|
-
CancellableInvitation,
|
|
10
|
-
INVITATION_TIMEOUT,
|
|
11
|
-
} from '@dxos/client-protocol';
|
|
6
|
+
import { AuthenticatingInvitation, INVITATION_TIMEOUT } from '@dxos/client-protocol';
|
|
12
7
|
import { Context } from '@dxos/context';
|
|
13
|
-
import {
|
|
8
|
+
import { createKeyPair, sign } from '@dxos/crypto';
|
|
14
9
|
import { invariant } from '@dxos/invariant';
|
|
15
10
|
import { PublicKey } from '@dxos/keys';
|
|
16
11
|
import { log } from '@dxos/log';
|
|
@@ -21,9 +16,9 @@ import {
|
|
|
21
16
|
type SwarmConnection,
|
|
22
17
|
} from '@dxos/network-manager';
|
|
23
18
|
import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
|
|
24
|
-
import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
19
|
+
import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
|
|
25
20
|
import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
|
|
26
|
-
import { AuthenticationResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
21
|
+
import { AuthenticationResponse, type IntroductionResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
|
|
27
22
|
|
|
28
23
|
import {
|
|
29
24
|
InvitationGuestExtension,
|
|
@@ -66,50 +61,12 @@ export class InvitationsHandler {
|
|
|
66
61
|
*/
|
|
67
62
|
constructor(private readonly _networkManager: NetworkManager) {}
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
timeout = INVITATION_TIMEOUT,
|
|
76
|
-
swarmKey = PublicKey.random(),
|
|
77
|
-
persistent = true,
|
|
78
|
-
created = new Date(),
|
|
79
|
-
lifetime = 86400, // 1 day
|
|
80
|
-
} = options ?? {};
|
|
81
|
-
const authCode =
|
|
82
|
-
options?.authCode ??
|
|
83
|
-
(authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
|
|
84
|
-
invariant(protocol);
|
|
85
|
-
|
|
86
|
-
const invitation: Invitation = {
|
|
87
|
-
invitationId,
|
|
88
|
-
type,
|
|
89
|
-
authMethod,
|
|
90
|
-
state,
|
|
91
|
-
swarmKey,
|
|
92
|
-
authCode,
|
|
93
|
-
timeout,
|
|
94
|
-
persistent,
|
|
95
|
-
created,
|
|
96
|
-
lifetime,
|
|
97
|
-
...protocol.getInvitationContext(),
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const stream = new PushStream<Invitation>();
|
|
101
|
-
const ctx = new Context({
|
|
102
|
-
onError: (err) => {
|
|
103
|
-
stream.error(err);
|
|
104
|
-
void ctx.dispose();
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
ctx.onDispose(() => {
|
|
109
|
-
log('complete', { ...protocol.toJSON() });
|
|
110
|
-
stream.complete();
|
|
111
|
-
});
|
|
112
|
-
|
|
64
|
+
handleInvitationFlow(
|
|
65
|
+
ctx: Context,
|
|
66
|
+
stream: PushStream<Invitation>,
|
|
67
|
+
protocol: InvitationProtocol,
|
|
68
|
+
invitation: Invitation,
|
|
69
|
+
): void {
|
|
113
70
|
// Called for every connecting peer.
|
|
114
71
|
const createExtension = (): InvitationHostExtension => {
|
|
115
72
|
const extension = new InvitationHostExtension({
|
|
@@ -128,7 +85,7 @@ export class InvitationsHandler {
|
|
|
128
85
|
try {
|
|
129
86
|
const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
|
|
130
87
|
invariant(deviceKey);
|
|
131
|
-
const admissionResponse = await protocol.admit(admissionRequest, extension.guestProfile);
|
|
88
|
+
const admissionResponse = await protocol.admit(invitation, admissionRequest, extension.guestProfile);
|
|
132
89
|
|
|
133
90
|
// Updating credentials complete.
|
|
134
91
|
extension.completedTrigger.wake(deviceKey);
|
|
@@ -148,7 +105,7 @@ export class InvitationsHandler {
|
|
|
148
105
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
|
|
149
106
|
log('connected', { ...protocol.toJSON() });
|
|
150
107
|
stream.next({ ...invitation, state: Invitation.State.CONNECTED });
|
|
151
|
-
const deviceKey = await extension.completedTrigger.wait({ timeout });
|
|
108
|
+
const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
|
|
152
109
|
log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
|
|
153
110
|
stream.next({ ...invitation, state: Invitation.State.SUCCESS });
|
|
154
111
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
|
|
@@ -162,7 +119,7 @@ export class InvitationsHandler {
|
|
|
162
119
|
}
|
|
163
120
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
|
|
164
121
|
} finally {
|
|
165
|
-
if (
|
|
122
|
+
if (!invitation.multiUse) {
|
|
166
123
|
// Wait for graceful close before disposing.
|
|
167
124
|
await swarmConnection.close();
|
|
168
125
|
await ctx.dispose();
|
|
@@ -187,7 +144,7 @@ export class InvitationsHandler {
|
|
|
187
144
|
return extension;
|
|
188
145
|
};
|
|
189
146
|
|
|
190
|
-
if (invitation.lifetime && invitation.created
|
|
147
|
+
if (invitation.lifetime && invitation.created) {
|
|
191
148
|
if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
|
|
192
149
|
log.warn('invitation has already expired');
|
|
193
150
|
} else {
|
|
@@ -223,18 +180,6 @@ export class InvitationsHandler {
|
|
|
223
180
|
|
|
224
181
|
stream.next({ ...invitation, state: Invitation.State.CONNECTING });
|
|
225
182
|
});
|
|
226
|
-
|
|
227
|
-
// TODO(burdon): Stop anything pending.
|
|
228
|
-
const observable = new CancellableInvitation({
|
|
229
|
-
initialInvitation: invitation,
|
|
230
|
-
subscriber: stream.observable,
|
|
231
|
-
onCancel: async () => {
|
|
232
|
-
stream.next({ ...invitation, state: Invitation.State.CANCELLED });
|
|
233
|
-
await ctx.dispose();
|
|
234
|
-
},
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
return observable;
|
|
238
183
|
}
|
|
239
184
|
|
|
240
185
|
acceptInvitation(
|
|
@@ -245,7 +190,6 @@ export class InvitationsHandler {
|
|
|
245
190
|
const { timeout = INVITATION_TIMEOUT } = invitation;
|
|
246
191
|
invariant(protocol);
|
|
247
192
|
|
|
248
|
-
// TODO(nf): duplicate check in InvitationsService
|
|
249
193
|
if (deviceProfile) {
|
|
250
194
|
invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
251
195
|
}
|
|
@@ -317,26 +261,13 @@ export class InvitationsHandler {
|
|
|
317
261
|
|
|
318
262
|
// 2. Get authentication code.
|
|
319
263
|
if (isAuthenticationRequired(invitation)) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
setState({ state: Invitation.State.AUTHENTICATING });
|
|
327
|
-
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
328
|
-
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
264
|
+
switch (invitation.authMethod) {
|
|
265
|
+
case Invitation.AuthMethod.SHARED_SECRET:
|
|
266
|
+
await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
|
|
267
|
+
break;
|
|
268
|
+
case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
|
|
269
|
+
await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
|
|
329
270
|
break;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
|
|
333
|
-
if (attempt === MAX_OTP_ATTEMPTS) {
|
|
334
|
-
throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
|
|
335
|
-
} else {
|
|
336
|
-
log('retrying invalid code', { attempt });
|
|
337
|
-
authenticated.reset();
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
271
|
}
|
|
341
272
|
}
|
|
342
273
|
|
|
@@ -423,13 +354,61 @@ export class InvitationsHandler {
|
|
|
423
354
|
|
|
424
355
|
return observable;
|
|
425
356
|
}
|
|
357
|
+
|
|
358
|
+
private async _handleGuestOtpAuth(
|
|
359
|
+
extension: InvitationGuestExtension,
|
|
360
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
361
|
+
authenticated: Trigger<string>,
|
|
362
|
+
options: { timeout: number },
|
|
363
|
+
) {
|
|
364
|
+
for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
|
|
365
|
+
log('guest waiting for authentication code...');
|
|
366
|
+
setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
|
|
367
|
+
const authCode = await authenticated.wait(options);
|
|
368
|
+
|
|
369
|
+
log('sending authentication request');
|
|
370
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
371
|
+
const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
|
|
372
|
+
if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
|
|
377
|
+
if (attempt === MAX_OTP_ATTEMPTS) {
|
|
378
|
+
throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
|
|
379
|
+
} else {
|
|
380
|
+
log('retrying invalid code', { attempt });
|
|
381
|
+
authenticated.reset();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private async _handleGuestKpkAuth(
|
|
388
|
+
extension: InvitationGuestExtension,
|
|
389
|
+
setState: (newState: Partial<Invitation>) => void,
|
|
390
|
+
invitation: Invitation,
|
|
391
|
+
introductionResponse: IntroductionResponse,
|
|
392
|
+
) {
|
|
393
|
+
if (invitation.guestKeypair?.privateKey == null) {
|
|
394
|
+
throw new Error('keypair missing in the invitation');
|
|
395
|
+
}
|
|
396
|
+
if (introductionResponse.challenge == null) {
|
|
397
|
+
throw new Error('challenge missing in the introduction');
|
|
398
|
+
}
|
|
399
|
+
log('sending authentication request');
|
|
400
|
+
setState({ state: Invitation.State.AUTHENTICATING });
|
|
401
|
+
const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
|
|
402
|
+
const response = await extension.rpc.InvitationHostService.authenticate({
|
|
403
|
+
signedChallenge: signature,
|
|
404
|
+
});
|
|
405
|
+
if (response.status !== AuthenticationResponse.Status.OK) {
|
|
406
|
+
throw new Error(`Authentication failed with code: ${response.status}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
426
409
|
}
|
|
427
410
|
|
|
428
|
-
export const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
invitation.lifetime &&
|
|
432
|
-
invitation.lifetime !== 0 &&
|
|
433
|
-
invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()
|
|
434
|
-
);
|
|
411
|
+
export const createAdmissionKeypair = (): AdmissionKeypair => {
|
|
412
|
+
const keypair = createKeyPair();
|
|
413
|
+
return { publicKey: PublicKey.from(keypair.publicKey), privateKey: keypair.secretKey };
|
|
435
414
|
};
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Event, PushStream } from '@dxos/async';
|
|
6
|
+
import {
|
|
7
|
+
type AuthenticatingInvitation,
|
|
8
|
+
AUTHENTICATION_CODE_LENGTH,
|
|
9
|
+
CancellableInvitation,
|
|
10
|
+
INVITATION_TIMEOUT,
|
|
11
|
+
} from '@dxos/client-protocol';
|
|
12
|
+
import { Context } from '@dxos/context';
|
|
13
|
+
import { generatePasscode } from '@dxos/credentials';
|
|
14
|
+
import { hasInvitationExpired, type MetadataStore } from '@dxos/echo-pipeline';
|
|
15
|
+
import { invariant } from '@dxos/invariant';
|
|
16
|
+
import { PublicKey } from '@dxos/keys';
|
|
17
|
+
import { log } from '@dxos/log';
|
|
18
|
+
import {
|
|
19
|
+
type AcceptInvitationRequest,
|
|
20
|
+
type AuthenticationRequest,
|
|
21
|
+
Invitation,
|
|
22
|
+
} from '@dxos/protocols/proto/dxos/client/services';
|
|
23
|
+
|
|
24
|
+
import type { InvitationProtocol } from './invitation-protocol';
|
|
25
|
+
import { createAdmissionKeypair, type InvitationsHandler } from './invitations-handler';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Entry point for creating and accepting invitations, keeps track of existing invitation set and
|
|
29
|
+
* emits events when the set changes.
|
|
30
|
+
*/
|
|
31
|
+
export class InvitationsManager {
|
|
32
|
+
private readonly _createInvitations = new Map<string, CancellableInvitation>();
|
|
33
|
+
private readonly _acceptInvitations = new Map<string, AuthenticatingInvitation>();
|
|
34
|
+
|
|
35
|
+
public readonly invitationCreated = new Event<Invitation>();
|
|
36
|
+
public readonly invitationAccepted = new Event<Invitation>();
|
|
37
|
+
public readonly removedCreated = new Event<Invitation>();
|
|
38
|
+
public readonly removedAccepted = new Event<Invitation>();
|
|
39
|
+
public readonly saved = new Event<Invitation>();
|
|
40
|
+
|
|
41
|
+
private readonly _persistentInvitationsLoadedEvent = new Event();
|
|
42
|
+
private _persistentInvitationsLoaded = false;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly _invitationsHandler: InvitationsHandler,
|
|
46
|
+
private readonly _getHandler: (invitation: Partial<Invitation> & Pick<Invitation, 'kind'>) => InvitationProtocol,
|
|
47
|
+
private readonly _metadataStore: MetadataStore,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
async createInvitation(options: Partial<Invitation> & Pick<Invitation, 'kind'>): Promise<CancellableInvitation> {
|
|
51
|
+
if (options.invitationId) {
|
|
52
|
+
const existingInvitation = this._createInvitations.get(options.invitationId);
|
|
53
|
+
if (existingInvitation) {
|
|
54
|
+
return existingInvitation;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handler = this._getHandler(options);
|
|
59
|
+
const invitation = this._createInvitation(handler, options);
|
|
60
|
+
const { ctx, stream, observableInvitation } = this._createObservableInvitation(handler, invitation);
|
|
61
|
+
|
|
62
|
+
this._createInvitations.set(invitation.invitationId, observableInvitation);
|
|
63
|
+
this.invitationCreated.emit(invitation);
|
|
64
|
+
// onComplete is called on cancel, expiration, or redemption of a single-use invitation
|
|
65
|
+
this._onInvitationComplete(observableInvitation, async () => {
|
|
66
|
+
this._createInvitations.delete(observableInvitation.get().invitationId);
|
|
67
|
+
this.removedCreated.emit(observableInvitation.get());
|
|
68
|
+
if (observableInvitation.get().persistent) {
|
|
69
|
+
await this._safeDeleteInvitation(observableInvitation.get());
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await this._persistIfRequired(handler, stream, invitation);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.catch(err);
|
|
77
|
+
await observableInvitation.cancel();
|
|
78
|
+
return observableInvitation;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this._invitationsHandler.handleInvitationFlow(ctx, stream, handler, observableInvitation.get());
|
|
82
|
+
|
|
83
|
+
return observableInvitation;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadPersistentInvitations(): Promise<{ invitations: Invitation[] }> {
|
|
87
|
+
if (this._persistentInvitationsLoaded) {
|
|
88
|
+
const invitations = this.getCreatedInvitations().filter((i) => i.persistent);
|
|
89
|
+
return { invitations };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const persistentInvitations = this._metadataStore.getInvitations();
|
|
93
|
+
// get saved persistent invitations, filter and remove from storage those that have expired.
|
|
94
|
+
const freshInvitations = persistentInvitations.filter((invitation) => !hasInvitationExpired(invitation));
|
|
95
|
+
|
|
96
|
+
const loadTasks = freshInvitations.map((persistentInvitation) => {
|
|
97
|
+
invariant(!this._createInvitations.get(persistentInvitation.invitationId), 'invitation already exists');
|
|
98
|
+
return this.createInvitation({ ...persistentInvitation, persistent: false });
|
|
99
|
+
});
|
|
100
|
+
const cInvitations = await Promise.all(loadTasks);
|
|
101
|
+
|
|
102
|
+
return { invitations: cInvitations.map((invitation) => invitation.get()) };
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.catch(err);
|
|
105
|
+
return { invitations: [] };
|
|
106
|
+
} finally {
|
|
107
|
+
this._persistentInvitationsLoadedEvent.emit();
|
|
108
|
+
this._persistentInvitationsLoaded = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
acceptInvitation(request: AcceptInvitationRequest): AuthenticatingInvitation {
|
|
113
|
+
const options = request.invitation;
|
|
114
|
+
const existingInvitation = this._acceptInvitations.get(options.invitationId);
|
|
115
|
+
if (existingInvitation) {
|
|
116
|
+
return existingInvitation;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const handler = this._getHandler(options);
|
|
120
|
+
const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
|
|
121
|
+
this._acceptInvitations.set(invitation.get().invitationId, invitation);
|
|
122
|
+
this.invitationAccepted.emit(invitation.get());
|
|
123
|
+
|
|
124
|
+
this._onInvitationComplete(invitation, () => {
|
|
125
|
+
this._acceptInvitations.delete(invitation.get().invitationId);
|
|
126
|
+
this.removedAccepted.emit(invitation.get());
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return invitation;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async authenticate({ invitationId, authCode }: AuthenticationRequest): Promise<void> {
|
|
133
|
+
log('authenticating...');
|
|
134
|
+
invariant(invitationId);
|
|
135
|
+
const observable = this._acceptInvitations.get(invitationId);
|
|
136
|
+
if (!observable) {
|
|
137
|
+
log.warn('invalid invitation', { invitationId });
|
|
138
|
+
} else {
|
|
139
|
+
await observable.authenticate(authCode);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async cancelInvitation({ invitationId }: { invitationId: string }): Promise<void> {
|
|
144
|
+
log('cancelInvitation...', { invitationId });
|
|
145
|
+
invariant(invitationId);
|
|
146
|
+
const created = this._createInvitations.get(invitationId);
|
|
147
|
+
if (created) {
|
|
148
|
+
// remove from storage before modifying in-memory state, higher chance of failing
|
|
149
|
+
if (created.get().persistent) {
|
|
150
|
+
await this._metadataStore.removeInvitation(invitationId);
|
|
151
|
+
}
|
|
152
|
+
await created.cancel();
|
|
153
|
+
this._createInvitations.delete(invitationId);
|
|
154
|
+
this.removedCreated.emit(created.get());
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const accepted = this._acceptInvitations.get(invitationId);
|
|
159
|
+
if (accepted) {
|
|
160
|
+
await accepted.cancel();
|
|
161
|
+
this._acceptInvitations.delete(invitationId);
|
|
162
|
+
this.removedAccepted.emit(accepted.get());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getCreatedInvitations(): Invitation[] {
|
|
167
|
+
return [...this._createInvitations.values()].map((i) => i.get());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getAcceptedInvitations(): Invitation[] {
|
|
171
|
+
return [...this._acceptInvitations.values()].map((i) => i.get());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onPersistentInvitationsLoaded(ctx: Context, callback: () => void) {
|
|
175
|
+
if (this._persistentInvitationsLoaded) {
|
|
176
|
+
callback();
|
|
177
|
+
} else {
|
|
178
|
+
this._persistentInvitationsLoadedEvent.once(ctx, () => callback());
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private _createInvitation(protocol: InvitationProtocol, options?: Partial<Invitation>): Invitation {
|
|
183
|
+
const {
|
|
184
|
+
invitationId = PublicKey.random().toHex(),
|
|
185
|
+
type = Invitation.Type.INTERACTIVE,
|
|
186
|
+
authMethod = Invitation.AuthMethod.SHARED_SECRET,
|
|
187
|
+
state = Invitation.State.INIT,
|
|
188
|
+
timeout = INVITATION_TIMEOUT,
|
|
189
|
+
swarmKey = PublicKey.random(),
|
|
190
|
+
persistent = options?.authMethod !== Invitation.AuthMethod.KNOWN_PUBLIC_KEY, // default no not storing keypairs
|
|
191
|
+
created = new Date(),
|
|
192
|
+
guestKeypair = undefined,
|
|
193
|
+
lifetime = 86400, // 1 day,
|
|
194
|
+
multiUse = false,
|
|
195
|
+
} = options ?? {};
|
|
196
|
+
const authCode =
|
|
197
|
+
options?.authCode ??
|
|
198
|
+
(authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
invitationId,
|
|
202
|
+
type,
|
|
203
|
+
authMethod,
|
|
204
|
+
state,
|
|
205
|
+
swarmKey,
|
|
206
|
+
authCode,
|
|
207
|
+
timeout,
|
|
208
|
+
persistent: persistent && type !== Invitation.Type.DELEGATED, // delegated invitations are persisted in control feed
|
|
209
|
+
guestKeypair:
|
|
210
|
+
guestKeypair ?? (authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? createAdmissionKeypair() : undefined),
|
|
211
|
+
created,
|
|
212
|
+
lifetime,
|
|
213
|
+
multiUse,
|
|
214
|
+
delegationCredentialId: options?.delegationCredentialId,
|
|
215
|
+
...protocol.getInvitationContext(),
|
|
216
|
+
} satisfies Invitation;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private _createObservableInvitation(handler: InvitationProtocol, invitation: Invitation) {
|
|
220
|
+
const stream = new PushStream<Invitation>();
|
|
221
|
+
const ctx = new Context({
|
|
222
|
+
onError: (err) => {
|
|
223
|
+
stream.error(err);
|
|
224
|
+
void ctx.dispose();
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
ctx.onDispose(() => {
|
|
228
|
+
log('complete', { ...handler.toJSON() });
|
|
229
|
+
stream.complete();
|
|
230
|
+
});
|
|
231
|
+
const observableInvitation = new CancellableInvitation({
|
|
232
|
+
initialInvitation: invitation,
|
|
233
|
+
subscriber: stream.observable,
|
|
234
|
+
onCancel: async () => {
|
|
235
|
+
stream.next({ ...invitation, state: Invitation.State.CANCELLED });
|
|
236
|
+
await ctx.dispose();
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
return { ctx, stream, observableInvitation };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _persistIfRequired(
|
|
243
|
+
handler: InvitationProtocol,
|
|
244
|
+
changeStream: PushStream<Invitation>,
|
|
245
|
+
invitation: Invitation,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
if (invitation.type === Invitation.Type.DELEGATED && invitation.delegationCredentialId == null) {
|
|
248
|
+
const delegationCredentialId = await handler.delegate(invitation);
|
|
249
|
+
changeStream.next({ ...invitation, delegationCredentialId });
|
|
250
|
+
} else if (invitation.persistent) {
|
|
251
|
+
await this._metadataStore.addInvitation(invitation);
|
|
252
|
+
this.saved.emit(invitation);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async _safeDeleteInvitation(invitation: Invitation): Promise<void> {
|
|
257
|
+
try {
|
|
258
|
+
await this._metadataStore.removeInvitation(invitation.invitationId);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
log.catch(err);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _onInvitationComplete(invitation: CancellableInvitation, callback: () => void) {
|
|
265
|
+
invitation.subscribe(
|
|
266
|
+
() => {},
|
|
267
|
+
() => {},
|
|
268
|
+
callback,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|