Files
CASAN/packages/casan-control-panel/backend/src/ingest/ingest.service.ts
T

120 lines
4.8 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';
import { appendFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { BadRequestException, ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
import { APP_ROOT, PATHS } from '../common/app-root.js';
type Row = Record<string, unknown>;
export interface IngestEnvelope {
schema_version: 1;
sent_at: string;
project_id: string;
trace_id: string;
receipt: Row;
metric: Row;
trace: Row;
events: Row[];
}
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
const FORBIDDEN_KEYS = new Set([
'prompt', 'raw_prompt', 'assistant_summary', 'tool_input', 'tool_output',
'result_content', 'secret', 'password', 'authorization',
]);
export function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
const record = value as Row;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
}
return JSON.stringify(value) ?? 'null';
}
export function expectedSignature(token: string, timestamp: string, body: unknown): string {
return `sha256=${createHmac('sha256', token).update(`${timestamp}.${stableJson(body)}`).digest('hex')}`;
}
function containsForbiddenKey(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsForbiddenKey);
if (!value || typeof value !== 'object') return false;
return Object.entries(value as Row).some(([key, nested]) => (
FORBIDDEN_KEYS.has(key.toLowerCase()) || containsForbiddenKey(nested)
));
}
function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function atomicJson(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
renameSync(temporary, path);
}
@Injectable()
export class IngestService {
ingest(raw: unknown, timestamp: string | undefined, signature: string | undefined) {
const token = process.env.CASAN_CP_INGEST_TOKEN;
if (!token) throw new ServiceUnavailableException('CASAN_INGEST_DISABLED');
const epoch = Number(timestamp);
if (!Number.isInteger(epoch) || Math.abs(Math.floor(Date.now() / 1000) - epoch) > 300) {
throw new ForbiddenException('CASAN_INGEST_TIMESTAMP_INVALID');
}
const expected = expectedSignature(token, String(timestamp), raw);
if (!signature || !safeEqual(signature, expected)) {
throw new ForbiddenException('CASAN_INGEST_SIGNATURE_INVALID');
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new BadRequestException('CASAN_INGEST_INVALID_ENVELOPE');
}
const envelope = raw as Partial<IngestEnvelope>;
if (
envelope.schema_version !== 1
|| typeof envelope.project_id !== 'string'
|| typeof envelope.trace_id !== 'string'
|| !SAFE_ID.test(envelope.project_id)
|| !SAFE_ID.test(envelope.trace_id)
|| !envelope.metric
|| !envelope.trace
|| !Array.isArray(envelope.events)
|| envelope.events.length > 2000
) throw new BadRequestException('CASAN_INGEST_INVALID_ENVELOPE');
if (containsForbiddenKey(envelope)) {
throw new BadRequestException('CASAN_INGEST_RAW_CONTENT_FORBIDDEN');
}
if (
envelope.metric.trace_id !== envelope.trace_id
|| envelope.trace.trace_id !== envelope.trace_id
|| envelope.events.some((event) => event.trace_id !== envelope.trace_id)
) throw new BadRequestException('CASAN_INGEST_TRACE_MISMATCH');
const tracePath = join(PATHS.traceDir, `agentic-${envelope.trace_id}.json`);
if (existsSync(tracePath)) {
return { accepted: true, duplicate: true, trace_id: envelope.trace_id };
}
mkdirSync(dirname(PATHS.metrics), { recursive: true });
appendFileSync(PATHS.metrics, `${JSON.stringify(envelope.metric)}\n`, 'utf8');
atomicJson(tracePath, envelope.trace);
if (envelope.events.length > 0) {
const eventPath = join(PATHS.traceEventDir, `${envelope.trace_id}.jsonl`);
mkdirSync(dirname(eventPath), { recursive: true });
appendFileSync(eventPath, `${envelope.events.map((event) => JSON.stringify(event)).join('\n')}\n`, 'utf8');
}
const receiptPath = join(APP_ROOT, '.specify', 'state', 'ingested', `${envelope.trace_id}.json`);
atomicJson(receiptPath, envelope.receipt ?? {});
return {
accepted: true,
duplicate: false,
project_id: envelope.project_id,
trace_id: envelope.trace_id,
};
}
}