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