// // Copyright 2024 DXOS.org // import { Event, PushStream, TimeoutError, Trigger } from '@dxos/async'; import { AuthenticatingInvitation, AUTHENTICATION_CODE_LENGTH, CancellableInvitation, INVITATION_TIMEOUT, } from '@dxos/client-protocol'; import { Context } from '@dxos/context'; import { generatePasscode } from '@dxos/credentials'; import { hasInvitationExpired, type MetadataStore } from '@dxos/echo-pipeline'; import { invariant } from '@dxos/invariant'; import { PublicKey } from '@dxos/keys'; import { log } from '@dxos/log'; import { type AcceptInvitationRequest, type AuthenticationRequest, Invitation, } from '@dxos/protocols/proto/dxos/client/services'; import { SpaceMember } from '@dxos/protocols/proto/dxos/halo/credentials'; import type { InvitationProtocol } from './invitation-protocol'; import { createAdmissionKeypair, type InvitationsHandler } from './invitations-handler'; /** * Entry point for creating and accepting invitations, keeps track of existing invitation set and * emits events when the set changes. */ export class InvitationsManager { private readonly _createInvitations = new Map(); private readonly _acceptInvitations = new Map(); public readonly invitationCreated = new Event(); public readonly invitationAccepted = new Event(); public readonly removedCreated = new Event(); public readonly removedAccepted = new Event(); public readonly saved = new Event(); private readonly _persistentInvitationsLoadedEvent = new Event(); private _persistentInvitationsLoaded = false; constructor( private readonly _invitationsHandler: InvitationsHandler, private readonly _getHandler: (invitation: Partial & Pick) => InvitationProtocol, private readonly _metadataStore: MetadataStore, ) {} async createInvitation(options: Partial & Pick): Promise { if (options.invitationId) { const existingInvitation = this._createInvitations.get(options.invitationId); if (existingInvitation) { return existingInvitation; } } const handler = this._getHandler(options); const invitationError = handler.checkCanInviteNewMembers(); if (invitationError != null) { throw invitationError; } const invitation = this._createInvitation(handler, options); const { ctx, stream, observableInvitation } = this._createObservableInvitation(handler, invitation); this._createInvitations.set(invitation.invitationId, observableInvitation); this.invitationCreated.emit(invitation); // onComplete is called on cancel, expiration, or redemption of a single-use invitation this._onInvitationComplete(observableInvitation, async () => { this._createInvitations.delete(observableInvitation.get().invitationId); this.removedCreated.emit(observableInvitation.get()); if (observableInvitation.get().persistent) { await this._safeDeleteInvitation(observableInvitation.get()); } }); try { await this._persistIfRequired(handler, stream, invitation); } catch (err) { log.catch(err); await observableInvitation.cancel(); return observableInvitation; } this._invitationsHandler.handleInvitationFlow(ctx, stream, handler, observableInvitation.get()); return observableInvitation; } async loadPersistentInvitations(): Promise<{ invitations: Invitation[] }> { if (this._persistentInvitationsLoaded) { const invitations = this.getCreatedInvitations().filter((i) => i.persistent); return { invitations }; } try { const persistentInvitations = this._metadataStore.getInvitations(); // get saved persistent invitations, filter and remove from storage those that have expired. const freshInvitations = persistentInvitations.filter((invitation) => !hasInvitationExpired(invitation)); const loadTasks = freshInvitations.map((persistentInvitation) => { invariant(!this._createInvitations.get(persistentInvitation.invitationId), 'invitation already exists'); return this.createInvitation({ ...persistentInvitation, persistent: false }); }); const cInvitations = await Promise.all(loadTasks); return { invitations: cInvitations.map((invitation) => invitation.get()) }; } catch (err) { log.catch(err); return { invitations: [] }; } finally { this._persistentInvitationsLoadedEvent.emit(); this._persistentInvitationsLoaded = true; } } acceptInvitation(request: AcceptInvitationRequest): AuthenticatingInvitation { const options = request.invitation; const existingInvitation = this._acceptInvitations.get(options.invitationId); if (existingInvitation) { return existingInvitation; } const handler = this._getHandler(options); const { ctx, invitation, stream, otpEnteredTrigger } = this._createObservableAcceptingInvitation(handler, options); this._invitationsHandler.acceptInvitation(ctx, stream, handler, options, otpEnteredTrigger, request.deviceProfile); this._acceptInvitations.set(invitation.get().invitationId, invitation); this.invitationAccepted.emit(invitation.get()); this._onInvitationComplete(invitation, () => { this._acceptInvitations.delete(invitation.get().invitationId); this.removedAccepted.emit(invitation.get()); }); return invitation; } async authenticate({ invitationId, authCode }: AuthenticationRequest): Promise { log('authenticating...'); invariant(invitationId); const observable = this._acceptInvitations.get(invitationId); if (!observable) { log.warn('invalid invitation', { invitationId }); } else { await observable.authenticate(authCode); } } async cancelInvitation({ invitationId }: { invitationId: string }): Promise { log('cancelInvitation...', { invitationId }); invariant(invitationId); const created = this._createInvitations.get(invitationId); if (created) { // remove from storage before modifying in-memory state, higher chance of failing if (created.get().persistent) { await this._metadataStore.removeInvitation(invitationId); } if (created.get().type === Invitation.Type.DELEGATED) { const handler = this._getHandler(created.get()); await handler.cancelDelegation(created.get()); } await created.cancel(); this._createInvitations.delete(invitationId); this.removedCreated.emit(created.get()); return; } const accepted = this._acceptInvitations.get(invitationId); if (accepted) { await accepted.cancel(); this._acceptInvitations.delete(invitationId); this.removedAccepted.emit(accepted.get()); } } getCreatedInvitations(): Invitation[] { return [...this._createInvitations.values()].map((i) => i.get()); } getAcceptedInvitations(): Invitation[] { return [...this._acceptInvitations.values()].map((i) => i.get()); } onPersistentInvitationsLoaded(ctx: Context, callback: () => void): void { if (this._persistentInvitationsLoaded) { callback(); } else { this._persistentInvitationsLoadedEvent.once(ctx, () => callback()); } } private _createInvitation(protocol: InvitationProtocol, _options?: Partial): Invitation { const { invitationId = PublicKey.random().toHex(), type = Invitation.Type.INTERACTIVE, authMethod = Invitation.AuthMethod.SHARED_SECRET, state = Invitation.State.INIT, timeout = INVITATION_TIMEOUT, swarmKey = PublicKey.random(), persistent = _options?.authMethod !== Invitation.AuthMethod.KNOWN_PUBLIC_KEY, // default no not storing keypairs created = new Date(), guestKeypair = undefined, role = SpaceMember.Role.ADMIN, lifetime = 86400 * 7, // 7 days, multiUse = false, ...options } = _options ?? {}; const authCode = options?.authCode ?? (authMethod === Invitation.AuthMethod.SHARED_SECRET ? generatePasscode(AUTHENTICATION_CODE_LENGTH) : undefined); return { invitationId, type, authMethod, state, swarmKey, authCode, timeout, persistent: persistent && type !== Invitation.Type.DELEGATED, // delegated invitations are persisted in control feed guestKeypair: guestKeypair ?? (authMethod === Invitation.AuthMethod.KNOWN_PUBLIC_KEY ? createAdmissionKeypair() : undefined), created, lifetime, role, multiUse, delegationCredentialId: options?.delegationCredentialId, ...options, ...protocol.getInvitationContext(), } satisfies Invitation; } private _createObservableInvitation( handler: InvitationProtocol, invitation: Invitation, ): { ctx: Context; stream: PushStream; observableInvitation: CancellableInvitation } { const stream = new PushStream(); const ctx = new Context({ onError: (err) => { stream.error(err); void ctx.dispose(); }, }); ctx.onDispose(() => { log('complete', { ...handler.toJSON() }); stream.complete(); }); const observableInvitation = new CancellableInvitation({ initialInvitation: invitation, subscriber: stream.observable, onCancel: async () => { stream.next({ ...invitation, state: Invitation.State.CANCELLED }); await ctx.dispose(); }, }); return { ctx, stream, observableInvitation }; } private _createObservableAcceptingInvitation( handler: InvitationProtocol, initialState: Invitation, ): { ctx: Context; invitation: AuthenticatingInvitation; stream: PushStream; otpEnteredTrigger: Trigger; } { const otpEnteredTrigger = new Trigger(); const stream = new PushStream(); const ctx = new Context({ onError: (err) => { if (err instanceof TimeoutError) { log('timeout', { ...handler.toJSON() }); stream.next({ ...initialState, state: Invitation.State.TIMEOUT }); } else { log.warn('auth failed', err); stream.next({ ...initialState, state: Invitation.State.ERROR }); } void ctx.dispose(); }, }); ctx.onDispose(() => { log('complete', { ...handler.toJSON() }); stream.complete(); }); const invitation = new AuthenticatingInvitation({ initialInvitation: initialState, subscriber: stream.observable, onCancel: async () => { stream.next({ ...initialState, state: Invitation.State.CANCELLED }); await ctx.dispose(); }, onAuthenticate: async (code: string) => { // TODO(burdon): Reset creates a race condition? Event? otpEnteredTrigger.wake(code); }, }); return { ctx, invitation, stream, otpEnteredTrigger }; } private async _persistIfRequired( handler: InvitationProtocol, changeStream: PushStream, invitation: Invitation, ): Promise { if (invitation.type === Invitation.Type.DELEGATED && invitation.delegationCredentialId == null) { const delegationCredentialId = await handler.delegate(invitation); changeStream.next({ ...invitation, delegationCredentialId }); } else if (invitation.persistent) { await this._metadataStore.addInvitation(invitation); this.saved.emit(invitation); } } private async _safeDeleteInvitation(invitation: Invitation): Promise { try { await this._metadataStore.removeInvitation(invitation.invitationId); } catch (err) { log.catch(err); } } private _onInvitationComplete(invitation: CancellableInvitation, callback: () => void): void { invitation.subscribe( () => {}, () => {}, callback, ); } }