feat(appservice): mosaic-as daemon host + container (M4a) (#531)
This commit was merged in pull request #531.
This commit is contained in:
124
apps/appservice/src/server.ts
Normal file
124
apps/appservice/src/server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
AppserviceIntent,
|
||||
TransactionHandler,
|
||||
validateBridgeMessage,
|
||||
validateBridgeTyping,
|
||||
} 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\/([^/]+)$/;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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.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 ?? '?'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
||||
const presented = authorizationHeader.slice('Bearer '.length);
|
||||
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
||||
}
|
||||
|
||||
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/')) {
|
||||
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||
}
|
||||
try {
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||
validateBridgeMessage(req.body);
|
||||
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);
|
||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
} 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' } };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user