diff --git a/packages/appservice/package.json b/packages/appservice/package.json new file mode 100644 index 0000000..07012c8 --- /dev/null +++ b/packages/appservice/package.json @@ -0,0 +1,36 @@ +{ + "name": "@mosaicstack/appservice", + "version": "0.0.1", + "type": "module", + "repository": { + "type": "git", + "url": "https://git.mosaicstack.dev/mosaicstack/stack.git", + "directory": "packages/appservice" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "lint": "eslint src", + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0", + "vitest": "^2.0.0" + }, + "publishConfig": { + "registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/", + "access": "public" + }, + "files": [ + "dist" + ] +} diff --git a/packages/appservice/src/__tests__/appservice.test.ts b/packages/appservice/src/__tests__/appservice.test.ts new file mode 100644 index 0000000..3b20603 --- /dev/null +++ b/packages/appservice/src/__tests__/appservice.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { validateBridgeMessage, validateBridgeTyping } from '../bridge.dto.js'; +import { AppserviceIntent, MatrixApiError } from '../intent.js'; +import { buildRegistration, registrationToYaml } from '../registration.js'; +import { TransactionHandler } from '../transactions.js'; +import type { AppserviceConfig, MatrixEvent } from '../types.js'; + +const cfg: AppserviceConfig = { + homeserverUrl: 'https://hs.example', + domain: 'hs.example', + asToken: 'as-secret', + hsToken: 'hs-secret', +}; + +const jsonResponse = (status: number, body: unknown): Response => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('TransactionHandler', () => { + const makeHandler = (onEvent = vi.fn()) => ({ + onEvent, + handler: new TransactionHandler({ hsToken: 'hs-secret', onEvent }), + }); + + it('rejects a bad hs_token with M_FORBIDDEN', async () => { + const { handler, onEvent } = makeHandler(); + const res = await handler.handle( + 't1', + { events: [{ type: 'm.room.message' }] }, + { authorizationHeader: 'Bearer wrong' }, + ); + expect(res.status).toBe(403); + expect(res.body.errcode).toBe('M_FORBIDDEN'); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('accepts Bearer auth and legacy access_token param', async () => { + const { handler } = makeHandler(); + expect( + (await handler.handle('t1', { events: [] }, { authorizationHeader: 'Bearer hs-secret' })) + .status, + ).toBe(200); + expect( + (await handler.handle('t2', { events: [] }, { accessTokenParam: 'hs-secret' })).status, + ).toBe(200); + }); + + it('processes events once per txnId (idempotent retries)', async () => { + const { handler, onEvent } = makeHandler(); + const body = { events: [{ type: 'm.room.message', event_id: '$e1' }] }; + await handler.handle('t1', body, { authorizationHeader: 'Bearer hs-secret' }); + const retry = await handler.handle('t1', body, { authorizationHeader: 'Bearer hs-secret' }); + expect(retry.status).toBe(200); + expect(onEvent).toHaveBeenCalledTimes(1); + }); + + it('a throwing event handler does not fail the transaction', async () => { + const onError = vi.fn(); + const handler = new TransactionHandler({ + hsToken: 'hs-secret', + onEvent: () => { + throw new Error('boom'); + }, + onError, + }); + const res = await handler.handle( + 't1', + { events: [{ type: 'x' }, { type: 'y' }] }, + { authorizationHeader: 'Bearer hs-secret' }, + ); + expect(res.status).toBe(200); + expect(onError).toHaveBeenCalledTimes(2); + }); +}); + +describe('AppserviceIntent', () => { + it('derives namespaced user ids and rejects bad slugs', () => { + const intent = new AppserviceIntent(cfg); + expect(intent.agentUserId('pi0-web1')).toBe('@agent-pi0-web1:hs.example'); + expect(intent.agentUserId('Pi0-Web1')).toBe('@agent-pi0-web1:hs.example'); + expect(() => intent.agentUserId('../evil')).toThrow(); + expect(() => intent.agentUserId('')).toThrow(); + }); + + it('uses uuid transaction ids', async () => { + const calls: string[] = []; + const fetchMock = vi.fn(async (input: URL | string) => { + calls.push(new URL(String(input)).pathname); + return jsonResponse(200, {}); + }); + const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch); + await intent.sendAsAgent({ roomId: '!r:hs.example', agent: 'pi0', body: 'x' }); + const send = calls.find((p) => p.includes('/send/m.room.message/')); + expect(send).toMatch(/mosaic-as-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('registers once, impersonates via user_id, threads replies', async () => { + const calls: Array<{ url: URL; init: RequestInit }> = []; + const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => { + calls.push({ url: new URL(String(input)), init: init ?? {} }); + return jsonResponse(200, { event_id: '$sent' }); + }); + const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch); + + const eventId = await intent.sendAsAgent({ + roomId: '!room:hs.example', + agent: 'pi0-web1', + body: 'hello', + threadRoot: '$req', + }); + await intent.sendAsAgent({ roomId: '!room:hs.example', agent: 'pi0-web1', body: 'again' }); + + expect(eventId).toBe('$sent'); + const paths = calls.map((c) => c.url.pathname); + expect(paths.filter((p) => p.endsWith('/register'))).toHaveLength(1); // cached + expect(paths.filter((p) => p.includes('/join'))).toHaveLength(1); // cached + + const send = calls.find((c) => c.url.pathname.includes('/send/m.room.message/')); + expect(send).toBeDefined(); + expect(send!.url.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example'); + const content = JSON.parse(String(send!.init.body)) as Record; + const rel = content['m.relates_to'] as Record; + expect(rel.rel_type).toBe('m.thread'); + expect(rel.event_id).toBe('$req'); + expect(rel.is_falling_back).toBe(true); + expect( + calls.every( + (c) => (c.init.headers as Record).Authorization === 'Bearer as-secret', + ), + ).toBe(true); + }); + + it('tolerates M_USER_IN_USE and surfaces other register errors', async () => { + const inUse = vi.fn(async () => + jsonResponse(400, { errcode: 'M_USER_IN_USE', error: 'taken' }), + ); + const intent = new AppserviceIntent(cfg, inUse as unknown as typeof fetch); + await expect(intent.ensureRegistered('pi0-web1')).resolves.toBe('@agent-pi0-web1:hs.example'); + + const denied = vi.fn(async () => + jsonResponse(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'nope' }), + ); + const intent2 = new AppserviceIntent(cfg, denied as unknown as typeof fetch); + await expect(intent2.ensureRegistered('pi0-web1')).rejects.toThrow(MatrixApiError); + }); + + it('invites then joins on M_FORBIDDEN join', async () => { + const paths: string[] = []; + const fetchMock = vi.fn(async (input: URL | string) => { + const url = new URL(String(input)); + paths.push(url.pathname); + if (url.pathname.endsWith('/join') && paths.filter((p) => p.endsWith('/join')).length === 1) { + return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'not invited' }); + } + return jsonResponse(200, {}); + }); + const intent = new AppserviceIntent(cfg, fetchMock as unknown as typeof fetch); + await intent.ensureJoined('!room:hs.example', 'pi0-web1'); + expect(paths.filter((p) => p.endsWith('/invite'))).toHaveLength(1); + expect(paths.filter((p) => p.endsWith('/join'))).toHaveLength(2); + }); +}); + +describe('registration', () => { + it('builds an exclusive escaped user namespace', () => { + const reg = buildRegistration(cfg, { url: 'http://mosaic-as:8008' }); + expect(reg.namespaces.users[0]).toEqual({ + regex: '@agent-.*:hs\\.example', + exclusive: true, + }); + expect(reg.rate_limited).toBe(false); + const yaml = registrationToYaml(reg); + expect(yaml).toContain("sender_localpart: 'mosaic-as'"); + expect(yaml).toContain("as_token: 'as-secret'"); + expect(yaml).toContain('exclusive: true'); + }); +}); + +describe('registration hardening', () => { + it('rejects control characters in registration values', () => { + const reg = buildRegistration( + { ...cfg, asToken: 'abc\nhttp_injected: true' }, + { url: 'http://mosaic-as:8008' }, + ); + expect(() => registrationToYaml(reg)).toThrow(/control characters/); + }); + + it('escapes single quotes in token values', () => { + const reg = buildRegistration({ ...cfg, asToken: "it's" }, { url: 'http://mosaic-as:8008' }); + expect(registrationToYaml(reg)).toContain("as_token: 'it''s'"); + }); +}); + +describe('bridge DTOs', () => { + it('validates message and typing payloads', () => { + expect(() => + validateBridgeMessage({ room_id: '!r:hs', agent: 'pi0', body: 'x' }), + ).not.toThrow(); + expect(() => validateBridgeMessage({ room_id: 'bad', agent: 'pi0', body: 'x' })).toThrow(); + expect(() => validateBridgeMessage({ room_id: '!r:hs', agent: '', body: 'x' })).toThrow(); + expect(() => validateBridgeMessage({ room_id: '!r:hs', agent: '../evil', body: 'x' })).toThrow( + /agent must match/, + ); + expect(() => + validateBridgeTyping({ room_id: '!r:hs', agent: 'pi0', typing: true }), + ).not.toThrow(); + expect(() => validateBridgeTyping({ room_id: '!r:hs', agent: 'pi0', typing: 'yes' })).toThrow(); + }); +}); + +describe('event shape', () => { + it('transaction events flow through to the handler', async () => { + const seen: MatrixEvent[] = []; + const handler = new TransactionHandler({ + hsToken: 'hs-secret', + onEvent: (e) => void seen.push(e), + }); + await handler.handle( + 't1', + { + events: [ + { type: 'm.room.message', room_id: '!r:hs', sender: '@u:hs', content: { body: 'hi' } }, + ], + }, + { authorizationHeader: 'Bearer hs-secret' }, + ); + expect(seen).toHaveLength(1); + expect(seen[0]!.content?.body).toBe('hi'); + }); +}); diff --git a/packages/appservice/src/bridge.dto.ts b/packages/appservice/src/bridge.dto.ts new file mode 100644 index 0000000..52d14e6 --- /dev/null +++ b/packages/appservice/src/bridge.dto.ts @@ -0,0 +1,52 @@ +/** DTOs for the internal bridge API consumed by agent-comms host daemons. */ + +export interface BridgeMessageDto { + room_id: string; + /** Agent slug (localpart suffix), e.g. "pi0-web1". */ + agent: string; + body: string; + thread_root?: string; + msgtype?: string; + /** Optional protocol payload merged into content (e.g. org.uscllc.agent). */ + extra_content?: Record; +} + +export interface BridgeTypingDto { + room_id: string; + agent: string; + typing: boolean; +} + +const AGENT_SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/; + +const assertAgentSlug = (agent: unknown): void => { + if (typeof agent !== 'string' || !AGENT_SLUG_RE.test(agent.toLowerCase())) { + throw new Error('agent must match [a-z0-9][a-z0-9_.-]*'); + } +}; + +export function validateBridgeMessage(input: unknown): asserts input is BridgeMessageDto { + const o = input as Partial | null | undefined; + if (!o || typeof o !== 'object') throw new Error('payload must be an object'); + if (typeof o.room_id !== 'string' || !o.room_id.startsWith('!')) + throw new Error('room_id must be a Matrix room id'); + assertAgentSlug(o.agent); + if (typeof o.body !== 'string') throw new Error('body must be a string'); + if (o.thread_root !== undefined && typeof o.thread_root !== 'string') + throw new Error('thread_root must be a string'); + if ( + o.extra_content !== undefined && + (typeof o.extra_content !== 'object' || o.extra_content === null) + ) { + throw new Error('extra_content must be an object'); + } +} + +export function validateBridgeTyping(input: unknown): asserts input is BridgeTypingDto { + const o = input as Partial | null | undefined; + if (!o || typeof o !== 'object') throw new Error('payload must be an object'); + if (typeof o.room_id !== 'string' || !o.room_id.startsWith('!')) + throw new Error('room_id must be a Matrix room id'); + assertAgentSlug(o.agent); + if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean'); +} diff --git a/packages/appservice/src/index.ts b/packages/appservice/src/index.ts new file mode 100644 index 0000000..3499aa7 --- /dev/null +++ b/packages/appservice/src/index.ts @@ -0,0 +1,15 @@ +export { AppserviceIntent, MatrixApiError } from './intent.js'; +export type { SendMessageOptions } from './intent.js'; +export { TransactionHandler } from './transactions.js'; +export type { TransactionHandlerOptions } from './transactions.js'; +export { buildRegistration, registrationToYaml } from './registration.js'; +export type { RegistrationOptions } from './registration.js'; +export { validateBridgeMessage, validateBridgeTyping } from './bridge.dto.js'; +export type { BridgeMessageDto, BridgeTypingDto } from './bridge.dto.js'; +export type { + AppserviceConfig, + EventHandler, + HandlerResult, + MatrixEvent, + Transaction, +} from './types.js'; diff --git a/packages/appservice/src/intent.ts b/packages/appservice/src/intent.ts new file mode 100644 index 0000000..024e17f --- /dev/null +++ b/packages/appservice/src/intent.ts @@ -0,0 +1,184 @@ +import crypto from 'node:crypto'; + +import type { AppserviceConfig } from './types.js'; + +export interface SendMessageOptions { + roomId: string; + /** Agent slug, e.g. "pi0-web1" -> @agent-pi0-web1:domain */ + agent: string; + body: string; + /** Request event id to thread off (m.thread, spec v1.4). */ + threadRoot?: string; + msgtype?: string; + /** Extra content keys merged into the message content (e.g. org.uscllc.agent). */ + extraContent?: Record; +} + +export class MatrixApiError extends Error { + constructor( + readonly status: number, + readonly errcode: string | undefined, + message: string, + ) { + super(message); + this.name = 'MatrixApiError'; + } +} + +type FetchLike = typeof fetch; + +/** + * Acts on the homeserver as appservice-namespaced virtual users + * (Application Service API: as_token auth + user_id impersonation). + */ +export class AppserviceIntent { + private readonly registered = new Set(); + private readonly joined = new Set(); + private readonly fetchImpl: FetchLike; + + constructor( + private readonly cfg: AppserviceConfig, + fetchImpl?: FetchLike, + ) { + this.fetchImpl = fetchImpl ?? fetch; + } + + get userPrefix(): string { + return this.cfg.userPrefix ?? 'agent-'; + } + + get senderUserId(): string { + return `@${this.cfg.senderLocalpart ?? 'mosaic-as'}:${this.cfg.domain}`; + } + + agentLocalpart(agent: string): string { + const slug = agent.toLowerCase(); + if (!/^[a-z0-9][a-z0-9_.-]*$/.test(slug)) { + throw new Error(`invalid agent slug: ${agent}`); + } + return `${this.userPrefix}${slug}`; + } + + agentUserId(agent: string): string { + return `@${this.agentLocalpart(agent)}:${this.cfg.domain}`; + } + + private async request( + method: string, + path: string, + options: { userId?: string; body?: unknown } = {}, + ): Promise> { + const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path); + if (options.userId) { + url.searchParams.set('user_id', options.userId); + } + const res = await this.fetchImpl(url, { + method, + headers: { + Authorization: `Bearer ${this.cfg.asToken}`, + 'Content-Type': 'application/json', + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await res.text(); + const data = (text ? JSON.parse(text) : {}) as Record; + if (!res.ok) { + throw new MatrixApiError( + res.status, + typeof data.errcode === 'string' ? data.errcode : undefined, + `${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`, + ); + } + return data; + } + + /** Register the virtual user if it does not exist yet. Idempotent. */ + async ensureRegistered(agent: string): Promise { + const localpart = this.agentLocalpart(agent); + const userId = this.agentUserId(agent); + if (this.registered.has(userId)) return userId; + try { + await this.request('POST', '/_matrix/client/v3/register', { + body: { type: 'm.login.application_service', username: localpart }, + }); + } catch (err) { + if (!(err instanceof MatrixApiError && err.errcode === 'M_USER_IN_USE')) { + throw err; + } + } + this.registered.add(userId); + return userId; + } + + /** Join the agent to a room; on invite-only rooms the AS sender invites first. */ + async ensureJoined(roomId: string, agent: string): Promise { + const userId = await this.ensureRegistered(agent); + const key = `${userId} ${roomId}`; + if (this.joined.has(key)) return; + const room = encodeURIComponent(roomId); + try { + await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} }); + } catch (err) { + if (!(err instanceof MatrixApiError && err.errcode === 'M_FORBIDDEN')) throw err; + await this.request('POST', `/_matrix/client/v3/rooms/${room}/invite`, { + userId: this.senderUserId, + body: { user_id: userId }, + }); + await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} }); + } + this.joined.add(key); + } + + /** Send a message AS the agent's virtual user. */ + async sendAsAgent(options: SendMessageOptions): Promise { + const userId = this.agentUserId(options.agent); + await this.ensureJoined(options.roomId, options.agent); + const content: Record = { + msgtype: options.msgtype ?? 'm.text', + body: options.body, + ...options.extraContent, + }; + if (options.threadRoot) { + content['m.relates_to'] = { + rel_type: 'm.thread', + event_id: options.threadRoot, + is_falling_back: true, + 'm.in_reply_to': { event_id: options.threadRoot }, + }; + } + const txn = `mosaic-as-${crypto.randomUUID()}`; + const room = encodeURIComponent(options.roomId); + const res = await this.request( + 'PUT', + `/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`, + { userId, body: content }, + ); + return typeof res.event_id === 'string' ? res.event_id : undefined; + } + + /** Set the agent's typing indicator in a room. */ + async setTyping( + roomId: string, + agent: string, + typing: boolean, + timeoutMs = 30000, + ): Promise { + const userId = await this.ensureRegistered(agent); + const room = encodeURIComponent(roomId); + const user = encodeURIComponent(userId); + await this.request('PUT', `/_matrix/client/v3/rooms/${room}/typing/${user}`, { + userId, + body: typing ? { typing: true, timeout: timeoutMs } : { typing: false }, + }); + } + + /** Set display name for an agent's virtual user. */ + async setDisplayName(agent: string, displayName: string): Promise { + const userId = await this.ensureRegistered(agent); + const user = encodeURIComponent(userId); + await this.request('PUT', `/_matrix/client/v3/profile/${user}/displayname`, { + userId, + body: { displayname: displayName }, + }); + } +} diff --git a/packages/appservice/src/registration.ts b/packages/appservice/src/registration.ts new file mode 100644 index 0000000..57c78d7 --- /dev/null +++ b/packages/appservice/src/registration.ts @@ -0,0 +1,76 @@ +import type { AppserviceConfig } from './types.js'; + +export interface RegistrationOptions { + /** Unique appservice id in Synapse. Default: "mosaic-as". */ + id?: string; + /** URL where Synapse reaches the appservice, e.g. http://mosaic-as:8008 */ + url: string; + /** Alias namespace regex prefix. Default: "#mosaic-". */ + aliasPrefix?: string; +} + +const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Build the Synapse appservice registration document (mosaic-as.yaml). + * Deployment (infrastructure repo) serializes this to YAML and mounts it via + * app_service_config_files. + */ +export function buildRegistration(cfg: AppserviceConfig, options: RegistrationOptions) { + const prefix = cfg.userPrefix ?? 'agent-'; + return { + id: options.id ?? 'mosaic-as', + url: options.url, + as_token: cfg.asToken, + hs_token: cfg.hsToken, + sender_localpart: cfg.senderLocalpart ?? 'mosaic-as', + rate_limited: false, + namespaces: { + users: [ + { + regex: `@${escapeRegex(prefix)}.*:${escapeRegex(cfg.domain)}`, + exclusive: true, + }, + ], + aliases: [ + { + regex: `${escapeRegex(options.aliasPrefix ?? '#mosaic-')}.*:${escapeRegex(cfg.domain)}`, + exclusive: false, + }, + ], + rooms: [], + }, + }; +} + +const assertYamlSafe = (field: string, value: string): string => { + // Tokens/urls/ids are single-line opaque strings; control characters would + // let a crafted value terminate the scalar and inject YAML keys. + if (/[\r\n\x00-\x08\x0b-\x1f]/.test(value)) { + throw new Error(`registration field ${field} contains control characters`); + } + return value.replace(/'/g, "''"); +}; + +/** Minimal YAML serialization for the flat registration document. */ +export function registrationToYaml(registration: ReturnType): string { + const ns = registration.namespaces; + const nsBlock = (entries: Array<{ regex: string; exclusive: boolean }>): string => + entries.length === 0 + ? ' []' + : '\n' + + entries.map((e) => ` - regex: '${e.regex}'\n exclusive: ${e.exclusive}`).join('\n'); + return [ + `id: '${assertYamlSafe('id', registration.id)}'`, + `url: '${assertYamlSafe('url', registration.url)}'`, + `as_token: '${assertYamlSafe('as_token', registration.as_token)}'`, + `hs_token: '${assertYamlSafe('hs_token', registration.hs_token)}'`, + `sender_localpart: '${assertYamlSafe('sender_localpart', registration.sender_localpart)}'`, + `rate_limited: ${registration.rate_limited}`, + 'namespaces:', + ` users:${nsBlock(ns.users)}`, + ` aliases:${nsBlock(ns.aliases)}`, + ` rooms:${nsBlock(ns.rooms)}`, + '', + ].join('\n'); +} diff --git a/packages/appservice/src/transactions.ts b/packages/appservice/src/transactions.ts new file mode 100644 index 0000000..22321c9 --- /dev/null +++ b/packages/appservice/src/transactions.ts @@ -0,0 +1,89 @@ +import { timingSafeEqual } from 'node:crypto'; + +import type { EventHandler, HandlerResult, Transaction } from './types.js'; + +const MAX_SEEN_TXN_IDS = 1000; + +function safeTokenCompare(presented: string | undefined, expected: string): boolean { + if (presented === undefined) return false; + const a = Buffer.from(presented); + const b = Buffer.from(expected); + if (a.length !== b.length) { + // Compare against a same-length dummy so length is not a timing oracle. + timingSafeEqual(a, Buffer.alloc(a.length)); + return false; + } + return timingSafeEqual(a, b); +} + +export interface TransactionHandlerOptions { + hsToken: string; + onEvent: EventHandler; + /** Called for handler errors; events are at-most-once, errors must not 500. */ + onError?: (error: unknown, txnId: string) => void; +} + +/** + * Framework-agnostic handler for the Application Service transactions API + * (PUT /_matrix/app/v1/transactions/{txnId}). Host apps (Fastify/Nest) wrap + * this in a route. + * + * Spec requirements covered: hs_token verification (Authorization: Bearer, + * with legacy ?access_token fallback), txnId idempotency, always-200 on + * accepted transactions (homeserver retries on any other status). + * + * KNOWN LIMITATION: the txnId dedupe ring is in-process memory only. After a + * restart the homeserver may redeliver pending transactions — event handlers + * must be idempotent (delivery is at-least-once across process lifetimes). + */ +export class TransactionHandler { + private readonly seen: string[] = []; + private readonly seenSet = new Set(); + + constructor(private readonly options: TransactionHandlerOptions) {} + + authorized( + authorizationHeader: string | undefined, + accessTokenParam: string | undefined, + ): boolean { + const bearer = authorizationHeader?.startsWith('Bearer ') + ? authorizationHeader.slice('Bearer '.length) + : undefined; + const presented = bearer ?? accessTokenParam; + return safeTokenCompare(presented, this.options.hsToken); + } + + async handle( + txnId: string, + body: unknown, + auth: { authorizationHeader?: string; accessTokenParam?: string }, + ): Promise { + if (!this.authorized(auth.authorizationHeader, auth.accessTokenParam)) { + return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad hs_token' } }; + } + if (this.seenSet.has(txnId)) { + return { status: 200, body: {} }; + } + this.markSeen(txnId); + const txn = (body ?? {}) as Partial; + for (const event of txn.events ?? []) { + try { + await this.options.onEvent(event); + } catch (error) { + // A failing handler must not fail the transaction: the homeserver + // would retry the whole batch forever. + this.options.onError?.(error, txnId); + } + } + return { status: 200, body: {} }; + } + + private markSeen(txnId: string): void { + this.seen.push(txnId); + this.seenSet.add(txnId); + while (this.seen.length > MAX_SEEN_TXN_IDS) { + const evicted = this.seen.shift(); + if (evicted !== undefined) this.seenSet.delete(evicted); + } + } +} diff --git a/packages/appservice/src/types.ts b/packages/appservice/src/types.ts new file mode 100644 index 0000000..39427bc --- /dev/null +++ b/packages/appservice/src/types.ts @@ -0,0 +1,35 @@ +export interface AppserviceConfig { + /** Homeserver client-server API base, e.g. https://chat.uscllc.com */ + homeserverUrl: string; + /** Server name used in user IDs, e.g. chat.uscllc.com */ + domain: string; + /** Token the appservice presents to the homeserver (as_token). */ + asToken: string; + /** Token the homeserver presents to the appservice (hs_token). */ + hsToken: string; + /** Localpart prefix owned by this appservice. Default: "agent-". */ + userPrefix?: string; + /** The appservice's own sender user localpart. Default: "mosaic-as". */ + senderLocalpart?: string; +} + +export interface MatrixEvent { + type: string; + event_id?: string; + room_id?: string; + sender?: string; + state_key?: string; + content?: Record; + origin_server_ts?: number; +} + +export interface Transaction { + events: MatrixEvent[]; +} + +export type EventHandler = (event: MatrixEvent) => void | Promise; + +export interface HandlerResult { + status: number; + body: Record; +} diff --git a/packages/appservice/tsconfig.json b/packages/appservice/tsconfig.json new file mode 100644 index 0000000..c973386 --- /dev/null +++ b/packages/appservice/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c5055c..acab223 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,6 +297,18 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + packages/appservice: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.8.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + packages/auth: dependencies: '@mosaicstack/db':