@dxos/client-services 0.1.35 → 0.1.36

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 (95) hide show
  1. package/dist/lib/browser/{chunk-H5IXSSI7.mjs → chunk-2AYYVPWN.mjs} +835 -950
  2. package/dist/lib/browser/chunk-2AYYVPWN.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +9 -11
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/packlets/testing/index.mjs +212 -74
  6. package/dist/lib/browser/packlets/testing/index.mjs.map +3 -3
  7. package/dist/lib/node/index.cjs +942 -1058
  8. package/dist/lib/node/index.cjs.map +4 -4
  9. package/dist/lib/node/meta.json +1 -1
  10. package/dist/lib/node/packlets/testing/index.cjs +1089 -1067
  11. package/dist/lib/node/packlets/testing/index.cjs.map +4 -4
  12. package/dist/types/src/packlets/identity/identity-manager.d.ts +1 -0
  13. package/dist/types/src/packlets/identity/identity-manager.d.ts.map +1 -1
  14. package/dist/types/src/packlets/identity/identity.d.ts +3 -3
  15. package/dist/types/src/packlets/identity/identity.d.ts.map +1 -1
  16. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts +18 -0
  17. package/dist/types/src/packlets/invitations/device-invitation-protocol.d.ts.map +1 -0
  18. package/dist/types/src/packlets/invitations/device-invitation-protocol.test.d.ts +2 -0
  19. package/dist/types/src/packlets/invitations/device-invitation-protocol.test.d.ts.map +1 -0
  20. package/dist/types/src/packlets/invitations/index.d.ts +4 -4
  21. package/dist/types/src/packlets/invitations/index.d.ts.map +1 -1
  22. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts +12 -0
  23. package/dist/types/src/packlets/invitations/invitation-protocol.d.ts.map +1 -0
  24. package/dist/types/src/packlets/invitations/invitations-handler.d.ts +38 -0
  25. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -0
  26. package/dist/types/src/packlets/invitations/invitations-service.d.ts +13 -15
  27. package/dist/types/src/packlets/invitations/invitations-service.d.ts.map +1 -1
  28. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts +22 -0
  29. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -0
  30. package/dist/types/src/packlets/invitations/space-invitation-protocol.test.d.ts +2 -0
  31. package/dist/types/src/packlets/invitations/space-invitation-protocol.test.d.ts.map +1 -0
  32. package/dist/types/src/packlets/services/service-context.d.ts +6 -4
  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.map +1 -1
  35. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +2 -0
  36. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  37. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  38. package/dist/types/src/packlets/testing/invitation-utils.d.ts +31 -2
  39. package/dist/types/src/packlets/testing/invitation-utils.d.ts.map +1 -1
  40. package/dist/types/src/packlets/testing/test-builder.d.ts +3 -3
  41. package/dist/types/src/packlets/testing/test-builder.d.ts.map +1 -1
  42. package/dist/types/src/packlets/tests/invitations.test.d.ts +2 -0
  43. package/dist/types/src/packlets/tests/invitations.test.d.ts.map +1 -0
  44. package/dist/types/src/packlets/vault/worker-runtime.d.ts +2 -0
  45. package/dist/types/src/packlets/vault/worker-runtime.d.ts.map +1 -1
  46. package/package.json +30 -30
  47. package/src/packlets/identity/identity-manager.ts +2 -1
  48. package/src/packlets/identity/identity.ts +3 -3
  49. package/src/packlets/invitations/{device-invitations-handler.test.ts → device-invitation-protocol.test.ts} +3 -3
  50. package/src/packlets/invitations/device-invitation-protocol.ts +83 -0
  51. package/src/packlets/invitations/index.ts +4 -4
  52. package/src/packlets/invitations/invitation-protocol.ts +21 -0
  53. package/src/packlets/invitations/invitations-handler.ts +462 -0
  54. package/src/packlets/invitations/invitations-service.ts +127 -104
  55. package/src/packlets/invitations/space-invitation-protocol.test.ts +221 -0
  56. package/src/packlets/invitations/space-invitation-protocol.ts +117 -0
  57. package/src/packlets/services/service-context.test.ts +11 -4
  58. package/src/packlets/services/service-context.ts +40 -19
  59. package/src/packlets/services/service-host.ts +4 -12
  60. package/src/packlets/services/service-registry.test.ts +23 -25
  61. package/src/packlets/spaces/data-space-manager.ts +5 -0
  62. package/src/packlets/spaces/spaces-service.ts +7 -2
  63. package/src/packlets/storage/storage.ts +2 -0
  64. package/src/packlets/testing/invitation-utils.ts +219 -31
  65. package/src/packlets/testing/test-builder.ts +33 -22
  66. package/src/packlets/tests/client-services.test.ts +33 -76
  67. package/src/packlets/tests/halo-profile.test.ts +2 -28
  68. package/src/packlets/tests/invitations.test.ts +389 -0
  69. package/src/packlets/tests/spaces-invitations.test.ts +12 -30
  70. package/src/packlets/tests/spaces.test.ts +30 -28
  71. package/src/packlets/vault/worker-runtime.ts +4 -0
  72. package/dist/lib/browser/chunk-H5IXSSI7.mjs.map +0 -7
  73. package/dist/types/src/packlets/invitations/device-invitations-handler.d.ts +0 -30
  74. package/dist/types/src/packlets/invitations/device-invitations-handler.d.ts.map +0 -1
  75. package/dist/types/src/packlets/invitations/device-invitations-handler.test.d.ts +0 -2
  76. package/dist/types/src/packlets/invitations/device-invitations-handler.test.d.ts.map +0 -1
  77. package/dist/types/src/packlets/invitations/device-invitations-service.d.ts +0 -13
  78. package/dist/types/src/packlets/invitations/device-invitations-service.d.ts.map +0 -1
  79. package/dist/types/src/packlets/invitations/device-invitations-service.test.d.ts +0 -2
  80. package/dist/types/src/packlets/invitations/device-invitations-service.test.d.ts.map +0 -1
  81. package/dist/types/src/packlets/invitations/space-invitations-handler.d.ts +0 -26
  82. package/dist/types/src/packlets/invitations/space-invitations-handler.d.ts.map +0 -1
  83. package/dist/types/src/packlets/invitations/space-invitations-handler.test.d.ts +0 -2
  84. package/dist/types/src/packlets/invitations/space-invitations-handler.test.d.ts.map +0 -1
  85. package/dist/types/src/packlets/invitations/space-invitations-service.d.ts +0 -16
  86. package/dist/types/src/packlets/invitations/space-invitations-service.d.ts.map +0 -1
  87. package/dist/types/src/packlets/invitations/space-invitations-service.test.d.ts +0 -2
  88. package/dist/types/src/packlets/invitations/space-invitations-service.test.d.ts.map +0 -1
  89. package/src/packlets/invitations/device-invitations-handler.ts +0 -370
  90. package/src/packlets/invitations/device-invitations-service.test.ts +0 -96
  91. package/src/packlets/invitations/device-invitations-service.ts +0 -24
  92. package/src/packlets/invitations/space-invitations-handler.test.ts +0 -246
  93. package/src/packlets/invitations/space-invitations-handler.ts +0 -463
  94. package/src/packlets/invitations/space-invitations-service.test.ts +0 -129
  95. package/src/packlets/invitations/space-invitations-service.ts +0 -35
@@ -0,0 +1,83 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import assert from 'node:assert';
6
+
7
+ import { Keyring } from '@dxos/keyring';
8
+ import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
9
+ import { AdmissionRequest, AdmissionResponse, IntroductionRequest } from '@dxos/protocols/proto/dxos/halo/invitations';
10
+
11
+ import { Identity, JoinIdentityParams } from '../identity';
12
+ import { InvitationProtocol } from './invitation-protocol';
13
+
14
+ export class DeviceInvitationProtocol implements InvitationProtocol {
15
+ constructor(
16
+ private readonly _keyring: Keyring,
17
+ private readonly _getIdentity: () => Identity,
18
+ private readonly _acceptIdentity: (identity: JoinIdentityParams) => Promise<Identity>
19
+ ) {}
20
+
21
+ toJSON(): object {
22
+ return {};
23
+ }
24
+
25
+ getInvitationContext(): Partial<Invitation> & Pick<Invitation, 'kind'> {
26
+ return {
27
+ kind: Invitation.Kind.DEVICE
28
+ };
29
+ }
30
+
31
+ async admit(request: AdmissionRequest): Promise<AdmissionResponse> {
32
+ assert(request.device);
33
+ const identity = this._getIdentity();
34
+ await identity.admitDevice(request.device);
35
+
36
+ return {
37
+ device: {
38
+ identityKey: identity.identityKey,
39
+ haloSpaceKey: identity.haloSpaceKey,
40
+ genesisFeedKey: identity.haloGenesisFeedKey,
41
+ controlTimeframe: identity.controlPipeline.state.timeframe
42
+ }
43
+ };
44
+ }
45
+
46
+ createIntroduction(): IntroductionRequest {
47
+ return {};
48
+ }
49
+
50
+ async createAdmissionRequest(): Promise<AdmissionRequest> {
51
+ const deviceKey = await this._keyring.createKey();
52
+ const controlFeedKey = await this._keyring.createKey();
53
+ const dataFeedKey = await this._keyring.createKey();
54
+
55
+ return {
56
+ device: {
57
+ deviceKey,
58
+ controlFeedKey,
59
+ dataFeedKey
60
+ }
61
+ };
62
+ }
63
+
64
+ async accept(response: AdmissionResponse, request: AdmissionRequest): Promise<Partial<Invitation>> {
65
+ assert(response.device);
66
+ const { identityKey, haloSpaceKey, genesisFeedKey, controlTimeframe } = response.device;
67
+
68
+ assert(request.device);
69
+ const { deviceKey, controlFeedKey, dataFeedKey } = request.device;
70
+
71
+ await this._acceptIdentity({
72
+ identityKey,
73
+ deviceKey,
74
+ haloSpaceKey,
75
+ haloGenesisFeedKey: genesisFeedKey,
76
+ controlFeedKey,
77
+ dataFeedKey,
78
+ controlTimeframe
79
+ });
80
+
81
+ return { identityKey };
82
+ }
83
+ }
@@ -2,8 +2,8 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- export * from './device-invitations-handler';
6
- export * from './device-invitations-service';
5
+ export * from './device-invitation-protocol';
6
+ export * from './invitation-protocol';
7
+ export * from './invitations-handler';
7
8
  export * from './invitations-service';
8
- export * from './space-invitations-handler';
9
- export * from './space-invitations-service';
9
+ export * from './space-invitation-protocol';
@@ -0,0 +1,21 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
6
+ import { ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
7
+ import { AdmissionRequest, AdmissionResponse, IntroductionRequest } from '@dxos/protocols/proto/dxos/halo/invitations';
8
+
9
+ export interface InvitationProtocol {
10
+ // Debugging
11
+ toJSON(): object;
12
+
13
+ // Host
14
+ getInvitationContext(): Partial<Invitation> & Pick<Invitation, 'kind'>;
15
+ admit(request: AdmissionRequest, guestProfile?: ProfileDocument): Promise<AdmissionResponse>;
16
+
17
+ // Guest
18
+ createIntroduction(): IntroductionRequest;
19
+ createAdmissionRequest(): Promise<AdmissionRequest>;
20
+ accept(response: AdmissionResponse, request: AdmissionRequest): Promise<Partial<Invitation>>;
21
+ }
@@ -0,0 +1,462 @@
1
+ //
2
+ // Copyright 2022 DXOS.org
3
+ //
4
+
5
+ import assert from 'node:assert';
6
+
7
+ import { PushStream, scheduleTask, sleep, TimeoutError, Trigger } from '@dxos/async';
8
+ import {
9
+ AUTHENTICATION_CODE_LENGTH,
10
+ CancellableInvitationObservable,
11
+ INVITATION_TIMEOUT,
12
+ ON_CLOSE_DELAY,
13
+ AuthenticatingInvitationObservable
14
+ } from '@dxos/client';
15
+ import { Context } from '@dxos/context';
16
+ import { generatePasscode } from '@dxos/credentials';
17
+ import { PublicKey } from '@dxos/keys';
18
+ import { log } from '@dxos/log';
19
+ import { createTeleportProtocolFactory, NetworkManager, StarTopology } from '@dxos/network-manager';
20
+ import { schema } from '@dxos/protocols';
21
+ import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
22
+ import { ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
23
+ import {
24
+ AuthenticationRequest,
25
+ AuthenticationResponse,
26
+ IntroductionRequest,
27
+ IntroductionResponse,
28
+ AdmissionRequest,
29
+ AdmissionResponse,
30
+ InvitationHostService
31
+ } from '@dxos/protocols/proto/dxos/halo/invitations';
32
+ import { ExtensionContext, RpcExtension } from '@dxos/teleport';
33
+
34
+ import { InvitationProtocol } from './invitation-protocol';
35
+
36
+ const MAX_OTP_ATTEMPTS = 3;
37
+
38
+ /**
39
+ * Generic handler for Halo and Space invitations.
40
+ * Handles the life-cycle of invitations between peers.
41
+ *
42
+ * Host
43
+ * - Creates an invitation containing a swarm topic (which can be shared via a URL, QR code, or direct message).
44
+ * - Joins the swarm with the topic and waits for guest's introduction.
45
+ * - Wait for guest to authenticate with OTP.
46
+ * - Waits for guest to present credentials (containing local device and feed keys).
47
+ * - Writes credentials to control feed then exits.
48
+ *
49
+ * Guest
50
+ * - Joins the swarm with the topic.
51
+ * - Sends an introduction.
52
+ * - Sends authentication OTP.
53
+ * - If Space handler then creates a local cloned space (with genesis block).
54
+ * - Sends admission credentials.
55
+ *
56
+ * TODO(burdon): Show proxy/service relationship and reference design doc/diagram.
57
+ *
58
+ * ```
59
+ * [Guest] [Host]
60
+ * |------------------------------------Introduce-->|
61
+ * |-------------------------------[Authenticate]-->|
62
+ * |----------------------------------------Admit-->|
63
+ * ```
64
+ */
65
+ export class InvitationsHandler {
66
+ constructor(private readonly _networkManager: NetworkManager) {}
67
+
68
+ createInvitation(protocol: InvitationProtocol, options?: Partial<Invitation>): CancellableInvitationObservable {
69
+ const {
70
+ invitationId = PublicKey.random().toHex(),
71
+ type = Invitation.Type.INTERACTIVE,
72
+ authMethod = Invitation.AuthMethod.SHARED_SECRET,
73
+ state = Invitation.State.INIT,
74
+ timeout = INVITATION_TIMEOUT,
75
+ swarmKey = PublicKey.random()
76
+ } = options ?? {};
77
+ const authCode =
78
+ options?.authCode ??
79
+ (authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined);
80
+ assert(protocol);
81
+
82
+ const invitation: Invitation = {
83
+ invitationId,
84
+ type,
85
+ authMethod,
86
+ state,
87
+ swarmKey,
88
+ authCode,
89
+ timeout,
90
+ ...protocol.getInvitationContext()
91
+ };
92
+
93
+ const stream = new PushStream<Invitation>();
94
+ const ctx = new Context({
95
+ onError: (err) => {
96
+ void ctx.dispose();
97
+ stream.error(err);
98
+ }
99
+ });
100
+
101
+ ctx.onDispose(() => {
102
+ log('complete', { ...protocol.toJSON() });
103
+ stream.complete();
104
+ });
105
+
106
+ // Called for every connecting peer.
107
+ const createExtension = (): InvitationHostExtension => {
108
+ const success = new Trigger<PublicKey>();
109
+ let guestProfile: ProfileDocument | undefined;
110
+ let authenticationPassed = false;
111
+ let authenticationRetry = 0;
112
+
113
+ const extension = new InvitationHostExtension({
114
+ introduce: async ({ profile }) => {
115
+ log('guest introduced itself', {
116
+ guestProfile: profile,
117
+ ...protocol.toJSON()
118
+ });
119
+
120
+ guestProfile = profile;
121
+
122
+ // TODO(dmaretskyi): Should we expose guest's profile in this callback?
123
+ stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
124
+
125
+ // TODO(wittjosiah): Make when the space details are revealed configurable.
126
+ // Spaces may want to have public details (name, member count, etc.) or hide that until guest is authed.
127
+ return {
128
+ spaceKey: authMethod === Invitation.AuthMethod.NONE ? protocol.getInvitationContext().spaceKey : undefined,
129
+ authMethod
130
+ };
131
+ },
132
+
133
+ authenticate: async ({ authCode: code }) => {
134
+ log('received authentication request', { authCode: code });
135
+ let status = AuthenticationResponse.Status.OK;
136
+
137
+ switch (invitation.authMethod) {
138
+ case Invitation.AuthMethod.NONE: {
139
+ log('authentication not required');
140
+ return { status: AuthenticationResponse.Status.OK };
141
+ }
142
+
143
+ case Invitation.AuthMethod.SHARED_SECRET: {
144
+ if (invitation.authCode) {
145
+ if (authenticationRetry++ > MAX_OTP_ATTEMPTS) {
146
+ status = AuthenticationResponse.Status.INVALID_OPT_ATTEMPTS;
147
+ } else if (code !== invitation.authCode) {
148
+ status = AuthenticationResponse.Status.INVALID_OTP;
149
+ } else {
150
+ authenticationPassed = true;
151
+ }
152
+ }
153
+ break;
154
+ }
155
+
156
+ default: {
157
+ log.error('invalid authentication method', { authMethod: invitation.authMethod });
158
+ status = AuthenticationResponse.Status.INTERNAL_ERROR;
159
+ break;
160
+ }
161
+ }
162
+
163
+ return { status };
164
+ },
165
+
166
+ admit: async (admissionRequest) => {
167
+ try {
168
+ // Check authenticated.
169
+ if (isAuthenticationRequired(invitation) && !authenticationPassed) {
170
+ throw new Error('Not authenticated');
171
+ }
172
+
173
+ const deviceKey = admissionRequest.device?.deviceKey ?? admissionRequest.space?.deviceKey;
174
+ assert(deviceKey);
175
+ const admissionResponse = await protocol.admit(admissionRequest, guestProfile);
176
+
177
+ // Updating credentials complete.
178
+ success.wake(deviceKey);
179
+
180
+ return admissionResponse;
181
+ } catch (err: any) {
182
+ // TODO(burdon): Generic RPC callback to report error to client.
183
+ stream.error(err);
184
+ throw err; // Propagate error to guest.
185
+ }
186
+ },
187
+
188
+ onOpen: () => {
189
+ scheduleTask(ctx, async () => {
190
+ try {
191
+ log('connected', { ...protocol.toJSON() });
192
+ stream.next({ ...invitation, state: Invitation.State.CONNECTED });
193
+ const deviceKey = await success.wait({ timeout });
194
+ log('admitted guest', { guest: deviceKey, ...protocol.toJSON() });
195
+ stream.next({ ...invitation, state: Invitation.State.SUCCESS });
196
+ } catch (err: any) {
197
+ if (err instanceof TimeoutError) {
198
+ log('timeout', { ...protocol.toJSON() });
199
+ stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
200
+ } else {
201
+ log.error('failed', err);
202
+ stream.error(err);
203
+ }
204
+ } finally {
205
+ if (type !== Invitation.Type.MULTIUSE) {
206
+ await sleep(ON_CLOSE_DELAY);
207
+ await ctx.dispose();
208
+ }
209
+ }
210
+ });
211
+ }
212
+ });
213
+
214
+ return extension;
215
+ };
216
+
217
+ scheduleTask(ctx, async () => {
218
+ const topic = invitation.swarmKey!;
219
+ const swarmConnection = await this._networkManager.joinSwarm({
220
+ topic,
221
+ peerId: topic,
222
+ protocolProvider: createTeleportProtocolFactory(async (teleport) => {
223
+ teleport.addExtension('dxos.halo.invitations', createExtension());
224
+ }),
225
+ topology: new StarTopology(topic)
226
+ });
227
+ ctx.onDispose(() => swarmConnection.close());
228
+
229
+ stream.next({ ...invitation, state: Invitation.State.CONNECTING });
230
+ });
231
+
232
+ // TODO(burdon): Stop anything pending.
233
+ const observable = new CancellableInvitationObservable({
234
+ initialInvitation: invitation,
235
+ subscriber: stream.observable,
236
+ onCancel: async () => {
237
+ stream.next({ ...invitation, state: Invitation.State.CANCELLED });
238
+ await ctx.dispose();
239
+ }
240
+ });
241
+
242
+ return observable;
243
+ }
244
+
245
+ acceptInvitation(protocol: InvitationProtocol, invitation: Invitation): AuthenticatingInvitationObservable {
246
+ const { timeout = INVITATION_TIMEOUT } = invitation;
247
+ assert(protocol);
248
+
249
+ const authenticated = new Trigger<string>();
250
+
251
+ const stream = new PushStream<Invitation>();
252
+ const ctx = new Context({
253
+ onError: (err) => {
254
+ if (err instanceof TimeoutError) {
255
+ log('timeout', { ...protocol.toJSON() });
256
+ stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
257
+ } else {
258
+ log.warn('auth failed', err);
259
+ stream.error(err);
260
+ }
261
+ void ctx.dispose();
262
+ }
263
+ });
264
+
265
+ ctx.onDispose(() => {
266
+ log('complete', { ...protocol.toJSON() });
267
+ stream.complete();
268
+ });
269
+
270
+ const createExtension = (): InvitationGuestExtension => {
271
+ let connectionCount = 0;
272
+
273
+ const extension = new InvitationGuestExtension({
274
+ onOpen: () => {
275
+ scheduleTask(ctx, async () => {
276
+ try {
277
+ // TODO(burdon): Bug where guest may create multiple connections.
278
+ if (++connectionCount > 1) {
279
+ throw new Error(`multiple connections detected: ${connectionCount}`);
280
+ }
281
+
282
+ scheduleTask(ctx, () => ctx.raise(new TimeoutError(timeout)), timeout);
283
+
284
+ log('connected', { ...protocol.toJSON() });
285
+ stream.next({ ...invitation, state: Invitation.State.CONNECTED });
286
+
287
+ // 1. Introduce guest to host.
288
+ log('introduce', { ...protocol.toJSON() });
289
+ const introductionResponse = await extension.rpc.InvitationHostService.introduce(
290
+ protocol.createIntroduction()
291
+ );
292
+ log('introduce response', { ...protocol.toJSON(), response: introductionResponse });
293
+ invitation.authMethod = introductionResponse.authMethod;
294
+ if (introductionResponse.spaceKey) {
295
+ invitation.spaceKey = introductionResponse.spaceKey;
296
+ }
297
+
298
+ // 2. Get authentication code.
299
+ if (isAuthenticationRequired(invitation)) {
300
+ for (let attempt = 1; attempt <= MAX_OTP_ATTEMPTS; attempt++) {
301
+ log('guest waiting for authentication code...');
302
+ stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
303
+ const authCode = await authenticated.wait({ timeout });
304
+
305
+ log('sending authentication request');
306
+ stream.next({ ...invitation, state: Invitation.State.AUTHENTICATING });
307
+ const response = await extension.rpc.InvitationHostService.authenticate({ authCode });
308
+ if (response.status === undefined || response.status === AuthenticationResponse.Status.OK) {
309
+ break;
310
+ }
311
+
312
+ if (response.status === AuthenticationResponse.Status.INVALID_OTP) {
313
+ if (attempt === MAX_OTP_ATTEMPTS) {
314
+ throw new Error(`Maximum retry attempts: ${MAX_OTP_ATTEMPTS}`);
315
+ } else {
316
+ log('retrying invalid code', { attempt });
317
+ authenticated.reset();
318
+ }
319
+ }
320
+ }
321
+ } else {
322
+ // Notify that introduction is complete even if auth is not required.
323
+ stream.next({ ...invitation, state: Invitation.State.READY_FOR_AUTHENTICATION });
324
+ }
325
+
326
+ // 3. Send admission credentials to host (with local space keys).
327
+ log('request admission', { ...protocol.toJSON() });
328
+ const admissionRequest = await protocol.createAdmissionRequest();
329
+ const admissionResponse = await extension.rpc.InvitationHostService.admit(admissionRequest);
330
+
331
+ // 4. Record credential in our HALO.
332
+ const result = await protocol.accept(admissionResponse, admissionRequest);
333
+
334
+ // 5. Success.
335
+ log('admitted by host', { ...protocol.toJSON() });
336
+ stream.next({ ...invitation, ...result, state: Invitation.State.SUCCESS });
337
+ } catch (err: any) {
338
+ if (err instanceof TimeoutError) {
339
+ log('timeout', { ...protocol.toJSON() });
340
+ stream.next({ ...invitation, state: Invitation.State.TIMEOUT });
341
+ } else {
342
+ log.warn('auth failed', err);
343
+ stream.error(err);
344
+ }
345
+ } finally {
346
+ await ctx.dispose();
347
+ }
348
+ });
349
+ }
350
+ });
351
+
352
+ return extension;
353
+ };
354
+
355
+ scheduleTask(ctx, async () => {
356
+ assert(invitation.swarmKey);
357
+ const topic = invitation.swarmKey;
358
+ const swarmConnection = await this._networkManager.joinSwarm({
359
+ topic,
360
+ peerId: PublicKey.random(),
361
+ protocolProvider: createTeleportProtocolFactory(async (teleport) => {
362
+ teleport.addExtension('dxos.halo.invitations', createExtension());
363
+ }),
364
+ topology: new StarTopology(topic)
365
+ });
366
+ ctx.onDispose(() => swarmConnection.close());
367
+
368
+ stream.next({ ...invitation, state: Invitation.State.CONNECTING });
369
+ });
370
+
371
+ const observable = new AuthenticatingInvitationObservable({
372
+ initialInvitation: invitation,
373
+ subscriber: stream.observable,
374
+ onCancel: async () => {
375
+ stream.next({ ...invitation, state: Invitation.State.CANCELLED });
376
+ await ctx.dispose();
377
+ },
378
+ onAuthenticate: async (code: string) => {
379
+ // TODO(burdon): Reset creates a race condition? Event?
380
+ authenticated.wake(code);
381
+ }
382
+ });
383
+
384
+ return observable;
385
+ }
386
+ }
387
+
388
+ const isAuthenticationRequired = (invitation: Invitation) => invitation.authMethod !== Invitation.AuthMethod.NONE;
389
+
390
+ type InvitationHostExtensionCallbacks = {
391
+ // Deliberately not async to not block the extensions opening.
392
+ onOpen: () => void;
393
+
394
+ introduce: (request: IntroductionRequest) => Promise<IntroductionResponse>;
395
+ authenticate: (request: AuthenticationRequest) => Promise<AuthenticationResponse>;
396
+ admit: (request: AdmissionRequest) => Promise<AdmissionResponse>;
397
+ };
398
+
399
+ /**
400
+ * Host's side for a connection to a concrete peer in p2p network during invitation.
401
+ */
402
+ class InvitationHostExtension extends RpcExtension<{}, { InvitationHostService: InvitationHostService }> {
403
+ constructor(private readonly _callbacks: InvitationHostExtensionCallbacks) {
404
+ super({
405
+ exposed: {
406
+ InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService')
407
+ }
408
+ });
409
+ }
410
+
411
+ protected override async getHandlers(): Promise<{ InvitationHostService: InvitationHostService }> {
412
+ return {
413
+ // TODO(dmaretskyi): For now this is just forwarding the data to callbacks since we don't have session-specific logic.
414
+ // Perhaps in the future we will have more complex logic here.
415
+ InvitationHostService: {
416
+ introduce: async (request) => {
417
+ return this._callbacks.introduce(request);
418
+ },
419
+
420
+ authenticate: async (request) => {
421
+ return this._callbacks.authenticate(request);
422
+ },
423
+
424
+ admit: async (request) => {
425
+ return this._callbacks.admit(request);
426
+ }
427
+ }
428
+ };
429
+ }
430
+
431
+ override async onOpen(context: ExtensionContext) {
432
+ await super.onOpen(context);
433
+ this._callbacks.onOpen();
434
+ }
435
+ }
436
+
437
+ type InvitationGuestExtensionCallbacks = {
438
+ // Deliberately not async to not block the extensions opening.
439
+ onOpen: () => void;
440
+ };
441
+
442
+ /**
443
+ * Guest's side for a connection to a concrete peer in p2p network during invitation.
444
+ */
445
+ class InvitationGuestExtension extends RpcExtension<{ InvitationHostService: InvitationHostService }, {}> {
446
+ constructor(private readonly _callbacks: InvitationGuestExtensionCallbacks) {
447
+ super({
448
+ requested: {
449
+ InvitationHostService: schema.getService('dxos.halo.invitations.InvitationHostService')
450
+ }
451
+ });
452
+ }
453
+
454
+ protected override async getHandlers() {
455
+ return {};
456
+ }
457
+
458
+ override async onOpen(context: ExtensionContext) {
459
+ await super.onOpen(context);
460
+ this._callbacks.onOpen();
461
+ }
462
+ }