/** * auditRepository.ts * * Cosmos-backed persistence for TradeAuditEvent records. * * Container: audit-events * Partition key: /productId * * Write policy: best-effort — audit persistence failures are logged and swallowed; * they must never block the primary operation that triggered the audit event. * * The container name intentionally uses a hyphen to match Azure naming conventions. * When creating the container in Cosmos, set: * - Partition key: /productId * - TTL: 7776000 (90 days) — set at container level, not per-document */ import { getContainer } from '@bytelyst/cosmos'; import { config } from '../config/index.js'; import logger from '../utils/logger.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AuditEventRecord { event: string; userId?: string; profileId?: string; symbol?: string; outcome?: 'accepted' | 'rejected' | 'error'; reason?: string; details?: Record; } interface AuditEventDocument extends AuditEventRecord { /** Cosmos document id — UUID generated at write time */ id: string; productId: string; /** ISO-8601 timestamp */ ts: string; /** Unix epoch ms — enables numeric range queries */ tsMs: number; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- const CONTAINER_NAME = 'audit-events'; function isCosmosConfigured(): boolean { return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY); } function generateAuditId(): string { // Use crypto.randomUUID if available (Node ≥ 18), otherwise fall back to a // timestamp + random suffix that is unique enough for append-only audit records. if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Persist a single audit event to Cosmos. * * Returns true if the write succeeded, false if Cosmos is not configured or * the write failed. Never throws. */ export async function persistAuditEvent(event: AuditEventRecord): Promise { if (!isCosmosConfigured()) { return false; } try { const container = getContainer(CONTAINER_NAME); const now = new Date(); const doc: AuditEventDocument = { id: generateAuditId(), productId: config.PRODUCT_ID, ts: now.toISOString(), tsMs: now.getTime(), event: event.event, ...(event.userId !== undefined && { userId: event.userId }), ...(event.profileId !== undefined && { profileId: event.profileId }), ...(event.symbol !== undefined && { symbol: event.symbol }), ...(event.outcome !== undefined && { outcome: event.outcome }), ...(event.reason !== undefined && { reason: event.reason }), ...(event.details !== undefined && { details: event.details }), }; await container.items.create(doc); return true; } catch (error) { logger.warn(`[Audit] Cosmos persist failed: ${error instanceof Error ? error.message : 'unknown error'}`); return false; } } /** * Query recent audit events for a given user, optionally filtered by event type. * Returns results ordered newest-first, capped at `limit` (default 100). * * Returns an empty array if Cosmos is not configured or the query fails. */ export async function listAuditEvents(options: { userId?: string; event?: string; sinceMs?: number; limit?: number; }): Promise { if (!isCosmosConfigured()) { return []; } const { userId, event, sinceMs, limit = 100 } = options; const conditions: string[] = ['c.productId = @productId']; const parameters: Array<{ name: string; value: string | number | boolean | null }> = [ { name: '@productId', value: config.PRODUCT_ID }, ]; if (userId) { conditions.push('c.userId = @userId'); parameters.push({ name: '@userId', value: userId }); } if (event) { conditions.push('c.event = @event'); parameters.push({ name: '@event', value: event }); } if (sinceMs !== undefined) { conditions.push('c.tsMs >= @sinceMs'); parameters.push({ name: '@sinceMs', value: sinceMs }); } const query = `SELECT TOP ${Math.min(limit, 500)} * FROM c WHERE ${conditions.join(' AND ')} ORDER BY c.tsMs DESC`; try { const container = getContainer(CONTAINER_NAME); const { resources } = await container.items .query({ query, parameters }) .fetchAll(); return resources; } catch (error) { logger.warn(`[Audit] Cosmos query failed: ${error instanceof Error ? error.message : 'unknown error'}`); return []; } }