226 lines
8.4 KiB
TypeScript
226 lines
8.4 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
// 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<BridgePrincipal> {
|
|
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<DaemonResponse> {
|
|
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' } };
|
|
}
|
|
}
|