90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
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<string>();
|
|
|
|
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<HandlerResult> {
|
|
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<Transaction>;
|
|
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);
|
|
}
|
|
}
|
|
}
|