120 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|