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); } } }