import { createHandler } from './_shared/middleware' import { adminDb } from './_shared/db' import * as crypto from 'crypto' import * as fs from 'fs' import * as path from 'path' // ─── TYPES ──────────────────────────────────────────────────────────────────── interface ChunkToValidate { chunk_id: string // item id of the cil_chunk chunk_name: string content: string // raw content between CHUNK_START and CHUNK_END (markers excluded) } interface ValidationResult { chunk_id: string chunk_name: string status: 'current' | 'drift_detected' | 'not_found' stored_hash: string | null validation_hash: string message: string } interface ValidateRequest { file_path?: string chunks: ChunkToValidate[] } interface UpdateHashRequest { chunk_id: string content: string } // ─── HASH UTILITIES ─────────────────────────────────────────────────────────── /** * Normalise chunk content for deterministic hashing. * Rules: * 1. Strip CHUNK_START and CHUNK_END marker lines (they contain the name, not logic) * 2. Collapse runs of blank lines to a single blank line * 3. Trim leading/trailing whitespace from the whole block * 4. Normalise line endings to \n */ function normaliseContent(raw: string): string { const lines = raw.replace(/\r\n/g, '\n').split('\n') const filtered = lines.filter(line => { const trimmed = line.trim() // Strip legacy // markers and new /** */ markers return !trimmed.match(/^\/\/\s*[─\-]+\s*CHUNK_(START|END):/) && !trimmed.match(/^\/\*\*?\s*CHUNK_(START|END):.+\*\//) }) // Collapse multiple blank lines const collapsed: string[] = [] let lastBlank = false for (const line of filtered) { const isBlank = line.trim() === '' if (isBlank && lastBlank) continue collapsed.push(line) lastBlank = isBlank } return collapsed.join('\n').trim() } function computeHash(content: string): string { return crypto.createHash('sha256').update(normaliseContent(content)).digest('hex').slice(0, 16) } // ─── HANDLER ────────────────────────────────────────────────────────────────── export const handler = createHandler(async (ctx, body) => { const action = ctx.query?.action // ── VALIDATE — compare live content hash vs stored hash ────────────────────── if (action === 'validate' || !action) { const req = body as ValidateRequest if (!req?.chunks || !Array.isArray(req.chunks)) { throw new Error('chunks array is required') } const results: ValidationResult[] = [] for (const chunk of req.chunks) { const validationHash = computeHash(chunk.content) // Look up the stored hash on the cil_chunk item const { data: item, error } = await adminDb .from('items') .select('id, data') .eq('id', chunk.chunk_id) .maybeSingle() if (error || !item) { results.push({ chunk_id: chunk.chunk_id, chunk_name: chunk.chunk_name, status: 'not_found', stored_hash: null, validation_hash: validationHash, message: 'Chunk not found in graph — needs ingestion', }) continue } const storedHash: string | null = item.data?.stored_hash || null if (!storedHash) { results.push({ chunk_id: chunk.chunk_id, chunk_name: chunk.chunk_name, status: 'drift_detected', stored_hash: null, validation_hash: validationHash, message: 'No stored hash — chunk has never been hash-verified', }) continue } const status: 'current' | 'drift_detected' = storedHash === validationHash ? 'current' : 'drift_detected' results.push({ chunk_id: chunk.chunk_id, chunk_name: chunk.chunk_name, status, stored_hash: storedHash, validation_hash: validationHash, message: status === 'current' ? 'Embedding is current — content matches stored hash' : 'Content has drifted since last ingestion — re-ingest required before trusting contract', }) } const driftCount = results.filter(r => r.status === 'drift_detected').length const currentCount = results.filter(r => r.status === 'current').length return { file_path: req.file_path || null, results, summary: { total: results.length, current: currentCount, drift_detected: driftCount, not_found: results.filter(r => r.status === 'not_found').length, trust_rating: results.length > 0 ? Math.round((currentCount / results.length) * 100) : 0, } } } // ── UPDATE HASH — store new hash after re-ingestion ─────────────────────────── if (action === 'update_hash') { const req = body as UpdateHashRequest if (!req?.chunk_id) throw new Error('chunk_id is required') if (!req?.content) throw new Error('content is required') const newHash = computeHash(req.content) const { data: item } = await adminDb .from('items') .select('id, data') .eq('id', req.chunk_id) .maybeSingle() if (!item) throw new Error(`Chunk not found: ${req.chunk_id}`) const { error } = await adminDb .from('items') .update({ data: { ...item.data, stored_hash: newHash, hash_updated_at: new Date().toISOString() }, updated_at: new Date().toISOString(), }) .eq('id', req.chunk_id) if (error) throw new Error(`Failed to update hash: ${error.message}`) return { chunk_id: req.chunk_id, new_hash: newHash, updated_at: new Date().toISOString(), } } // ── COMPUTE HASH — return hash without storing (utility) ────────────────────── if (action === 'compute_hash') { const content = body?.content if (!content) throw new Error('content is required') return { hash: computeHash(content) } } // ── VALIDATE FILE — re-read from disk and validate all chunks in a file ─────── // Unlike `validate`, this action does NOT rely on caller-provided content. // It reads the source file directly from the repo path, extracts each chunk's // content by line range, hashes it, and compares against the stored_hash. // // Required: file_id (cil_file item UUID) // Optional: repo_path override — defaults to the project's repo_path if (action === 'validate_file') { const fileId = ctx.query?.file_id || body?.file_id const repoPathOverride: string | null = ctx.query?.repo_path || body?.repo_path || null if (!fileId) throw new Error('file_id is required') // Load the cil_file item const { data: fileItem } = await adminDb .from('items') .select('id, title, data') .eq('id', fileId) .maybeSingle() if (!fileItem) throw new Error(`File not found: ${fileId}`) const filePath: string = fileItem.data?.file_path || fileItem.title const projectId: string = fileItem.data?.project_id // Resolve repo path let repoPath = repoPathOverride if (!repoPath && projectId) { const { data: project } = await adminDb .from('items') .select('data') .eq('id', projectId) .maybeSingle() repoPath = project?.data?.repo_path || null } if (!repoPath) throw new Error('Could not determine repo_path. Pass repo_path in request body or ensure project has repo_path set.') const absPath = path.join(repoPath, filePath) if (!fs.existsSync(absPath)) { throw new Error(`Source file not found on disk: ${absPath}`) } const content = fs.readFileSync(absPath, 'utf-8') const lines = content.split('\n') // Load all cil_chunk items for this file const { data: chunks } = await adminDb .from('items') .select('id, data') .filter('data->>file_id', 'eq', fileId) .filter('data->>kb_type', 'eq', 'cil_chunk') .eq('is_active', true) if (!chunks?.length) { return { file_id: fileId, file_path: filePath, results: [], summary: { total: 0, current: 0, drift_detected: 0, not_found: 0, trust_rating: 100 }, } } const results: ValidationResult[] = [] for (const chunk of chunks) { const d = chunk.data || {} const lineStart: number = d.line_start || 0 const lineEnd: number = d.line_end || 0 const chunkName: string = d.chunk_name || '' const storedHash: string | null = d.stored_hash || null if (!lineStart || !lineEnd) { results.push({ chunk_id: chunk.id, chunk_name: chunkName, status: 'not_found', stored_hash: storedHash, validation_hash: '', message: 'Chunk has no line_start / line_end — cannot extract from file', }) continue } // Extract the raw lines for this chunk (0-indexed slicing, line numbers are 1-based) const chunkLines = lines.slice(lineStart - 1, lineEnd) const liveContent = chunkLines.join('\n') const validationHash = computeHash(liveContent) if (!storedHash) { results.push({ chunk_id: chunk.id, chunk_name: chunkName, status: 'drift_detected', stored_hash: null, validation_hash: validationHash, message: 'No stored hash — chunk has never been hash-verified', }) continue } const status: 'current' | 'drift_detected' = storedHash === validationHash ? 'current' : 'drift_detected' results.push({ chunk_id: chunk.id, chunk_name: chunkName, status, stored_hash: storedHash, validation_hash: validationHash, message: status === 'current' ? 'Content matches stored hash' : 'Content has drifted since last ingestion — re-ingest required', }) } const currentCount = results.filter(r => r.status === 'current').length const driftCount = results.filter(r => r.status === 'drift_detected').length return { file_id: fileId, file_path: filePath, repo_path: repoPath, results, summary: { total: results.length, current: currentCount, drift_detected: driftCount, not_found: results.filter(r => r.status === 'not_found').length, trust_rating: results.length > 0 ? Math.round((currentCount / results.length) * 100) : 0, }, } } throw new Error(`Unknown action: ${action}. Valid: validate, validate_file, update_hash, compute_hash`) })