import type { Envelope } from '../types/envelope.ts' import { Node, type NodeLike } from '../types/node.ts' import type { Session } from '../types/session.ts' import type { Authentication } from './security.ts' import { OpenConnectionSender } from './sender.ts' export type ConnectionSession = { id: string localNode: Node remoteNode: Node scheme: Authentication['scheme'] } export class SessionNegotiator { public state: Session['state'] | 'present' = 'new' public session: ConnectionSession | null = null private presencePromise: Promise | null = null private currentSessionResolver: Pick, 'reject' | 'resolve'> | null = null constructor( private readonly sender: OpenConnectionSender, private readonly sendSession: (session: Session) => void, ) {} public async negotiate(options: { node: NodeLike; authentication: Authentication }): Promise { const timeout = setTimeout(async () => { if (this.negotiating) { await this.sender.close() throw new Error('Negotiation timeout') } }, 60000) // 60 seconds try { this.sendSession({ state: 'new', } as Session) const negotiation = await this.waitForSessionResponse() let authenticating: Session if (negotiation.state === 'negotiating') { if (!negotiation.encryptionOptions?.includes('none')) { throw new Error('Unsupported encryption options') } this.sendSession({ id: negotiation.id, state: 'negotiating', encryption: 'none', compression: negotiation.compressionOptions?.at(-1), }) await this.waitForSessionResponse() authenticating = await this.waitForSessionResponse() } else if (negotiation.state === 'authenticating') { authenticating = negotiation } else { throw new Error('Unexpected session state') } if (options.authentication.scheme === 'token') { const { secret } = OpenConnectionSender.parseToken(options.authentication.token) options.authentication = { scheme: 'key', key: secret, } } if (!authenticating.schemeOptions?.includes(options.authentication.scheme)) { throw new Error( `Unsupported authentication scheme: ${options.authentication.scheme} (${authenticating.schemeOptions})`, ) } const { scheme, ...authenticationOptions } = options.authentication this.sendSession({ id: authenticating.id, from: options.node, state: 'authenticating', scheme, authentication: authenticationOptions, }) const authenticated = await this.waitForSessionResponse() if (authenticated.state !== 'established') { throw new Error('Authentication failed') } this.session = { id: authenticated.id, localNode: Node.from(authenticated.to!), remoteNode: Node.from(authenticated.from!), scheme, } } finally { clearTimeout(timeout) } } public handleEnvelope(envelope: Envelope) { if (this.currentSessionResolver) { this.currentSessionResolver.resolve(envelope as Session) this.currentSessionResolver = null } } public async ensurePresence(currentCommandUri = ''): Promise { if (!this.session) { throw new Error('Session not established') } if (this.state === 'present') { return } if (this.session.scheme === 'guest') { return } if (currentCommandUri === '/presence') { // someone is already setting the presence manually if (!this.presencePromise) { this.state = 'present' } // its actually setting the presence return } if (this.presencePromise !== null) { return this.presencePromise } this.presencePromise = this.sender.sendCommand({ id: Date.now().toString(), method: 'set', uri: '/presence', type: 'application/vnd.lime.presence+json', resource: { status: 'available', }, }) as Promise await this.presencePromise this.state = 'present' } public finish() { if (!this.session) { throw new Error('Session not established') } this.sendSession({ id: this.session?.id, state: 'finishing', }) } public get negotiating(): boolean { return this.state !== 'established' && this.state !== 'present' } private async waitForSessionResponse(): Promise { const { promise, resolve, reject } = Promise.withResolvers() this.currentSessionResolver = { resolve, reject } const session = await promise if (session.state === 'failed') { throw new Error(`Session negotiation failed: ${session.reason?.description} (${session.reason?.code})`) } this.state = session.state return session } }