import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { AgentTokenStore, AppserviceIntent, TransactionHandler, validateBridgeMessage, validateBridgeTyping, validateProvisionRoom, validateRegisterAgent, validateRevokeAgent, } from '@mosaicstack/appservice'; import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice'; export interface DaemonConfig extends AppserviceConfig { /** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */ bridgeTokens: string[]; } export interface DaemonRequest { method: string; /** URL path without query string. */ path: string; searchParams: URLSearchParams; authorizationHeader?: string; body: unknown; } export interface DaemonResponse { status: number; body: Record; } // Compare equal-length HMAC digests so neither content nor LENGTH of the // stored secret is observable through timing. const HMAC_KEY = randomBytes(32); const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest(); const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b)); const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/; /** * Resolved identity for an authenticated /bridge/v1/* caller. Host principals * (the agent-comms host daemons) are unrestricted; agent principals are scoped * to a single virtual user and may only act as themselves. */ export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null; /** * HTTP-framework-agnostic request router for the mosaic-as daemon: the * Application Service transactions endpoint (Synapse-facing) plus the * internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to * node:http; tests drive it directly. */ export class AppserviceDaemon { readonly intent: AppserviceIntent; private readonly transactions: TransactionHandler; private readonly agents: AgentTokenStore; constructor( private readonly cfg: DaemonConfig, fetchImpl?: typeof fetch, private readonly log: (line: string) => void = (line) => console.log(line), ) { this.intent = new AppserviceIntent(cfg, fetchImpl); this.agents = new AgentTokenStore(this.intent); this.transactions = new TransactionHandler({ hsToken: cfg.hsToken, onEvent: (event) => this.onEvent(event), onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`), }); } /** v1: the daemon only observes; room logic lives in the agent-comms daemons. */ private onEvent(event: MatrixEvent): void { if (event.type === 'm.room.message') { this.log( `event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`, ); } } /** Resolve the calling principal, or null when unauthorized. Fail-closed: * host tokens win (timing-safe compare); otherwise a magt_* bearer is looked * up in the agent token store; anything else is rejected. */ private async bridgeAuthorized( authorizationHeader: string | undefined, ): Promise { if (!authorizationHeader?.startsWith('Bearer ')) return null; const presented = authorizationHeader.slice('Bearer '.length); if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) { return { kind: 'host' }; } const agentUserId = await this.agents.verifyToken(presented); if (agentUserId) return { kind: 'agent', agentUserId }; return null; } async handle(req: DaemonRequest): Promise { if (req.method === 'GET' && req.path === '/health') { return { status: 200, body: { ok: true } }; } const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null; if (txnMatch?.[1] !== undefined) { return this.transactions.handle(txnMatch[1], req.body, { authorizationHeader: req.authorizationHeader, accessTokenParam: req.searchParams.get('access_token') ?? undefined, }); } if (req.path.startsWith('/bridge/v1/')) { const principal = await this.bridgeAuthorized(req.authorizationHeader); if (!principal) { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } }; } try { if (req.method === 'POST' && req.path === '/bridge/v1/agents') { if (principal.kind !== 'host') { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' }, }; } validateRegisterAgent(req.body); const { agentUserId, token } = await this.agents.register({ alias: req.body.alias, host: req.body.host, displayName: req.body.display_name, }); this.log(`registered agent ${agentUserId}`); return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } }; } if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') { if (principal.kind !== 'host') { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' }, }; } validateRevokeAgent(req.body); const revoked = await this.agents.revoke(req.body.agent_user_id); this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`); return { status: 200, body: { revoked } }; } if (req.method === 'GET' && req.path === '/bridge/v1/agents') { if (principal.kind !== 'host') { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' }, }; } const agents = await this.agents.list(); return { status: 200, body: { agents } }; } if (req.method === 'POST' && req.path === '/bridge/v1/messages') { validateBridgeMessage(req.body); if ( principal.kind === 'agent' && this.intent.agentUserId(req.body.agent) !== principal.agentUserId ) { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' }, }; } const eventId = await this.intent.sendAsAgent({ roomId: req.body.room_id, agent: req.body.agent, body: req.body.body, threadRoot: req.body.thread_root, msgtype: req.body.msgtype, extraContent: req.body.extra_content, }); return { status: 200, body: { event_id: eventId ?? null } }; } if (req.method === 'POST' && req.path === '/bridge/v1/typing') { validateBridgeTyping(req.body); if ( principal.kind === 'agent' && this.intent.agentUserId(req.body.agent) !== principal.agentUserId ) { return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' }, }; } await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing); return { status: 200, body: {} }; } if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') { validateProvisionRoom(req.body); const result = await this.intent.createRoom({ name: req.body.name, alias: req.body.alias, topic: req.body.topic, invite: req.body.invite, spaceId: req.body.space_id, }); this.log( `provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`, ); return { status: 200, body: { room_id: result.roomId, space_linked: result.spaceLinked, ...(result.spaceError ? { space_error: result.spaceError } : {}), }, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log(`bridge error ${req.method} ${req.path}: ${message}`); return { status: 400, body: { error: message } }; } // Explicit: never fall out of the authenticated bridge block, so future // sub-paths cannot accidentally route around the auth guard above. return { status: 405, body: { error: 'unsupported bridge method/path' } }; } return { status: 404, body: { error: 'not found' } }; } }