learning_ai_invt_trdg/backend/src/services/auditRepository.ts
Saravana Achu Mac 4cfb446f57 feat(backend): WebSocket namespaces, audit persistence, tab flags, telemetry
- Add /trading and /admin named Socket.IO namespaces; root namespace kept for
  backward compat; admin namespace rejects non-admins at connect time
- Wire auditRepository.ts: persist TradeAuditEvent to Cosmos audit-events
  container (best-effort); expose GET /api/admin/audit for admin queries
- Add tradingTelemetry singleton (Node.js Map-based storage adapter); init
  and fatal-error tracking wired in index.ts main()
- Add TAB_MARKETPLACE_ENABLED / TAB_MEMBERSHIP_ENABLED config flags; expose
  tabs.* shape in GET /api/feature-flags response
- Fix SupabaseService URL validation (regex check before createClient)
- Wire check:api-contract and check:audit-repository into npm run test
- Switch @bytelyst/* deps to file:../vendor/* references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:35:00 -04:00

153 lines
5.2 KiB
TypeScript

/**
* 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<string, unknown>;
}
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<boolean> {
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<AuditEventDocument[]> {
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<AuditEventDocument>({ query, parameters })
.fetchAll();
return resources;
} catch (error) {
logger.warn(`[Audit] Cosmos query failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];
}
}