@dxos/client-services 0.4.10-main.c42bfdb → 0.4.10-main.c75170d

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 (50) hide show
  1. package/dist/lib/browser/{chunk-W7UANCHR.mjs → chunk-JP7F2IH3.mjs} +513 -403
  2. package/dist/lib/browser/chunk-JP7F2IH3.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +5 -3
  4. package/dist/lib/browser/index.mjs.map +1 -1
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/packlets/testing/index.mjs +9 -4
  7. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  8. package/dist/lib/node/{chunk-JSVLZGJM.cjs → chunk-34EZSH65.cjs} +520 -411
  9. package/dist/lib/node/chunk-34EZSH65.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +45 -43
  11. package/dist/lib/node/index.cjs.map +1 -1
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/packlets/testing/index.cjs +14 -9
  14. package/dist/lib/node/packlets/testing/index.cjs.map +3 -3
  15. package/dist/types/src/packlets/indexing/util.d.ts +2 -1
  16. package/dist/types/src/packlets/indexing/util.d.ts.map +1 -1
  17. package/dist/types/src/packlets/invitations/index.d.ts +1 -0
  18. package/dist/types/src/packlets/invitations/index.d.ts.map +1 -1
  19. package/dist/types/src/packlets/invitations/invitation-extension.d.ts +1 -0
  20. package/dist/types/src/packlets/invitations/invitation-extension.d.ts.map +1 -1
  21. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +4 -2
  22. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  23. package/dist/types/src/packlets/invitations/invitations-manager.d.ts +42 -0
  24. package/dist/types/src/packlets/invitations/invitations-manager.d.ts.map +1 -0
  25. package/dist/types/src/packlets/invitations/invitations-service.d.ts +7 -23
  26. package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
  27. package/dist/types/src/packlets/services/service-context.d.ts +2 -0
  28. package/dist/types/src/packlets/services/service-context.d.ts.map +1 -1
  29. package/dist/types/src/packlets/services/service-host.d.ts +3 -1
  30. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  31. package/dist/types/src/packlets/testing/test-builder.d.ts +3 -1
  32. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  33. package/dist/types/src/version.d.ts +1 -1
  34. package/package.json +34 -34
  35. package/src/packlets/indexing/util.ts +2 -2
  36. package/src/packlets/invitations/index.ts +1 -0
  37. package/src/packlets/invitations/invitation-extension.ts +28 -1
  38. package/src/packlets/invitations/invitations-handler.ts +74 -34
  39. package/src/packlets/invitations/invitations-manager.ts +197 -0
  40. package/src/packlets/invitations/invitations-service.ts +21 -168
  41. package/src/packlets/services/automerge-host.test.ts +9 -3
  42. package/src/packlets/services/service-context.test.ts +4 -1
  43. package/src/packlets/services/service-context.ts +16 -3
  44. package/src/packlets/services/service-host.ts +15 -13
  45. package/src/packlets/spaces/data-space-manager.test.ts +4 -4
  46. package/src/packlets/storage/level.ts +1 -1
  47. package/src/packlets/testing/test-builder.ts +20 -4
  48. package/src/version.ts +1 -1
  49. package/dist/lib/browser/chunk-W7UANCHR.mjs.map +0 -7
  50. package/dist/lib/node/chunk-JSVLZGJM.cjs.map +0 -7
@@ -7,7 +7,7 @@ import { type DocHandle } from '@dxos/automerge/automerge-repo';
7
7
  import { warnAfterTimeout } from '@dxos/debug';
8
8
  import { type AutomergeHost } from '@dxos/echo-pipeline';
9
9
  import { type ObjectSnapshot } from '@dxos/indexing';
10
- import { idCodec } from '@dxos/protocols';
10
+ import { type ObjectPointerEncoded, idCodec } from '@dxos/protocols';
11
11
 
12
12
  /**
13
13
  * Factory for `loadDocuments` iterator.
@@ -18,7 +18,7 @@ export const createSelectedDocumentsIterator = (automergeHost: AutomergeHost) =>
18
18
  * @param ids
19
19
  */
20
20
  // TODO(mykola): Unload automerge handles after usage.
21
- async function* loadDocuments(ids: string[]) {
21
+ async function* loadDocuments(ids: ObjectPointerEncoded[]) {
22
22
  for (const id of ids) {
23
23
  const { documentId, objectId } = idCodec.decode(id);
24
24
  const handle = automergeHost.repo.find(documentId as any);
@@ -7,3 +7,4 @@ export * from './invitation-protocol';
7
7
  export * from './invitations-handler';
8
8
  export * from './invitations-service';
9
9
  export * from './space-invitation-protocol';
10
+ export * from './invitations-manager';
@@ -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;
@@ -11,6 +11,7 @@ import {
11
11
  } from '@dxos/client-protocol';
12
12
  import { Context } from '@dxos/context';
13
13
  import { generatePasscode } from '@dxos/credentials';
14
+ import { createKeyPair, sign } from '@dxos/crypto';
14
15
  import { invariant } from '@dxos/invariant';
15
16
  import { PublicKey } from '@dxos/keys';
16
17
  import { log } from '@dxos/log';
@@ -21,9 +22,9 @@ import {
21
22
  type SwarmConnection,
22
23
  } from '@dxos/network-manager';
23
24
  import { InvalidInvitationExtensionRoleError, trace } from '@dxos/protocols';
24
- import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
25
+ import { type AdmissionKeypair, Invitation } from '@dxos/protocols/proto/dxos/client/services';
25
26
  import { type DeviceProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
26
- import { AuthenticationResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
27
+ import { AuthenticationResponse, type IntroductionResponse } from '@dxos/protocols/proto/dxos/halo/invitations';
27
28
 
28
29
  import {
29
30
  InvitationGuestExtension,
@@ -74,9 +75,11 @@ export class InvitationsHandler {
74
75
  state = Invitation.State.INIT,
75
76
  timeout = INVITATION_TIMEOUT,
76
77
  swarmKey = PublicKey.random(),
77
- persistent = true,
78
+ persistent = options?.authMethod !== Invitation.AuthMethod.KNOWN_PUBLIC_KEY, // default no not storing keypairs
78
79
  created = new Date(),
79
- lifetime = 86400, // 1 day
80
+ guestKeypair = undefined,
81
+ lifetime = 86400, // 1 day,
82
+ multiUse = false,
80
83
  } = options ?? {};
81
84
  const authCode =
82
85
  options?.authCode ??
@@ -91,9 +94,12 @@ export class InvitationsHandler {
91
94
  swarmKey,
92
95
  authCode,
93
96
  timeout,
94
- persistent,
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),
95
100
  created,
96
101
  lifetime,
102
+ multiUse,
97
103
  ...protocol.getInvitationContext(),
98
104
  };
99
105
 
@@ -162,7 +168,7 @@ export class InvitationsHandler {
162
168
  }
163
169
  log.trace('dxos.sdk.invitations-handler.host.onOpen', trace.error({ id: traceId, error: err }));
164
170
  } finally {
165
- if (type !== Invitation.Type.MULTIUSE) {
171
+ if (!multiUse) {
166
172
  // Wait for graceful close before disposing.
167
173
  await swarmConnection.close();
168
174
  await ctx.dispose();
@@ -187,7 +193,7 @@ export class InvitationsHandler {
187
193
  return extension;
188
194
  };
189
195
 
190
- if (invitation.lifetime && invitation.created && invitation.lifetime !== 0) {
196
+ if (invitation.lifetime && invitation.created) {
191
197
  if (invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()) {
192
198
  log.warn('invitation has already expired');
193
199
  } else {
@@ -245,7 +251,6 @@ export class InvitationsHandler {
245
251
  const { timeout = INVITATION_TIMEOUT } = invitation;
246
252
  invariant(protocol);
247
253
 
248
- // TODO(nf): duplicate check in InvitationsService
249
254
  if (deviceProfile) {
250
255
  invariant(invitation.kind === Invitation.Kind.DEVICE, 'deviceProfile provided for non-device invitation');
251
256
  }
@@ -317,26 +322,13 @@ export class InvitationsHandler {
317
322
 
318
323
  // 2. Get authentication code.
319
324
  if (isAuthenticationRequired(invitation)) {
320
- for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
321
- log('guest waiting for authentication code...');
322
- setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
323
- const authCode = await authenticated.wait({ timeout });
324
-
325
- log('sending authentication request');
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) {
325
+ switch (invitation.authMethod) {
326
+ case Invitation.AuthMethod.SHARED_SECRET:
327
+ await this._handleGuestOtpAuth(extension, setState, authenticated, { timeout });
328
+ break;
329
+ case Invitation.AuthMethod.KNOWN_PUBLIC_KEY:
330
+ await this._handleGuestKpkAuth(extension, setState, invitation, introductionResponse);
329
331
  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
332
  }
341
333
  }
342
334
 
@@ -423,13 +415,61 @@ export class InvitationsHandler {
423
415
 
424
416
  return observable;
425
417
  }
418
+
419
+ private async _handleGuestOtpAuth(
420
+ extension: InvitationGuestExtension,
421
+ setState: (newState: Partial<Invitation>) => void,
422
+ authenticated: Trigger<string>,
423
+ options: { timeout: number },
424
+ ) {
425
+ for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
426
+ log('guest waiting for authentication code...');
427
+ setState({ state: Invitation.State.READY_FOR_AUTHENTICATION });
428
+ const authCode = await authenticated.wait(options);
429
+
430
+ log('sending authentication request');
431
+ setState({ state: Invitation.State.AUTHENTICATING });
432
+ const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
433
+ if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
434
+ break;
435
+ }
436
+
437
+ if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
438
+ if (attempt === MAX_OTP_ATTEMPTS) {
439
+ throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
440
+ } else {
441
+ log('retrying invalid code', { attempt });
442
+ authenticated.reset();
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ private async _handleGuestKpkAuth(
449
+ extension: InvitationGuestExtension,
450
+ setState: (newState: Partial<Invitation>) => void,
451
+ invitation: Invitation,
452
+ introductionResponse: IntroductionResponse,
453
+ ) {
454
+ if (invitation.guestKeypair?.privateKey == null) {
455
+ throw new Error('keypair missing in the invitation');
456
+ }
457
+ if (introductionResponse.challenge == null) {
458
+ throw new Error('challenge missing in the introduction');
459
+ }
460
+ log('sending authentication request');
461
+ setState({ state: Invitation.State.AUTHENTICATING });
462
+ const signature = sign(Buffer.from(introductionResponse.challenge), invitation.guestKeypair.privateKey);
463
+ const response = await extension.rpc.InvitationHostService.authenticate({
464
+ signedChallenge: signature,
465
+ });
466
+ if (response.status !== AuthenticationResponse.Status.OK) {
467
+ throw new Error(`Authentication failed with code: ${response.status}`);
468
+ }
469
+ }
426
470
  }
427
471
 
428
- export const invitationExpired = (invitation: Invitation) => {
429
- return (
430
- invitation.created &&
431
- invitation.lifetime &&
432
- invitation.lifetime !== 0 &&
433
- invitation.created.getTime() + invitation.lifetime * 1000 < Date.now()
434
- );
472
+ export const createAdmissionKeypair = (): AdmissionKeypair => {
473
+ const keypair = createKeyPair();
474
+ return { publicKey: PublicKey.from(keypair.publicKey), privateKey: keypair.secretKey };
435
475
  };
@@ -0,0 +1,197 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Event } from '@dxos/async';
6
+ import type { AuthenticatingInvitation, CancellableInvitation } from '@dxos/client-protocol';
7
+ import { type Context } from '@dxos/context';
8
+ import { hasInvitationExpired, type MetadataStore } from '@dxos/echo-pipeline';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { log } from '@dxos/log';
11
+ import {
12
+ type AcceptInvitationRequest,
13
+ type Invitation,
14
+ type AuthenticationRequest,
15
+ } from '@dxos/protocols/proto/dxos/client/services';
16
+
17
+ import type { InvitationProtocol } from './invitation-protocol';
18
+ import type { InvitationsHandler } from './invitations-handler';
19
+
20
+ /**
21
+ * Entry point for creating and accepting invitations, keeps track of existing invitation set and
22
+ * emits events when the set changes.
23
+ */
24
+ export class InvitationsManager {
25
+ private readonly _createInvitations = new Map<string, CancellableInvitation>();
26
+ private readonly _acceptInvitations = new Map<string, AuthenticatingInvitation>();
27
+
28
+ public readonly invitationCreated = new Event<Invitation>();
29
+ public readonly invitationAccepted = new Event<Invitation>();
30
+ public readonly removedCreated = new Event<Invitation>();
31
+ public readonly removedAccepted = new Event<Invitation>();
32
+ public readonly saved = new Event<Invitation>();
33
+
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
+ ) {}
42
+
43
+ createInvitation(options: Invitation): CancellableInvitation {
44
+ const existingInvitation = this._createInvitations.get(options.invitationId);
45
+ if (existingInvitation) {
46
+ return existingInvitation;
47
+ }
48
+
49
+ const handler = this._getHandler(options);
50
+ const invitation = this._invitationsHandler.createInvitation(handler, options);
51
+ this._createInvitations.set(invitation.get().invitationId, invitation);
52
+ this.invitationCreated.emit(invitation.get());
53
+
54
+ const saveInvitationTask = invitation.get().persistent
55
+ ? this._safePersistInBackground(invitation)
56
+ : Promise.resolve();
57
+
58
+ // onComplete is called on cancel, expiration, or redemption of a single-use invitation
59
+ this._onInvitationComplete(invitation, async () => {
60
+ this._createInvitations.delete(invitation.get().invitationId);
61
+ this.removedCreated.emit(invitation.get());
62
+ if (invitation.get().persistent) {
63
+ await saveInvitationTask;
64
+ await this._safeDeleteInvitation(invitation.get());
65
+ }
66
+ });
67
+
68
+ return invitation;
69
+ }
70
+
71
+ async loadPersistentInvitations(): Promise<{ invitations: Invitation[] }> {
72
+ if (this._persistentInvitationsLoaded) {
73
+ const invitations = this.getCreatedInvitations().filter((i) => i.persistent);
74
+ return { invitations };
75
+ }
76
+ try {
77
+ const persistentInvitations = this._metadataStore.getInvitations();
78
+ // get saved persistent invitations, filter and remove from storage those that have expired.
79
+ const freshInvitations = persistentInvitations.filter((invitation) => !hasInvitationExpired(invitation));
80
+
81
+ const cInvitations = freshInvitations.map((persistentInvitation) => {
82
+ invariant(!this._createInvitations.get(persistentInvitation.invitationId), 'invitation already exists');
83
+ return this.createInvitation({ ...persistentInvitation, persistent: false }).get();
84
+ });
85
+
86
+ return { invitations: cInvitations };
87
+ } catch (err) {
88
+ log.catch(err);
89
+ return { invitations: [] };
90
+ } finally {
91
+ this._persistentInvitationsLoadedEvent.emit();
92
+ this._persistentInvitationsLoaded = true;
93
+ }
94
+ }
95
+
96
+ acceptInvitation(request: AcceptInvitationRequest): AuthenticatingInvitation {
97
+ const options = request.invitation;
98
+ const existingInvitation = this._acceptInvitations.get(options.invitationId);
99
+ if (existingInvitation) {
100
+ return existingInvitation;
101
+ }
102
+
103
+ const handler = this._getHandler(options);
104
+ const invitation = this._invitationsHandler.acceptInvitation(handler, options, request.deviceProfile);
105
+ this._acceptInvitations.set(invitation.get().invitationId, invitation);
106
+ this.invitationAccepted.emit(invitation.get());
107
+
108
+ this._onInvitationComplete(invitation, () => {
109
+ this._acceptInvitations.delete(invitation.get().invitationId);
110
+ this.removedAccepted.emit(invitation.get());
111
+ });
112
+
113
+ return invitation;
114
+ }
115
+
116
+ async authenticate({ invitationId, authCode }: AuthenticationRequest): Promise<void> {
117
+ log('authenticating...');
118
+ invariant(invitationId);
119
+ const observable = this._acceptInvitations.get(invitationId);
120
+ if (!observable) {
121
+ log.warn('invalid invitation', { invitationId });
122
+ } else {
123
+ await observable.authenticate(authCode);
124
+ }
125
+ }
126
+
127
+ async cancelInvitation({ invitationId }: { invitationId: string }): Promise<void> {
128
+ log('cancelInvitation...', { invitationId });
129
+ invariant(invitationId);
130
+ const created = this._createInvitations.get(invitationId);
131
+ if (created) {
132
+ // remove from storage before modifying in-memory state, higher chance of failing
133
+ if (created.get().persistent) {
134
+ await this._metadataStore.removeInvitation(invitationId);
135
+ }
136
+ await created.cancel();
137
+ this._createInvitations.delete(invitationId);
138
+ this.removedCreated.emit(created.get());
139
+ return;
140
+ }
141
+
142
+ const accepted = this._acceptInvitations.get(invitationId);
143
+ if (accepted) {
144
+ await accepted.cancel();
145
+ this._acceptInvitations.delete(invitationId);
146
+ this.removedAccepted.emit(accepted.get());
147
+ }
148
+ }
149
+
150
+ getCreatedInvitations(): Invitation[] {
151
+ return [...this._createInvitations.values()].map((i) => i.get());
152
+ }
153
+
154
+ getAcceptedInvitations(): Invitation[] {
155
+ return [...this._acceptInvitations.values()].map((i) => i.get());
156
+ }
157
+
158
+ onPersistentInvitationsLoaded(ctx: Context, callback: () => void) {
159
+ if (this._persistentInvitationsLoaded) {
160
+ callback();
161
+ } else {
162
+ this._persistentInvitationsLoadedEvent.once(ctx, () => callback());
163
+ }
164
+ }
165
+
166
+ private _safePersistInBackground(invitation: CancellableInvitation): Promise<void> {
167
+ return new Promise((resolve) => {
168
+ setTimeout(async () => {
169
+ try {
170
+ await this._metadataStore.addInvitation(invitation.get());
171
+ this.saved.emit(invitation.get());
172
+ } catch (err: any) {
173
+ log.catch(err);
174
+ await invitation.cancel();
175
+ } finally {
176
+ resolve();
177
+ }
178
+ });
179
+ });
180
+ }
181
+
182
+ private async _safeDeleteInvitation(invitation: Invitation): Promise<void> {
183
+ try {
184
+ await this._metadataStore.removeInvitation(invitation.invitationId);
185
+ } catch (err) {
186
+ log.catch(err);
187
+ }
188
+ }
189
+
190
+ private _onInvitationComplete(invitation: CancellableInvitation, callback: () => void) {
191
+ invitation.subscribe(
192
+ () => {},
193
+ () => {},
194
+ callback,
195
+ );
196
+ }
197
+ }