feat(appservice): Matrix Application Service core library (M4a) (#530)
This commit was merged in pull request #530.
This commit is contained in:
89
packages/appservice/src/transactions.ts
Normal file
89
packages/appservice/src/transactions.ts
Normal file
@@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user