- 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>
153 lines
5.2 KiB
TypeScript
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 [];
|
|
}
|
|
}
|