@dxos/client-services 0.4.10-main.3e35a2f → 0.4.10-main.3f5e2d2

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.
Files changed (75) hide show
  1. package/dist/lib/browser/{chunk-S3G2RM7S.mjs → chunk-HIQTBJPW.mjs} +837 -715
  2. package/dist/lib/browser/chunk-HIQTBJPW.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +10 -4
  4. package/dist/lib/browser/index.mjs.map +3 -3
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/packlets/testing/index.mjs +132 -116
  7. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-3T6D6GIB.cjs → chunk-JGUWA36I.cjs} +859 -733
  9. package/dist/lib/node/chunk-JGUWA36I.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +50 -44
  11. package/dist/lib/node/index.cjs.map +3 -3
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/packlets/testing/index.cjs +131 -118
  14. package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
  15. package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
  16. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +3 -1
  17. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -1
  18. package/dist/types/src/packlets/invitations/index.d.ts +1 -0
  19. package/dist/types/src/packlets/invitations/index.d.ts.map +1 -1
  20. package/dist/types/src/packlets/invitations/invitation-extension.d.ts +1 -0
  21. package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -1
  22. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +6 -1
  23. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -1
  24. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +8 -3
  25. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  26. package/dist/types/src/packlets/invitations/invitations-manager.d.ts +44 -0
  27. package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -0
  28. package/dist/types/src/packlets/invitations/invitations-service.d.ts +7 -23
  29. package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
  30. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +2 -1
  31. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  32. package/dist/types/src/packlets/services/service-context.d.ts +6 -6
  33. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  34. package/dist/types/src/packlets/services/service-host.d.ts +2 -2
  35. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  36. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +8 -3
  37. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  38. package/dist/types/src/packlets/spaces/data-space.d.ts +4 -3
  39. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  40. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  41. package/dist/types/src/packlets/testing/test-builder.d.ts +9 -6
  42. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  43. package/dist/types/src/packlets/vault/worker-runtime.d.ts.map +1 -1
  44. package/dist/types/src/version.d.ts +1 -1
  45. package/package.json +34 -34
  46. package/src/packlets/identity/identity-manager.ts +1 -0
  47. package/src/packlets/identity/identity.test.ts +3 -0
  48. package/src/packlets/invitations/device-invitation-protocol.ts +6 -1
  49. package/src/packlets/invitations/index.ts +1 -0
  50. package/src/packlets/invitations/invitation-extension.ts +28 -1
  51. package/src/packlets/invitations/invitation-protocol.ts +7 -1
  52. package/src/packlets/invitations/invitations-handler.ts +77 -91
  53. package/src/packlets/invitations/invitations-manager.ts +271 -0
  54. package/src/packlets/invitations/invitations-service.ts +23 -168
  55. package/src/packlets/invitations/space-invitation-protocol.ts +45 -3
  56. package/src/packlets/services/automerge-host.test.ts +4 -4
  57. package/src/packlets/services/service-context.test.ts +3 -3
  58. package/src/packlets/services/service-context.ts +23 -25
  59. package/src/packlets/services/service-host.test.ts +6 -0
  60. package/src/packlets/services/service-host.ts +9 -29
  61. package/src/packlets/spaces/data-space-manager.test.ts +4 -4
  62. package/src/packlets/spaces/data-space-manager.ts +56 -13
  63. package/src/packlets/spaces/data-space.ts +14 -19
  64. package/src/packlets/testing/invitation-utils.ts +100 -97
  65. package/src/packlets/testing/test-builder.ts +29 -16
  66. package/src/packlets/vault/worker-runtime.ts +3 -1
  67. package/src/version.ts +1 -1
  68. package/dist/lib/browser/chunk-S3G2RM7S.mjs.map +0 -7
  69. package/dist/lib/node/chunk-3T6D6GIB.cjs.map +0 -7
  70. package/dist/types/src/packlets/indexing/index.d.ts +0 -2
  71. package/dist/types/src/packlets/indexing/index.d.ts.map +0 -1
  72. package/dist/types/src/packlets/indexing/util.d.ts +0 -15
  73. package/dist/types/src/packlets/indexing/util.d.ts.map +0 -1
  74. package/src/packlets/indexing/index.ts +0 -5
  75. package/src/packlets/indexing/util.ts +0 -89
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { Trigger } from '@dxos/async';
6
6
  import { cancelWithContext, Context } from '@dxos/context';
7
+ import { randomBytes, verify } from '@dxos/crypto';
7
8
  import { invariant } from '@dxos/invariant';
8
9
  import { PublicKey } from '@dxos/keys';
9
10
  import { log } from '@dxos/log';
@@ -51,6 +52,8 @@ export class InvitationHostExtension extends RpcExtension<
51
52
  private _remoteOptions?: Options;
52
53
  private _remoteOptionsTrigger = new Trigger();
53
54
 
55
+ private _challenge?: Buffer = undefined;
56
+
54
57
  public invitation?: Invitation = undefined;
55
58
 
56
59
  public guestProfile?: ProfileDocument = undefined;
@@ -113,13 +116,17 @@ export class InvitationHostExtension extends RpcExtension<
113
116
 
114
117
  this._callbacks.onStateUpdate({ ...this.invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
115
118
 
119
+ this._challenge =
120
+ this.invitation.authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? randomBytes(32) : undefined;
121
+
116
122
  log.trace('dxos.sdk.invitation-handler.host.introduce', trace.end({ id: traceId }));
117
123
  return {
118
124
  authMethod: this.invitation.authMethod,
125
+ challenge: this._challenge,
119
126
  };
120
127
  },
121
128
 
122
- authenticate: async ({ authCode: code }) => {
129
+ authenticate: async ({ authCode: code, signedChallenge }) => {
123
130
  const traceId = PublicKey.random().toHex();
124
131
  log.trace('dxos.sdk.invitation-handler.host.authenticate', trace.begin({ id: traceId }));
125
132
  log('received authentication request', { authCode: code });
@@ -145,6 +152,26 @@ export class InvitationHostExtension extends RpcExtension<
145
152
  break;
146
153
  }
147
154
 
155
+ case Invitation.AuthMethod.KNOWN_PUBLIC_KEY: {
156
+ if (!this.invitation.guestKeypair) {
157
+ status = AuthenticationResponse.Status.INTERNAL_ERROR;
158
+ break;
159
+ }
160
+ const isSignatureValid =
161
+ this._challenge &&
162
+ verify(
163
+ this._challenge,
164
+ Buffer.from(signedChallenge ?? []),
165
+ this.invitation.guestKeypair.publicKey.asBuffer(),
166
+ );
167
+ if (isSignatureValid) {
168
+ this.authenticationPassed = true;
169
+ } else {
170
+ status = AuthenticationResponse.Status.INVALID_SIGNATURE;
171
+ }
172
+ break;
173
+ }
174
+
148
175
  default: {
149
176
  log.error('invalid authentication method', { authMethod: this.invitation.authMethod });
150
177
  status = AuthenticationResponse.Status.INTERNAL_ERROR;
@@ -2,6 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { type PublicKey } from '@dxos/keys';
5
6
  import type { ApiError } from '@dxos/protocols';
6
7
  import type { Invitation } from '@dxos/protocols/proto/dxos/client/services';
7
8
  import type { ProfileDocument, DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
@@ -33,7 +34,12 @@ export interface InvitationProtocol {
33
34
  /**
34
35
  * Once authentication is successful, the host can admit the guest to the requested resource.
35
36
  */
36
- admit(request: AdmissionRequest, guestProfile?: ProfileDocument): Promise<AdmissionResponse>;
37
+ delegate(invitation: Invitation): Promise<PublicKey>;
38
+
39
+ /**
40
+ * Once authentication is successful, the host can admit the guest to the requested resource.
41
+ */
42
+ admit(invitation: Invitation, request: AdmissionRequest, guestProfile?: ProfileDocument): Promise<AdmissionResponse>;
37
43
 
38
44
  //
39
45
  // Guest
@@ -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 { generatePasscode } from '@dxos/credentials';
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,52 +61,12 @@ export class InvitationsHandler {
66
61
  */
67
62
  constructor(private readonly _networkManager: NetworkManager) {}
68
63
 
69
- createInvitation(protocol: InvitationProtocol, options?: Partial<Invitation>): CancellableInvitation {
70
- const {
71
- invitationId = PublicKey.random().toHex(),
72
- type = Invitation.Type.INTERACTIVE,
73
- authMethod = Invitation.AuthMethod.SHARED_SECRET,
74
- state = Invitation.State.INIT,
75
- timeout = INVITATION_TIMEOUT,
76
- swarmKey = PublicKey.random(),
77
- persistent = true,
78
- created = new Date(),
79
- lifetime = 86400, // 1 day,
80
- multiUse = false,
81
- } = options ?? {};
82
- const authCode =
83
- options?.authCode ??
84
- (authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
85
- invariant(protocol);
86
-
87
- const invitation: Invitation = {
88
- invitationId,
89
- type,
90
- authMethod,
91
- state,
92
- swarmKey,
93
- authCode,
94
- timeout,
95
- persistent: persistent && type !== Invitation.Type.OFFLINE, // offline invitations are persisted in control feed
96
- created,
97
- lifetime,
98
- multiUse,
99
- ...protocol.getInvitationContext(),
100
- };
101
-
102
- const stream = new PushStream<Invitation>();
103
- const ctx = new Context({
104
- onError: (err) => {
105
- stream.error(err);
106
- void ctx.dispose();
107
- },
108
- });
109
-
110
- ctx.onDispose(() => {
111
- log('complete', { ...protocol.toJSON() });
112
- stream.complete();
113
- });
114
-
64
+ handleInvitationFlow(
65
+ ctx: Context,
66
+ stream: PushStream<Invitation>,
67
+ protocol: InvitationProtocol,
68
+ invitation: Invitation,
69
+ ): void {
115
70
  // Called for every connecting peer.
116
71
  const createExtension = (): InvitationHostExtension => {
117
72
  const extension = new InvitationHostExtension({
@@ -130,7 +85,7 @@ export class InvitationsHandler {
130
85
  try {
131
86
  const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
132
87
  invariant(deviceKey);
133
- const admissionResponse = await protocol.admit(admissionRequest, extension.guestProfile);
88
+ const admissionResponse = await protocol.admit(invitation, admissionRequest, extension.guestProfile);
134
89
 
135
90
  // Updating credentials complete.
136
91
  extension.completedTrigger.wake(deviceKey);
@@ -150,7 +105,7 @@ export class InvitationsHandler {
150
105
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.begin({ id: traceId }));
151
106
  log('connected', { ...protocol.toJSON() });
152
107
  stream.next({ ...invitation, state: Invitation.State.CONNECTED });
153
- const deviceKey = await extension.completedTrigger.wait({ timeout });
108
+ const deviceKey = await extension.completedTrigger.wait({ timeout: invitation.timeout });
154
109
  log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
155
110
  stream.next({ ...invitation, state: Invitation.State.SUCCESS });
156
111
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.end({ id: traceId }));
@@ -164,7 +119,7 @@ export class InvitationsHandler {
164
119
  }
165
120
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
166
121
  } finally {
167
- if (!multiUse) {
122
+ if (!invitation.multiUse) {
168
123
  // Wait for graceful close before disposing.
169
124
  await swarmConnection.close();
170
125
  await ctx.dispose();
@@ -189,7 +144,7 @@ export class InvitationsHandler {
189
144
  return extension;
190
145
  };
191
146
 
192
- if (invitation.lifetime && invitation.created && invitation.lifetime !== 0) {
147
+ if (invitation.lifetime && invitation.created) {
193
148
  if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
194
149
  log.warn('invitation has already expired');
195
150
  } else {
@@ -225,18 +180,6 @@ export class InvitationsHandler {
225
180
 
226
181
  stream.next({ ...invitation, state: Invitation.State.CONNECTING });
227
182
  });
228
-
229
- // TODO(burdon): Stop anything pending.
230
- const observable = new CancellableInvitation({
231
- initialInvitation: invitation,
232
- subscriber: stream.observable,
233
- onCancel: async () => {
234
- stream.next({ ...invitation, state: Invitation.State.CANCELLED });
235
- await ctx.dispose();
236
- },
237
- });
238
-
239
- return observable;
240
183
  }
241
184
 
242
185
  acceptInvitation(
@@ -247,7 +190,6 @@ export class InvitationsHandler {
247
190
  const { timeout = INVITATION_TIMEOUT } = invitation;
248
191
  invariant(protocol);
249
192
 
250
- // TODO(nf): duplicate check in InvitationsService
251
193
  if (deviceProfile) {
252
194
  invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
253
195
  }
@@ -319,26 +261,13 @@ export class InvitationsHandler {
319
261
 
320
262
  // 2. Get authentication code.
321
263
  if (isAuthenticationRequired(invitation)) {
322
- for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
323
- log('guest waiting for authentication code...');
324
- setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
325
- const authCode = await authenticated.wait({ timeout });
326
-
327
- log('sending authentication request');
328
- setState({ state: Invitation.State.AUTHENTICATING });
329
- const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
330
- 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);
331
270
  break;
332
- }
333
-
334
- if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
335
- if (attempt === MAX_OTP_ATTEMPTS) {
336
- throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
337
- } else {
338
- log('retrying invalid code', { attempt });
339
- authenticated.reset();
340
- }
341
- }
342
271
  }
343
272
  }
344
273
 
@@ -425,4 +354,61 @@ export class InvitationsHandler {
425
354
 
426
355
  return observable;
427
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
+ }
428
409
  }
410
+
411
+ export const createAdmissionKeypair = (): AdmissionKeypair => {
412
+ const keypair = createKeyPair();
413
+ return { publicKey: PublicKey.from(keypair.publicKey), privateKey: keypair.secretKey };
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
+ }