@dxos/client-services 0.4.10-main.e2d2318 → 0.4.10-main.e3078ed
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-HAFART26.mjs → chunk-IYPYLH4D.mjs} +631 -511
- package/dist/lib/browser/chunk-IYPYLH4D.mjs.map +7 -0
- package/dist/lib/browser/index.mjs +3 -1
- 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 +127 -113
- package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
- package/dist/lib/node/{chunk-5YDVC6TU.cjs → chunk-PZOCMCLY.cjs} +699 -580
- package/dist/lib/node/chunk-PZOCMCLY.cjs.map +7 -0
- package/dist/lib/node/index.cjs +44 -42
- 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 +127 -116
- package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
- 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-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 +4 -2
- 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/service-context.d.ts +2 -0
- package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
- package/dist/types/src/packlets/services/service-host.d.ts +3 -2
- package/dist/types/src/packlets/services/service-host.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/testing/invitation-utils.d.ts.map +1 -1
- package/dist/types/src/packlets/testing/test-builder.d.ts +6 -4
- 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 +34 -34
- package/src/packlets/identity/identity-manager.ts +1 -0
- package/src/packlets/identity/identity.test.ts +3 -0
- package/src/packlets/indexing/util.ts +9 -66
- package/src/packlets/invitations/device-invitation-protocol.ts +6 -1
- package/src/packlets/invitations/index.ts +1 -0
- package/src/packlets/invitations/invitation-protocol.ts +7 -1
- package/src/packlets/invitations/invitations-handler.ts +11 -73
- 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/services/automerge-host.test.ts +1 -1
- package/src/packlets/services/service-context.ts +17 -5
- package/src/packlets/services/service-host.ts +13 -17
- package/src/packlets/spaces/data-space-manager.ts +48 -2
- package/src/packlets/spaces/data-space.ts +1 -1
- package/src/packlets/testing/invitation-utils.ts +100 -97
- package/src/packlets/testing/test-builder.ts +23 -5
- package/src/version.ts +1 -1
- package/dist/lib/browser/chunk-HAFART26.mjs.map +0 -7
- package/dist/lib/node/chunk-5YDVC6TU.cjs.map +0 -7
|
@@ -3,14 +3,8 @@
|
|
|
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 { generatePasscode } from '@dxos/credentials';
|
|
14
8
|
import { createKeyPair, sign } from '@dxos/crypto';
|
|
15
9
|
import { invariant } from '@dxos/invariant';
|
|
16
10
|
import { PublicKey } from '@dxos/keys';
|
|
@@ -67,55 +61,12 @@ export class InvitationsHandler {
|
|
|
67
61
|
*/
|
|
68
62
|
constructor(private readonly _networkManager: NetworkManager) {}
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
timeout = INVITATION_TIMEOUT,
|
|
77
|
-
swarmKey = PublicKey.random(),
|
|
78
|
-
persistent = options?.authMethod !== Invitation.AuthMethod.KNOWN_PUBLIC_KEY, // default no not storing keypairs
|
|
79
|
-
created = new Date(),
|
|
80
|
-
guestKeypair = undefined,
|
|
81
|
-
lifetime = 86400, // 1 day,
|
|
82
|
-
multiUse = false,
|
|
83
|
-
} = options ?? {};
|
|
84
|
-
const authCode =
|
|
85
|
-
options?.authCode ??
|
|
86
|
-
(authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
|
|
87
|
-
invariant(protocol);
|
|
88
|
-
|
|
89
|
-
const invitation: Invitation = {
|
|
90
|
-
invitationId,
|
|
91
|
-
type,
|
|
92
|
-
authMethod,
|
|
93
|
-
state,
|
|
94
|
-
swarmKey,
|
|
95
|
-
authCode,
|
|
96
|
-
timeout,
|
|
97
|
-
persistent: persistent && type !== Invitation.Type.DELEGATED, // delegated invitations are persisted in control feed
|
|
98
|
-
guestKeypair:
|
|
99
|
-
guestKeypair ?? (authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? createAdmissionKeypair() : undefined),
|
|
100
|
-
created,
|
|
101
|
-
lifetime,
|
|
102
|
-
multiUse,
|
|
103
|
-
...protocol.getInvitationContext(),
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const stream = new PushStream<Invitation>();
|
|
107
|
-
const ctx = new Context({
|
|
108
|
-
onError: (err) => {
|
|
109
|
-
stream.error(err);
|
|
110
|
-
void ctx.dispose();
|
|
111
|
-
},
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
ctx.onDispose(() => {
|
|
115
|
-
log('complete', { ...protocol.toJSON() });
|
|
116
|
-
stream.complete();
|
|
117
|
-
});
|
|
118
|
-
|
|
64
|
+
handleInvitationFlow(
|
|
65
|
+
ctx: Context,
|
|
66
|
+
stream: PushStream<Invitation>,
|
|
67
|
+
protocol: InvitationProtocol,
|
|
68
|
+
invitation: Invitation,
|
|
69
|
+
): void {
|
|
119
70
|
// Called for every connecting peer.
|
|
120
71
|
const createExtension = (): InvitationHostExtension => {
|
|
121
72
|
const extension = new InvitationHostExtension({
|
|
@@ -134,7 +85,7 @@ export class InvitationsHandler {
|
|
|
134
85
|
try {
|
|
135
86
|
const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
|
|
136
87
|
invariant(deviceKey);
|
|
137
|
-
const admissionResponse = await protocol.admit(admissionRequest, extension.guestProfile);
|
|
88
|
+
const admissionResponse = await protocol.admit(invitation, admissionRequest, extension.guestProfile);
|
|
138
89
|
|
|
139
90
|
// Updating credentials complete.
|
|
140
91
|
extension.completedTrigger.wake(deviceKey);
|
|
@@ -154,7 +105,7 @@ export class InvitationsHandler {
|
|
|
154
105
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
|
|
155
106
|
log('connected', { ...protocol.toJSON() });
|
|
156
107
|
stream.next({ ...invitation, state: Invitation.State.CONNECTED });
|
|
157
|
-
const deviceKey = await extension.completedTrigger.wait({ timeout });
|
|
108
|
+
const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
|
|
158
109
|
log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
|
|
159
110
|
stream.next({ ...invitation, state: Invitation.State.SUCCESS });
|
|
160
111
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
|
|
@@ -168,7 +119,7 @@ export class InvitationsHandler {
|
|
|
168
119
|
}
|
|
169
120
|
log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
|
|
170
121
|
} finally {
|
|
171
|
-
if (!multiUse) {
|
|
122
|
+
if (!invitation.multiUse) {
|
|
172
123
|
// Wait for graceful close before disposing.
|
|
173
124
|
await swarmConnection.close();
|
|
174
125
|
await ctx.dispose();
|
|
@@ -193,7 +144,7 @@ export class InvitationsHandler {
|
|
|
193
144
|
return extension;
|
|
194
145
|
};
|
|
195
146
|
|
|
196
|
-
if (invitation.lifetime && invitation.created
|
|
147
|
+
if (invitation.lifetime && invitation.created) {
|
|
197
148
|
if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
|
|
198
149
|
log.warn('invitation has already expired');
|
|
199
150
|
} else {
|
|
@@ -229,18 +180,6 @@ export class InvitationsHandler {
|
|
|
229
180
|
|
|
230
181
|
stream.next({ ...invitation, state: Invitation.State.CONNECTING });
|
|
231
182
|
});
|
|
232
|
-
|
|
233
|
-
// TODO(burdon): Stop anything pending.
|
|
234
|
-
const observable = new CancellableInvitation({
|
|
235
|
-
initialInvitation: invitation,
|
|
236
|
-
subscriber: stream.observable,
|
|
237
|
-
onCancel: async () => {
|
|
238
|
-
stream.next({ ...invitation, state: Invitation.State.CANCELLED });
|
|
239
|
-
await ctx.dispose();
|
|
240
|
-
},
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
return observable;
|
|
244
183
|
}
|
|
245
184
|
|
|
246
185
|
acceptInvitation(
|
|
@@ -251,7 +190,6 @@ export class InvitationsHandler {
|
|
|
251
190
|
const { timeout = INVITATION_TIMEOUT } = invitation;
|
|
252
191
|
invariant(protocol);
|
|
253
192
|
|
|
254
|
-
// TODO(nf): duplicate check in InvitationsService
|
|
255
193
|
if (deviceProfile) {
|
|
256
194
|
invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
257
195
|
}
|
|
@@ -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
|
+
}
|
|
@@ -2,43 +2,22 @@
|
|
|
2
2
|
// Copyright 2022 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Event, scheduleTask } from '@dxos/async';
|
|
6
|
-
import { type AuthenticatingInvitation, type CancellableInvitation } from '@dxos/client-protocol';
|
|
7
5
|
import { Stream } from '@dxos/codec-protobuf';
|
|
8
|
-
import { Context } from '@dxos/context';
|
|
9
|
-
import { hasInvitationExpired, type MetadataStore } from '@dxos/echo-pipeline';
|
|
10
|
-
import { invariant } from '@dxos/invariant';
|
|
11
|
-
import { log } from '@dxos/log';
|
|
12
6
|
import {
|
|
13
7
|
type AuthenticationRequest,
|
|
14
8
|
type AcceptInvitationRequest,
|
|
15
|
-
Invitation,
|
|
9
|
+
type Invitation,
|
|
16
10
|
type InvitationsService,
|
|
17
11
|
QueryInvitationsResponse,
|
|
18
12
|
} from '@dxos/protocols/proto/dxos/client/services';
|
|
19
13
|
|
|
20
|
-
import { type
|
|
21
|
-
import { type InvitationsHandler } from './invitations-handler';
|
|
14
|
+
import { type InvitationsManager } from './invitations-manager';
|
|
22
15
|
|
|
23
16
|
/**
|
|
24
17
|
* Adapts invitation service observable to client/service stream.
|
|
25
18
|
*/
|
|
26
19
|
export class InvitationsServiceImpl implements InvitationsService {
|
|
27
|
-
private readonly
|
|
28
|
-
private readonly _acceptInvitations = new Map<string, AuthenticatingInvitation>();
|
|
29
|
-
private readonly _invitationCreated = new Event<Invitation>();
|
|
30
|
-
private readonly _invitationAccepted = new Event<Invitation>();
|
|
31
|
-
private readonly _removedCreated = new Event<Invitation>();
|
|
32
|
-
private readonly _removedAccepted = new Event<Invitation>();
|
|
33
|
-
private readonly _saved = new Event<Invitation>();
|
|
34
|
-
private readonly _persistentInvitationsLoadedEvent = new Event();
|
|
35
|
-
private _persistentInvitationsLoaded = false;
|
|
36
|
-
|
|
37
|
-
constructor(
|
|
38
|
-
private readonly _invitationsHandler: InvitationsHandler,
|
|
39
|
-
private readonly _getHandler: (invitation: Invitation) => InvitationProtocol,
|
|
40
|
-
private readonly _metadataStore: MetadataStore,
|
|
41
|
-
) {}
|
|
20
|
+
constructor(private readonly _invitationsManager: InvitationsManager) {}
|
|
42
21
|
|
|
43
22
|
// TODO(burdon): Guest/host label.
|
|
44
23
|
getLoggingContext() {
|
|
@@ -48,148 +27,33 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
48
27
|
}
|
|
49
28
|
|
|
50
29
|
createInvitation(options: Invitation): Stream<Invitation> {
|
|
51
|
-
let invitation: CancellableInvitation;
|
|
52
|
-
|
|
53
|
-
const savePersistentInvitationCtx = new Context();
|
|
54
|
-
const existingInvitation = this._createInvitations.get(options.invitationId);
|
|
55
|
-
if (existingInvitation) {
|
|
56
|
-
invitation = existingInvitation;
|
|
57
|
-
} else {
|
|
58
|
-
const handler = this._getHandler(options);
|
|
59
|
-
invitation = this._invitationsHandler.createInvitation(handler, options);
|
|
60
|
-
this._createInvitations.set(invitation.get().invitationId, invitation);
|
|
61
|
-
this._invitationCreated.emit(invitation.get());
|
|
62
|
-
}
|
|
63
|
-
|
|
64
30
|
return new Stream<Invitation>(({ next, close }) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
this._saved.emit(invitation.get());
|
|
70
|
-
} catch (err: any) {
|
|
71
|
-
close(err);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
invitation.subscribe(
|
|
76
|
-
(invitation) => {
|
|
77
|
-
next(invitation);
|
|
78
|
-
},
|
|
79
|
-
async (err: Error) => {
|
|
80
|
-
await savePersistentInvitationCtx.dispose();
|
|
81
|
-
|
|
82
|
-
// TODO(nf): also remove from storage?
|
|
83
|
-
close(err);
|
|
84
|
-
},
|
|
85
|
-
async () => {
|
|
86
|
-
close();
|
|
87
|
-
if (invitation.get().persistent) {
|
|
88
|
-
await savePersistentInvitationCtx.dispose();
|
|
89
|
-
// TODO(nf): remove on all complete conditions?
|
|
90
|
-
await this._metadataStore.removeInvitation(invitation.get().invitationId);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
this._createInvitations.delete(invitation.get().invitationId);
|
|
94
|
-
if (!invitation.get().multiUse) {
|
|
95
|
-
this._removedCreated.emit(invitation.get());
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async loadPersistentInvitations() {
|
|
103
|
-
const persistentInvitations = this._metadataStore.getInvitations();
|
|
104
|
-
|
|
105
|
-
// get saved persistent invitations, filter and remove from storage those that have expired.
|
|
106
|
-
const freshInvitations = persistentInvitations.filter(async (invitation) => !hasInvitationExpired(invitation));
|
|
107
|
-
|
|
108
|
-
const cInvitations = freshInvitations.map((persistentInvitation) => {
|
|
109
|
-
invariant(!this._createInvitations.get(persistentInvitation.invitationId), 'invitation already exists');
|
|
110
|
-
|
|
111
|
-
const handler = this._getHandler(persistentInvitation);
|
|
112
|
-
const invitation = this._invitationsHandler.createInvitation(handler, persistentInvitation);
|
|
113
|
-
this._createInvitations.set(invitation.get().invitationId, invitation);
|
|
114
|
-
this._invitationCreated.emit(invitation.get());
|
|
115
|
-
return persistentInvitation;
|
|
31
|
+
void this._invitationsManager
|
|
32
|
+
.createInvitation(options)
|
|
33
|
+
.then((invitation) => invitation.subscribe(next, close, close))
|
|
34
|
+
.catch(close);
|
|
116
35
|
});
|
|
117
|
-
this._persistentInvitationsLoadedEvent.emit();
|
|
118
|
-
this._persistentInvitationsLoaded = true;
|
|
119
|
-
return { invitations: cInvitations };
|
|
120
36
|
}
|
|
121
37
|
|
|
122
|
-
acceptInvitation(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// TODO(nf): duplicate check in InvitationHandler
|
|
126
|
-
if (deviceProfile) {
|
|
127
|
-
invariant(options.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const existingInvitation = this._acceptInvitations.get(options.invitationId);
|
|
131
|
-
if (existingInvitation) {
|
|
132
|
-
invitation = existingInvitation;
|
|
133
|
-
} else {
|
|
134
|
-
const handler = this._getHandler(options);
|
|
135
|
-
invitation = this._invitationsHandler.acceptInvitation(handler, options, deviceProfile);
|
|
136
|
-
this._acceptInvitations.set(invitation.get().invitationId, invitation);
|
|
137
|
-
this._invitationAccepted.emit(invitation.get());
|
|
138
|
-
}
|
|
139
|
-
|
|
38
|
+
acceptInvitation(request: AcceptInvitationRequest): Stream<Invitation> {
|
|
39
|
+
const invitation = this._invitationsManager.acceptInvitation(request);
|
|
140
40
|
return new Stream<Invitation>(({ next, close }) => {
|
|
141
|
-
invitation.subscribe(
|
|
142
|
-
(invitation) => {
|
|
143
|
-
next(invitation);
|
|
144
|
-
},
|
|
145
|
-
(err: Error) => {
|
|
146
|
-
close(err);
|
|
147
|
-
},
|
|
148
|
-
() => {
|
|
149
|
-
close();
|
|
150
|
-
this._acceptInvitations.delete(invitation.get().invitationId);
|
|
151
|
-
if (!invitation.get().multiUse) {
|
|
152
|
-
this._removedAccepted.emit(invitation.get());
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
);
|
|
41
|
+
invitation.subscribe(next, close, close);
|
|
156
42
|
});
|
|
157
43
|
}
|
|
158
44
|
|
|
159
|
-
async authenticate(
|
|
160
|
-
|
|
161
|
-
invariant(invitationId);
|
|
162
|
-
const observable = this._acceptInvitations.get(invitationId);
|
|
163
|
-
if (!observable) {
|
|
164
|
-
log.warn('invalid invitation', { invitationId });
|
|
165
|
-
} else {
|
|
166
|
-
await observable.authenticate(authCode);
|
|
167
|
-
}
|
|
45
|
+
async authenticate(request: AuthenticationRequest): Promise<void> {
|
|
46
|
+
return this._invitationsManager.authenticate(request);
|
|
168
47
|
}
|
|
169
48
|
|
|
170
|
-
async cancelInvitation(
|
|
171
|
-
|
|
172
|
-
invariant(invitationId);
|
|
173
|
-
const created = this._createInvitations.get(invitationId);
|
|
174
|
-
const accepted = this._acceptInvitations.get(invitationId);
|
|
175
|
-
if (created) {
|
|
176
|
-
await created.cancel();
|
|
177
|
-
this._createInvitations.delete(invitationId);
|
|
178
|
-
this._removedCreated.emit(created.get());
|
|
179
|
-
if (created.get().persistent) {
|
|
180
|
-
await this._metadataStore.removeInvitation(created.get().invitationId);
|
|
181
|
-
}
|
|
182
|
-
} else if (accepted) {
|
|
183
|
-
await accepted.cancel();
|
|
184
|
-
this._acceptInvitations.delete(invitationId);
|
|
185
|
-
this._removedAccepted.emit(accepted.get());
|
|
186
|
-
}
|
|
49
|
+
async cancelInvitation(request: { invitationId: string }): Promise<void> {
|
|
50
|
+
return this._invitationsManager.cancelInvitation(request);
|
|
187
51
|
}
|
|
188
52
|
|
|
189
53
|
queryInvitations(): Stream<QueryInvitationsResponse> {
|
|
190
54
|
return new Stream<QueryInvitationsResponse>(({ next, ctx }) => {
|
|
191
55
|
// Push added invitations to the stream.
|
|
192
|
-
this.
|
|
56
|
+
this._invitationsManager.invitationCreated.on(ctx, (invitation) => {
|
|
193
57
|
next({
|
|
194
58
|
action: QueryInvitationsResponse.Action.ADDED,
|
|
195
59
|
type: QueryInvitationsResponse.Type.CREATED,
|
|
@@ -197,7 +61,7 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
197
61
|
});
|
|
198
62
|
});
|
|
199
63
|
|
|
200
|
-
this.
|
|
64
|
+
this._invitationsManager.invitationAccepted.on(ctx, (invitation) => {
|
|
201
65
|
next({
|
|
202
66
|
action: QueryInvitationsResponse.Action.ADDED,
|
|
203
67
|
type: QueryInvitationsResponse.Type.ACCEPTED,
|
|
@@ -206,7 +70,7 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
206
70
|
});
|
|
207
71
|
|
|
208
72
|
// Push removed invitations to the stream.
|
|
209
|
-
this.
|
|
73
|
+
this._invitationsManager.removedCreated.on(ctx, (invitation) => {
|
|
210
74
|
next({
|
|
211
75
|
action: QueryInvitationsResponse.Action.REMOVED,
|
|
212
76
|
type: QueryInvitationsResponse.Type.CREATED,
|
|
@@ -214,7 +78,7 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
214
78
|
});
|
|
215
79
|
});
|
|
216
80
|
|
|
217
|
-
this.
|
|
81
|
+
this._invitationsManager.removedAccepted.on(ctx, (invitation) => {
|
|
218
82
|
next({
|
|
219
83
|
action: QueryInvitationsResponse.Action.REMOVED,
|
|
220
84
|
type: QueryInvitationsResponse.Type.ACCEPTED,
|
|
@@ -223,7 +87,7 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
223
87
|
});
|
|
224
88
|
|
|
225
89
|
// used only for testing
|
|
226
|
-
this.
|
|
90
|
+
this._invitationsManager.saved.on(ctx, (invitation) => {
|
|
227
91
|
next({
|
|
228
92
|
action: QueryInvitationsResponse.Action.SAVED,
|
|
229
93
|
type: QueryInvitationsResponse.Type.CREATED,
|
|
@@ -235,33 +99,24 @@ export class InvitationsServiceImpl implements InvitationsService {
|
|
|
235
99
|
next({
|
|
236
100
|
action: QueryInvitationsResponse.Action.ADDED,
|
|
237
101
|
type: QueryInvitationsResponse.Type.CREATED,
|
|
238
|
-
invitations:
|
|
102
|
+
invitations: this._invitationsManager.getCreatedInvitations(),
|
|
239
103
|
existing: true,
|
|
240
104
|
});
|
|
241
105
|
|
|
242
106
|
next({
|
|
243
107
|
action: QueryInvitationsResponse.Action.ADDED,
|
|
244
108
|
type: QueryInvitationsResponse.Type.ACCEPTED,
|
|
245
|
-
invitations:
|
|
109
|
+
invitations: this._invitationsManager.getAcceptedInvitations(),
|
|
246
110
|
existing: true,
|
|
247
111
|
});
|
|
248
112
|
|
|
249
|
-
|
|
113
|
+
this._invitationsManager.onPersistentInvitationsLoaded(ctx, () => {
|
|
250
114
|
next({
|
|
251
115
|
action: QueryInvitationsResponse.Action.LOAD_COMPLETE,
|
|
252
116
|
type: QueryInvitationsResponse.Type.CREATED,
|
|
253
117
|
// TODO(nf): populate with invitations
|
|
254
118
|
});
|
|
255
|
-
}
|
|
256
|
-
this._persistentInvitationsLoadedEvent.on(ctx, () => {
|
|
257
|
-
next({
|
|
258
|
-
action: QueryInvitationsResponse.Action.LOAD_COMPLETE,
|
|
259
|
-
type: QueryInvitationsResponse.Type.CREATED,
|
|
260
|
-
// TODO(nf): populate with invitations
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
119
|
+
});
|
|
265
120
|
// TODO(nf): expired invitations?
|
|
266
121
|
});
|
|
267
122
|
}
|