From 1291d5e55c7bd47d8f90f26a28fcd1242a325477 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Sun, 18 Oct 2026 00:55:05 +0100 Subject: [PATCH] feat: payout batching, money-movement audit, payout anomaly detection, hardened JWT tokens Payout scheduling and batching (#1008) - PayoutBatcher schedules individual payouts and, when due, groups them by provider and asset into batches sent in one provider call each. - Atomic per call: a batch settles every item under one transaction hash or fails every item; partial acknowledgements count as failure. - Failures are isolated per batch and retried with backoff up to maxAttempts. - Batch and run reports show status, items, totals and fees saved. A worker runs due batches on an interval. Money-movement audit log (#1011) - MoneyMovementAuditLog appends hash-chained, frozen entries recording actor, amount, asset and reference for every balance change, reservation, approval, rejection, settlement, failure and release in the payout engine and batcher. Compatible with verifyAuditChain, so edits, deletions and reordering are detected. - Compliance queries by actor, merchant, operation, reference, asset, amount range and time window, plus GET /payouts/audit and /payouts/audit/verify. Payout anomaly detection (#1013) - PayoutAnomalyDetector flags single-payout size, count/amount velocity and deviation from the merchant baseline. Thresholds come from PAYOUT_ANOMALY_* env vars and can be updated at runtime by admins. - Every flag is emitted to alert subscribers. Flagged engine batches always need approval and cannot be approved until reviewed; flagged scheduled payouts are held out of batches. - Review path: confirm or mark false positive (optionally suppressing the rule for the merchant for a bounded time); false positives release held payouts, confirmations cancel them. Hardened JWT handling (#1014) - Access tokens are now HS256 JWTs (15 min default, clamped to 1 min-1 h) instead of unverifiable opaque strings. The algorithm is pinned (none, asymmetric and other HMAC algorithms rejected), iss/aud/sub/jti/iat/exp are required, over-long lifetimes are rejected and signatures are compared in constant time. Supports key rotation via JWT_SECRET_PREVIOUS and refuses to run in production without JWT_SECRET. - Refresh rotation issues family-bound access tokens. A revocation list (local cache mirrored to Redis) is enforced by token-auth, WebSocket auth and the refresh routes, and is fed by logout, refresh-token replay and sign-out-everywhere. Also fixes a syntax error in middleware/logger.ts that stopped every module importing it (including token-auth) from loading. Closes #1008 Closes #1011 Closes #1013 Closes #1014 --- ENV_VARS.md | 14 + .../__tests__/money-movement-audit.test.ts | 158 +++++ backend/src/audit/money-movement-audit.ts | 250 ++++++++ backend/src/auth/__tests__/jwt.test.ts | 251 ++++++++ backend/src/auth/access-token-revocation.ts | 138 +++++ backend/src/auth/jwt.ts | 310 ++++++++++ backend/src/auth/token-rotation.ts | 26 +- backend/src/index.ts | 4 + .../middleware/__tests__/token-auth.test.ts | 86 +++ backend/src/middleware/auth.ts | 18 +- backend/src/middleware/logger.ts | 2 +- backend/src/middleware/token-auth.ts | 44 +- backend/src/middleware/ws-auth.ts | 18 +- .../routes/__tests__/payouts-batching.test.ts | 251 ++++++++ .../routes/__tests__/token-refresh.test.ts | 93 ++- backend/src/routes/payouts.ts | 280 ++++++++- backend/src/routes/token-refresh.ts | 36 +- .../__tests__/payout-anomaly-detector.test.ts | 178 ++++++ .../payouts/__tests__/payout-batcher.test.ts | 292 +++++++++ .../payout-engine-audit-anomaly.test.ts | 139 +++++ .../payouts/payout-anomaly-detector.ts | 404 ++++++++++++ .../src/services/payouts/payout-batcher.ts | 584 ++++++++++++++++++ backend/src/services/payouts/payout-engine.ts | 90 ++- 23 files changed, 3618 insertions(+), 48 deletions(-) create mode 100644 backend/src/audit/__tests__/money-movement-audit.test.ts create mode 100644 backend/src/audit/money-movement-audit.ts create mode 100644 backend/src/auth/__tests__/jwt.test.ts create mode 100644 backend/src/auth/access-token-revocation.ts create mode 100644 backend/src/auth/jwt.ts create mode 100644 backend/src/middleware/__tests__/token-auth.test.ts create mode 100644 backend/src/routes/__tests__/payouts-batching.test.ts create mode 100644 backend/src/services/payouts/__tests__/payout-anomaly-detector.test.ts create mode 100644 backend/src/services/payouts/__tests__/payout-batcher.test.ts create mode 100644 backend/src/services/payouts/__tests__/payout-engine-audit-anomaly.test.ts create mode 100644 backend/src/services/payouts/payout-anomaly-detector.ts create mode 100644 backend/src/services/payouts/payout-batcher.ts diff --git a/ENV_VARS.md b/ENV_VARS.md index 5e3841e5..f14c9061 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -15,6 +15,20 @@ | WS_ENABLED | Enable/disable WebSocket support | true | No | | WS_PORT | WebSocket port | 3001 | No | | PAYOUT_APPROVAL_THRESHOLD | Payouts at or above this amount require approval | 10000 | No | +| JWT_SECRET | HS256 signing secret for access tokens (min 32 chars). Required in production; dev/test fall back to a per-process random secret | - | Prod | +| JWT_SECRET_PREVIOUS | Previous signing secret, still accepted for verification during key rotation | - | No | +| JWT_ACCESS_TOKEN_TTL_SECONDS | Access token lifetime, clamped to 60–3600 | 900 | No | +| JWT_ISSUER / JWT_AUDIENCE | Expected `iss` / `aud` claims | agenticpay / agenticpay-api | No | +| PAYOUT_ANOMALY_DETECTION_ENABLED | Set to `false` to stop raising payout anomaly flags | true | No | +| PAYOUT_ANOMALY_SINGLE_THRESHOLD | A single payout at or above this amount is flagged | 50000 | No | +| PAYOUT_ANOMALY_VELOCITY_WINDOW_MS | Window for payout velocity rules | 3600000 | No | +| PAYOUT_ANOMALY_VELOCITY_MAX_COUNT | Payouts per merchant per window before flagging | 20 | No | +| PAYOUT_ANOMALY_VELOCITY_MAX_AMOUNT | Payout volume per merchant per window before flagging | 100000 | No | +| PAYOUT_ANOMALY_BASELINE_MULTIPLIER | Flag payouts this many times the merchant's rolling average | 5 | No | +| PAYOUT_ANOMALY_BASELINE_MIN_SAMPLES | Payouts needed before the baseline rule applies | 5 | No | +| PAYOUT_ANOMALY_BASELINE_SAMPLE_SIZE | Payouts kept per merchant for the rolling baseline | 50 | No | +| PAYOUT_BATCH_MAX_SIZE | Maximum payouts sent in one provider call | 100 | No | +| PAYOUT_PROVIDER_FEE_PER_CALL | Flat provider fee per call, used for the fees-saved estimate in batch reports | 0 | No | ## Frontend diff --git a/backend/src/audit/__tests__/money-movement-audit.test.ts b/backend/src/audit/__tests__/money-movement-audit.test.ts new file mode 100644 index 00000000..d0a3e8b3 --- /dev/null +++ b/backend/src/audit/__tests__/money-movement-audit.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MoneyMovementAuditLog } from '../money-movement-audit.js'; +import { verifyAuditChain } from '../chain-verifier.js'; +import type { ImmutableAuditLogEntry, ImmutableAuditStore } from '../immutable-logger.js'; + +describe('MoneyMovementAuditLog — Issue #1011', () => { + let clock: Date; + let log: MoneyMovementAuditLog; + + beforeEach(() => { + clock = new Date('2026-03-01T10:00:00Z'); + log = new MoneyMovementAuditLog({ now: () => clock }); + }); + + function seed() { + log.record({ actor: 'fin_1', operation: 'balance.set', amount: 500, asset: 'USDC', reference: 'balance:m1', merchantId: 'm1' }); + clock = new Date('2026-03-01T11:00:00Z'); + log.record({ actor: 'system', operation: 'payout.reserved', amount: 500, asset: 'USDC', reference: 'po_1', merchantId: 'm1' }); + clock = new Date('2026-03-02T09:00:00Z'); + log.record({ + actor: 'fin_2', + operation: 'payout.settled', + amount: 500, + asset: 'USDC', + reference: 'po_1', + merchantId: 'm1', + metadata: { transactionHash: '0xabc' }, + }); + log.record({ actor: 'system', operation: 'payout.reserved', amount: 75, asset: 'XLM', reference: 'po_2', merchantId: 'm2' }); + } + + describe('records actor, amount and reference', () => { + it('returns a typed record with every movement field', () => { + const record = log.record({ + actor: 'fin_1', + operation: 'payout.approved', + amount: 1250.5, + asset: 'USDC', + reference: 'po_123', + merchantId: 'm1', + metadata: { scheduleId: 'sch_m1' }, + }); + + expect(record).toMatchObject({ + sequence: 1, + actor: 'fin_1', + operation: 'payout.approved', + amount: 1250.5, + asset: 'USDC', + reference: 'po_123', + merchantId: 'm1', + metadata: { scheduleId: 'sch_m1' }, + timestamp: '2026-03-01T10:00:00.000Z', + }); + expect(record.hash).toMatch(/^[0-9a-f]{64}$/); + expect(record.previousHash).toBe('0'.repeat(64)); + }); + + it('refuses entries without an actor, reference or finite amount', () => { + const base = { actor: 'a', operation: 'payout.settled' as const, amount: 1, asset: 'USDC', reference: 'r' }; + expect(() => log.record({ ...base, actor: '' })).toThrow('AUDIT_ACTOR_REQUIRED'); + expect(() => log.record({ ...base, reference: '' })).toThrow('AUDIT_REFERENCE_REQUIRED'); + expect(() => log.record({ ...base, amount: Number.NaN })).toThrow('AUDIT_AMOUNT_INVALID'); + expect(log.size).toBe(0); + }); + }); + + describe('tamper evidence', () => { + it('chains every entry to the previous one and verifies clean', async () => { + seed(); + const entries = log.getEntries(); + for (let i = 1; i < entries.length; i++) expect(entries[i].previousHash).toBe(entries[i - 1].hash); + expect(await log.verify()).toEqual({ valid: true, checked: 4 }); + }); + + it('exposes entries as frozen copies so callers cannot rewrite history', async () => { + seed(); + const entries = log.getEntries(); + expect(Object.isFrozen(entries[0])).toBe(true); + expect(Object.isFrozen(entries[0].details)).toBe(true); + expect(() => { + (entries[0].details as Record).amount = 1; + }).toThrow(); + expect(log.query().records[0].amount).toBe(500); + expect(await log.verify()).toMatchObject({ valid: true }); + }); + + it.each([ + ['amount', (e: ImmutableAuditLogEntry) => ((e.details as Record).amount = 1)], + ['actor', (e: ImmutableAuditLogEntry) => (e.actor = 'someone_else')], + ['reference', (e: ImmutableAuditLogEntry) => ((e.details as Record).reference = 'po_x')], + ])('detects an edited %s in an exported copy', async (_field, mutate) => { + seed(); + const exported = structuredClone(log.getEntries()); + mutate(exported[1]); + const result = await verifyAuditChain(exported); + expect(result.valid).toBe(false); + expect(result.brokenAt).toBe(exported[1].id); + }); + + it('detects deleted and reordered entries', async () => { + seed(); + const dropped = structuredClone(log.getEntries()); + dropped.splice(1, 1); + expect((await verifyAuditChain(dropped)).valid).toBe(false); + + const reordered = structuredClone(log.getEntries()); + [reordered[1], reordered[2]] = [reordered[2], reordered[1]]; + expect((await verifyAuditChain(reordered)).valid).toBe(false); + }); + + it('persists entries to a durable store in chain order without blocking', async () => { + const appended: ImmutableAuditLogEntry[] = []; + const store: ImmutableAuditStore = { + getLatestAuditHash: vi.fn(), + appendAuditEntry: vi.fn(async (entry) => { + appended.push(entry); + }), + }; + const durable = new MoneyMovementAuditLog({ store, now: () => clock }); + durable.record({ actor: 'a', operation: 'payout.reserved', amount: 1, asset: 'USDC', reference: 'r1' }); + durable.record({ actor: 'a', operation: 'payout.settled', amount: 1, asset: 'USDC', reference: 'r1' }); + await durable.flush(); + expect(appended.map((e) => e.action)).toEqual(['payout.reserved', 'payout.settled']); + expect((await verifyAuditChain(appended)).valid).toBe(true); + }); + }); + + describe('compliance queries', () => { + beforeEach(seed); + + it('filters by actor, merchant, reference, operation and asset', () => { + expect(log.query({ actor: 'fin_2' }).records.map((r) => r.operation)).toEqual(['payout.settled']); + expect(log.query({ merchantId: 'm2' }).total).toBe(1); + expect(log.query({ reference: 'po_1' }).records.map((r) => r.operation)).toEqual([ + 'payout.reserved', + 'payout.settled', + ]); + expect(log.query({ operation: ['payout.reserved', 'balance.set'] }).total).toBe(3); + expect(log.query({ asset: 'XLM' }).records[0].reference).toBe('po_2'); + }); + + it('filters by amount range and time window and totals by asset', () => { + const big = log.query({ minAmount: 100 }); + expect(big.total).toBe(3); + expect(big.totalAmountByAsset).toEqual({ USDC: 1500 }); + + const day2 = log.query({ from: new Date('2026-03-02T00:00:00Z'), to: new Date('2026-03-02T23:59:59Z') }); + expect(day2.records.map((r) => r.reference)).toEqual(['po_1', 'po_2']); + }); + + it('paginates while reporting the full match count', () => { + const page = log.query({ limit: 2, offset: 1 }); + expect(page.total).toBe(4); + expect(page.records.map((r) => r.sequence)).toEqual([2, 3]); + }); + }); +}); diff --git a/backend/src/audit/money-movement-audit.ts b/backend/src/audit/money-movement-audit.ts new file mode 100644 index 00000000..ab3eef30 --- /dev/null +++ b/backend/src/audit/money-movement-audit.ts @@ -0,0 +1,250 @@ +/** + * Money-movement audit log — Issue #1011 + * + * Every operation that moves (or reserves, or returns) money is appended here as an + * immutable, hash-chained entry recording who did it, how much moved and against which + * reference. Entries reuse the `ImmutableAuditLogEntry` shape and `computeAuditHash`, so + * the existing `verifyAuditChain` tooling (and its tamper alerting) applies unchanged. + * + * - Append-only: there is no update or delete API, and returned entries are frozen copies. + * - Tamper-evident: each entry's hash covers the previous hash plus the full movement + * record (actor, amount, asset, reference, ...). Editing, reordering or dropping an + * entry breaks the chain and `verify()` reports where. + * - Queryable: `query()` filters by actor, merchant, operation, reference, asset, amount + * range and time window for compliance review. + */ + +import { randomUUID } from 'node:crypto'; +import { + AUDIT_GENESIS_HASH, + computeAuditHash, + type ImmutableAuditLogEntry, + type ImmutableAuditStore, +} from './immutable-logger.js'; +import { verifyAuditChain, type AuditIntegrityResult } from './chain-verifier.js'; + +/** Resource name every money-movement entry is filed under. */ +export const MONEY_MOVEMENT_RESOURCE = 'money_movement'; + +export type MoneyMovementOperation = + | 'balance.set' + | 'payout.reserved' + | 'payout.approved' + | 'payout.rejected' + | 'payout.settled' + | 'payout.failed' + | 'payout.released' + | 'payout.scheduled' + | 'payout.cancelled' + | 'payout.held' + | 'payout_batch.settled' + | 'payout_batch.failed'; + +export interface MoneyMovementInput { + /** Who initiated the movement: a user/approver id, or `system` for automated passes. */ + actor: string; + operation: MoneyMovementOperation; + amount: number; + asset: string; + /** Business reference the movement belongs to (batch id, scheduled payout id, ...). */ + reference: string; + merchantId?: string; + /** Extra context. Never put secrets here: entries are retained for compliance. */ + metadata?: Record; +} + +/** Typed view of an entry, flattened from the hashed `details` payload. */ +export interface MoneyMovementRecord { + id: string; + sequence: number; + timestamp: string; + actor: string; + operation: MoneyMovementOperation; + amount: number; + asset: string; + reference: string; + merchantId?: string; + metadata?: Record; + previousHash: string; + hash: string; +} + +export interface MoneyMovementQuery { + actor?: string; + merchantId?: string; + operation?: MoneyMovementOperation | MoneyMovementOperation[]; + reference?: string; + asset?: string; + minAmount?: number; + maxAmount?: number; + from?: Date; + to?: Date; + /** Max records returned (newest last). Defaults to 500, capped at 5,000. */ + limit?: number; + offset?: number; +} + +export interface MoneyMovementQueryResult { + records: MoneyMovementRecord[]; + total: number; + totalAmountByAsset: Record; +} + +export interface MoneyMovementAuditLogOptions { + /** Optional durable sink; writes are best-effort and never block the money path. */ + store?: ImmutableAuditStore; + now?: () => Date; +} + +const DEFAULT_QUERY_LIMIT = 500; +const MAX_QUERY_LIMIT = 5_000; + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const nested of Object.values(value as Record)) deepFreeze(nested); + } + return value; +} + +function cloneEntry(entry: ImmutableAuditLogEntry): ImmutableAuditLogEntry { + return deepFreeze(structuredClone(entry)); +} + +export class MoneyMovementAuditLog { + private readonly entries: ImmutableAuditLogEntry[] = []; + private readonly store?: ImmutableAuditStore; + private readonly now: () => Date; + private persistQueue: Promise = Promise.resolve(); + + constructor(options: MoneyMovementAuditLogOptions = {}) { + this.store = options.store; + this.now = options.now ?? (() => new Date()); + } + + /** Appends a movement and returns its frozen record. */ + record(input: MoneyMovementInput): MoneyMovementRecord { + if (!input.actor) throw new Error('AUDIT_ACTOR_REQUIRED'); + if (!input.reference) throw new Error('AUDIT_REFERENCE_REQUIRED'); + if (!Number.isFinite(input.amount)) throw new Error('AUDIT_AMOUNT_INVALID'); + + const previousHash = this.entries.at(-1)?.hash ?? AUDIT_GENESIS_HASH; + const timestamp = this.now().toISOString(); + const details: Record = { + sequence: this.entries.length + 1, + operation: input.operation, + amount: input.amount, + asset: input.asset, + reference: input.reference, + ...(input.merchantId !== undefined ? { merchantId: input.merchantId } : {}), + ...(input.metadata !== undefined ? { metadata: structuredClone(input.metadata) } : {}), + }; + + const entry: ImmutableAuditLogEntry = deepFreeze({ + id: randomUUID(), + timestamp, + actor: input.actor, + action: input.operation, + resource: MONEY_MOVEMENT_RESOURCE, + details, + previousHash, + hash: computeAuditHash({ + previousHash, + timestamp, + actor: input.actor, + action: input.operation, + resource: MONEY_MOVEMENT_RESOURCE, + details, + }), + }); + + this.entries.push(entry); + this.persist(entry); + return toRecord(entry); + } + + /** Filtered, paginated view for compliance review. */ + query(filter: MoneyMovementQuery = {}): MoneyMovementQueryResult { + const operations = filter.operation + ? new Set(Array.isArray(filter.operation) ? filter.operation : [filter.operation]) + : undefined; + + const matched = this.entries.map(toRecord).filter((record) => { + if (filter.actor && record.actor !== filter.actor) return false; + if (filter.merchantId && record.merchantId !== filter.merchantId) return false; + if (operations && !operations.has(record.operation)) return false; + if (filter.reference && record.reference !== filter.reference) return false; + if (filter.asset && record.asset !== filter.asset) return false; + if (filter.minAmount !== undefined && record.amount < filter.minAmount) return false; + if (filter.maxAmount !== undefined && record.amount > filter.maxAmount) return false; + const at = Date.parse(record.timestamp); + if (filter.from && at < filter.from.getTime()) return false; + if (filter.to && at > filter.to.getTime()) return false; + return true; + }); + + const totalAmountByAsset: Record = {}; + for (const record of matched) { + totalAmountByAsset[record.asset] = (totalAmountByAsset[record.asset] ?? 0) + record.amount; + } + + const limit = Math.min(Math.max(1, filter.limit ?? DEFAULT_QUERY_LIMIT), MAX_QUERY_LIMIT); + const offset = Math.max(0, filter.offset ?? 0); + return { + records: matched.slice(offset, offset + limit), + total: matched.length, + totalAmountByAsset, + }; + } + + /** Raw chain entries (frozen copies), oldest first, for export or external verification. */ + getEntries(): ImmutableAuditLogEntry[] { + return this.entries.map(cloneEntry); + } + + /** Re-derives every hash from genesis. `valid: false` pinpoints the first broken entry. */ + verify(): Promise { + return verifyAuditChain(this.entries); + } + + /** Resolves once every pending write to the durable store has settled. */ + flush(): Promise { + return this.persistQueue; + } + + get size(): number { + return this.entries.length; + } + + private persist(entry: ImmutableAuditLogEntry): void { + if (!this.store) return; + const store = this.store; + // Serialised so the store receives entries in chain order. + this.persistQueue = this.persistQueue.then(() => + store.appendAuditEntry(entry).catch((error) => { + console.warn('[audit] Failed to persist money-movement entry; retained in memory', error); + }), + ); + } +} + +function toRecord(entry: ImmutableAuditLogEntry): MoneyMovementRecord { + const d = entry.details as Record; + return { + id: entry.id, + sequence: d.sequence as number, + timestamp: entry.timestamp, + actor: entry.actor, + operation: d.operation as MoneyMovementOperation, + amount: d.amount as number, + asset: d.asset as string, + reference: d.reference as string, + ...(d.merchantId !== undefined ? { merchantId: d.merchantId as string } : {}), + ...(d.metadata !== undefined ? { metadata: structuredClone(d.metadata) as Record } : {}), + previousHash: entry.previousHash, + hash: entry.hash, + }; +} + +/** Process-wide log shared by the payout engine, scheduler and compliance routes. */ +export const moneyMovementAuditLog = new MoneyMovementAuditLog(); diff --git a/backend/src/auth/__tests__/jwt.test.ts b/backend/src/auth/__tests__/jwt.test.ts new file mode 100644 index 00000000..d01a26f3 --- /dev/null +++ b/backend/src/auth/__tests__/jwt.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createHmac } from 'node:crypto'; + +vi.mock('../../config/rate-limit-redis.js', () => ({ + getSharedRateLimitRedis: vi.fn().mockResolvedValue(null), +})); + +import { + JwtService, + MAX_ACCESS_TOKEN_TTL_SECONDS, + MIN_ACCESS_TOKEN_TTL_SECONDS, + DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + resolveJwtSecret, +} from '../jwt.js'; +import { AccessTokenRevocationList } from '../access-token-revocation.js'; + +const SECRET = 's'.repeat(48); +const OTHER_SECRET = 'o'.repeat(48); + +function b64(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function forge(header: Record, payload: Record, secret = SECRET): string { + const head = b64(header); + const body = b64(payload); + const sig = createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url'); + return `${head}.${body}.${sig}`; +} + +describe('JwtService — Issue #1014', () => { + let clock: Date; + let revocations: AccessTokenRevocationList; + let service: JwtService; + const now = () => clock; + + beforeEach(() => { + clock = new Date('2026-01-01T00:00:00Z'); + revocations = new AccessTokenRevocationList({ sharedStore: async () => null, now }); + service = new JwtService({ secret: SECRET, revocationList: revocations, now }); + }); + + describe('short-lived access tokens', () => { + it('issues 15 minute tokens by default with the required claims', () => { + const { token, claims, expiresAt } = service.issue({ userId: 'u1', tenantId: 't1', familyId: 'f1' }); + + expect(claims.exp - claims.iat).toBe(DEFAULT_ACCESS_TOKEN_TTL_SECONDS); + expect(expiresAt.getTime()).toBe(clock.getTime() + 15 * 60 * 1000); + expect(claims).toMatchObject({ sub: 'u1', tenantId: 't1', fid: 'f1', iss: 'agenticpay', aud: 'agenticpay-api' }); + expect(claims.jti).toMatch(/[0-9a-f-]{36}/); + + const verified = service.verify(token); + expect(verified).toEqual({ ok: true, claims }); + }); + + it('clamps requested lifetimes into [1 minute, 1 hour]', () => { + const long = service.issue({ userId: 'u1', tenantId: 't1', ttlSeconds: 30 * 24 * 3600 }).claims; + const short = service.issue({ userId: 'u1', tenantId: 't1', ttlSeconds: 1 }).claims; + expect(long.exp - long.iat).toBe(MAX_ACCESS_TOKEN_TTL_SECONDS); + expect(short.exp - short.iat).toBe(MIN_ACCESS_TOKEN_TTL_SECONDS); + expect(new JwtService({ secret: SECRET, ttlSeconds: 99_999 }).ttlSeconds).toBe(MAX_ACCESS_TOKEN_TTL_SECONDS); + }); + + it('rejects tokens once expired, allowing only the clock-skew grace', () => { + const { token } = service.issue({ userId: 'u1', tenantId: 't1' }); + + clock = new Date(clock.getTime() + 15 * 60 * 1000 + 10_000); + expect(service.verify(token).ok).toBe(true); // within 30s skew + + clock = new Date(clock.getTime() + 60_000); + expect(service.verify(token)).toEqual({ ok: false, reason: 'expired' }); + }); + + it('rejects correctly signed tokens whose lifetime exceeds the ceiling', () => { + const iat = Math.floor(clock.getTime() / 1000); + const token = forge( + { alg: 'HS256', typ: 'JWT', kid: 'k1' }, + { iss: 'agenticpay', aud: 'agenticpay-api', sub: 'u1', jti: 'j1', tenantId: 't1', iat, exp: iat + 86_400 }, + ); + expect(service.verify(token)).toEqual({ ok: false, reason: 'lifetime_exceeded' }); + }); + + it('rejects tokens issued in the future or not yet valid', () => { + const iat = Math.floor(clock.getTime() / 1000); + const base = { iss: 'agenticpay', aud: 'agenticpay-api', sub: 'u1', jti: 'j1', tenantId: 't1' }; + const header = { alg: 'HS256', typ: 'JWT', kid: 'k1' }; + expect(service.verify(forge(header, { ...base, iat: iat + 600, exp: iat + 900 }))).toEqual({ + ok: false, + reason: 'not_yet_valid', + }); + expect(service.verify(forge(header, { ...base, iat, nbf: iat + 600, exp: iat + 900 }))).toEqual({ + ok: false, + reason: 'not_yet_valid', + }); + }); + }); + + describe('algorithm enforcement', () => { + const iat = () => Math.floor(Date.parse('2026-01-01T00:00:00Z') / 1000); + const payload = () => ({ + iss: 'agenticpay', + aud: 'agenticpay-api', + sub: 'u1', + jti: 'j1', + tenantId: 't1', + iat: iat(), + exp: iat() + 600, + }); + + it('rejects alg "none" tokens with an empty or missing signature', () => { + const unsigned = `${b64({ alg: 'none', typ: 'JWT', kid: 'k1' })}.${b64(payload())}.`; + expect(service.verify(unsigned).ok).toBe(false); + const withSig = forge({ alg: 'none', typ: 'JWT', kid: 'k1' }, payload()); + expect(service.verify(withSig)).toEqual({ ok: false, reason: 'unsupported_algorithm' }); + }); + + it.each(['RS256', 'HS384', 'HS512', 'ES256', 'hs256'])('rejects %s even when HMAC-signed with the secret', (alg) => { + expect(service.verify(forge({ alg, typ: 'JWT', kid: 'k1' }, payload()))).toEqual({ + ok: false, + reason: 'unsupported_algorithm', + }); + }); + + it('rejects tokens with critical header extensions or unknown key ids', () => { + expect(service.verify(forge({ alg: 'HS256', kid: 'k1', crit: ['exp'] }, payload())).ok).toBe(false); + expect(service.verify(forge({ alg: 'HS256', kid: 'nope' }, payload()))).toEqual({ + ok: false, + reason: 'unknown_key', + }); + }); + + it('rejects tokens signed with a different secret or tampered payloads', () => { + expect(service.verify(forge({ alg: 'HS256', kid: 'k1' }, payload(), OTHER_SECRET))).toEqual({ + ok: false, + reason: 'invalid_signature', + }); + + const { token } = service.issue({ userId: 'u1', tenantId: 't1' }); + const [h, , s] = token.split('.'); + const escalated = `${h}.${b64({ ...payload(), sub: 'admin', role: 'admin' })}.${s}`; + expect(service.verify(escalated)).toEqual({ ok: false, reason: 'invalid_signature' }); + }); + + it('rejects wrong issuer/audience and missing claims', () => { + const header = { alg: 'HS256', kid: 'k1' }; + expect(service.verify(forge(header, { ...payload(), iss: 'evil' }))).toEqual({ ok: false, reason: 'invalid_claims' }); + expect(service.verify(forge(header, { ...payload(), aud: 'other-api' }))).toEqual({ + ok: false, + reason: 'invalid_claims', + }); + const { jti: _jti, ...noJti } = payload(); + expect(service.verify(forge(header, noJti))).toEqual({ ok: false, reason: 'invalid_claims' }); + }); + + it('rejects malformed input', () => { + for (const bad of ['', 'a.b', 'a.b.c.d', 'at_' + 'a'.repeat(64), '!!.@@.##', 'x'.repeat(5000)]) { + expect(service.verify(bad).ok).toBe(false); + } + }); + }); + + describe('key rotation', () => { + it('verifies tokens signed by the previous secret only when it is configured', () => { + const old = new JwtService({ secret: OTHER_SECRET, revocationList: revocations, now }); + const token = old.issue({ userId: 'u1', tenantId: 't1' }).token; + // `old` signs with kid k1; re-sign the same claims as the previous key (k0). + const [, body] = token.split('.'); + const head = b64({ alg: 'HS256', typ: 'JWT', kid: 'k0' }); + const sig = createHmac('sha256', OTHER_SECRET).update(`${head}.${body}`).digest('base64url'); + const previousKeyToken = `${head}.${body}.${sig}`; + + expect(service.verify(previousKeyToken)).toEqual({ ok: false, reason: 'unknown_key' }); + const rotated = new JwtService({ secret: SECRET, previousSecret: OTHER_SECRET, revocationList: revocations, now }); + expect(rotated.verify(previousKeyToken).ok).toBe(true); + }); + }); + + describe('revocation list enforcement', () => { + it('rejects a revoked token by jti while other tokens keep working', async () => { + const a = service.issue({ userId: 'u1', tenantId: 't1' }).token; + const b = service.issue({ userId: 'u1', tenantId: 't1' }).token; + + expect(await service.revoke(a)).toBe(true); + expect(service.verify(a)).toEqual({ ok: false, reason: 'revoked' }); + expect(await service.verifyAsync(a)).toEqual({ ok: false, reason: 'revoked' }); + expect(service.verify(b).ok).toBe(true); + }); + + it('rejects every token of a revoked refresh family', async () => { + const a = service.issue({ userId: 'u1', tenantId: 't1', familyId: 'fam-1' }).token; + const b = service.issue({ userId: 'u1', tenantId: 't1', familyId: 'fam-2' }).token; + await revocations.revokeFamily('fam-1'); + expect(service.verify(a)).toEqual({ ok: false, reason: 'revoked' }); + expect(service.verify(b).ok).toBe(true); + }); + + it('rejects tokens issued before a subject cut-off but accepts later ones', async () => { + const before = service.issue({ userId: 'u1', tenantId: 't1' }).token; + const otherUser = service.issue({ userId: 'u2', tenantId: 't1' }).token; + await revocations.revokeSubject('u1'); + + clock = new Date(clock.getTime() + 2000); + const after = service.issue({ userId: 'u1', tenantId: 't1' }).token; + + expect(service.verify(before)).toEqual({ ok: false, reason: 'revoked' }); + expect(service.verify(otherUser).ok).toBe(true); + expect(service.verify(after).ok).toBe(true); + }); + + it('consults the shared store so revocations reach other instances', async () => { + const shared = new Map(); + const store = { + get: async (key: string) => shared.get(key) ?? null, + set: async (key: string, value: string) => { + shared.set(key, value); + return 'OK'; + }, + }; + const instanceA = new AccessTokenRevocationList({ sharedStore: async () => store, now }); + const instanceB = new AccessTokenRevocationList({ sharedStore: async () => store, now }); + const serviceA = new JwtService({ secret: SECRET, revocationList: instanceA, now }); + const serviceB = new JwtService({ secret: SECRET, revocationList: instanceB, now }); + + const token = serviceA.issue({ userId: 'u1', tenantId: 't1' }).token; + await serviceA.revoke(token); + + expect(serviceB.verify(token).ok).toBe(true); // local cache on B knows nothing + expect(await serviceB.verifyAsync(token)).toEqual({ ok: false, reason: 'revoked' }); + }); + + it('drops revocation entries once the covered tokens could no longer be valid', async () => { + const { token, claims } = service.issue({ userId: 'u1', tenantId: 't1' }); + await service.revoke(token); + clock = new Date((claims.exp + 3600) * 1000); + expect(revocations.isRevoked(claims)).toBe(false); + }); + }); + + describe('secret handling', () => { + it('requires a strong secret', () => { + expect(() => new JwtService({ secret: 'short' })).toThrow(/at least 32/); + expect(() => resolveJwtSecret({ JWT_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow(/at least 32/); + }); + + it('refuses to fall back to an ephemeral secret in production', () => { + expect(() => resolveJwtSecret({ NODE_ENV: 'production' } as NodeJS.ProcessEnv)).toThrow(/must be set/); + expect(resolveJwtSecret({ NODE_ENV: 'test' } as NodeJS.ProcessEnv)).toHaveLength(96); + expect(resolveJwtSecret({ JWT_SECRET: SECRET } as NodeJS.ProcessEnv)).toBe(SECRET); + }); + }); +}); diff --git a/backend/src/auth/access-token-revocation.ts b/backend/src/auth/access-token-revocation.ts new file mode 100644 index 00000000..b4595c67 --- /dev/null +++ b/backend/src/auth/access-token-revocation.ts @@ -0,0 +1,138 @@ +/** + * Access-token revocation list — Issue #1014 + * + * Short-lived JWTs still need to die immediately on logout, compromise or "sign out + * everywhere". Three kinds of entry are kept: + * + * - by `jti` — one specific token (logout) + * - by family — every access token minted from a refresh-token family (replay detected) + * - by subject — every token for a user issued at or before a cut-off (sign out all) + * + * Entries only need to live as long as the tokens they cover, so each expires once the + * longest possible access token would have. A local cache answers synchronously (used by + * WebSocket upgrades); when Redis is configured, writes are mirrored there and + * `isRevokedShared` consults it so revocation reaches every instance. + */ + +import { getSharedRateLimitRedis } from '../config/rate-limit-redis.js'; + +/** Longest access-token lifetime plus skew; kept in sync with `MAX_ACCESS_TOKEN_TTL_SECONDS`. */ +const MAX_COVERED_TOKEN_LIFETIME_MS = (60 * 60 + 60) * 1000; + +const KEY_PREFIX = 'at:revoked:'; + +export interface RevocableClaims { + jti: string; + sub: string; + iat: number; + exp: number; + fid?: string; +} + +interface SharedStore { + get(key: string): Promise; + set(key: string, value: string, mode: 'PX', ttlMs: number): Promise; +} + +export interface AccessTokenRevocationListOptions { + /** Resolves the shared store; defaults to the shared rate-limit Redis connection. */ + sharedStore?: () => Promise; + now?: () => Date; +} + +export class AccessTokenRevocationList { + private readonly tokens = new Map(); // jti -> expires epoch ms + private readonly families = new Map(); // fid -> expires epoch ms + private readonly subjects = new Map(); + private readonly sharedStore: () => Promise; + private readonly now: () => Date; + + constructor(options: AccessTokenRevocationListOptions = {}) { + this.sharedStore = + options.sharedStore ?? + (async () => (await getSharedRateLimitRedis()) as unknown as SharedStore | null | undefined); + this.now = options.now ?? (() => new Date()); + } + + async revokeToken(jti: string, expiresAt: Date): Promise { + const until = Math.min(expiresAt.getTime(), this.nowMs() + MAX_COVERED_TOKEN_LIFETIME_MS); + if (until <= this.nowMs()) return; + this.tokens.set(jti, until); + await this.mirror(`jti:${jti}`, '1', until - this.nowMs()); + } + + async revokeFamily(familyId: string): Promise { + const until = this.nowMs() + MAX_COVERED_TOKEN_LIFETIME_MS; + this.families.set(familyId, until); + await this.mirror(`fid:${familyId}`, '1', MAX_COVERED_TOKEN_LIFETIME_MS); + } + + /** Rejects every token for `subject` issued at or before now. */ + async revokeSubject(subject: string): Promise { + const cutoffSeconds = Math.floor(this.nowMs() / 1000); + const expiresAt = this.nowMs() + MAX_COVERED_TOKEN_LIFETIME_MS; + this.subjects.set(subject, { cutoffSeconds, expiresAt }); + await this.mirror(`sub:${subject}`, String(cutoffSeconds), MAX_COVERED_TOKEN_LIFETIME_MS); + } + + /** Local check only. */ + isRevoked(claims: RevocableClaims): boolean { + this.prune(); + if (this.tokens.has(claims.jti)) return true; + if (claims.fid && this.families.has(claims.fid)) return true; + const subject = this.subjects.get(claims.sub); + return subject !== undefined && claims.iat <= subject.cutoffSeconds; + } + + /** Local check, then the shared store. A shared-store outage falls back to local only. */ + async isRevokedShared(claims: RevocableClaims): Promise { + if (this.isRevoked(claims)) return true; + let store: SharedStore | null | undefined; + try { + store = await this.sharedStore(); + } catch { + return false; + } + if (!store) return false; + try { + const [byToken, byFamily, bySubject] = await Promise.all([ + store.get(`${KEY_PREFIX}jti:${claims.jti}`), + claims.fid ? store.get(`${KEY_PREFIX}fid:${claims.fid}`) : Promise.resolve(null), + store.get(`${KEY_PREFIX}sub:${claims.sub}`), + ]); + if (byToken !== null || byFamily !== null) return true; + return bySubject !== null && claims.iat <= Number(bySubject); + } catch { + return false; + } + } + + /** Clears every entry. Intended for tests. */ + clear(): void { + this.tokens.clear(); + this.families.clear(); + this.subjects.clear(); + } + + private async mirror(key: string, value: string, ttlMs: number): Promise { + try { + const store = await this.sharedStore(); + await store?.set(`${KEY_PREFIX}${key}`, value, 'PX', Math.max(1, Math.ceil(ttlMs))); + } catch { + // Local revocation already applies; the shared mirror is best-effort. + } + } + + private prune(): void { + const now = this.nowMs(); + for (const [key, until] of this.tokens) if (until <= now) this.tokens.delete(key); + for (const [key, until] of this.families) if (until <= now) this.families.delete(key); + for (const [key, entry] of this.subjects) if (entry.expiresAt <= now) this.subjects.delete(key); + } + + private nowMs(): number { + return this.now().getTime(); + } +} + +export const accessTokenRevocationList = new AccessTokenRevocationList(); diff --git a/backend/src/auth/jwt.ts b/backend/src/auth/jwt.ts new file mode 100644 index 00000000..f5728fca --- /dev/null +++ b/backend/src/auth/jwt.ts @@ -0,0 +1,310 @@ +/** + * Hardened JWT access tokens — Issue #1014 + * + * Access tokens used to be opaque random strings that were only checked for *shape*, so + * any well-formed string was accepted and nothing could be revoked. They are now signed + * JWTs with: + * + * - Short lifetimes: 15 minutes by default (`JWT_ACCESS_TOKEN_TTL_SECONDS`), clamped to + * [1 minute, 1 hour]. A token whose own `exp - iat` exceeds the ceiling is rejected + * even if correctly signed. + * - A pinned algorithm: only `HS256` is accepted. `none`, asymmetric algorithms (the + * classic RS/HS confusion) and missing headers are rejected before any signature work. + * - Strict claims: `iss`, `aud`, `sub`, `jti`, `iat` and `exp` are required; `nbf` is + * honoured; a small clock-skew allowance applies. + * - Constant-time signature comparison and a minimum secret length. + * - Key rotation: `kid` selects the current or previous secret (`JWT_SECRET_PREVIOUS`). + * - Revocation: every verified token is checked against the access-token revocation + * list by `jti`, by refresh-token family (`fid`) and by subject cut-off. + */ + +import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { accessTokenRevocationList, type AccessTokenRevocationList } from './access-token-revocation.js'; + +export const JWT_ALGORITHM = 'HS256'; +export const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60; +export const MIN_ACCESS_TOKEN_TTL_SECONDS = 60; +export const MAX_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +export const DEFAULT_CLOCK_SKEW_SECONDS = 30; +export const MIN_JWT_SECRET_LENGTH = 32; +export const DEFAULT_JWT_ISSUER = 'agenticpay'; +export const DEFAULT_JWT_AUDIENCE = 'agenticpay-api'; + +export interface AccessTokenClaims { + iss: string; + aud: string; + /** Subject: the user id. */ + sub: string; + /** Unique token id, used for revocation. */ + jti: string; + iat: number; + nbf?: number; + exp: number; + tenantId: string; + role?: string; + /** Refresh-token family this access token was minted from. */ + fid?: string; +} + +export interface IssueAccessTokenInput { + userId: string; + tenantId: string; + role?: string; + familyId?: string; + /** Requested lifetime; clamped to the allowed range. */ + ttlSeconds?: number; +} + +export interface IssuedAccessToken { + token: string; + claims: AccessTokenClaims; + expiresAt: Date; +} + +export type JwtVerificationFailure = + | 'malformed' + | 'unsupported_algorithm' + | 'unknown_key' + | 'invalid_signature' + | 'invalid_claims' + | 'not_yet_valid' + | 'expired' + | 'lifetime_exceeded' + | 'revoked'; + +export type JwtVerificationResult = + | { ok: true; claims: AccessTokenClaims } + | { ok: false; reason: JwtVerificationFailure }; + +export interface JwtServiceOptions { + secret: string; + previousSecret?: string; + issuer?: string; + audience?: string; + ttlSeconds?: number; + clockSkewSeconds?: number; + revocationList?: AccessTokenRevocationList; + now?: () => Date; +} + +const CURRENT_KID = 'k1'; +const PREVIOUS_KID = 'k0'; + +function base64UrlJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function parseBase64UrlJson(segment: string): unknown { + if (!/^[A-Za-z0-9_-]+$/.test(segment)) throw new Error('malformed'); + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +function clampTtl(ttlSeconds: number): number { + if (!Number.isFinite(ttlSeconds)) return DEFAULT_ACCESS_TOKEN_TTL_SECONDS; + return Math.min(MAX_ACCESS_TOKEN_TTL_SECONDS, Math.max(MIN_ACCESS_TOKEN_TTL_SECONDS, Math.floor(ttlSeconds))); +} + +function assertSecret(secret: string | undefined, name: string): void { + if (typeof secret !== 'string' || secret.length < MIN_JWT_SECRET_LENGTH) { + throw new Error(`${name} must be at least ${MIN_JWT_SECRET_LENGTH} characters`); + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= 256; +} + +function isIntegerSeconds(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + +export class JwtService { + private readonly keys: Map; + readonly issuer: string; + readonly audience: string; + readonly ttlSeconds: number; + private readonly clockSkewSeconds: number; + private readonly revocationList: AccessTokenRevocationList; + private readonly now: () => Date; + + constructor(options: JwtServiceOptions) { + assertSecret(options.secret, 'JWT_SECRET'); + if (options.previousSecret !== undefined) assertSecret(options.previousSecret, 'JWT_SECRET_PREVIOUS'); + + this.keys = new Map([[CURRENT_KID, options.secret]]); + if (options.previousSecret) this.keys.set(PREVIOUS_KID, options.previousSecret); + this.issuer = options.issuer ?? DEFAULT_JWT_ISSUER; + this.audience = options.audience ?? DEFAULT_JWT_AUDIENCE; + this.ttlSeconds = clampTtl(options.ttlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS); + this.clockSkewSeconds = Math.max(0, Math.min(options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS, 300)); + this.revocationList = options.revocationList ?? accessTokenRevocationList; + this.now = options.now ?? (() => new Date()); + } + + /** Mints a signed access token. Lifetimes outside the allowed range are clamped. */ + issue(input: IssueAccessTokenInput): IssuedAccessToken { + if (!isNonEmptyString(input.userId)) throw new Error('JWT_SUBJECT_REQUIRED'); + if (!isNonEmptyString(input.tenantId)) throw new Error('JWT_TENANT_REQUIRED'); + + const iat = Math.floor(this.now().getTime() / 1000); + const claims: AccessTokenClaims = { + iss: this.issuer, + aud: this.audience, + sub: input.userId, + jti: randomUUID(), + iat, + nbf: iat, + exp: iat + clampTtl(input.ttlSeconds ?? this.ttlSeconds), + tenantId: input.tenantId, + ...(input.role ? { role: input.role } : {}), + ...(input.familyId ? { fid: input.familyId } : {}), + }; + + const header = base64UrlJson({ alg: JWT_ALGORITHM, typ: 'JWT', kid: CURRENT_KID }); + const payload = base64UrlJson(claims); + const signature = this.sign(`${header}.${payload}`, this.keys.get(CURRENT_KID)!); + return { token: `${header}.${payload}.${signature}`, claims, expiresAt: new Date(claims.exp * 1000) }; + } + + /** + * Verifies signature, algorithm, claims, lifetime and revocation. Uses only the local + * revocation cache, so it is synchronous; `verifyAsync` also consults shared storage. + */ + verify(token: string): JwtVerificationResult { + const decoded = this.decodeAndCheck(token); + if (!decoded.ok) return decoded; + if (this.revocationList.isRevoked(decoded.claims)) return { ok: false, reason: 'revoked' }; + return decoded; + } + + /** As `verify`, but also checks the shared (Redis) revocation list when configured. */ + async verifyAsync(token: string): Promise { + const decoded = this.decodeAndCheck(token); + if (!decoded.ok) return decoded; + if (await this.revocationList.isRevokedShared(decoded.claims)) return { ok: false, reason: 'revoked' }; + return decoded; + } + + /** Revokes a single verified token until it would have expired anyway. */ + async revoke(token: string): Promise { + const decoded = this.decodeAndCheck(token); + if (!decoded.ok) return false; + await this.revocationList.revokeToken(decoded.claims.jti, new Date(decoded.claims.exp * 1000)); + return true; + } + + private decodeAndCheck(token: string): JwtVerificationResult { + if (typeof token !== 'string' || token.length > 4096) return { ok: false, reason: 'malformed' }; + const parts = token.split('.'); + if (parts.length !== 3 || parts.some((part) => part.length === 0)) return { ok: false, reason: 'malformed' }; + const [headerSegment, payloadSegment, signatureSegment] = parts; + + let header: Record; + let payload: Record; + try { + header = parseBase64UrlJson(headerSegment) as Record; + payload = parseBase64UrlJson(payloadSegment) as Record; + } catch { + return { ok: false, reason: 'malformed' }; + } + if (!header || typeof header !== 'object' || !payload || typeof payload !== 'object') { + return { ok: false, reason: 'malformed' }; + } + + // Pin the algorithm before touching the signature: never let the token choose it. + if (header.alg !== JWT_ALGORITHM || (header.typ !== undefined && header.typ !== 'JWT')) { + return { ok: false, reason: 'unsupported_algorithm' }; + } + if (header.crit !== undefined) return { ok: false, reason: 'unsupported_algorithm' }; + + const secret = typeof header.kid === 'string' ? this.keys.get(header.kid) : undefined; + if (!secret) return { ok: false, reason: 'unknown_key' }; + + const expected = Buffer.from(this.sign(`${headerSegment}.${payloadSegment}`, secret)); + const actual = Buffer.from(signatureSegment); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return { ok: false, reason: 'invalid_signature' }; + } + + const claims = payload as Partial; + if ( + claims.iss !== this.issuer || + claims.aud !== this.audience || + !isNonEmptyString(claims.sub) || + !isNonEmptyString(claims.jti) || + !isNonEmptyString(claims.tenantId) || + !isIntegerSeconds(claims.iat) || + !isIntegerSeconds(claims.exp) || + (claims.nbf !== undefined && !isIntegerSeconds(claims.nbf)) || + (claims.role !== undefined && !isNonEmptyString(claims.role)) || + (claims.fid !== undefined && !isNonEmptyString(claims.fid)) + ) { + return { ok: false, reason: 'invalid_claims' }; + } + + const nowSeconds = Math.floor(this.now().getTime() / 1000); + if (claims.exp - claims.iat > MAX_ACCESS_TOKEN_TTL_SECONDS || claims.exp <= claims.iat) { + return { ok: false, reason: 'lifetime_exceeded' }; + } + if (claims.iat > nowSeconds + this.clockSkewSeconds) return { ok: false, reason: 'not_yet_valid' }; + if (claims.nbf !== undefined && claims.nbf > nowSeconds + this.clockSkewSeconds) { + return { ok: false, reason: 'not_yet_valid' }; + } + if (claims.exp <= nowSeconds - this.clockSkewSeconds) return { ok: false, reason: 'expired' }; + + return { ok: true, claims: claims as AccessTokenClaims }; + } + + private sign(input: string, secret: string): string { + return createHmac('sha256', secret).update(input).digest('base64url'); + } +} + +let ephemeralSecret: string | undefined; + +/** + * Resolves the signing secret from `JWT_SECRET`. Production refuses to start without a + * strong secret; other environments fall back to a per-process random secret (tokens + * then do not survive a restart, which is the safe failure mode). + */ +export function resolveJwtSecret(env: NodeJS.ProcessEnv = process.env): string { + const configured = env.JWT_SECRET; + if (configured) { + assertSecret(configured, 'JWT_SECRET'); + return configured; + } + if (env.NODE_ENV === 'production') { + throw new Error('JWT_SECRET must be set in production'); + } + ephemeralSecret ??= randomBytes(48).toString('hex'); + return ephemeralSecret; +} + +export function createJwtServiceFromEnv(env: NodeJS.ProcessEnv = process.env): JwtService { + const ttl = Number(env.JWT_ACCESS_TOKEN_TTL_SECONDS); + return new JwtService({ + secret: resolveJwtSecret(env), + ...(env.JWT_SECRET_PREVIOUS ? { previousSecret: env.JWT_SECRET_PREVIOUS } : {}), + ...(env.JWT_ISSUER ? { issuer: env.JWT_ISSUER } : {}), + ...(env.JWT_AUDIENCE ? { audience: env.JWT_AUDIENCE } : {}), + ...(Number.isFinite(ttl) && ttl > 0 ? { ttlSeconds: ttl } : {}), + }); +} + +let sharedService: JwtService | undefined; + +/** Lazily created so a missing production secret fails at first use with a clear error. */ +export function getJwtService(): JwtService { + sharedService ??= createJwtServiceFromEnv(); + return sharedService; +} + +/** Replaces the shared service. Intended for tests. */ +export function setJwtServiceForTesting(service: JwtService | undefined): void { + sharedService = service; +} + +/** Cheap structural check used to route a bearer credential to JWT verification. */ +export function looksLikeJwt(token: string): boolean { + return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token); +} diff --git a/backend/src/auth/token-rotation.ts b/backend/src/auth/token-rotation.ts index df5a4959..9a36663b 100644 --- a/backend/src/auth/token-rotation.ts +++ b/backend/src/auth/token-rotation.ts @@ -4,11 +4,16 @@ // - Token family tracking; reuse of a rotated token revokes the entire family // - Absolute TTL (configurable, default 30 days) + sliding expiration (default 7 days) // - Redis blacklist for immediate family revocation +// - Access tokens are short-lived signed JWTs bound to their refresh family (#1014); +// revoking a refresh token, a family or a user also revokes the access tokens minted +// from it via the access-token revocation list. import { randomBytes, createHash } from 'node:crypto'; import { prisma } from '../lib/prisma.js'; import { auditService } from '../services/auditService.js'; import { getSharedRateLimitRedis } from '../config/rate-limit-redis.js'; +import { getJwtService } from './jwt.js'; +import { accessTokenRevocationList } from './access-token-revocation.js'; // --------------------------------------------------------------------------- // Configuration @@ -67,6 +72,9 @@ async function revokeFamily(familyId: string, reason: string): Promise { } } catch { /* continue to DB update */ } + // Access tokens minted from this family must stop working now, not at their expiry. + await accessTokenRevocationList.revokeFamily(familyId); + await prisma.refreshToken.updateMany({ where: { familyId, revoked: false }, data: { revoked: true, revokedAt: new Date(), revokeReason: 'family_revoked' }, @@ -79,10 +87,15 @@ async function revokeFamily(familyId: string, reason: string): Promise { export interface IssuedTokens { accessToken: string; + accessTokenExpiresAt: Date; refreshToken: string; refreshTokenExpiresAt: Date; } +function issueAccessToken(userId: string, tenantId: string, familyId: string) { + return getJwtService().issue({ userId, tenantId, familyId }); +} + /** Issue the first token pair when a user authenticates. */ export async function issueTokenFamily( userId: string, @@ -114,8 +127,10 @@ export async function issueTokenFamily( details: { familyId, tenantId }, }); + const access = issueAccessToken(userId, tenantId, familyId); return { - accessToken: `at_${randomBytes(32).toString('hex')}`, + accessToken: access.token, + accessTokenExpiresAt: access.expiresAt, refreshToken: rawRefresh, refreshTokenExpiresAt: slidingExpiresAt, }; @@ -124,6 +139,7 @@ export async function issueTokenFamily( export interface RotateResult { ok: true; accessToken: string; + accessTokenExpiresAt: Date; refreshToken: string; refreshTokenExpiresAt: Date; } @@ -201,9 +217,11 @@ export async function rotateRefreshToken( details: { familyId: existing.familyId, newTokenId: newToken.id, tenantId: existing.tenantId }, }); + const access = issueAccessToken(existing.userId, existing.tenantId, existing.familyId); return { ok: true, - accessToken: `at_${randomBytes(32).toString('hex')}`, + accessToken: access.token, + accessTokenExpiresAt: access.expiresAt, refreshToken: rawNew, refreshTokenExpiresAt: slidingExpiresAt, }; @@ -219,6 +237,8 @@ export async function revokeToken(rawToken: string, userId?: string): Promise { // Payout retry worker: drives backed-off payout retries without a manual call startPayoutRetryWorker(); + // Payout batch worker: sends due scheduled payouts in provider batches (#1008) + startPayoutBatchWorker(); + // Job scheduler if (config.jobs.enabled) { startJobs(); diff --git a/backend/src/middleware/__tests__/token-auth.test.ts b/backend/src/middleware/__tests__/token-auth.test.ts new file mode 100644 index 00000000..51139807 --- /dev/null +++ b/backend/src/middleware/__tests__/token-auth.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { Request, Response } from 'express'; + +vi.mock('../../config/rate-limit-redis.js', () => ({ + getSharedRateLimitRedis: vi.fn().mockResolvedValue(null), +})); +vi.mock('../../services/auditService.js', () => ({ + auditService: { logAction: vi.fn().mockResolvedValue(undefined) }, +})); + +import { tokenAuthMiddleware } from '../token-auth.js'; +import { createWsAuthHandler } from '../ws-auth.js'; +import { JwtService, setJwtServiceForTesting } from '../../auth/jwt.js'; +import { AccessTokenRevocationList } from '../../auth/access-token-revocation.js'; + +function makeReq(authorization?: string): Request { + return { + headers: authorization ? { authorization } : {}, + socket: { remoteAddress: '127.0.0.1' }, + path: '/x', + } as unknown as Request; +} + +async function run(req: Request): Promise { + let forwarded: unknown = 'not-called'; + await tokenAuthMiddleware(req, {} as Response, (err?: unknown) => { + forwarded = err; + }); + return forwarded; +} + +describe('tokenAuthMiddleware — Issue #1014', () => { + let service: JwtService; + let revocations: AccessTokenRevocationList; + + beforeEach(() => { + revocations = new AccessTokenRevocationList({ sharedStore: async () => null }); + service = new JwtService({ secret: 'k'.repeat(40), revocationList: revocations }); + setJwtServiceForTesting(service); + }); + + afterEach(() => setJwtServiceForTesting(undefined)); + + it('is a no-op without a bearer header', async () => { + expect(await run(makeReq())).toBeUndefined(); + }); + + it('attaches verified claims for a valid token', async () => { + const { token } = service.issue({ userId: 'u1', tenantId: 't1', role: 'admin' }); + const req = makeReq(`Bearer ${token}`); + expect(await run(req)).toBeUndefined(); + expect(req.accessToken).toBe(token); + expect(req.accessTokenClaims).toMatchObject({ sub: 'u1', tenantId: 't1', role: 'admin' }); + }); + + it('rejects legacy opaque tokens that carry no verifiable claims', async () => { + const err = (await run(makeReq(`Bearer at_${'a'.repeat(64)}`))) as { statusCode: number; code: string }; + expect(err.statusCode).toBe(401); + expect(err.code).toBe('TOKEN_MALFORMED'); + }); + + it('rejects forged and revoked tokens', async () => { + const other = new JwtService({ secret: 'z'.repeat(40) }); + const forged = (await run(makeReq(`Bearer ${other.issue({ userId: 'u1', tenantId: 't1' }).token}`))) as { + code: string; + }; + expect(forged.code).toBe('TOKEN_INVALID'); + + const { token } = service.issue({ userId: 'u1', tenantId: 't1' }); + await service.revoke(token); + const revoked = (await run(makeReq(`Bearer ${token}`))) as { code: string }; + expect(revoked.code).toBe('TOKEN_REVOKED'); + }); + + it('applies the same verification to WebSocket upgrades', async () => { + const handler = createWsAuthHandler({ allowQueryParam: true }); + const { token } = service.issue({ userId: 'u1', tenantId: 't1' }); + + expect(handler({ headers: { authorization: `Bearer ${token}` }, url: '/' } as any)).toEqual({ valid: true, token }); + expect(handler({ headers: {}, url: `/?token=${token}` } as any)).toEqual({ valid: true, token }); + expect(handler({ headers: { authorization: `Bearer at_${'a'.repeat(64)}` }, url: '/' } as any).valid).toBe(false); + + await service.revoke(token); + expect(handler({ headers: { authorization: `Bearer ${token}` }, url: '/' } as any).valid).toBe(false); + }); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index c6fbd93f..b4f75ed4 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -26,6 +26,7 @@ import { hmacAuthMiddleware, HEADER_SIGNATURE } from './hmac-auth.js'; import { tokenAuthMiddleware } from './token-auth.js'; import { getSession } from '../services/session.js'; import { lookupApiKeyRecord } from '../services/api-key-registry.js'; +import type { AccessTokenClaims } from '../auth/jwt.js'; export type AuthMethod = 'session' | 'hmac' | 'token' | 'apiKey'; @@ -42,6 +43,10 @@ declare global { namespace Express { interface Request { user?: AuthPrincipal; + /** Raw bearer token, set by `tokenAuthMiddleware` once verified. */ + accessToken?: string; + /** Verified JWT claims, set by `tokenAuthMiddleware`. */ + accessTokenClaims?: AccessTokenClaims; } } } @@ -106,19 +111,20 @@ export const hmacStrategy: AuthStrategy = { }, }; -/** `Authorization: Bearer at_...` header — delegates to the existing token format validator. */ +/** `Authorization: Bearer ` header — delegates to the JWT access-token verifier. */ export const tokenStrategy: AuthStrategy = { name: 'token', applies: (req) => Boolean(req.headers.authorization?.startsWith('Bearer ')), async authenticate(req, res) { const result = await runMiddleware(tokenAuthMiddleware, req, res); if (!result.ok) throw result.error; - const token = (req as Request & { accessToken?: string }).accessToken; - if (!token) throw new AppError(401, 'Malformed access token', 'TOKEN_MALFORMED'); + const claims = req.accessTokenClaims; + if (!claims) throw new AppError(401, 'Malformed access token', 'TOKEN_MALFORMED'); + // Identity comes from the verified claims, never from caller-supplied headers. return { - id: token, - tenantId: getHeader(req, 'x-tenant-id') ?? 'default', - role: 'user', + id: claims.sub, + tenantId: claims.tenantId, + role: claims.role ?? 'user', method: 'token', }; }, diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts index 0971df8f..fe1a0fae 100644 --- a/backend/src/middleware/logger.ts +++ b/backend/src/middleware/logger.ts @@ -104,7 +104,7 @@ export const httpLogger = pinoHttp({ /** Bind trace + request IDs for async boundaries (jobs, webhooks). */ export function correlationMiddleware(req: Request, res: Response, next: NextFunction): void { const traceId = (req.headers['x-trace-id'] as string) || randomUUID(); - const requestId = req.requestId ?? (req.headers[REQUEST_ID_HEADER] as string) || randomUUID(); + const requestId = req.requestId ?? ((req.headers[REQUEST_ID_HEADER] as string) || randomUUID()); res.setHeader('X-Trace-Id', traceId); runWithLogContext({ traceId, requestId }, () => { diff --git a/backend/src/middleware/token-auth.ts b/backend/src/middleware/token-auth.ts index e8dc3328..05d20f15 100644 --- a/backend/src/middleware/token-auth.ts +++ b/backend/src/middleware/token-auth.ts @@ -1,16 +1,18 @@ -// Token authentication middleware — Issue #512 -// Validates access tokens and refresh token headers. +// Token authentication middleware — Issues #512, #1014 +// Verifies signed JWT access tokens (algorithm pinned, short-lived, revocable). // Works alongside existing API-key auth; is a no-op when headers are absent. import { Request, Response, NextFunction } from 'express'; import { AppError } from './errorHandler.js'; import { auditService } from '../services/auditService.js'; import { logger } from './logger.js'; +import { getJwtService, looksLikeJwt } from '../auth/jwt.js'; -// Access token format: "at_<64-hex-chars>" (opaque, validated server-side) -const ACCESS_TOKEN_RE = /^at_[0-9a-f]{64}$/; +function clientIp(req: Request): string | undefined { + return (req.headers['x-forwarded-for'] as string) ?? req.socket.remoteAddress; +} -export function tokenAuthMiddleware(req: Request, res: Response, next: NextFunction): void { +export async function tokenAuthMiddleware(req: Request, res: Response, next: NextFunction): Promise { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { next(); @@ -19,22 +21,42 @@ export function tokenAuthMiddleware(req: Request, res: Response, next: NextFunct const token = authHeader.slice(7).trim(); - // Minimal structural validation for access tokens - if (!ACCESS_TOKEN_RE.test(token)) { + // Legacy opaque `at_` tokens carried no verifiable claims and are no longer accepted. + if (!looksLikeJwt(token)) { void auditService.logAction({ action: 'token.auth.malformed', resource: 'access_token', - ipAddress: (req.headers['x-forwarded-for'] as string) ?? req.socket.remoteAddress, + ipAddress: clientIp(req), }); next(new AppError(401, 'Malformed access token', 'TOKEN_MALFORMED')); return; } + let result; + try { + result = await getJwtService().verifyAsync(token); + } catch (error) { + logger.error({ error }, 'token auth: verifier unavailable'); + next(new AppError(503, 'Token verification unavailable', 'TOKEN_VERIFIER_UNAVAILABLE')); + return; + } + + if (!result.ok) { + void auditService.logAction({ + action: `token.auth.${result.reason}`, + resource: 'access_token', + ipAddress: clientIp(req), + }); + const code = result.reason === 'expired' ? 'TOKEN_EXPIRED' : result.reason === 'revoked' ? 'TOKEN_REVOKED' : 'TOKEN_INVALID'; + next(new AppError(401, result.reason === 'expired' ? 'Access token has expired' : 'Invalid access token', code)); + return; + } + // Attach to request for downstream handlers - (req as any).accessToken = token; + req.accessToken = token; + req.accessTokenClaims = result.claims; - const ip = (req.headers['x-forwarded-for'] as string) ?? req.socket.remoteAddress; - logger.debug({ ip, path: req.path }, 'token auth: valid bearer token presented'); + logger.debug({ ip: clientIp(req), path: req.path, sub: result.claims.sub }, 'token auth: verified bearer token'); next(); } diff --git a/backend/src/middleware/ws-auth.ts b/backend/src/middleware/ws-auth.ts index a8bfc054..5c314996 100644 --- a/backend/src/middleware/ws-auth.ts +++ b/backend/src/middleware/ws-auth.ts @@ -1,7 +1,15 @@ import type { IncomingMessage } from 'node:http'; +import { getJwtService, looksLikeJwt } from '../auth/jwt.js'; -// Opaque access token format: at_<64 lowercase hex chars> -const ACCESS_TOKEN_RE = /^at_[0-9a-f]{64}$/; +/** Signature, algorithm, lifetime and (locally cached) revocation are all checked. */ +function isValidAccessToken(token: string): boolean { + if (!looksLikeJwt(token)) return false; + try { + return getJwtService().verify(token).ok; + } catch { + return false; + } +} export interface WsAuthResult { valid: boolean; @@ -15,7 +23,7 @@ export interface WsAuthHandlerOptions { /** * Validates WebSocket upgrade request credentials. * Operates on the raw HTTP IncomingMessage — not an Express middleware. - * Checks `Authorization: Bearer at_` header first, then optionally + * Checks `Authorization: Bearer ` header first, then optionally * falls back to the `?token=` query string parameter. */ export function createWsAuthHandler(options?: WsAuthHandlerOptions): (req: IncomingMessage) => WsAuthResult { @@ -25,14 +33,14 @@ export function createWsAuthHandler(options?: WsAuthHandlerOptions): (req: Incom const authHeader = req.headers['authorization']; if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7).trim(); - if (ACCESS_TOKEN_RE.test(token)) return { valid: true, token }; + if (isValidAccessToken(token)) return { valid: true, token }; } if (allowQueryParam) { try { const url = new URL(req.url ?? '', 'http://localhost'); const token = url.searchParams.get('token') ?? ''; - if (ACCESS_TOKEN_RE.test(token)) return { valid: true, token }; + if (isValidAccessToken(token)) return { valid: true, token }; } catch { // Malformed URL — fall through to invalid } diff --git a/backend/src/routes/__tests__/payouts-batching.test.ts b/backend/src/routes/__tests__/payouts-batching.test.ts new file mode 100644 index 00000000..050ed719 --- /dev/null +++ b/backend/src/routes/__tests__/payouts-batching.test.ts @@ -0,0 +1,251 @@ +/** + * Route coverage for payout batching (#1008), anomaly review (#1013) and the + * money-movement audit endpoints (#1011). + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import express, { type Express, type NextFunction, type Request, type Response } from 'express'; +import { payoutsRouter } from '../payouts.js'; +import { payoutEngine } from '../../services/payouts/payout-engine.js'; +import { payoutBatcher } from '../../services/payouts/payout-batcher.js'; +import { payoutAnomalyDetector, DEFAULT_PAYOUT_ANOMALY_CONFIG } from '../../services/payouts/payout-anomaly-detector.js'; +import { clearIdempotencyCache } from '../../middleware/idempotency.js'; + +const APPROVER = { 'x-actor-id': 'fin_1', 'x-actor-role': 'finance_approver' }; +const ADMIN = { 'x-actor-id': 'adm_1', 'x-actor-role': 'admin' }; + +function createTestApp(): Express { + const app = express(); + app.use(express.json()); + app.use('/api/v1/payouts', payoutsRouter); + // Duck-typed so the assertion does not depend on which AppError module instance threw. + app.use((err: { statusCode?: number; code?: string; message?: string }, _req: Request, res: Response, _next: NextFunction) => { + res.status(err.statusCode ?? 500).json({ error: { code: err.code, message: err.message } }); + }); + return app; +} + +async function call( + app: Express, + path: string, + options: { method?: string; body?: unknown; headers?: Record } = {}, +) { + const server = app.listen(0); + const port = (server.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/api/v1/payouts${path}`, { + method: options.method ?? 'GET', + headers: { 'Content-Type': 'application/json', ...options.headers }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + const text = await res.text(); + return { status: res.status, body: (text ? JSON.parse(text) : undefined) as any }; + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +let keySeq = 0; +const idem = (headers: Record) => ({ ...headers, 'Idempotency-Key': `k-${++keySeq}-${Date.now()}` }); + +describe('Payout batching, anomaly and audit routes', () => { + let app: Express; + + beforeEach(() => { + clearIdempotencyCache(); + payoutEngine.reset(); + payoutBatcher.reset(); + payoutAnomalyDetector.reset(); + payoutAnomalyDetector.updateConfig({ ...DEFAULT_PAYOUT_ANOMALY_CONFIG }); + app = createTestApp(); + }); + + describe('scheduled payouts (#1008)', () => { + it('requires an approver for every scheduling route', async () => { + for (const [method, path] of [ + ['POST', '/scheduled'], + ['GET', '/scheduled'], + ['POST', '/scheduled/run'], + ['GET', '/batches'], + ['GET', '/anomalies'], + ['GET', '/audit'], + ]) { + const res = await call(app, path, { method, body: method === 'POST' ? {} : undefined }); + expect(res.status, `${method} ${path}`).toBe(401); + } + }); + + it('schedules, batches and reports payouts', async () => { + for (const recipient of ['GA_1', 'GA_2']) { + const created = await call(app, '/scheduled', { + method: 'POST', + headers: idem(APPROVER), + body: { merchantId: 'merch_b', recipient, amount: 25, asset: 'USDC', reference: `inv-${recipient}` }, + }); + expect(created.status).toBe(201); + expect(created.body.data.status).toBe('scheduled'); + } + + const run = await call(app, '/scheduled/run', { method: 'POST', headers: idem(APPROVER) }); + expect(run.status).toBe(200); + expect(run.body.data.summary).toMatchObject({ batchCount: 1, completed: 1, payoutsPaid: 2 }); + + const batchId = run.body.data.batches[0].id; + const report = await call(app, `/batches/${batchId}`, { headers: APPROVER }); + expect(report.body.data).toMatchObject({ status: 'completed', itemCount: 2, totalAmount: 50, asset: 'USDC' }); + + const runReport = await call(app, `/batches/runs/${run.body.data.runId}`, { headers: APPROVER }); + expect(runReport.body.data.summary.payoutsPaid).toBe(2); + + const listed = await call(app, '/batches?status=completed', { headers: APPROVER }); + expect(listed.body.count).toBe(1); + + const paid = await call(app, '/scheduled?status=paid', { headers: APPROVER }); + expect(paid.body.count).toBe(2); + }); + + it('validates scheduled payout input', async () => { + const bad = [ + { merchantId: 'm', recipient: 'r', amount: -1, asset: 'USDC' }, + { merchantId: 'm', recipient: 'r', amount: 10, asset: 'DOGE' }, + { merchantId: 'm', recipient: 'r', amount: 10, asset: 'USDC', scheduledFor: 'tomorrow-ish' }, + { merchantId: 'm', recipient: 'r', amount: 10, asset: 'USDC', provider: 'unknown' }, + { recipient: 'r', amount: 10, asset: 'USDC' }, + ]; + for (const body of bad) { + const res = await call(app, '/scheduled', { method: 'POST', headers: idem(APPROVER), body }); + expect(res.status, JSON.stringify(body)).toBe(400); + } + }); + + it('cancels a scheduled payout once', async () => { + const created = await call(app, '/scheduled', { + method: 'POST', + headers: idem(APPROVER), + body: { merchantId: 'merch_c', recipient: 'GA_9', amount: 5, asset: 'USDC', scheduledFor: '2099-01-01T00:00:00Z' }, + }); + const id = created.body.data.id; + expect((await call(app, `/scheduled/${id}`, { method: 'DELETE', headers: APPROVER })).body.data.status).toBe( + 'cancelled', + ); + expect((await call(app, `/scheduled/${id}`, { method: 'DELETE', headers: APPROVER })).status).toBe(409); + }); + + it('returns 404 for unknown batches and runs', async () => { + expect((await call(app, '/batches/pbat_missing', { headers: APPROVER })).status).toBe(404); + expect((await call(app, '/batches/runs/prun_missing', { headers: APPROVER })).status).toBe(404); + }); + }); + + describe('anomaly review (#1013)', () => { + async function scheduleLarge() { + return call(app, '/scheduled', { + method: 'POST', + headers: idem(APPROVER), + body: { merchantId: 'merch_big', recipient: 'GA_X', amount: 75_000, asset: 'USDC' }, + }); + } + + it('holds a large payout and releases it after a false-positive review', async () => { + const created = await scheduleLarge(); + expect(created.body.data.status).toBe('held'); + + const open = await call(app, '/anomalies?status=open', { headers: APPROVER }); + const flag = open.body.data.find((f: any) => f.rule === 'single_amount'); + expect(flag).toMatchObject({ merchantId: 'merch_big', severity: 'high' }); + + // Releasing needs every flag on the payout cleared. + for (const f of open.body.data) { + const reviewed = await call(app, `/anomalies/${f.id}/review`, { + method: 'POST', + headers: idem(APPROVER), + body: { decision: 'false_positive', note: 'known payroll run' }, + }); + expect(reviewed.status).toBe(200); + expect(reviewed.body.data).toMatchObject({ status: 'false_positive', reviewedBy: 'fin_1' }); + } + + const payouts = await call(app, '/scheduled?merchantId=merch_big', { headers: APPROVER }); + expect(payouts.body.data[0].status).toBe('scheduled'); + + const again = await call(app, `/anomalies/${flag.id}/review`, { + method: 'POST', + headers: idem(APPROVER), + body: { decision: 'confirmed' }, + }); + expect(again.status).toBe(409); + }); + + it('validates review input', async () => { + await scheduleLarge(); + const [flag] = (await call(app, '/anomalies', { headers: APPROVER })).body.data; + const bad = [{ decision: 'maybe' }, { decision: 'confirmed', suppressForMs: 1000 }, { decision: 'false_positive', note: 5 }]; + for (const body of bad) { + const res = await call(app, `/anomalies/${flag.id}/review`, { method: 'POST', headers: idem(APPROVER), body }); + expect(res.status, JSON.stringify(body)).toBe(400); + } + const missing = await call(app, '/anomalies/pan_missing/review', { + method: 'POST', + headers: idem(APPROVER), + body: { decision: 'confirmed' }, + }); + expect(missing.status).toBe(404); + }); + + it('lets only admins change thresholds', async () => { + const denied = await call(app, '/anomalies/config', { + method: 'PUT', + headers: APPROVER, + body: { singlePayoutThreshold: 1 }, + }); + expect(denied.status).toBe(403); + + const updated = await call(app, '/anomalies/config', { + method: 'PUT', + headers: ADMIN, + body: { singlePayoutThreshold: 1_000, velocityMaxCount: 5 }, + }); + expect(updated.status).toBe(200); + expect(updated.body.data).toMatchObject({ singlePayoutThreshold: 1_000, velocityMaxCount: 5 }); + expect((await call(app, '/anomalies/config', { headers: APPROVER })).body.data.singlePayoutThreshold).toBe(1_000); + + for (const body of [{ singlePayoutThreshold: -5 }, { bogus: 1 }]) { + expect((await call(app, '/anomalies/config', { method: 'PUT', headers: ADMIN, body })).status).toBe(400); + } + }); + }); + + describe('money-movement audit (#1011)', () => { + it('records engine and batcher movements and answers compliance queries', async () => { + await call(app, '/balances/merch_audit', { method: 'POST', headers: idem(APPROVER), body: { amount: 120 } }); + const scheduled = await call(app, '/scheduled', { + method: 'POST', + headers: idem(APPROVER), + body: { merchantId: 'merch_audit', recipient: 'GA_A', amount: 30, asset: 'USDC' }, + }); + await call(app, '/scheduled/run', { method: 'POST', headers: idem(APPROVER) }); + + const byMerchant = await call(app, '/audit?merchantId=merch_audit', { headers: APPROVER }); + const ops = byMerchant.body.data.map((r: any) => r.operation); + expect(ops).toEqual(expect.arrayContaining(['balance.set', 'payout.scheduled', 'payout.settled'])); + expect(byMerchant.body.data.find((r: any) => r.operation === 'balance.set')).toMatchObject({ + actor: 'fin_1', + amount: 120, + }); + + const byReference = await call(app, `/audit?reference=${scheduled.body.data.id}&operation=payout.settled`, { + headers: APPROVER, + }); + expect(byReference.body.total).toBe(1); + expect(byReference.body.totalAmountByAsset).toEqual({ USDC: 30 }); + + const verified = await call(app, '/audit/verify', { headers: APPROVER }); + expect(verified.body.data.valid).toBe(true); + }); + + it('rejects malformed audit filters', async () => { + for (const query of ['minAmount=-1', 'limit=abc', 'from=not-a-date']) { + expect((await call(app, `/audit?${query}`, { headers: APPROVER })).status, query).toBe(400); + } + }); + }); +}); diff --git a/backend/src/routes/__tests__/token-refresh.test.ts b/backend/src/routes/__tests__/token-refresh.test.ts index c929382c..89d41511 100644 --- a/backend/src/routes/__tests__/token-refresh.test.ts +++ b/backend/src/routes/__tests__/token-refresh.test.ts @@ -56,6 +56,8 @@ import { listUserTokenFamilies, pruneExpiredTokens, } from '../../auth/token-rotation.js'; +import { getJwtService } from '../../auth/jwt.js'; +import { accessTokenRevocationList } from '../../auth/access-token-revocation.js'; // --------------------------------------------------------------------------- // Helpers @@ -111,12 +113,21 @@ describe('Token Rotation Service — Issue #804', () => { expect(result).toHaveProperty('refreshTokenExpiresAt'); }); - it('accessToken should start with "at_"', async () => { + it('accessToken should be a signed JWT bound to the user, tenant and family (#1014)', async () => { vi.mocked(prisma.refreshToken.create).mockResolvedValue(makeMockToken() as any); - const { accessToken } = await issueTokenFamily(USER_ID, TENANT_ID); + const { accessToken, accessTokenExpiresAt } = await issueTokenFamily(USER_ID, TENANT_ID); - expect(accessToken).toMatch(/^at_/); + expect(accessToken.split('.')).toHaveLength(3); + const verified = getJwtService().verify(accessToken); + expect(verified.ok).toBe(true); + if (verified.ok) { + expect(verified.claims.sub).toBe(USER_ID); + expect(verified.claims.tenantId).toBe(TENANT_ID); + expect(verified.claims.fid).toMatch(/^[0-9a-f]{32}$/); + } + // Short-lived: 15 minutes by default. + expect(accessTokenExpiresAt.getTime() - Date.now()).toBeLessThanOrEqual(15 * 60 * 1000 + 1000); }); it('refreshToken should be a 64-char hex string (32 raw bytes)', async () => { @@ -193,7 +204,7 @@ describe('Token Rotation Service — Issue #804', () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.accessToken).toMatch(/^at_/); + expect(getJwtService().verify(result.accessToken).ok).toBe(true); expect(result.refreshToken).toMatch(/^[0-9a-f]{64}$/); expect(result.refreshTokenExpiresAt).toBeInstanceOf(Date); } @@ -711,3 +722,77 @@ describe('Token Rotation Service — Issue #804', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Issue #1014 — access tokens die with their refresh session +// --------------------------------------------------------------------------- + +describe('Access token revocation on refresh-token events — Issue #1014', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Earlier suites revoke USER_ID wholesale; start each case from a clean list. + accessTokenRevocationList.clear(); + vi.mocked(getSharedRateLimitRedis).mockResolvedValue(null); + vi.mocked(prisma.refreshToken.create).mockResolvedValue(makeMockToken() as any); + vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 1 } as any); + vi.mocked(prisma.refreshToken.update).mockResolvedValue(makeMockToken() as any); + }); + + function familyOf(accessToken: string): string { + const verified = getJwtService().verify(accessToken); + if (!verified.ok || !verified.claims.fid) throw new Error('expected a family-bound token'); + return verified.claims.fid; + } + + it('rotation issues a fresh access token in the same family', async () => { + const issued = await issueTokenFamily(USER_ID, TENANT_ID); + const familyId = familyOf(issued.accessToken); + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(makeMockToken({ familyId }) as any); + vi.mocked(prisma.refreshToken.count).mockResolvedValue(0); + vi.mocked(prisma.$transaction).mockResolvedValue([makeMockToken({ id: 'rt-new', familyId }), {}] as any); + + const rotated = await rotateRefreshToken('raw-token'); + + expect(rotated.ok).toBe(true); + if (rotated.ok) { + expect(rotated.accessToken).not.toBe(issued.accessToken); + expect(familyOf(rotated.accessToken)).toBe(familyId); + } + }); + + it('refresh-token replay revokes the access tokens of the compromised family', async () => { + const victim = await issueTokenFamily(USER_ID, TENANT_ID); + const bystander = await issueTokenFamily(USER_ID, TENANT_ID); + const familyId = familyOf(victim.accessToken); + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue(makeMockToken({ familyId, revoked: true }) as any); + + const result = await rotateRefreshToken('replayed-token'); + + expect(result).toEqual({ ok: false, reason: 'family_compromised' }); + expect(getJwtService().verify(victim.accessToken)).toEqual({ ok: false, reason: 'revoked' }); + expect(getJwtService().verify(bystander.accessToken).ok).toBe(true); + }); + + it('revoking a refresh token ends its access tokens', async () => { + const session = await issueTokenFamily(USER_ID, TENANT_ID); + vi.mocked(prisma.refreshToken.findUnique).mockResolvedValue( + makeMockToken({ familyId: familyOf(session.accessToken) }) as any, + ); + + expect(await revokeToken('raw-token')).toBe(true); + expect(getJwtService().verify(session.accessToken)).toEqual({ ok: false, reason: 'revoked' }); + }); + + it('sign out everywhere revokes every access token of the user', async () => { + const a = await issueTokenFamily('user-signout', TENANT_ID); + const b = await issueTokenFamily('user-signout', TENANT_ID); + const other = await issueTokenFamily('someone-else', TENANT_ID); + vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([] as any); + + await revokeAllUserTokens('user-signout', TENANT_ID); + + expect(getJwtService().verify(a.accessToken).ok).toBe(false); + expect(getJwtService().verify(b.accessToken).ok).toBe(false); + expect(getJwtService().verify(other.accessToken).ok).toBe(true); + }); +}); diff --git a/backend/src/routes/payouts.ts b/backend/src/routes/payouts.ts index 4bd01051..58c68e05 100644 --- a/backend/src/routes/payouts.ts +++ b/backend/src/routes/payouts.ts @@ -5,6 +5,12 @@ import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { idempotency } from '../middleware/idempotency.js'; import { SUPPORTED_PAYOUT_ASSETS, payoutEngine } from '../services/payouts/payout-engine.js'; import type { PayoutApprover, PayoutApproverRole } from '../services/payouts/payout-engine.js'; +import { payoutBatcher } from '../services/payouts/payout-batcher.js'; +import type { ScheduledPayoutStatus, PayoutBatchStatus } from '../services/payouts/payout-batcher.js'; +import { payoutAnomalyDetector } from '../services/payouts/payout-anomaly-detector.js'; +import type { PayoutAnomalyConfig, PayoutAnomalyStatus } from '../services/payouts/payout-anomaly-detector.js'; +import { moneyMovementAuditLog } from '../audit/money-movement-audit.js'; +import type { MoneyMovementOperation } from '../audit/money-movement-audit.js'; export const payoutsRouter = Router(); @@ -110,9 +116,12 @@ function asHttpError(error: unknown): AppError { if (error instanceof AppError) return error; const code = error instanceof Error ? error.message.split(':')[0] : 'PAYOUT_ERROR'; - const status = code === 'PAYOUT_APPROVER_FORBIDDEN' ? 403 : 400; + const status = + code === 'PAYOUT_APPROVER_FORBIDDEN' ? 403 : code === 'PAYOUT_ANOMALY_REVIEW_PENDING' ? 409 : 400; const message = - code === 'UNSUPPORTED_PAYOUT_ASSET' + code === 'PAYOUT_ANOMALY_REVIEW_PENDING' + ? 'Batch has anomaly flags awaiting review' + : code === 'UNSUPPORTED_PAYOUT_ASSET' ? `preferredAsset must be one of: ${SUPPORTED_ASSETS.join(', ')}` : code === 'PAYOUT_APPROVER_FORBIDDEN' ? 'Insufficient permissions for this action' @@ -184,11 +193,11 @@ payoutsRouter.post( '/balances/:merchantId', idempotency(), asyncHandler(async (req, res) => { - requireApprover(req); + const principal = requireApprover(req); const merchantId = requireId(req.params.merchantId, 'merchantId'); const amount = requireAmount(req.body?.amount); - payoutEngine.setPendingAmount(merchantId, amount); + payoutEngine.setPendingAmount(merchantId, amount, principal.id); res.json({ data: { merchantId, pendingAmount: payoutEngine.getPendingAmount(merchantId) } }); }), ); @@ -296,6 +305,269 @@ const approver = toApprover(requireApprover(req)); }), ); +// ── Scheduled & batched payouts (#1008) ───────────────────────────────────── + +const SCHEDULED_STATUSES: readonly ScheduledPayoutStatus[] = ['scheduled', 'held', 'batched', 'paid', 'failed', 'cancelled']; +const BATCH_STATUSES: readonly PayoutBatchStatus[] = ['executing', 'completed', 'failed']; + +/** Maps batcher/detector sentinel errors to client errors without leaking internals. */ +function asValidationError(error: unknown): AppError { + if (error instanceof AppError) return error; + const code = error instanceof Error ? error.message.split(':')[0] : 'PAYOUT_ERROR'; + if (code === 'PAYOUT_ANOMALY_NOT_FOUND') return new AppError(404, 'Anomaly flag not found', 'NOT_FOUND'); + if (code === 'PAYOUT_ANOMALY_ALREADY_REVIEWED') { + return new AppError(409, 'Anomaly flag has already been reviewed', 'ALREADY_REVIEWED'); + } + if (!/^[A-Z_]+$/.test(code)) return new AppError(400, 'Invalid request', 'VALIDATION_ERROR'); + return new AppError(400, code.toLowerCase().replace(/_/g, ' '), code); +} + +function optionalDate(value: unknown, field: string): Date | undefined { + if (value === undefined) return undefined; + const date = typeof value === 'string' ? new Date(value) : undefined; + if (!date || Number.isNaN(date.getTime())) { + throw new AppError(400, `${field} must be an ISO-8601 timestamp`, 'VALIDATION_ERROR'); + } + return date; +} + +payoutsRouter.post( + '/scheduled', + idempotency(), + asyncHandler(async (req, res) => { + const principal = requireApprover(req); + const { merchantId, recipient, amount, asset, scheduledFor, provider, reference } = req.body ?? {}; + requireAmount(amount); + if (typeof asset !== 'string' || !SUPPORTED_ASSETS.includes(asset)) { + throw new AppError(400, `asset must be one of: ${SUPPORTED_ASSETS.join(', ')}`, 'VALIDATION_ERROR'); + } + try { + const payout = payoutBatcher.schedule({ + merchantId: requireId(merchantId, 'merchantId'), + recipient: requireId(recipient, 'recipient'), + amount, + asset, + scheduledFor: optionalDate(scheduledFor, 'scheduledFor'), + ...(provider !== undefined ? { provider } : {}), + ...(reference !== undefined ? { reference } : {}), + actorId: principal.id, + }); + res.status(201).json({ data: payout }); + } catch (error) { + throw asValidationError(error); + } + }), +); + +payoutsRouter.get( + '/scheduled', + asyncHandler(async (req, res) => { + requireApprover(req); + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + if (status && !SCHEDULED_STATUSES.includes(status as ScheduledPayoutStatus)) { + throw new AppError(400, `status must be one of: ${SCHEDULED_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + const data = payoutBatcher.listPayouts({ merchantId, status: status as ScheduledPayoutStatus | undefined }); + res.json({ data, count: data.length }); + }), +); + +payoutsRouter.delete( + '/scheduled/:payoutId', + asyncHandler(async (req, res) => { + const principal = requireApprover(req); + const payoutId = requireId(req.params.payoutId, 'payoutId'); + if (!payoutBatcher.cancel(payoutId, principal.id)) { + throw new AppError(409, 'Payout is not cancellable', 'INVALID_PAYOUT_STATE'); + } + res.json({ data: payoutBatcher.getPayout(payoutId) }); + }), +); + +payoutsRouter.post( + '/scheduled/run', + idempotency(), + payoutDecisionLimiter, + asyncHandler(async (req, res) => { + requireApprover(req); + const report = await payoutBatcher.runDue(); + if (!report) throw new AppError(409, 'A batch run is already in progress', 'BATCH_RUN_IN_PROGRESS'); + res.json({ data: report }); + }), +); + +payoutsRouter.get( + '/batches', + asyncHandler(async (req, res) => { + requireApprover(req); + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + if (status && !BATCH_STATUSES.includes(status as PayoutBatchStatus)) { + throw new AppError(400, `status must be one of: ${BATCH_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + const data = payoutBatcher.listBatchReports({ status: status as PayoutBatchStatus | undefined }); + res.json({ data, count: data.length }); + }), +); + +payoutsRouter.get( + '/batches/runs/:runId', + asyncHandler(async (req, res) => { + requireApprover(req); + const report = payoutBatcher.getRunReport(requireId(req.params.runId, 'runId')); + if (!report) throw new AppError(404, 'Batch run not found', 'NOT_FOUND'); + res.json({ data: report }); + }), +); + +payoutsRouter.get( + '/batches/:batchId', + asyncHandler(async (req, res) => { + requireApprover(req); + const report = payoutBatcher.getBatchReport(requireId(req.params.batchId, 'batchId')); + if (!report) throw new AppError(404, 'Payout batch not found', 'NOT_FOUND'); + res.json({ data: report }); + }), +); + +// ── Anomaly review (#1013) ─────────────────────────────────────────────────── + +const ANOMALY_STATUSES: readonly PayoutAnomalyStatus[] = ['open', 'confirmed', 'false_positive']; +const ANOMALY_CONFIG_FIELDS: readonly (keyof PayoutAnomalyConfig)[] = [ + 'enabled', + 'singlePayoutThreshold', + 'velocityWindowMs', + 'velocityMaxCount', + 'velocityMaxAmount', + 'baselineMultiplier', + 'baselineMinSamples', + 'baselineSampleSize', +]; + +payoutsRouter.get( + '/anomalies', + asyncHandler(async (req, res) => { + requireApprover(req); + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + if (status && !ANOMALY_STATUSES.includes(status as PayoutAnomalyStatus)) { + throw new AppError(400, `status must be one of: ${ANOMALY_STATUSES.join(', ')}`, 'VALIDATION_ERROR'); + } + const merchantId = typeof req.query.merchantId === 'string' ? req.query.merchantId : undefined; + const data = payoutAnomalyDetector.listFlags({ status: status as PayoutAnomalyStatus | undefined, merchantId }); + res.json({ data, count: data.length }); + }), +); + +payoutsRouter.get( + '/anomalies/config', + asyncHandler(async (req, res) => { + requireApprover(req); + res.json({ data: payoutAnomalyDetector.getConfig() }); + }), +); + +payoutsRouter.put( + '/anomalies/config', + payoutDecisionLimiter, + asyncHandler(async (req, res) => { + const principal = requireApprover(req); + // Thresholds gate fraud review, so only admins may loosen or tighten them. + if (principal.role !== 'admin') { + throw new AppError(403, 'Insufficient permissions for this action', 'PAYOUT_APPROVER_FORBIDDEN'); + } + const body = (req.body ?? {}) as Record; + const unknown = Object.keys(body).filter((key) => !ANOMALY_CONFIG_FIELDS.includes(key as keyof PayoutAnomalyConfig)); + if (unknown.length > 0) { + throw new AppError(400, `Unknown config fields: ${unknown.join(', ')}`, 'VALIDATION_ERROR'); + } + try { + res.json({ data: payoutAnomalyDetector.updateConfig(body as Partial) }); + } catch (error) { + throw asValidationError(error); + } + }), +); + +payoutsRouter.post( + '/anomalies/:flagId/review', + idempotency(), + payoutDecisionLimiter, + asyncHandler(async (req, res) => { + const principal = requireApprover(req); + const flagId = requireId(req.params.flagId, 'flagId'); + const { decision, note, suppressForMs } = req.body ?? {}; + if (decision !== 'confirmed' && decision !== 'false_positive') { + throw new AppError(400, 'decision must be one of: confirmed, false_positive', 'VALIDATION_ERROR'); + } + if (note !== undefined && (typeof note !== 'string' || note.length > 1_000)) { + throw new AppError(400, 'note must be a string of at most 1000 characters', 'VALIDATION_ERROR'); + } + if (suppressForMs !== undefined && (decision !== 'false_positive' || typeof suppressForMs !== 'number')) { + throw new AppError(400, 'suppressForMs only applies to false_positive decisions', 'VALIDATION_ERROR'); + } + try { + const flag = payoutAnomalyDetector.review(flagId, { + reviewerId: principal.id, + decision, + ...(note !== undefined ? { note } : {}), + ...(suppressForMs !== undefined ? { suppressForMs } : {}), + }); + res.json({ data: flag }); + } catch (error) { + throw asValidationError(error); + } + }), +); + +// ── Money-movement audit (#1011) ───────────────────────────────────────────── + +payoutsRouter.get( + '/audit', + asyncHandler(async (req, res) => { + requireApprover(req); + const q = req.query; + const text = (value: unknown, field: string): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) { + throw new AppError(400, `${field} is invalid`, 'VALIDATION_ERROR'); + } + return value; + }; + const num = (value: unknown, field: string): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number(value); + if (typeof value !== 'string' || !Number.isFinite(parsed) || parsed < 0) { + throw new AppError(400, `${field} must be a non-negative number`, 'VALIDATION_ERROR'); + } + return parsed; + }; + const operation = text(q.operation, 'operation'); + + const result = moneyMovementAuditLog.query({ + actor: text(q.actor, 'actor'), + merchantId: text(q.merchantId, 'merchantId'), + reference: text(q.reference, 'reference'), + asset: text(q.asset, 'asset'), + ...(operation ? { operation: operation.split(',') as MoneyMovementOperation[] } : {}), + minAmount: num(q.minAmount, 'minAmount'), + maxAmount: num(q.maxAmount, 'maxAmount'), + from: optionalDate(q.from, 'from'), + to: optionalDate(q.to, 'to'), + limit: num(q.limit, 'limit'), + offset: num(q.offset, 'offset'), + }); + res.json({ data: result.records, total: result.total, totalAmountByAsset: result.totalAmountByAsset }); + }), +); + +payoutsRouter.get( + '/audit/verify', + asyncHandler(async (req, res) => { + requireApprover(req); + res.json({ data: await moneyMovementAuditLog.verify() }); + }), +); + // ── Reads ──────────────────────────────────────────────────────────────────── payoutsRouter.get( diff --git a/backend/src/routes/token-refresh.ts b/backend/src/routes/token-refresh.ts index 51fe78de..63eef711 100644 --- a/backend/src/routes/token-refresh.ts +++ b/backend/src/routes/token-refresh.ts @@ -1,6 +1,6 @@ // Token refresh & revocation routes — Issue #512 // POST /api/v1/auth/refresh — rotate refresh token, issue new pair -// POST /api/v1/auth/revoke — revoke a specific refresh token +// POST /api/v1/auth/revoke — revoke a specific refresh token (and the bearer access token) // POST /api/v1/auth/revoke-all — sign out everywhere (revoke all families) // GET /api/v1/auth/sessions — list active token families (session management UI) // POST /api/v1/auth/login — issue initial token family @@ -16,6 +16,7 @@ import { listUserTokenFamilies, } from '../auth/token-rotation.js'; import { getSharedRateLimitRedis } from '../config/rate-limit-redis.js'; +import { getJwtService, looksLikeJwt } from '../auth/jwt.js'; export const tokenRefreshRouter = Router(); @@ -64,8 +65,22 @@ async function checkRefreshRateLimit(userId: string): Promise { return true; } -// Simulated user resolution — in production, extract from signed session or API key -function resolveUser(req: any): { userId: string; tenantId: string } { +function bearerToken(req: any): string | undefined { + const header = req.headers?.authorization; + if (typeof header !== 'string' || !header.startsWith('Bearer ')) return undefined; + const token = header.slice(7).trim(); + return looksLikeJwt(token) ? token : undefined; +} + +// A verified access token identifies the caller; the header fallback is kept for the +// login bootstrap and existing integrations that have not moved to bearer tokens yet. +async function resolveUser(req: any): Promise<{ userId: string; tenantId: string }> { + const token = bearerToken(req); + if (token) { + const result = await getJwtService().verifyAsync(token); + if (!result.ok) throw new AppError(401, 'Invalid access token', 'TOKEN_INVALID'); + return { userId: result.claims.sub, tenantId: result.claims.tenantId }; + } return { userId: (req.headers['x-user-id'] as string) || 'anonymous', tenantId: (req.headers['x-tenant-id'] as string) || 'default', @@ -74,10 +89,11 @@ function resolveUser(req: any): { userId: string; tenantId: string } { // POST /api/v1/auth/login — issue initial token family tokenRefreshRouter.post('/login', asyncHandler(async (req, res) => { - const { userId, tenantId } = resolveUser(req); + const { userId, tenantId } = await resolveUser(req); const tokens = await issueTokenFamily(userId, tenantId); res.json({ accessToken: tokens.accessToken, + accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshToken: tokens.refreshToken, refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, }); @@ -113,6 +129,7 @@ tokenRefreshRouter.post('/refresh', asyncHandler(async (req, res) => { res.json({ accessToken: result.accessToken, + accessTokenExpiresAt: result.accessTokenExpiresAt, refreshToken: result.refreshToken, refreshTokenExpiresAt: result.refreshTokenExpiresAt, }); @@ -125,21 +142,24 @@ tokenRefreshRouter.post('/revoke', asyncHandler(async (req, res) => { throw new AppError(400, 'refreshToken is required', 'MISSING_REFRESH_TOKEN'); } - const { userId } = resolveUser(req); + const { userId } = await resolveUser(req); const revoked = await revokeToken(refreshToken, userId); - res.json({ success: revoked }); + // The presented access token dies with the session rather than at its expiry. + const access = bearerToken(req); + const accessRevoked = access ? await getJwtService().revoke(access) : false; + res.json({ success: revoked, accessTokenRevoked: accessRevoked }); })); // POST /api/v1/auth/revoke-all — revoke all token families for a user tokenRefreshRouter.post('/revoke-all', asyncHandler(async (req, res) => { - const { userId, tenantId } = resolveUser(req); + const { userId, tenantId } = await resolveUser(req); const count = await revokeAllUserTokens(userId, tenantId); res.json({ success: true, revokedCount: count }); })); // GET /api/v1/auth/sessions — list active token families (session management UI) tokenRefreshRouter.get('/sessions', asyncHandler(async (req, res) => { - const { userId, tenantId } = resolveUser(req); + const { userId, tenantId } = await resolveUser(req); const families = await listUserTokenFamilies(userId, tenantId); res.json({ sessions: families }); })); diff --git a/backend/src/services/payouts/__tests__/payout-anomaly-detector.test.ts b/backend/src/services/payouts/__tests__/payout-anomaly-detector.test.ts new file mode 100644 index 00000000..ae71839b --- /dev/null +++ b/backend/src/services/payouts/__tests__/payout-anomaly-detector.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + PayoutAnomalyDetector, + DEFAULT_PAYOUT_ANOMALY_CONFIG, + MAX_ANOMALY_SUPPRESSION_MS, + payoutAnomalyConfigFromEnv, + type PayoutAnomalyFlag, +} from '../payout-anomaly-detector.js'; + +describe('PayoutAnomalyDetector — Issue #1013', () => { + let clock: Date; + let alerts: PayoutAnomalyFlag[]; + let detector: PayoutAnomalyDetector; + let seq = 0; + + const observe = (merchantId: string, amount: number) => + detector.evaluate({ reference: `ref_${++seq}`, merchantId, amount, asset: 'USDC' }); + + beforeEach(() => { + clock = new Date('2026-05-01T12:00:00Z'); + alerts = []; + detector = new PayoutAnomalyDetector({ + config: { + singlePayoutThreshold: 10_000, + velocityWindowMs: 60_000, + velocityMaxCount: 3, + velocityMaxAmount: 5_000, + baselineMultiplier: 4, + baselineMinSamples: 3, + }, + onAlert: (flag) => alerts.push(flag), + now: () => clock, + }); + }); + + describe('configurable thresholds', () => { + it('does not flag ordinary payouts', () => { + expect(observe('m1', 100)).toEqual([]); + expect(alerts).toEqual([]); + }); + + it('flags a payout at or above the single-payout threshold', () => { + const flags = observe('m1', 10_000); + expect(flags.map((f) => f.rule)).toContain('single_amount'); + expect(flags.find((f) => f.rule === 'single_amount')).toMatchObject({ + severity: 'high', + status: 'open', + merchantId: 'm1', + observed: { amount: 10_000, threshold: 10_000 }, + }); + }); + + it('flags payout count velocity inside the window but not after it', () => { + for (let i = 0; i < 3; i++) expect(observe('m1', 10)).toEqual([]); + expect(observe('m1', 10).map((f) => f.rule)).toEqual(['velocity_count']); + + clock = new Date(clock.getTime() + 61_000); + expect(observe('m1', 10)).toEqual([]); + }); + + it('flags payout amount velocity across several payouts', () => { + expect(observe('m1', 2_000)).toEqual([]); + expect(observe('m1', 2_000)).toEqual([]); + expect(observe('m1', 2_000).map((f) => f.rule)).toEqual(['velocity_amount']); + }); + + it('flags deviation from the merchant baseline once enough history exists', () => { + observe('m1', 100); + observe('m1', 100); + // Only 2 samples: no baseline yet. + clock = new Date(clock.getTime() + 120_000); + expect(observe('m1', 900).map((f) => f.rule)).toEqual([]); + clock = new Date(clock.getTime() + 120_000); + // Baseline is now (100+100+900)/3 ≈ 366; 4x ≈ 1466. + expect(observe('m1', 1_500).map((f) => f.rule)).toEqual(['baseline_deviation']); + }); + + it('keeps history per merchant', () => { + for (let i = 0; i < 3; i++) observe('m1', 10); + expect(observe('m2', 10)).toEqual([]); + }); + + it('applies runtime config updates and rejects invalid ones atomically', () => { + detector.updateConfig({ singlePayoutThreshold: 50 }); + expect(observe('m1', 60).map((f) => f.rule)).toContain('single_amount'); + + expect(() => detector.updateConfig({ singlePayoutThreshold: 1, velocityMaxCount: -1 })).toThrow( + 'INVALID_PAYOUT_ANOMALY_CONFIG:velocityMaxCount', + ); + expect(detector.getConfig().singlePayoutThreshold).toBe(50); + }); + + it('raises nothing while disabled', () => { + detector.updateConfig({ enabled: false }); + expect(observe('m1', 1_000_000)).toEqual([]); + }); + + it('reads thresholds from the environment, ignoring invalid values', () => { + const config = payoutAnomalyConfigFromEnv({ + PAYOUT_ANOMALY_SINGLE_THRESHOLD: '2500', + PAYOUT_ANOMALY_VELOCITY_MAX_COUNT: 'abc', + PAYOUT_ANOMALY_VELOCITY_WINDOW_MS: '-5', + PAYOUT_ANOMALY_DETECTION_ENABLED: 'false', + } as NodeJS.ProcessEnv); + expect(config).toEqual({ singlePayoutThreshold: 2500, enabled: false }); + expect(new PayoutAnomalyDetector({ config }).getConfig()).toMatchObject({ + singlePayoutThreshold: 2500, + velocityMaxCount: DEFAULT_PAYOUT_ANOMALY_CONFIG.velocityMaxCount, + }); + }); + }); + + describe('alerts', () => { + it('emits every new flag to all subscribers', () => { + const extra: string[] = []; + const unsubscribe = detector.onAlert((flag) => extra.push(flag.rule)); + observe('m1', 20_000); + expect(alerts).toHaveLength(2); // single_amount + velocity_amount + expect(extra).toEqual(['single_amount', 'velocity_amount']); + + unsubscribe(); + observe('m3', 20_000); + expect(extra).toHaveLength(2); + }); + + it('keeps evaluating when a subscriber throws', () => { + detector.onAlert(() => { + throw new Error('pager down'); + }); + expect(observe('m1', 20_000)).toHaveLength(2); + }); + }); + + describe('false-positive review path', () => { + it('lists open flags and records a confirmed decision', () => { + const [flag] = observe('m1', 20_000); + expect(detector.listFlags({ status: 'open' }).map((f) => f.id)).toContain(flag.id); + expect(detector.hasOpenFlags(flag.reference)).toBe(true); + + const reviewed = detector.review(flag.id, { reviewerId: 'risk_1', decision: 'confirmed', note: 'fraud' }); + expect(reviewed).toMatchObject({ status: 'confirmed', reviewedBy: 'risk_1', reviewNote: 'fraud' }); + expect(() => detector.review(flag.id, { reviewerId: 'risk_2', decision: 'false_positive' })).toThrow( + 'PAYOUT_ANOMALY_ALREADY_REVIEWED', + ); + }); + + it('marks a false positive and can suppress the rule for that merchant', () => { + const [flag] = observe('m1', 20_000); + detector.review(flag.id, { reviewerId: 'risk_1', decision: 'false_positive', suppressForMs: 3_600_000 }); + + clock = new Date(clock.getTime() + 120_000); + expect(observe('m1', 20_000).map((f) => f.rule)).toEqual(['velocity_amount']); + // Another merchant is not covered by the suppression. + expect(observe('m2', 20_000).map((f) => f.rule)).toContain('single_amount'); + + clock = new Date(clock.getTime() + 3_600_000); + expect(observe('m1', 20_000).map((f) => f.rule)).toContain('single_amount'); + }); + + it('caps suppression and rejects invalid review input', () => { + const [a, b] = observe('m1', 20_000); + detector.review(a.id, { reviewerId: 'r', decision: 'false_positive', suppressForMs: 10 * MAX_ANOMALY_SUPPRESSION_MS }); + clock = new Date(clock.getTime() + MAX_ANOMALY_SUPPRESSION_MS + 1); + expect(observe('m1', 20_000).map((f) => f.rule)).toContain('single_amount'); + + expect(() => detector.review(b.id, { reviewerId: '', decision: 'confirmed' })).toThrow('REVIEWER_REQUIRED'); + expect(() => detector.review('pan_missing', { reviewerId: 'r', decision: 'confirmed' })).toThrow('NOT_FOUND'); + }); + + it('notifies review subscribers', () => { + const reviewed: string[] = []; + detector.onReview((flag) => reviewed.push(`${flag.id}:${flag.status}`)); + const [flag] = observe('m1', 20_000); + detector.review(flag.id, { reviewerId: 'r', decision: 'false_positive' }); + expect(reviewed).toEqual([`${flag.id}:false_positive`]); + }); + }); +}); diff --git a/backend/src/services/payouts/__tests__/payout-batcher.test.ts b/backend/src/services/payouts/__tests__/payout-batcher.test.ts new file mode 100644 index 00000000..3d5d0c03 --- /dev/null +++ b/backend/src/services/payouts/__tests__/payout-batcher.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { PayoutBatcher, type ProviderBatchRequest, type PayoutBatchProvider } from '../payout-batcher.js'; +import { MoneyMovementAuditLog } from '../../../audit/money-movement-audit.js'; +import { PayoutAnomalyDetector } from '../payout-anomaly-detector.js'; + +describe('PayoutBatcher — Issue #1008', () => { + let clock: Date; + let calls: ProviderBatchRequest[]; + let auditLog: MoneyMovementAuditLog; + + const okProvider: PayoutBatchProvider = async (request) => { + calls.push(request); + return { transactionHash: `tx_${calls.length}` }; + }; + + function makeBatcher({ providers, ...options }: ConstructorParameters[0] = {}) { + return new PayoutBatcher({ + auditLog, + now: () => clock, + ...options, + providers: { stellar: okProvider, ...providers }, + }); + } + + beforeEach(() => { + clock = new Date('2026-06-01T00:00:00Z'); + calls = []; + auditLog = new MoneyMovementAuditLog({ now: () => clock }); + }); + + describe('scheduling', () => { + it('only sends payouts whose scheduled time has arrived', async () => { + const batcher = makeBatcher(); + const now = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 10, asset: 'USDC', provider: 'stellar' }); + const later = batcher.schedule({ + merchantId: 'm1', + recipient: 'r2', + amount: 20, + asset: 'USDC', + provider: 'stellar', + scheduledFor: new Date('2026-06-02T00:00:00Z'), + }); + + const first = await batcher.runDue(); + expect(first?.summary.payoutsPaid).toBe(1); + expect(batcher.getPayout(now.id)?.status).toBe('paid'); + expect(batcher.getPayout(later.id)?.status).toBe('scheduled'); + + clock = new Date('2026-06-02T00:00:00Z'); + expect((await batcher.runDue())?.summary.payoutsPaid).toBe(1); + expect(batcher.getPayout(later.id)?.status).toBe('paid'); + }); + + it('validates input and unknown providers', () => { + const batcher = makeBatcher(); + const base = { merchantId: 'm1', recipient: 'r1', amount: 10, asset: 'USDC' }; + expect(() => batcher.schedule({ ...base, amount: 0 })).toThrow('INVALID_PAYOUT_AMOUNT'); + expect(() => batcher.schedule({ ...base, recipient: '' })).toThrow('INVALID_PAYOUT_RECIPIENT'); + expect(() => batcher.schedule({ ...base, provider: 'nope' })).toThrow('UNKNOWN_PAYOUT_PROVIDER'); + expect(() => batcher.schedule({ ...base, scheduledFor: new Date('bad') })).toThrow('INVALID_PAYOUT_SCHEDULED_FOR'); + }); + + it('cancels payouts that have not been sent', async () => { + const batcher = makeBatcher(); + const payout = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 10, asset: 'USDC' }); + expect(batcher.cancel(payout.id, 'fin_1')).toBe(true); + expect(batcher.cancel(payout.id)).toBe(false); + expect((await batcher.runDue())?.batches).toEqual([]); + expect(auditLog.query({ reference: payout.id, operation: 'payout.cancelled' }).records[0].actor).toBe('fin_1'); + }); + }); + + describe('batching', () => { + it('groups by provider and asset and sends each batch in one provider call', async () => { + const other = vi.fn(async () => ({ transactionHash: 'tx_other' })); + const batcher = makeBatcher({ providers: { other }, feePerProviderCall: 0.5 }); + for (let i = 0; i < 3; i++) { + batcher.schedule({ merchantId: `m${i}`, recipient: `r${i}`, amount: 10, asset: 'USDC', provider: 'stellar' }); + } + batcher.schedule({ merchantId: 'm9', recipient: 'r9', amount: 5, asset: 'XLM', provider: 'stellar' }); + batcher.schedule({ merchantId: 'm8', recipient: 'r8', amount: 7, asset: 'USDC', provider: 'other' }); + + const report = await batcher.runDue(); + + expect(calls).toHaveLength(2); + expect(other).toHaveBeenCalledTimes(1); + const usdc = calls.find((c) => c.asset === 'USDC')!; + expect(usdc.items).toHaveLength(3); + expect(usdc.totalAmount).toBe(30); + expect(report?.summary).toMatchObject({ batchCount: 3, completed: 3, payoutsPaid: 5, estimatedFeesSaved: 1 }); + expect(report?.summary.amountPaidByAsset).toEqual({ USDC: 37, XLM: 5 }); + }); + + it('splits large groups at the configured batch size', async () => { + const batcher = makeBatcher({ maxBatchSize: 2 }); + for (let i = 0; i < 5; i++) { + batcher.schedule({ merchantId: 'm1', recipient: `r${i}`, amount: 1, asset: 'USDC', provider: 'stellar' }); + } + await batcher.runDue(); + expect(calls.map((c) => c.items.length)).toEqual([2, 2, 1]); + }); + + it('refuses overlapping runs so no payout is batched twice', async () => { + let release!: () => void; + const slow: PayoutBatchProvider = (request) => + new Promise((resolve) => { + release = () => resolve({ transactionHash: request.batchId }); + }); + const batcher = makeBatcher({ providers: { slow } }); + batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 1, asset: 'USDC', provider: 'slow' }); + + const first = batcher.runDue(); + expect(await batcher.runDue()).toBeUndefined(); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + release(); + expect((await first)?.summary.payoutsPaid).toBe(1); + }); + }); + + describe('atomic per provider call', () => { + it('fails every item of a batch when the provider call fails', async () => { + const failing: PayoutBatchProvider = async () => { + throw new Error('RAIL_DOWN'); + }; + const batcher = makeBatcher({ providers: { failing } }); + const ids = [0, 1, 2].map( + (i) => batcher.schedule({ merchantId: 'm1', recipient: `r${i}`, amount: 5, asset: 'USDC', provider: 'failing' }).id, + ); + + const report = await batcher.runDue(); + + expect(report?.batches[0]).toMatchObject({ status: 'failed', error: 'RAIL_DOWN', itemCount: 3 }); + expect(report?.batches[0].items.every((item) => item.status === 'failed')).toBe(true); + for (const id of ids) { + expect(batcher.getPayout(id)).toMatchObject({ status: 'scheduled', lastError: 'RAIL_DOWN', attempts: 1 }); + expect(batcher.getPayout(id)?.transactionHash).toBeUndefined(); + } + }); + + it('treats a partial acknowledgement as a failure of the whole batch', async () => { + const partial: PayoutBatchProvider = async (request) => ({ + transactionHash: 'tx_partial', + acceptedPayoutIds: [request.items[0].payoutId], + }); + const batcher = makeBatcher({ providers: { partial } }); + batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'partial' }); + batcher.schedule({ merchantId: 'm1', recipient: 'r2', amount: 5, asset: 'USDC', provider: 'partial' }); + + const report = await batcher.runDue(); + + expect(report?.batches[0]).toMatchObject({ status: 'failed', error: 'PARTIAL_PAYOUT_BATCH_ACKNOWLEDGEMENT' }); + expect(batcher.listPayouts({ status: 'paid' })).toEqual([]); + }); + + it('settles every item under one transaction hash on success', async () => { + const batcher = makeBatcher(); + batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'stellar' }); + batcher.schedule({ merchantId: 'm2', recipient: 'r2', amount: 6, asset: 'USDC', provider: 'stellar' }); + await batcher.runDue(); + const hashes = new Set(batcher.listPayouts().map((p) => p.transactionHash)); + expect([...hashes]).toEqual(['tx_1']); + }); + }); + + describe('failure isolation', () => { + it('one failing batch does not affect the other batches of the run', async () => { + const failing: PayoutBatchProvider = async () => { + throw new Error('RAIL_DOWN'); + }; + const batcher = makeBatcher({ providers: { failing } }); + const bad = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'failing' }); + const good = batcher.schedule({ merchantId: 'm2', recipient: 'r2', amount: 6, asset: 'USDC', provider: 'stellar' }); + + const report = await batcher.runDue(); + + expect(report?.summary).toMatchObject({ batchCount: 2, completed: 1, failed: 1, payoutsPaid: 1, payoutsFailed: 1 }); + expect(batcher.getPayout(good.id)?.status).toBe('paid'); + expect(batcher.getPayout(bad.id)?.status).toBe('scheduled'); + }); + + it('retries failed items after the delay and stops after maxAttempts', async () => { + let healthy = false; + const flaky: PayoutBatchProvider = async (request) => { + if (!healthy) throw new Error('RAIL_DOWN'); + return { transactionHash: request.idempotencyKey }; + }; + const batcher = makeBatcher({ providers: { flaky }, maxAttempts: 3, retryDelayMs: 1_000 }); + const payout = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'flaky' }); + + await batcher.runDue(); + expect((await batcher.runDue())?.batches).toEqual([]); // still backing off + + clock = new Date(clock.getTime() + 1_000); + healthy = true; + const retry = await batcher.runDue(); + expect(retry?.batches[0]).toMatchObject({ status: 'completed', attempt: 2 }); + expect(batcher.getPayout(payout.id)?.status).toBe('paid'); + + const dead: PayoutBatchProvider = async () => { + throw new Error('NOPE'); + }; + const exhausted = makeBatcher({ providers: { dead }, maxAttempts: 2, retryDelayMs: 0 }); + const doomed = exhausted.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'dead' }); + await exhausted.runDue(); + await exhausted.runDue(); + expect(exhausted.getPayout(doomed.id)).toMatchObject({ status: 'failed', attempts: 2 }); + expect((await exhausted.runDue())?.batches).toEqual([]); + }); + }); + + describe('reports', () => { + it('shows batch status, items and history that later retries do not rewrite', async () => { + let healthy = false; + const flaky: PayoutBatchProvider = async (request) => { + if (!healthy) throw new Error('RAIL_DOWN'); + return { transactionHash: request.idempotencyKey }; + }; + const batcher = makeBatcher({ providers: { flaky }, retryDelayMs: 0 }); + batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 5, asset: 'USDC', provider: 'flaky', reference: 'inv_1' }); + + const failedRun = await batcher.runDue(); + healthy = true; + await batcher.runDue(); + + const failedBatch = batcher.getBatchReport(failedRun!.batches[0].id)!; + expect(failedBatch).toMatchObject({ status: 'failed', provider: 'flaky', asset: 'USDC', itemCount: 1 }); + expect(failedBatch.items[0]).toMatchObject({ status: 'failed', reference: 'inv_1', recipient: 'r1' }); + + expect(batcher.listBatchReports({ status: 'completed' })).toHaveLength(1); + expect(batcher.listBatchReports({ status: 'failed' })).toHaveLength(1); + expect(batcher.getRunReport(failedRun!.runId)?.summary.failed).toBe(1); + }); + + it('writes every scheduling and settlement decision to the audit log', async () => { + const batcher = makeBatcher(); + const payout = batcher.schedule({ + merchantId: 'm1', + recipient: 'r1', + amount: 5, + asset: 'USDC', + provider: 'stellar', + actorId: 'fin_1', + }); + const report = await batcher.runDue(); + + expect(auditLog.query({ reference: payout.id }).records.map((r) => [r.operation, r.actor])).toEqual([ + ['payout.scheduled', 'fin_1'], + ['payout.settled', 'system'], + ]); + expect(auditLog.query({ reference: report!.batches[0].id }).records[0]).toMatchObject({ + operation: 'payout_batch.settled', + amount: 5, + }); + expect((await auditLog.verify()).valid).toBe(true); + }); + }); + + describe('anomaly holds (#1013)', () => { + it('holds flagged payouts out of batches until every flag is cleared', async () => { + const detector = new PayoutAnomalyDetector({ config: { singlePayoutThreshold: 100 }, onAlert: () => {}, now: () => clock }); + const batcher = makeBatcher({ anomalyDetector: detector }); + const big = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 500, asset: 'USDC', provider: 'stellar' }); + const small = batcher.schedule({ merchantId: 'm2', recipient: 'r2', amount: 5, asset: 'USDC', provider: 'stellar' }); + + expect(batcher.getPayout(big.id)?.status).toBe('held'); + await batcher.runDue(); + expect(calls[0].items.map((i) => i.payoutId)).toEqual([small.id]); + + const [flag] = detector.listFlags({ reference: big.id }); + detector.review(flag.id, { reviewerId: 'risk_1', decision: 'false_positive' }); + expect(batcher.getPayout(big.id)?.status).toBe('scheduled'); + await batcher.runDue(); + expect(batcher.getPayout(big.id)?.status).toBe('paid'); + }); + + it('cancels a held payout when its flag is confirmed', () => { + const detector = new PayoutAnomalyDetector({ config: { singlePayoutThreshold: 100 }, onAlert: () => {}, now: () => clock }); + const batcher = makeBatcher({ anomalyDetector: detector }); + const big = batcher.schedule({ merchantId: 'm1', recipient: 'r1', amount: 500, asset: 'USDC', provider: 'stellar' }); + + const [flag] = detector.listFlags({ reference: big.id }); + detector.review(flag.id, { reviewerId: 'risk_1', decision: 'confirmed' }); + + expect(batcher.getPayout(big.id)?.status).toBe('cancelled'); + expect(auditLog.query({ reference: big.id }).records.map((r) => r.operation)).toEqual([ + 'payout.scheduled', + 'payout.held', + 'payout.cancelled', + ]); + }); + }); +}); diff --git a/backend/src/services/payouts/__tests__/payout-engine-audit-anomaly.test.ts b/backend/src/services/payouts/__tests__/payout-engine-audit-anomaly.test.ts new file mode 100644 index 00000000..f530c0d6 --- /dev/null +++ b/backend/src/services/payouts/__tests__/payout-engine-audit-anomaly.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PayoutEngine, type PayoutApprover, type PayoutExecutor } from '../payout-engine.js'; +import { MoneyMovementAuditLog } from '../../../audit/money-movement-audit.js'; +import { PayoutAnomalyDetector } from '../payout-anomaly-detector.js'; + +const finance: PayoutApprover = { id: 'fin_1', role: 'finance_approver' }; + +describe('PayoutEngine money-movement audit (#1011) and anomaly holds (#1013)', () => { + let clock: Date; + let auditLog: MoneyMovementAuditLog; + let detector: PayoutAnomalyDetector; + + function makeEngine(executor?: PayoutExecutor, autoApproveLimit = 500) { + const engine = new PayoutEngine({ + auditLog, + anomalyDetector: detector, + executor, + maxAttempts: 1, + now: () => clock, + random: () => 0.5, + }); + engine.configureSchedule({ + id: 'sch_1', + merchantId: 'merch_1', + scheduleType: 'threshold', + thresholdAmount: 100, + preferredAsset: 'USDC', + autoApproveLimit, + }); + return engine; + } + + beforeEach(() => { + clock = new Date('2026-07-01T00:00:00Z'); + auditLog = new MoneyMovementAuditLog({ now: () => clock }); + detector = new PayoutAnomalyDetector({ + config: { singlePayoutThreshold: 5_000 }, + onAlert: () => {}, + now: () => clock, + }); + }); + + it('audits balance, reservation and settlement of an auto-approved payout', async () => { + const engine = makeEngine(); + engine.setPendingAmount('merch_1', 200, 'fin_1'); + const [batch] = await engine.evaluatePayouts(); + + expect(auditLog.query().records.map((r) => [r.operation, r.actor, r.amount, r.reference])).toEqual([ + ['balance.set', 'fin_1', 200, 'balance:merch_1'], + ['payout.reserved', 'system', 200, batch.id], + ['payout.settled', 'system', 200, batch.id], + ]); + expect(auditLog.query({ operation: 'payout.settled' }).records[0].metadata).toMatchObject({ + transactionHash: batch.transactionHash, + }); + expect((await auditLog.verify()).valid).toBe(true); + }); + + it('audits manual approval, rejection and fund release with the approver as actor', async () => { + const engine = makeEngine(undefined, 50); + engine.setPendingAmount('merch_1', 300); + const [first] = await engine.evaluatePayouts(); + await engine.approveManualPayout(first.id, finance); + + engine.setPendingAmount('merch_1', 400); + const [second] = await engine.evaluatePayouts(); + await engine.rejectManualPayout(second.id, { id: 'fin_2', role: 'admin' }); + + expect(auditLog.query({ reference: first.id }).records.map((r) => [r.operation, r.actor])).toEqual([ + ['payout.reserved', 'system'], + ['payout.approved', 'fin_1'], + ['payout.settled', 'fin_1'], + ]); + expect(auditLog.query({ reference: second.id }).records.map((r) => [r.operation, r.actor])).toEqual([ + ['payout.reserved', 'system'], + ['payout.rejected', 'fin_2'], + ['payout.released', 'fin_2'], + ]); + }); + + it('audits failed settlement and a single release of the reservation', async () => { + const engine = makeEngine(async () => { + throw new Error('RAIL_DOWN'); + }); + engine.setPendingAmount('merch_1', 200); + const [batch] = await engine.evaluatePayouts(); + await engine.releaseReservedFunds(batch.id, finance); + + const ops = auditLog.query({ reference: batch.id }).records.map((r) => r.operation); + expect(ops).toEqual(['payout.reserved', 'payout.failed', 'payout.released']); + expect(auditLog.query({ operation: 'payout.failed' }).records[0].metadata).toMatchObject({ + error: 'RAIL_DOWN', + final: true, + }); + }); + + it('forces review of a flagged batch even under the auto-approve limit', async () => { + const engine = makeEngine(undefined, 1_000_000); + engine.setPendingAmount('merch_1', 6_000); + const [batch] = await engine.evaluatePayouts(); + + expect(batch.requiresApproval).toBe(true); + expect(batch.status).toBe('pending'); + expect(engine.getAuditTrail(batch.id).some((e) => e.details?.includes('anomaly review'))).toBe(true); + expect(auditLog.query({ reference: batch.id }).records[0].metadata).toMatchObject({ + anomalyFlags: ['single_amount'], + }); + }); + + it('blocks approval until the anomaly flag is reviewed', async () => { + const engine = makeEngine(undefined, 1_000_000); + engine.setPendingAmount('merch_1', 6_000); + const [batch] = await engine.evaluatePayouts(); + + await expect(engine.approveManualPayout(batch.id, finance)).rejects.toThrow('PAYOUT_ANOMALY_REVIEW_PENDING'); + + const [flag] = detector.listFlags({ reference: batch.id }); + detector.review(flag.id, { reviewerId: 'risk_1', decision: 'false_positive' }); + + expect(await engine.approveManualPayout(batch.id, finance)).toBe(true); + expect(engine.getBatch(batch.id)?.status).toBe('completed'); + }); + + it('leaves engines without a detector or audit log unchanged', async () => { + const engine = new PayoutEngine({ now: () => clock }); + engine.configureSchedule({ + id: 'sch_1', + merchantId: 'merch_1', + scheduleType: 'threshold', + thresholdAmount: 100, + preferredAsset: 'USDC', + autoApproveLimit: 1_000_000, + }); + engine.setPendingAmount('merch_1', 6_000); + const [batch] = await engine.evaluatePayouts(); + expect(batch.status).toBe('completed'); + expect(auditLog.size).toBe(0); + }); +}); diff --git a/backend/src/services/payouts/payout-anomaly-detector.ts b/backend/src/services/payouts/payout-anomaly-detector.ts new file mode 100644 index 00000000..a0057045 --- /dev/null +++ b/backend/src/services/payouts/payout-anomaly-detector.ts @@ -0,0 +1,404 @@ +/** + * Payout anomaly detection — Issue #1013 + * + * Flags payouts that are unusually large or arrive unusually fast. Every rule threshold + * is configurable (constructor, environment, or `updateConfig` at runtime), every flag + * emits an alert to the configured sinks, and every flag enters a review queue where an + * operator either confirms it or marks it a false positive. + * + * Rules (all evaluated against the merchant's own history): + * - `single_amount` — one payout at or above `singlePayoutThreshold` + * - `velocity_count` — more than `velocityMaxCount` payouts inside `velocityWindowMs` + * - `velocity_amount` — more than `velocityMaxAmount` paid out inside `velocityWindowMs` + * - `baseline_deviation` — a payout more than `baselineMultiplier` x the merchant's + * rolling average (once `baselineMinSamples` payouts exist) + * + * A false-positive decision can suppress the same rule for that merchant for a while, so + * a legitimately large merchant is not flagged on every payout while thresholds are tuned. + */ + +import { randomUUID } from 'node:crypto'; + +export type PayoutAnomalyRule = 'single_amount' | 'velocity_count' | 'velocity_amount' | 'baseline_deviation'; + +export type PayoutAnomalySeverity = 'medium' | 'high'; + +export type PayoutAnomalyStatus = 'open' | 'confirmed' | 'false_positive'; + +export interface PayoutAnomalyConfig { + /** Master switch. When false, `evaluate` records history but raises nothing. */ + enabled: boolean; + singlePayoutThreshold: number; + velocityWindowMs: number; + velocityMaxCount: number; + velocityMaxAmount: number; + baselineMultiplier: number; + baselineMinSamples: number; + /** How many past payouts per merchant feed the rolling baseline. */ + baselineSampleSize: number; +} + +export const DEFAULT_PAYOUT_ANOMALY_CONFIG: Readonly = Object.freeze({ + enabled: true, + singlePayoutThreshold: 50_000, + velocityWindowMs: 60 * 60 * 1000, + velocityMaxCount: 20, + velocityMaxAmount: 100_000, + baselineMultiplier: 5, + baselineMinSamples: 5, + baselineSampleSize: 50, +}); + +export interface PayoutObservation { + /** Reference the payout is tracked under (batch id, scheduled payout id, ...). */ + reference: string; + merchantId: string; + amount: number; + asset: string; + at?: Date; +} + +export interface PayoutAnomalyFlag { + id: string; + reference: string; + merchantId: string; + amount: number; + asset: string; + rule: PayoutAnomalyRule; + severity: PayoutAnomalySeverity; + message: string; + /** The values the rule compared, for the reviewer. */ + observed: Record; + status: PayoutAnomalyStatus; + createdAt: Date; + reviewedBy?: string; + reviewedAt?: Date; + reviewNote?: string; +} + +export interface PayoutAnomalyReview { + reviewerId: string; + decision: 'confirmed' | 'false_positive'; + note?: string; + /** For false positives: skip this rule for this merchant for the given duration. */ + suppressForMs?: number; +} + +export type PayoutAnomalyAlertSink = (flag: PayoutAnomalyFlag) => void; +export type PayoutAnomalyReviewSink = (flag: PayoutAnomalyFlag) => void; + +export interface PayoutAnomalyDetectorOptions { + config?: Partial; + /** Receives every new flag. Defaults to a structured console warning. */ + onAlert?: PayoutAnomalyAlertSink; + /** Receives every reviewed flag, so held payouts can be released or cancelled. */ + onReview?: PayoutAnomalyReviewSink; + now?: () => Date; +} + +/** Upper bound on suppression so a false positive cannot silence a rule indefinitely. */ +export const MAX_ANOMALY_SUPPRESSION_MS = 30 * 24 * 60 * 60 * 1000; + +const MAX_TRACKED_FLAGS = 10_000; + +const ENV_KEYS: Readonly, string>> = Object.freeze({ + singlePayoutThreshold: 'PAYOUT_ANOMALY_SINGLE_THRESHOLD', + velocityWindowMs: 'PAYOUT_ANOMALY_VELOCITY_WINDOW_MS', + velocityMaxCount: 'PAYOUT_ANOMALY_VELOCITY_MAX_COUNT', + velocityMaxAmount: 'PAYOUT_ANOMALY_VELOCITY_MAX_AMOUNT', + baselineMultiplier: 'PAYOUT_ANOMALY_BASELINE_MULTIPLIER', + baselineMinSamples: 'PAYOUT_ANOMALY_BASELINE_MIN_SAMPLES', + baselineSampleSize: 'PAYOUT_ANOMALY_BASELINE_SAMPLE_SIZE', +}); + +/** Reads thresholds from `PAYOUT_ANOMALY_*` variables, ignoring unset or invalid values. */ +export function payoutAnomalyConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Partial { + const config: Partial = {}; + for (const [field, key] of Object.entries(ENV_KEYS) as [keyof typeof ENV_KEYS, string][]) { + const raw = env[key]; + if (raw === undefined || raw === '') continue; + const value = Number(raw); + if (Number.isFinite(value) && value > 0) config[field] = value; + } + if (env.PAYOUT_ANOMALY_DETECTION_ENABLED !== undefined) { + config.enabled = env.PAYOUT_ANOMALY_DETECTION_ENABLED !== 'false'; + } + return config; +} + +function validateConfig(config: PayoutAnomalyConfig): PayoutAnomalyConfig { + for (const field of Object.keys(ENV_KEYS) as (keyof typeof ENV_KEYS)[]) { + const value = config[field]; + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`INVALID_PAYOUT_ANOMALY_CONFIG:${field}`); + } + } + if (typeof config.enabled !== 'boolean') throw new Error('INVALID_PAYOUT_ANOMALY_CONFIG:enabled'); + return { + ...config, + velocityMaxCount: Math.floor(config.velocityMaxCount), + baselineMinSamples: Math.floor(config.baselineMinSamples), + baselineSampleSize: Math.floor(config.baselineSampleSize), + }; +} + +const defaultAlertSink: PayoutAnomalyAlertSink = (flag) => { + console.warn( + JSON.stringify({ + level: 'warn', + msg: 'payout anomaly flagged', + flagId: flag.id, + rule: flag.rule, + severity: flag.severity, + merchantId: flag.merchantId, + reference: flag.reference, + amount: flag.amount, + asset: flag.asset, + }), + ); +}; + +function snapshotFlag(flag: PayoutAnomalyFlag): PayoutAnomalyFlag { + return { + ...flag, + observed: { ...flag.observed }, + createdAt: new Date(flag.createdAt), + ...(flag.reviewedAt ? { reviewedAt: new Date(flag.reviewedAt) } : {}), + }; +} + +interface HistoryPoint { + amount: number; + at: number; +} + +export class PayoutAnomalyDetector { + private config: PayoutAnomalyConfig; + private readonly history = new Map(); // merchantId -> payouts + private readonly flags = new Map(); + private readonly suppressions = new Map(); // `${merchantId}:${rule}` -> until epoch ms + private readonly alertSinks: PayoutAnomalyAlertSink[]; + private readonly reviewSinks: PayoutAnomalyReviewSink[]; + private readonly now: () => Date; + + constructor(options: PayoutAnomalyDetectorOptions = {}) { + this.config = validateConfig({ ...DEFAULT_PAYOUT_ANOMALY_CONFIG, ...options.config }); + this.alertSinks = [options.onAlert ?? defaultAlertSink]; + this.reviewSinks = options.onReview ? [options.onReview] : []; + this.now = options.now ?? (() => new Date()); + } + + getConfig(): PayoutAnomalyConfig { + return { ...this.config }; + } + + /** Applies a partial config change atomically; an invalid value rejects the whole update. */ + updateConfig(patch: Partial): PayoutAnomalyConfig { + this.config = validateConfig({ ...this.config, ...patch }); + return this.getConfig(); + } + + /** Adds an alert subscriber (webhook, pager, metrics). Returns an unsubscribe function. */ + onAlert(sink: PayoutAnomalyAlertSink): () => void { + this.alertSinks.push(sink); + return () => { + const index = this.alertSinks.indexOf(sink); + if (index >= 0) this.alertSinks.splice(index, 1); + }; + } + + /** Adds a review subscriber. Returns an unsubscribe function. */ + onReview(sink: PayoutAnomalyReviewSink): () => void { + this.reviewSinks.push(sink); + return () => { + const index = this.reviewSinks.indexOf(sink); + if (index >= 0) this.reviewSinks.splice(index, 1); + }; + } + + /** + * Scores a payout against the merchant's history, records it, and returns the flags it + * raised (empty when nothing is unusual). Rules compare against history *before* this + * payout, so a payout never counts towards its own baseline. + */ + evaluate(observation: PayoutObservation): PayoutAnomalyFlag[] { + if (!Number.isFinite(observation.amount) || observation.amount < 0) { + throw new Error('INVALID_PAYOUT_ANOMALY_AMOUNT'); + } + + const at = (observation.at ?? this.now()).getTime(); + const history = this.history.get(observation.merchantId) ?? []; + const raised: PayoutAnomalyFlag[] = []; + + if (this.config.enabled) { + const cfg = this.config; + const windowStart = at - cfg.velocityWindowMs; + const inWindow = history.filter((point) => point.at > windowStart); + const windowCount = inWindow.length + 1; + const windowAmount = inWindow.reduce((sum, point) => sum + point.amount, 0) + observation.amount; + + if (observation.amount >= cfg.singlePayoutThreshold) { + this.raise(raised, observation, 'single_amount', 'high', 'Payout exceeds the single-payout threshold', { + amount: observation.amount, + threshold: cfg.singlePayoutThreshold, + }); + } + + if (windowCount > cfg.velocityMaxCount) { + this.raise(raised, observation, 'velocity_count', 'medium', 'Too many payouts inside the velocity window', { + count: windowCount, + maxCount: cfg.velocityMaxCount, + windowMs: cfg.velocityWindowMs, + }); + } + + if (windowAmount > cfg.velocityMaxAmount) { + this.raise(raised, observation, 'velocity_amount', 'high', 'Payout volume inside the velocity window is too high', { + amount: windowAmount, + maxAmount: cfg.velocityMaxAmount, + windowMs: cfg.velocityWindowMs, + }); + } + + if (history.length >= cfg.baselineMinSamples) { + const baseline = history.reduce((sum, point) => sum + point.amount, 0) / history.length; + if (baseline > 0 && observation.amount > baseline * cfg.baselineMultiplier) { + this.raise(raised, observation, 'baseline_deviation', 'medium', 'Payout is far above the merchant baseline', { + amount: observation.amount, + baseline, + multiplier: cfg.baselineMultiplier, + }); + } + } + } + + history.push({ amount: observation.amount, at }); + while (history.length > this.config.baselineSampleSize) history.shift(); + this.history.set(observation.merchantId, history); + + return raised.map(snapshotFlag); + } + + /** Records a reviewer's decision on an open flag. Returns the updated flag. */ + review(flagId: string, review: PayoutAnomalyReview): PayoutAnomalyFlag { + const flag = this.flags.get(flagId); + if (!flag) throw new Error('PAYOUT_ANOMALY_NOT_FOUND'); + if (flag.status !== 'open') throw new Error('PAYOUT_ANOMALY_ALREADY_REVIEWED'); + if (!review.reviewerId) throw new Error('PAYOUT_ANOMALY_REVIEWER_REQUIRED'); + if (review.decision !== 'confirmed' && review.decision !== 'false_positive') { + throw new Error('PAYOUT_ANOMALY_INVALID_DECISION'); + } + + flag.status = review.decision; + flag.reviewedBy = review.reviewerId; + flag.reviewedAt = this.now(); + if (review.note) flag.reviewNote = review.note; + + if (review.decision === 'false_positive' && review.suppressForMs !== undefined) { + if (!Number.isFinite(review.suppressForMs) || review.suppressForMs <= 0) { + throw new Error('PAYOUT_ANOMALY_INVALID_SUPPRESSION'); + } + const duration = Math.min(review.suppressForMs, MAX_ANOMALY_SUPPRESSION_MS); + this.suppressions.set(`${flag.merchantId}:${flag.rule}`, this.now().getTime() + duration); + } + + const snapshot = snapshotFlag(flag); + for (const sink of this.reviewSinks) { + try { + sink(snapshot); + } catch { + // A failing subscriber must not undo a recorded review decision. + } + } + return snapshot; + } + + getFlag(flagId: string): PayoutAnomalyFlag | undefined { + const flag = this.flags.get(flagId); + return flag ? snapshotFlag(flag) : undefined; + } + + listFlags(filter: { status?: PayoutAnomalyStatus; merchantId?: string; reference?: string } = {}): PayoutAnomalyFlag[] { + return [...this.flags.values()] + .filter((flag) => !filter.status || flag.status === filter.status) + .filter((flag) => !filter.merchantId || flag.merchantId === filter.merchantId) + .filter((flag) => !filter.reference || flag.reference === filter.reference) + .map(snapshotFlag); + } + + /** True while any flag raised against `reference` is still awaiting review. */ + hasOpenFlags(reference: string): boolean { + for (const flag of this.flags.values()) { + if (flag.reference === reference && flag.status === 'open') return true; + } + return false; + } + + /** Clears history, flags and suppressions. Intended for tests. */ + reset(): void { + this.history.clear(); + this.flags.clear(); + this.suppressions.clear(); + } + + private isSuppressed(merchantId: string, rule: PayoutAnomalyRule): boolean { + const key = `${merchantId}:${rule}`; + const until = this.suppressions.get(key); + if (until === undefined) return false; + if (until <= this.now().getTime()) { + this.suppressions.delete(key); + return false; + } + return true; + } + + /** Raises a flag into `into` unless the rule is suppressed for this merchant. */ + private raise( + into: PayoutAnomalyFlag[], + observation: PayoutObservation, + rule: PayoutAnomalyRule, + severity: PayoutAnomalySeverity, + message: string, + observed: Record, + ): void { + if (this.isSuppressed(observation.merchantId, rule)) return; + + const flag: PayoutAnomalyFlag = { + id: `pan_${randomUUID()}`, + reference: observation.reference, + merchantId: observation.merchantId, + amount: observation.amount, + asset: observation.asset, + rule, + severity, + message, + observed, + status: 'open', + createdAt: this.now(), + }; + + this.flags.set(flag.id, flag); + this.evictReviewedFlags(); + + const snapshot = snapshotFlag(flag); + for (const sink of this.alertSinks) { + try { + sink(snapshot); + } catch { + // Alert delivery problems must never block the payout decision. + } + } + into.push(flag); + } + + /** Bounds memory by dropping the oldest reviewed flags; open flags are always kept. */ + private evictReviewedFlags(): void { + if (this.flags.size <= MAX_TRACKED_FLAGS) return; + for (const [id, flag] of this.flags) { + if (this.flags.size <= MAX_TRACKED_FLAGS) break; + if (flag.status !== 'open') this.flags.delete(id); + } + } +} + +export const payoutAnomalyDetector = new PayoutAnomalyDetector({ config: payoutAnomalyConfigFromEnv() }); diff --git a/backend/src/services/payouts/payout-batcher.ts b/backend/src/services/payouts/payout-batcher.ts new file mode 100644 index 00000000..ac9032d3 --- /dev/null +++ b/backend/src/services/payouts/payout-batcher.ts @@ -0,0 +1,584 @@ +/** + * Payout scheduling and batching — Issue #1008 + * + * Individual payouts are scheduled for a future time instead of being sent one at a time. + * When a run fires, due payouts are grouped by (provider, asset) and chunked into + * provider batches; each batch is sent to the provider in a single call so the per-call + * fee is paid once per batch rather than once per payout. + * + * - Atomic per provider call: a batch either settles every item it carries (one + * transaction hash for all of them) or fails every item. A provider that reports + * anything other than full success is treated as a failure for the whole batch. + * - Failures are isolated: one batch failing never affects the other batches of the same + * run. Its items return to the queue for a backed-off retry until `maxAttempts` is spent. + * - Reporting: `getBatchReport` / `getRunReport` show each batch's status, item outcomes, + * totals and the provider fees saved by batching. + * + * Optional integrations: every scheduling decision and settlement is written to the + * money-movement audit log (#1011), and every scheduled payout is scored by the anomaly + * detector (#1013). A flagged payout is held out of batches until its flag is reviewed. + */ + +import { randomUUID } from 'node:crypto'; +import { moneyMovementAuditLog, type MoneyMovementAuditLog } from '../../audit/money-movement-audit.js'; +import { + payoutAnomalyDetector, + type PayoutAnomalyDetector, + type PayoutAnomalyFlag, +} from './payout-anomaly-detector.js'; + +export const DEFAULT_PAYOUT_PROVIDER = 'default'; +export const DEFAULT_MAX_BATCH_SIZE = 100; +export const DEFAULT_SCHEDULED_PAYOUT_MAX_ATTEMPTS = 3; +export const DEFAULT_BATCH_RETRY_DELAY_MS = 5 * 60 * 1000; + +export type ScheduledPayoutStatus = 'scheduled' | 'held' | 'batched' | 'paid' | 'failed' | 'cancelled'; + +export interface ScheduleScheduledPayoutInput { + merchantId: string; + recipient: string; + amount: number; + asset: string; + /** When the payout becomes eligible for a batch. Defaults to now. */ + scheduledFor?: Date; + provider?: string; + /** Caller reference, echoed back in reports. */ + reference?: string; + actorId?: string; +} + +export interface ScheduledPayout { + id: string; + merchantId: string; + recipient: string; + amount: number; + asset: string; + provider: string; + reference?: string; + scheduledFor: Date; + status: ScheduledPayoutStatus; + attempts: number; + batchId?: string; + transactionHash?: string; + lastError?: string; + /** Anomaly flags holding this payout out of batches. */ + anomalyFlagIds?: string[]; + createdAt: Date; + updatedAt: Date; +} + +export interface ProviderBatchItem { + payoutId: string; + recipient: string; + amount: number; +} + +export interface ProviderBatchRequest { + batchId: string; + /** Stable per batch attempt, so a provider can deduplicate a replayed call. */ + idempotencyKey: string; + provider: string; + asset: string; + totalAmount: number; + items: ProviderBatchItem[]; +} + +export interface ProviderBatchResult { + /** One transaction covering every item in the batch. */ + transactionHash: string; + /** + * Optional per-item acknowledgement. When present it must acknowledge every item; + * a partial acknowledgement fails the whole batch. + */ + acceptedPayoutIds?: string[]; +} + +/** Sends one batch in one provider call. Rejecting fails every item in the batch. */ +export type PayoutBatchProvider = (request: ProviderBatchRequest) => Promise; + +export type PayoutBatchStatus = 'executing' | 'completed' | 'failed'; + +export interface PayoutBatchRecord { + id: string; + runId: string; + provider: string; + asset: string; + status: PayoutBatchStatus; + payoutIds: string[]; + totalAmount: number; + attempt: number; + transactionHash?: string; + error?: string; + startedAt: Date; + completedAt?: Date; +} + +export interface PayoutBatchReport extends PayoutBatchRecord { + itemCount: number; + items: Array>; + /** Provider fees avoided by sending the items together instead of one call each. */ + estimatedFeesSaved: number; +} + +export interface PayoutRunReport { + runId: string; + startedAt: Date; + completedAt: Date; + batches: PayoutBatchReport[]; + summary: { + batchCount: number; + completed: number; + failed: number; + payoutsPaid: number; + payoutsFailed: number; + amountPaidByAsset: Record; + estimatedFeesSaved: number; + }; +} + +export interface PayoutBatcherOptions { + /** Providers by name. A payout naming an unknown provider is rejected at scheduling time. */ + providers?: Record; + maxBatchSize?: number; + maxAttempts?: number; + retryDelayMs?: number; + /** Flat fee a provider charges per call, used for the fees-saved estimate. */ + feePerProviderCall?: number; + auditLog?: MoneyMovementAuditLog; + anomalyDetector?: PayoutAnomalyDetector; + now?: () => Date; +} + +const MAX_ID_LENGTH = 128; + +/** Local, no-network provider so the batcher has a working default path. */ +const defaultProvider: PayoutBatchProvider = async (request) => ({ + transactionHash: `pb_exec_${request.idempotencyKey}`, +}); + +function snapshotPayout(payout: ScheduledPayout): ScheduledPayout { + return { + ...payout, + ...(payout.anomalyFlagIds ? { anomalyFlagIds: [...payout.anomalyFlagIds] } : {}), + scheduledFor: new Date(payout.scheduledFor), + createdAt: new Date(payout.createdAt), + updatedAt: new Date(payout.updatedAt), + }; +} + +function requireText(value: unknown, code: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) throw new Error(code); + return value; +} + +export class PayoutBatcher { + private readonly payouts = new Map(); + private readonly batches = new Map(); + private readonly runs = new Map(); + private readonly providers: Record; + private readonly auditLog?: MoneyMovementAuditLog; + private readonly anomalyDetector?: PayoutAnomalyDetector; + private readonly now: () => Date; + private runInFlight = false; + + readonly maxBatchSize: number; + readonly maxAttempts: number; + readonly retryDelayMs: number; + readonly feePerProviderCall: number; + + constructor(options: PayoutBatcherOptions = {}) { + this.providers = { [DEFAULT_PAYOUT_PROVIDER]: defaultProvider, ...options.providers }; + this.maxBatchSize = Math.floor(options.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE); + this.maxAttempts = Math.floor(options.maxAttempts ?? DEFAULT_SCHEDULED_PAYOUT_MAX_ATTEMPTS); + this.retryDelayMs = options.retryDelayMs ?? DEFAULT_BATCH_RETRY_DELAY_MS; + this.feePerProviderCall = options.feePerProviderCall ?? 0; + if (!(this.maxBatchSize >= 1)) throw new Error('INVALID_PAYOUT_BATCH_SIZE'); + if (!(this.maxAttempts >= 1)) throw new Error('INVALID_PAYOUT_MAX_ATTEMPTS'); + if (!Number.isFinite(this.retryDelayMs) || this.retryDelayMs < 0) throw new Error('INVALID_PAYOUT_RETRY_DELAY'); + if (!Number.isFinite(this.feePerProviderCall) || this.feePerProviderCall < 0) { + throw new Error('INVALID_PAYOUT_PROVIDER_FEE'); + } + + this.auditLog = options.auditLog; + this.anomalyDetector = options.anomalyDetector; + this.now = options.now ?? (() => new Date()); + this.anomalyDetector?.onReview((flag) => this.onAnomalyReviewed(flag)); + } + + /** Queues a payout for the next run at or after `scheduledFor`. */ + schedule(input: ScheduleScheduledPayoutInput): ScheduledPayout { + const merchantId = requireText(input.merchantId, 'INVALID_PAYOUT_MERCHANT_ID'); + const recipient = requireText(input.recipient, 'INVALID_PAYOUT_RECIPIENT'); + const asset = requireText(input.asset, 'INVALID_PAYOUT_ASSET'); + const provider = input.provider ?? DEFAULT_PAYOUT_PROVIDER; + if (!Object.prototype.hasOwnProperty.call(this.providers, provider)) throw new Error('UNKNOWN_PAYOUT_PROVIDER'); + if (typeof input.amount !== 'number' || !Number.isFinite(input.amount) || input.amount <= 0) { + throw new Error('INVALID_PAYOUT_AMOUNT'); + } + if (input.reference !== undefined) requireText(input.reference, 'INVALID_PAYOUT_REFERENCE'); + const scheduledFor = input.scheduledFor ?? this.now(); + if (!(scheduledFor instanceof Date) || Number.isNaN(scheduledFor.getTime())) { + throw new Error('INVALID_PAYOUT_SCHEDULED_FOR'); + } + + const createdAt = this.now(); + const payout: ScheduledPayout = { + id: `spo_${randomUUID()}`, + merchantId, + recipient, + amount: input.amount, + asset, + provider, + ...(input.reference !== undefined ? { reference: input.reference } : {}), + scheduledFor: new Date(scheduledFor), + status: 'scheduled', + attempts: 0, + createdAt, + updatedAt: createdAt, + }; + this.payouts.set(payout.id, payout); + + const actor = input.actorId ?? 'system'; + this.audit(actor, 'payout.scheduled', payout, { scheduledFor: payout.scheduledFor.toISOString(), provider }); + + const flags = + this.anomalyDetector?.evaluate({ + reference: payout.id, + merchantId, + amount: payout.amount, + asset, + at: createdAt, + }) ?? []; + if (flags.length > 0) { + payout.status = 'held'; + payout.anomalyFlagIds = flags.map((flag) => flag.id); + this.audit('system', 'payout.held', payout, { rules: flags.map((flag) => flag.rule) }); + } + + return snapshotPayout(payout); + } + + /** Cancels a payout that has not been sent yet. */ + cancel(payoutId: string, actorId = 'system'): boolean { + const payout = this.payouts.get(payoutId); + if (!payout || (payout.status !== 'scheduled' && payout.status !== 'held')) return false; + this.setStatus(payout, 'cancelled'); + this.audit(actorId, 'payout.cancelled', payout); + return true; + } + + /** + * Sends every due payout. Due payouts are grouped by provider and asset, split into + * batches of at most `maxBatchSize`, and each batch is one provider call. Batches run + * one after another; a failed batch is recorded and the run moves on. + * + * Overlapping runs are refused (returns `undefined`) so a payout is never batched twice. + */ + async runDue(): Promise { + if (this.runInFlight) return undefined; + this.runInFlight = true; + + try { + const runId = `prun_${randomUUID()}`; + const startedAt = this.now(); + const due = [...this.payouts.values()] + .filter((payout) => payout.status === 'scheduled' && payout.scheduledFor.getTime() <= startedAt.getTime()) + .sort((a, b) => a.scheduledFor.getTime() - b.scheduledFor.getTime() || a.id.localeCompare(b.id)); + + const groups = new Map(); + for (const payout of due) { + const key = `${payout.provider}\u0000${payout.asset}`; + const group = groups.get(key) ?? []; + group.push(payout); + groups.set(key, group); + } + + const batchIds: string[] = []; + for (const group of groups.values()) { + for (let i = 0; i < group.length; i += this.maxBatchSize) { + batchIds.push(await this.sendBatch(runId, group.slice(i, i + this.maxBatchSize))); + } + } + + const report = this.buildRunReport(runId, startedAt, batchIds); + this.runs.set(runId, report); + return report; + } finally { + this.runInFlight = false; + } + } + + getPayout(payoutId: string): ScheduledPayout | undefined { + const payout = this.payouts.get(payoutId); + return payout ? snapshotPayout(payout) : undefined; + } + + listPayouts(filter: { merchantId?: string; status?: ScheduledPayoutStatus } = {}): ScheduledPayout[] { + return [...this.payouts.values()] + .filter((payout) => !filter.merchantId || payout.merchantId === filter.merchantId) + .filter((payout) => !filter.status || payout.status === filter.status) + .map(snapshotPayout); + } + + getBatchReport(batchId: string): PayoutBatchReport | undefined { + const batch = this.batches.get(batchId); + return batch ? this.toBatchReport(batch) : undefined; + } + + listBatchReports(filter: { status?: PayoutBatchStatus } = {}): PayoutBatchReport[] { + return [...this.batches.values()] + .filter((batch) => !filter.status || batch.status === filter.status) + .map((batch) => this.toBatchReport(batch)); + } + + getRunReport(runId: string): PayoutRunReport | undefined { + const run = this.runs.get(runId); + // Rebuilt so item statuses reflect any retries since the run. + return run ? this.buildRunReport(run.runId, run.startedAt, run.batches.map((b) => b.id), run.completedAt) : undefined; + } + + /** Clears all state. Intended for tests. */ + reset(): void { + this.payouts.clear(); + this.batches.clear(); + this.runs.clear(); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private async sendBatch(runId: string, items: ScheduledPayout[]): Promise { + const { provider, asset } = items[0]; + const totalAmount = items.reduce((sum, item) => sum + item.amount, 0); + const batch: PayoutBatchRecord = { + id: `pbat_${randomUUID()}`, + runId, + provider, + asset, + status: 'executing', + payoutIds: items.map((item) => item.id), + totalAmount, + attempt: Math.max(...items.map((item) => item.attempts)) + 1, + startedAt: this.now(), + }; + this.batches.set(batch.id, batch); + + for (const item of items) { + item.attempts += 1; + item.batchId = batch.id; + delete item.lastError; + this.setStatus(item, 'batched'); + } + + try { + const result = await this.providers[provider]({ + batchId: batch.id, + idempotencyKey: `${batch.id}:${batch.attempt}`, + provider, + asset, + totalAmount, + items: items.map((item) => ({ payoutId: item.id, recipient: item.recipient, amount: item.amount })), + }); + + if (!result?.transactionHash) throw new Error('EMPTY_PAYOUT_BATCH_TRANSACTION_HASH'); + if (result.acceptedPayoutIds) { + const accepted = new Set(result.acceptedPayoutIds); + if (accepted.size !== items.length || items.some((item) => !accepted.has(item.id))) { + // All-or-nothing: a partial acknowledgement is not a settled batch. + throw new Error('PARTIAL_PAYOUT_BATCH_ACKNOWLEDGEMENT'); + } + } + + batch.status = 'completed'; + batch.transactionHash = result.transactionHash; + batch.completedAt = this.now(); + for (const item of items) { + item.transactionHash = result.transactionHash; + this.setStatus(item, 'paid'); + this.audit('system', 'payout.settled', item, { batchId: batch.id, transactionHash: result.transactionHash }); + } + this.auditBatch('payout_batch.settled', batch, { transactionHash: result.transactionHash }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown payout batch error'; + batch.status = 'failed'; + batch.error = message; + batch.completedAt = this.now(); + for (const item of items) { + item.lastError = message; + if (item.attempts >= this.maxAttempts) { + this.setStatus(item, 'failed'); + this.audit('system', 'payout.failed', item, { batchId: batch.id, error: message, final: true }); + } else { + // Back to the queue on its own: the retry may land in a different batch. + item.scheduledFor = new Date(this.now().getTime() + this.retryDelayMs); + this.setStatus(item, 'scheduled'); + this.audit('system', 'payout.failed', item, { batchId: batch.id, error: message, final: false }); + } + } + this.auditBatch('payout_batch.failed', batch, { error: message }); + } + + return batch.id; + } + + private onAnomalyReviewed(flag: PayoutAnomalyFlag): void { + const payout = this.payouts.get(flag.reference); + if (!payout || payout.status !== 'held') return; + + if (flag.status === 'confirmed') { + this.setStatus(payout, 'cancelled'); + this.audit(flag.reviewedBy ?? 'system', 'payout.cancelled', payout, { anomalyFlagId: flag.id }); + return; + } + + // Released only once every flag on the payout has been cleared as a false positive. + if (this.anomalyDetector?.hasOpenFlags(payout.id)) return; + this.setStatus(payout, 'scheduled'); + this.audit(flag.reviewedBy ?? 'system', 'payout.scheduled', payout, { + releasedFromHold: true, + anomalyFlagIds: payout.anomalyFlagIds ?? [], + }); + } + + private toBatchReport(batch: PayoutBatchRecord): PayoutBatchReport { + const items = batch.payoutIds + .map((id) => this.payouts.get(id)) + .filter((payout): payout is ScheduledPayout => payout !== undefined) + .map((payout) => ({ + id: payout.id, + merchantId: payout.merchantId, + recipient: payout.recipient, + amount: payout.amount, + // Status of the item *in this batch*: a later retry does not rewrite history. + status: (batch.status === 'completed' ? 'paid' : batch.status === 'failed' ? 'failed' : 'batched') as ScheduledPayoutStatus, + ...(payout.reference !== undefined ? { reference: payout.reference } : {}), + })); + + return { + ...batch, + payoutIds: [...batch.payoutIds], + startedAt: new Date(batch.startedAt), + ...(batch.completedAt ? { completedAt: new Date(batch.completedAt) } : {}), + itemCount: items.length, + items, + estimatedFeesSaved: Math.max(0, items.length - 1) * this.feePerProviderCall, + }; + } + + private buildRunReport(runId: string, startedAt: Date, batchIds: string[], completedAt = this.now()): PayoutRunReport { + const batches = batchIds + .map((id) => this.batches.get(id)) + .filter((batch): batch is PayoutBatchRecord => batch !== undefined) + .map((batch) => this.toBatchReport(batch)); + + const amountPaidByAsset: Record = {}; + let payoutsPaid = 0; + let payoutsFailed = 0; + for (const batch of batches) { + if (batch.status === 'completed') { + payoutsPaid += batch.itemCount; + amountPaidByAsset[batch.asset] = (amountPaidByAsset[batch.asset] ?? 0) + batch.totalAmount; + } else if (batch.status === 'failed') { + payoutsFailed += batch.itemCount; + } + } + + return { + runId, + startedAt: new Date(startedAt), + completedAt: new Date(completedAt), + batches, + summary: { + batchCount: batches.length, + completed: batches.filter((batch) => batch.status === 'completed').length, + failed: batches.filter((batch) => batch.status === 'failed').length, + payoutsPaid, + payoutsFailed, + amountPaidByAsset, + estimatedFeesSaved: batches + .filter((batch) => batch.status === 'completed') + .reduce((sum, batch) => sum + batch.estimatedFeesSaved, 0), + }, + }; + } + + private setStatus(payout: ScheduledPayout, status: ScheduledPayoutStatus): void { + payout.status = status; + payout.updatedAt = this.now(); + } + + private audit( + actor: string, + operation: Parameters[0]['operation'], + payout: ScheduledPayout, + metadata: Record = {}, + ): void { + this.auditLog?.record({ + actor, + operation, + amount: payout.amount, + asset: payout.asset, + reference: payout.id, + merchantId: payout.merchantId, + metadata: { recipient: payout.recipient, ...(payout.reference ? { clientReference: payout.reference } : {}), ...metadata }, + }); + } + + private auditBatch( + operation: 'payout_batch.settled' | 'payout_batch.failed', + batch: PayoutBatchRecord, + metadata: Record, + ): void { + this.auditLog?.record({ + actor: 'system', + operation, + amount: batch.totalAmount, + asset: batch.asset, + reference: batch.id, + metadata: { provider: batch.provider, payoutIds: [...batch.payoutIds], attempt: batch.attempt, ...metadata }, + }); + } +} + +function positiveEnvNumber(name: string): number | undefined { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** Process-wide batcher, wired to the shared audit log and anomaly detector. */ +export const payoutBatcher = new PayoutBatcher({ + maxBatchSize: positiveEnvNumber('PAYOUT_BATCH_MAX_SIZE'), + feePerProviderCall: positiveEnvNumber('PAYOUT_PROVIDER_FEE_PER_CALL'), + auditLog: moneyMovementAuditLog, + anomalyDetector: payoutAnomalyDetector, +}); + +/** How often scheduled payouts are checked for a due batch run. */ +export const DEFAULT_PAYOUT_BATCH_INTERVAL_MS = 60_000; + +export interface PayoutBatchWorker { + /** Runs one batch pass now. Never rejects. */ + tick(): Promise; + stop(): void; +} + +/** Drives `runDue` on an interval; passes never overlap and a failed pass is logged. */ +export function startPayoutBatchWorker( + batcher: Pick = payoutBatcher, + intervalMs: number = DEFAULT_PAYOUT_BATCH_INTERVAL_MS, +): PayoutBatchWorker { + const tick = async (): Promise => { + try { + return await batcher.runDue(); + } catch (error) { + console.error('[payouts] Batch run failed', error); + return undefined; + } + }; + + const timer = setInterval(() => { + void tick(); + }, intervalMs); + timer.unref(); + + return { tick, stop: () => clearInterval(timer) }; +} diff --git a/backend/src/services/payouts/payout-engine.ts b/backend/src/services/payouts/payout-engine.ts index 58823d99..ed8eb1fd 100644 --- a/backend/src/services/payouts/payout-engine.ts +++ b/backend/src/services/payouts/payout-engine.ts @@ -1,5 +1,11 @@ import { randomUUID } from 'node:crypto'; import { quantizeNumber } from '../money.js'; +import { + moneyMovementAuditLog, + type MoneyMovementAuditLog, + type MoneyMovementOperation, +} from '../../audit/money-movement-audit.js'; +import { payoutAnomalyDetector, type PayoutAnomalyDetector } from './payout-anomaly-detector.js'; export const DEFAULT_PAYOUT_APPROVAL_THRESHOLD = 10_000; @@ -148,6 +154,13 @@ export interface PayoutEngineOptions { onEvent?: PayoutEventSink; /** Injectable randomness for retry jitter. */ random?: () => number; + /** Tamper-evident log every money movement is written to (#1011). */ + auditLog?: MoneyMovementAuditLog; + /** + * Scores each new batch (#1013). A flagged batch always needs approval, and cannot be + * approved until its flags have been reviewed. + */ + anomalyDetector?: PayoutAnomalyDetector; /** Clock injection point for tests. */ now?: () => Date; } @@ -236,6 +249,8 @@ export class PayoutEngine { private readonly retryMaxMs: number; private readonly executor: PayoutExecutor; private readonly onEvent?: PayoutEventSink; + private readonly auditLog?: MoneyMovementAuditLog; + private readonly anomalyDetector?: PayoutAnomalyDetector; private readonly random: () => number; private readonly now: () => Date; @@ -260,6 +275,8 @@ export class PayoutEngine { this.executor = options.executor ?? defaultExecutor; this.onEvent = options.onEvent; + this.auditLog = options.auditLog; + this.anomalyDetector = options.anomalyDetector; this.random = options.random ?? Math.random; this.now = options.now ?? (() => new Date()); } @@ -275,11 +292,21 @@ export class PayoutEngine { this.sequence = 0; } - setPendingAmount(merchantId: string, amount: number) { + setPendingAmount(merchantId: string, amount: number, actorId = 'system') { if (!Number.isFinite(amount) || amount < 0) { throw new Error('INVALID_PENDING_PAYOUT_AMOUNT'); } + const previous = this.getPendingAmount(merchantId); this.pendingAmounts.set(merchantId, amount); + this.auditLog?.record({ + actor: actorId, + operation: 'balance.set', + amount, + asset: this.schedules.find((s) => s.merchantId === merchantId)?.preferredAsset ?? 'unspecified', + reference: `balance:${merchantId}`, + merchantId, + metadata: { previousAmount: previous }, + }); } getPendingAmount(merchantId: string): number { @@ -404,8 +431,11 @@ export class PayoutEngine { this.assertApprover(approver); const batch = this.batches.find((b) => b.id === batchId); if (!batch || batch.status !== 'pending') return false; + // A flagged batch must have its anomaly flags reviewed before anyone can push it through. + if (this.anomalyDetector?.hasOpenFlags(batch.id)) throw new Error('PAYOUT_ANOMALY_REVIEW_PENDING'); this.record(batch.id, 'approved', approver.id); + this.audit('payout.approved', batch, approver.id); await this.settle(batch, approver.id); return true; } @@ -418,8 +448,9 @@ export class PayoutEngine { this.transition(batch, 'failed'); batch.errorMessage = 'Rejected by manual review'; batch.rejectedBy = approver.id; + this.audit('payout.rejected', batch, approver.id); // Return the reserved funds to the merchant's pending balance. - this.releaseFunds(batch); + this.releaseFunds(batch, approver.id); this.record(batch.id, 'rejected', approver.id, batch.errorMessage); this.emit('payout.failed', batch, batch.errorMessage); return true; @@ -454,7 +485,7 @@ export class PayoutEngine { const batch = this.batches.find((b) => b.id === batchId); if (!batch || batch.status !== 'failed' || batch.fundsReleased) return false; - this.releaseFunds(batch); + this.releaseFunds(batch, approver.id); this.record(batch.id, 'released', approver.id, `Released ${batch.amount} ${batch.asset} reservation`); return true; } @@ -568,10 +599,20 @@ export class PayoutEngine { } const createdAt = this.now(); - const requiresApproval = amount > schedule.autoApproveLimit || amount >= this.approvalThreshold; + const batchId = `po_${randomUUID()}`; + const anomalyFlags = + this.anomalyDetector?.evaluate({ + reference: batchId, + merchantId: schedule.merchantId, + amount, + asset: schedule.preferredAsset, + at: createdAt, + }) ?? []; + const requiresApproval = + amount > schedule.autoApproveLimit || amount >= this.approvalThreshold || anomalyFlags.length > 0; const batch: PayoutBatch = { - id: `po_${randomUUID()}`, + id: batchId, idempotencyKey: `po_${randomUUID()}`, merchantId: schedule.merchantId, scheduleId: schedule.id, @@ -591,6 +632,14 @@ export class PayoutEngine { // Reserve the funds for every batch so the next evaluation cannot create a duplicate payout. this.pendingAmounts.set(schedule.merchantId, 0); this.batches.push(batch); + this.audit('payout.reserved', batch, 'system', { + requiresApproval, + cycleKey, + ...(anomalyFlags.length > 0 ? { anomalyFlags: anomalyFlags.map((flag) => flag.rule) } : {}), + }); + if (anomalyFlags.length > 0) { + this.record(batch.id, 'created', 'system', `Held for anomaly review: ${anomalyFlags.map((f) => f.rule).join(', ')}`); + } if (!requiresApproval) { this.record(batch.id, 'created', 'system', 'Auto-approved'); @@ -632,12 +681,18 @@ export class PayoutEngine { batch.settledAt = result.settledAt ?? this.now(); delete batch.nextAttemptAt; this.record(batch.id, 'settled', actorId, result.transactionHash); + this.audit('payout.settled', batch, actorId, { transactionHash: result.transactionHash, attempt: batch.attempts }); this.emit('payout.completed', batch, result.transactionHash); } catch (error) { const detail = error instanceof Error ? error.message : 'Unknown payout execution error'; this.transition(batch, 'failed'); batch.errorMessage = detail; batch.errorCode = 'PAYOUT_EXECUTION_FAILED'; + this.audit('payout.failed', batch, actorId, { + error: detail, + attempt: batch.attempts, + final: batch.attempts >= batch.maxAttempts, + }); if (batch.attempts >= batch.maxAttempts) { // Terminal failure. The reservation is deliberately kept: returning the funds to @@ -666,9 +721,10 @@ export class PayoutEngine { } /** Returns a batch's reserved amount to the merchant's pending balance exactly once. */ - private releaseFunds(batch: PayoutBatch): void { + private releaseFunds(batch: PayoutBatch, actorId = 'system'): void { if (batch.fundsReleased) return; batch.fundsReleased = true; + this.audit('payout.released', batch, actorId); this.pendingAmounts.set(batch.merchantId, quantizeNumber(this.getPendingAmount(batch.merchantId) + batch.amount, assetDecimals(batch.asset))); this.record(batch.id, 'released', 'system', `Returned ${batch.amount} ${batch.asset} to pending balance`); } @@ -692,6 +748,23 @@ export class PayoutEngine { this.auditTrail.push({ batchId, action, actorId, timestamp: this.now(), details }); } + private audit( + operation: MoneyMovementOperation, + batch: PayoutBatch, + actorId: string, + metadata: Record = {}, + ): void { + this.auditLog?.record({ + actor: actorId, + operation, + amount: batch.amount, + asset: batch.asset, + reference: batch.id, + merchantId: batch.merchantId, + metadata: { scheduleId: batch.scheduleId, ...metadata }, + }); + } + private emit(type: PayoutEventType, batch: PayoutBatch, detail?: string): void { if (!this.onEvent) return; try { @@ -711,4 +784,7 @@ export class PayoutEngine { } } -export const payoutEngine = new PayoutEngine(); \ No newline at end of file +export const payoutEngine = new PayoutEngine({ + auditLog: moneyMovementAuditLog, + anomalyDetector: payoutAnomalyDetector, +}); \ No newline at end of file