diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5ac85e2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "singleQuote": true +} diff --git a/package.json b/package.json index ba18f19..b7eae6c 100644 --- a/package.json +++ b/package.json @@ -1 +1 @@ -{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs"},"engines":{"node":">=24"}} +{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs","format":"npx --yes prettier@3.9.5 --write \"src/*.mjs\" \"test/*.mjs\"","format:check":"npx --yes prettier@3.9.5 --check \"src/*.mjs\" \"test/*.mjs\""},"engines":{"node":">=24"}} diff --git a/src/gitea.mjs b/src/gitea.mjs index aedf232..f8df27f 100644 --- a/src/gitea.mjs +++ b/src/gitea.mjs @@ -1,9 +1,71 @@ -const FIX_WORDS=/\b(fix(?:e[ds])?|bug|broken|race|crash|regression|rollback|restore[ds]?|duplicate[ds]?|auth|fail(?:ed|ure|ing)?)\b/i; -const validRepo=value=>/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(value); -export async function scanGiteaCandidates({baseUrl,repositories,token='',limit=100,fetchImpl=fetch}){ - const candidates=[],errors=[],headers={accept:'application/json'};if(token)headers.authorization=`token ${token}`; - for(const repo of repositories){if(!validRepo(repo)){errors.push({repo,error:'invalid owner/repository identifier'});continue}const [owner,name]=repo.split('/');let seen=0; - try{for(let page=1;seenfile.filename).filter(Boolean).join('\n')}}catch{}candidates.push({repo,sha:commit.sha,committedAt:commit.commit?.committer?.date||commit.commit?.author?.date||commit.created||new Date().toISOString(),subject,paths,reason:`Gitea subject keyword: ${match[0].toLowerCase()}`})}if(commits.length /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(value); +export async function scanGiteaCandidates({ + baseUrl, + repositories, + token = '', + limit = 100, + fetchImpl = fetch, +}) { + const candidates = [], + errors = [], + headers = { accept: 'application/json' }; + if (token) headers.authorization = `token ${token}`; + for (const repo of repositories) { + if (!validRepo(repo)) { + errors.push({ repo, error: 'invalid owner/repository identifier' }); + continue; + } + const [owner, name] = repo.split('/'); + let seen = 0; + try { + for (let page = 1; seen < limit; page++) { + const count = Math.min(50, limit - seen), + url = `${baseUrl.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits?limit=${count}&page=${page}`; + const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`Gitea returned ${response.status}`); + const commits = await response.json(); + if (!Array.isArray(commits)) throw new Error('unexpected Gitea response'); + for (const commit of commits) { + seen++; + const subject = String(commit.commit?.message || '') + .split('\n')[0] + .trim(), + match = subject.match(FIX_WORDS); + if (!match) continue; + let paths = ''; + try { + const detail = await fetchImpl( + `${baseUrl.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(commit.sha)}`, + { headers, signal: AbortSignal.timeout(5000) }, + ); + if (detail.ok) { + const data = await detail.json(); + paths = (data.files || []) + .map((file) => file.filename) + .filter(Boolean) + .join('\n'); + } + } catch {} + candidates.push({ + repo, + sha: commit.sha, + committedAt: + commit.commit?.committer?.date || + commit.commit?.author?.date || + commit.created || + new Date().toISOString(), + subject, + paths, + reason: `Gitea subject keyword: ${match[0].toLowerCase()}`, + }); + } + if (commits.length < count) break; + } + } catch (error) { + errors.push({ repo, error: error.message }); + } + } + return { candidates, errors }; } diff --git a/src/receipts.mjs b/src/receipts.mjs index 6db0831..ce86fbb 100644 --- a/src/receipts.mjs +++ b/src/receipts.mjs @@ -1,19 +1,159 @@ import crypto from 'node:crypto'; -const RESULTS=new Set(['success','failed','cancelled','unknown']), TYPES=new Set(['build','deployment','release','rollback']); -const send=(res,status,value)=>res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':status===200?'public, max-age=30':'no-store'}).end(JSON.stringify(value)); -export function validateReceipt(v){const e=[];if(!v||typeof v!=='object'||Array.isArray(v))return['body must be an object'];if(v.schemaVersion!==1)e.push('schemaVersion must be 1');if(!TYPES.has(v.eventType))e.push('unsupported eventType');for(const f of ['eventId','project','occurredAt'])if(typeof v[f]!=='string'||!v[f].trim())e.push(`${f} is required`);if(v.occurredAt&&Number.isNaN(Date.parse(v.occurredAt)))e.push('occurredAt must be ISO-8601');if(!RESULTS.has(v.result))e.push('unsupported result');if(!v.source||typeof v.source.commit!=='string'||!/^[a-f0-9]{7,64}$/i.test(v.source.commit))e.push('source.commit must be a commit SHA');if(v.artifacts&&!Array.isArray(v.artifacts))e.push('artifacts must be an array');return e;} -export function createReceiptStore(db,config=''){ - db.exec(`CREATE TABLE IF NOT EXISTS deployment_events(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id TEXT NOT NULL UNIQUE,schema_version INTEGER NOT NULL,event_type TEXT NOT NULL,project TEXT NOT NULL,environment TEXT NOT NULL DEFAULT '',source_commit TEXT NOT NULL,source_branch TEXT NOT NULL DEFAULT '',result TEXT NOT NULL,health_result TEXT NOT NULL DEFAULT '',occurred_at TEXT NOT NULL,received_at TEXT NOT NULL,previous_event_id TEXT NOT NULL DEFAULT '',payload TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployment_events_project_time ON deployment_events(project,occurred_at DESC);`); - const tokens=new Map(config.split(',').filter(Boolean).map(p=>{const i=p.indexOf(':');return[p.slice(i+1),p.slice(0,i)]})); - const authenticated=(req,project)=>{const token=req.headers.authorization?.replace(/^Bearer\s+/i,'');if(!token)return false;for(const[candidate,allowed]of tokens)if(candidate.length===token.length&&crypto.timingSafeEqual(Buffer.from(candidate),Buffer.from(token))&&allowed===project)return true;return false}; - const publicEvent=row=>{if(!row)return null;const p=JSON.parse(row.payload);return{schemaVersion:row.schema_version,eventId:row.event_id,eventType:row.event_type,project:row.project,environment:row.environment||undefined,commit:row.source_commit,branch:row.source_branch||undefined,result:row.result,health:row.health_result||undefined,occurredAt:row.occurred_at,artifacts:(p.artifacts||[]).map(x=>({component:x.component,digest:x.digest?.slice(0,19)}))}}; - async function handle(req,res,path){ - if(path==='/api/health'&&req.method==='GET')return send(res,200,{ok:true}); - if(path==='/api/v1/events'&&req.method==='POST'){let raw='';for await(const c of req){raw+=c;if(raw.length>131072)return send(res,413,{error:'receipt too large'})}let v;try{v=JSON.parse(raw)}catch{return send(res,400,{error:'invalid JSON'})}const errors=validateReceipt(v);if(errors.length)return send(res,422,{error:'invalid receipt',details:errors});if(!authenticated(req,v.project))return send(res,401,{error:'invalid receipt credential'});if(db.prepare('SELECT event_id FROM deployment_events WHERE event_id=?').get(v.eventId))return send(res,200,{ok:true,eventId:v.eventId,duplicate:true});db.prepare(`INSERT INTO deployment_events(event_id,schema_version,event_type,project,environment,source_commit,source_branch,result,health_result,occurred_at,received_at,previous_event_id,payload) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(v.eventId,1,v.eventType,v.project,v.environment||'',v.source.commit,v.source.branch||'',v.result,v.verification?.health||'',v.occurredAt,new Date().toISOString(),v.previousEventId||'',raw);return send(res,201,{ok:true,eventId:v.eventId});} - const m=path.match(/^\/api\/v1\/public\/projects\/([a-z0-9-]+)\/latest$/);if(m&&req.method==='GET'){const row=db.prepare("SELECT * FROM deployment_events WHERE project=? AND event_type='deployment' ORDER BY occurred_at DESC LIMIT 1").get(m[1]);return send(res,200,{project:m[1],deployment:publicEvent(row)})} - if(path==='/api/v1/public/projects'&&req.method==='GET'){const rows=db.prepare(`SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`).all();return send(res,200,{projects:rows.map(publicEvent)})}return false; - }return{handle,publicEvent}; +const RESULTS = new Set(['success', 'failed', 'cancelled', 'unknown']), + TYPES = new Set(['build', 'deployment', 'release', 'rollback']); +const send = (res, status, value) => + res + .writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': status === 200 ? 'public, max-age=30' : 'no-store', + }) + .end(JSON.stringify(value)); +export function validateReceipt(v) { + const e = []; + if (!v || typeof v !== 'object' || Array.isArray(v)) return ['body must be an object']; + if (v.schemaVersion !== 1) e.push('schemaVersion must be 1'); + if (!TYPES.has(v.eventType)) e.push('unsupported eventType'); + for (const f of ['eventId', 'project', 'occurredAt']) + if (typeof v[f] !== 'string' || !v[f].trim()) e.push(`${f} is required`); + if (v.occurredAt && Number.isNaN(Date.parse(v.occurredAt))) e.push('occurredAt must be ISO-8601'); + if (!RESULTS.has(v.result)) e.push('unsupported result'); + if ( + !v.source || + typeof v.source.commit !== 'string' || + !/^[a-f0-9]{7,64}$/i.test(v.source.commit) + ) + e.push('source.commit must be a commit SHA'); + if (v.artifacts && !Array.isArray(v.artifacts)) e.push('artifacts must be an array'); + return e; +} +export function createReceiptStore(db, config = '') { + db.exec( + `CREATE TABLE IF NOT EXISTS deployment_events(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id TEXT NOT NULL UNIQUE,schema_version INTEGER NOT NULL,event_type TEXT NOT NULL,project TEXT NOT NULL,environment TEXT NOT NULL DEFAULT '',source_commit TEXT NOT NULL,source_branch TEXT NOT NULL DEFAULT '',result TEXT NOT NULL,health_result TEXT NOT NULL DEFAULT '',occurred_at TEXT NOT NULL,received_at TEXT NOT NULL,previous_event_id TEXT NOT NULL DEFAULT '',payload TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployment_events_project_time ON deployment_events(project,occurred_at DESC);`, + ); + const tokens = new Map( + config + .split(',') + .filter(Boolean) + .map((p) => { + const i = p.indexOf(':'); + return [p.slice(i + 1), p.slice(0, i)]; + }), + ); + const authenticated = (req, project) => { + const token = req.headers.authorization?.replace(/^Bearer\s+/i, ''); + if (!token) return false; + for (const [candidate, allowed] of tokens) + if ( + candidate.length === token.length && + crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(token)) && + allowed === project + ) + return true; + return false; + }; + const publicEvent = (row) => { + if (!row) return null; + const p = JSON.parse(row.payload); + return { + schemaVersion: row.schema_version, + eventId: row.event_id, + eventType: row.event_type, + project: row.project, + environment: row.environment || undefined, + commit: row.source_commit, + branch: row.source_branch || undefined, + result: row.result, + health: row.health_result || undefined, + occurredAt: row.occurred_at, + artifacts: (p.artifacts || []).map((x) => ({ + component: x.component, + digest: x.digest?.slice(0, 19), + })), + }; + }; + async function handle(req, res, path) { + if (path === '/api/health' && req.method === 'GET') return send(res, 200, { ok: true }); + if (path === '/api/v1/events' && req.method === 'POST') { + let raw = ''; + for await (const c of req) { + raw += c; + if (raw.length > 131072) return send(res, 413, { error: 'receipt too large' }); + } + let v; + try { + v = JSON.parse(raw); + } catch { + return send(res, 400, { error: 'invalid JSON' }); + } + const errors = validateReceipt(v); + if (errors.length) return send(res, 422, { error: 'invalid receipt', details: errors }); + if (!authenticated(req, v.project)) + return send(res, 401, { error: 'invalid receipt credential' }); + if (db.prepare('SELECT event_id FROM deployment_events WHERE event_id=?').get(v.eventId)) + return send(res, 200, { ok: true, eventId: v.eventId, duplicate: true }); + db.prepare( + `INSERT INTO deployment_events(event_id,schema_version,event_type,project,environment,source_commit,source_branch,result,health_result,occurred_at,received_at,previous_event_id,payload) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + v.eventId, + 1, + v.eventType, + v.project, + v.environment || '', + v.source.commit, + v.source.branch || '', + v.result, + v.verification?.health || '', + v.occurredAt, + new Date().toISOString(), + v.previousEventId || '', + raw, + ); + return send(res, 201, { ok: true, eventId: v.eventId }); + } + const m = path.match(/^\/api\/v1\/public\/projects\/([a-z0-9-]+)\/latest$/); + if (m && req.method === 'GET') { + const row = db + .prepare( + "SELECT * FROM deployment_events WHERE project=? AND event_type='deployment' ORDER BY occurred_at DESC LIMIT 1", + ) + .get(m[1]); + return send(res, 200, { project: m[1], deployment: publicEvent(row) }); + } + if (path === '/api/v1/public/projects' && req.method === 'GET') { + const rows = db + .prepare( + `SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`, + ) + .all(); + return send(res, 200, { projects: rows.map(publicEvent) }); + } + return false; + } + return { handle, publicEvent }; } -const escapeHtml=value=>String(value??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); -export function renderEventsPage(db){const rows=db.prepare('SELECT * FROM deployment_events ORDER BY occurred_at DESC LIMIT 100').all();const counts=db.prepare('SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result').all();return`operational events · trace
trace

Operational events

Builds say what CI produced. Deployments say what a host actually ran.

${counts.map(x=>`${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}`).join('')}
${rows.map(row=>{const payload=JSON.parse(row.payload),artifacts=payload.artifacts||[];return`
${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}

${escapeHtml(row.source_commit.slice(0,12))} ${escapeHtml(row.source_branch)} ${row.environment?`· ${escapeHtml(row.environment)}`:''}

${escapeHtml(row.result)}

${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}

${artifacts.map(x=>`

${escapeHtml(x.component)} ${escapeHtml(x.digest||'digest unavailable')}

`).join('')}
raw private receipt
${escapeHtml(JSON.stringify(payload,null,2))}
`}).join('')||'

No operational events received yet.

'}
`} +const escapeHtml = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +export function renderEventsPage(db) { + const rows = db + .prepare('SELECT * FROM deployment_events ORDER BY occurred_at DESC LIMIT 100') + .all(); + const counts = db + .prepare( + 'SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result', + ) + .all(); + return `operational events · trace
trace

Operational events

Builds say what CI produced. Deployments say what a host actually ran.

${counts.map((x) => `${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}`).join('')}
${ + rows + .map((row) => { + const payload = JSON.parse(row.payload), + artifacts = payload.artifacts || []; + return `
${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}

${escapeHtml(row.source_commit.slice(0, 12))} ${escapeHtml(row.source_branch)} ${row.environment ? `· ${escapeHtml(row.environment)}` : ''}

${escapeHtml(row.result)}

${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}

${artifacts.map((x) => `

${escapeHtml(x.component)} ${escapeHtml(x.digest || 'digest unavailable')}

`).join('')}
raw private receipt
${escapeHtml(JSON.stringify(payload, null, 2))}
`; + }) + .join('') || '

No operational events received yet.

' + }
`; +} diff --git a/src/server.mjs b/src/server.mjs index 9f4312b..3764bf8 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -8,8 +8,11 @@ import crypto from 'node:crypto'; import { createReceiptStore, renderEventsPage } from './receipts.mjs'; import { scanGiteaCandidates } from './gitea.mjs'; -const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || ''; -mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true }); +const exec = promisify(execFile), + port = Number(process.env.PORT || 3082), + password = process.env.TRACE_PASSWORD || ''; +mkdirSync('data', { recursive: true }); +mkdirSync('data/exports', { recursive: true }); const db = new DatabaseSync('data/trace.db'); db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,title TEXT NOT NULL,project TEXT,status TEXT NOT NULL DEFAULT 'investigating',severity TEXT NOT NULL DEFAULT 'annoyance',detected_at TEXT NOT NULL,symptom TEXT NOT NULL,impact TEXT DEFAULT '',root_cause TEXT DEFAULT '',confidence TEXT DEFAULT 'unknown',fix TEXT DEFAULT '',verification TEXT DEFAULT '',prevention TEXT DEFAULT '',remaining_risk TEXT DEFAULT '',lesson TEXT DEFAULT '',public_summary TEXT DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL); @@ -17,53 +20,457 @@ CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,inciden CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha)); CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`); -for(const sql of ["ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''","ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''"]){try{db.exec(sql)}catch(error){if(!String(error.message).includes('duplicate column'))throw error;}} +for (const sql of [ + "ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''", + "ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''", +]) { + try { + db.exec(sql); + } catch (error) { + if (!String(error.message).includes('duplicate column')) throw error; + } +} const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || ''); -const repos = new Map((process.env.TRACE_REPOS || '').split(',').filter(Boolean).map(x => { const i=x.indexOf(':'); return [x.slice(0,i),x.slice(i+1)]; })); -const giteaRepos=(process.env.TRACE_GITEA_REPOS||'').split(',').map(x=>x.trim()).filter(Boolean),giteaUrl=process.env.TRACE_GITEA_URL||'https://repo.explewd.com',giteaToken=process.env.TRACE_GITEA_TOKEN||''; -const esc=s=>String(s??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); -const form=async req=>{let b='';for await(const c of req)b+=c;return Object.fromEntries(new URLSearchParams(b));}; -function auth(req,res){if(!password)return true;const expected='Basic '+Buffer.from('trace:'+password).toString('base64');if(req.headers.authorization===expected)return true;res.writeHead(401,{'WWW-Authenticate':'Basic realm="trace"'}).end('Authentication required');return false;} -function layout(title,body){return `${esc(title)} · trace
trace
${body}
Private by default. Git discovers; a human explains.
`;} -const fields=['impact','root_cause','fix','verification','prevention','remaining_risk','lesson','public_summary']; -const slugify=s=>String(s||'failure').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'failure'; -const publicSeverity={annoyance:'paper-cut',degraded:'minor',unavailable:'significant','data-risk':'significant','security-risk':'significant'}; -const publicStatus={investigating:'open',mitigated:'worked-around',resolved:'resolved',abandoned:'abandoned'}; -function publicMarkdown(x){return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary||x.symptom)}\ndate: ${x.detected_at.slice(0,10)}\nprojectSlug: ${JSON.stringify(x.project||'')}\nkind: bug\nseverity: ${publicSeverity[x.severity]||'minor'}\nstatus: ${publicStatus[x.status]||'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;} -function publicIncident(x){return {id:x.id,slug:x.public_slug,title:x.title,summary:x.public_summary||x.symptom,date:x.detected_at.slice(0,10),projectSlug:x.project||'',kind:'bug',severity:publicSeverity[x.severity]||'minor',status:publicStatus[x.status]||'open',publishedAt:x.published_at,updatedAt:x.updated_at,markdownUrl:`/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`};} -function publicDetail(x){return {...publicIncident(x),sections:[['Symptom',x.symptom],['What was actually happening',x.root_cause],['How we proved it',x.verification],['The fix',x.fix],['What changed afterward',x.prevention],['Remaining risk',x.remaining_risk],['Lesson',x.lesson]].filter(([,body])=>body)};} -function incidentForm(v={}){return `
${fields.map(x=>``).join('')}
`;} -function redirect(res,to){res.writeHead(303,{Location:to}).end();} -async function scan(){for(const [id,path] of repos){let stdout;try{stdout=(await exec('git',['-C',path,'log','--all','--no-merges','-n','300','--pretty=format:%H%x1f%ad%x1f%s','--date=iso-strict'],{maxBuffer:2e6})).stdout;}catch(error){console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`);continue;}for(const line of stdout.split('\n')){const [sha,date,subject]=line.split('\x1f');if(!/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test(subject||''))continue;let paths='';try{paths=(await exec('git',['-C',path,'show','--name-only','--pretty=format:',sha],{maxBuffer:2e5})).stdout.trim();}catch{}db.prepare('INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)').run(id,sha,date,subject,paths,'local git keyword');}}const remote=await scanGiteaCandidates({baseUrl:giteaUrl,repositories:giteaRepos,token:giteaToken});for(const candidate of remote.candidates)db.prepare('INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)').run(candidate.repo,candidate.sha,candidate.committedAt,candidate.subject,candidate.paths,candidate.reason);for(const error of remote.errors)console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`)} -async function handler(req,res){if(!auth(req,res))return;const u=new URL(req.url,'http://x');if(u.pathname==='/style.css'){res.writeHead(200,{'content-type':'text/css'}).end(`:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`);return;} -if(req.method==='GET'&&u.pathname==='/'){const rows=db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();const counts=db.prepare("SELECT status,count(*) n FROM incidents GROUP BY status").all();res.end(layout('incidents',`

Incident notebook

${counts.map(x=>`${x.status}: ${x.n}`).join(' · ')||'No incidents yet.'}

${rows.map(x=>`${esc(x.title)}

${esc(x.symptom)}

${x.status} ${esc(x.project)} · ${esc(x.detected_at)}
`).join('')}`));return;} -if(req.method==='GET'&&u.pathname==='/incidents/new'){res.end(layout('new incident','

Record what happened

'+incidentForm()));return;} -if(req.method==='POST'&&u.pathname==='/incidents/new'){const f=await form(req),id=crypto.randomUUID(),now=new Date().toISOString();db.prepare(`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`).run(id,f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.slice(0,2).map(x=>f[x]||''),f.confidence||'unknown',...fields.slice(2).map(x=>f[x]||''),now,now);redirect(res,'/incidents/'+id);return;} -const match=u.pathname.match(/^\/incidents\/([^/]+)$/);if(match){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);if(!x){res.writeHead(404).end();return;}if(req.method==='POST'){const f=await form(req);db.prepare(`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map(x=>x+'=?').join(',')},confidence=?,updated_at=? WHERE id=?`).run(f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.map(x=>f[x]||''),f.confidence||'unknown',new Date().toISOString(),x.id);redirect(res,u.pathname);return;}const timeline=db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id), hyps=db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id), commits=db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);res.end(layout(x.title,`

${esc(x.title)}

${incidentForm(x)}

Timeline

${timeline.map(t=>`

${esc(t.kind)} ${esc(t.at)}
${esc(t.body)}

`).join('')}

Hypotheses

${hyps.map(h=>`

${esc(h.statement)} ${h.status}
${esc(h.evidence)}

`).join('')}

Linked commits

${commits.map(c=>`

${c.sha.slice(0,7)} ${esc(c.subject)}

`).join('')||'

None.

'}
`));return;} -const sub=u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);if(req.method==='POST'&&sub){const f=await form(req),id=sub[1];if(sub[2]==='timeline')db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(id,f.at,f.kind,f.body);if(sub[2]==='hypotheses')db.prepare('INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)').run(id,f.statement,f.status,f.evidence);if(sub[2]==='export'){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(id);const slug=x.title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');const md=`---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary||x.symptom)}\ndate: ${x.detected_at.slice(0,10)}\nprojectSlug: ${JSON.stringify(x.project||'')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;writeFileSync(join('data/exports',slug+'.md'),md);res.end(layout('export',`

Public draft exported

data/exports/${esc(slug)}.md

${esc(md)}
`));return;}redirect(res,'/incidents/'+id);return;} -if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/candidates');return;}if(u.pathname==='/candidates'){const rows=db.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC").all();res.end(layout('git inbox',`

Git inbox

${rows.map(c=>`
${esc(c.subject)}

${esc(c.repo)} · ${c.sha.slice(0,10)} · ${esc(c.committed_at)}

changed paths
${esc(c.paths)}
`).join('')}`));return;} -const link=u.pathname.match(/^\/candidates\/(\d+)\/link$/);if(link&&req.method==='POST'){const f=await form(req);db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(f.incident_id,link[1]);redirect(res,'/candidates');return;} -if(u.pathname==='/api/receipts'&&req.method==='POST'){let raw='';for await(const c of req)raw+=c;const x=JSON.parse(raw);db.prepare('INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)').run(x.project,x.commit,x.deployedAt,x.environment||'',x.health||'',raw);res.writeHead(201,{'content-type':'application/json'}).end('{"ok":true}');return;} -res.writeHead(404).end(layout('not found','

Not found

'));} +const repos = new Map( + (process.env.TRACE_REPOS || '') + .split(',') + .filter(Boolean) + .map((x) => { + const i = x.indexOf(':'); + return [x.slice(0, i), x.slice(i + 1)]; + }), +); +const giteaRepos = (process.env.TRACE_GITEA_REPOS || '') + .split(',') + .map((x) => x.trim()) + .filter(Boolean), + giteaUrl = process.env.TRACE_GITEA_URL || 'https://repo.explewd.com', + giteaToken = process.env.TRACE_GITEA_TOKEN || ''; +const esc = (s) => + String(s ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +const form = async (req) => { + let b = ''; + for await (const c of req) b += c; + return Object.fromEntries(new URLSearchParams(b)); +}; +function auth(req, res) { + if (!password) return true; + const expected = 'Basic ' + Buffer.from('trace:' + password).toString('base64'); + if (req.headers.authorization === expected) return true; + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="trace"' }).end('Authentication required'); + return false; +} +function layout(title, body) { + return `${esc(title)} · trace
trace
${body}
Private by default. Git discovers; a human explains.
`; +} +const fields = [ + 'impact', + 'root_cause', + 'fix', + 'verification', + 'prevention', + 'remaining_risk', + 'lesson', + 'public_summary', +]; +const slugify = (s) => + String(s || 'failure') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'failure'; +const publicSeverity = { + annoyance: 'paper-cut', + degraded: 'minor', + unavailable: 'significant', + 'data-risk': 'significant', + 'security-risk': 'significant', +}; +const publicStatus = { + investigating: 'open', + mitigated: 'worked-around', + resolved: 'resolved', + abandoned: 'abandoned', +}; +function publicMarkdown(x) { + return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${publicSeverity[x.severity] || 'minor'}\nstatus: ${publicStatus[x.status] || 'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`; +} +function publicIncident(x) { + return { + id: x.id, + slug: x.public_slug, + title: x.title, + summary: x.public_summary || x.symptom, + date: x.detected_at.slice(0, 10), + projectSlug: x.project || '', + kind: 'bug', + severity: publicSeverity[x.severity] || 'minor', + status: publicStatus[x.status] || 'open', + publishedAt: x.published_at, + updatedAt: x.updated_at, + markdownUrl: `/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`, + }; +} +function publicDetail(x) { + return { + ...publicIncident(x), + sections: [ + ['Symptom', x.symptom], + ['What was actually happening', x.root_cause], + ['How we proved it', x.verification], + ['The fix', x.fix], + ['What changed afterward', x.prevention], + ['Remaining risk', x.remaining_risk], + ['Lesson', x.lesson], + ].filter(([, body]) => body), + }; +} +function incidentForm(v = {}) { + return `
${fields.map((x) => ``).join('')}
`; +} +function redirect(res, to) { + res.writeHead(303, { Location: to }).end(); +} +async function scan() { + for (const [id, path] of repos) { + let stdout; + try { + stdout = ( + await exec( + 'git', + [ + '-C', + path, + 'log', + '--all', + '--no-merges', + '-n', + '300', + '--pretty=format:%H%x1f%ad%x1f%s', + '--date=iso-strict', + ], + { maxBuffer: 2e6 }, + ) + ).stdout; + } catch (error) { + console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`); + continue; + } + for (const line of stdout.split('\n')) { + const [sha, date, subject] = line.split('\x1f'); + if ( + !/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test( + subject || '', + ) + ) + continue; + let paths = ''; + try { + paths = ( + await exec('git', ['-C', path, 'show', '--name-only', '--pretty=format:', sha], { + maxBuffer: 2e5, + }) + ).stdout.trim(); + } catch {} + db.prepare( + 'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)', + ).run(id, sha, date, subject, paths, 'local git keyword'); + } + } + const remote = await scanGiteaCandidates({ + baseUrl: giteaUrl, + repositories: giteaRepos, + token: giteaToken, + }); + for (const candidate of remote.candidates) + db.prepare( + 'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)', + ).run( + candidate.repo, + candidate.sha, + candidate.committedAt, + candidate.subject, + candidate.paths, + candidate.reason, + ); + for (const error of remote.errors) + console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`); +} +async function handler(req, res) { + if (!auth(req, res)) return; + const u = new URL(req.url, 'http://x'); + if (u.pathname === '/style.css') { + res + .writeHead(200, { 'content-type': 'text/css' }) + .end( + `:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`, + ); + return; + } + if (req.method === 'GET' && u.pathname === '/') { + const rows = db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all(); + const counts = db.prepare('SELECT status,count(*) n FROM incidents GROUP BY status').all(); + res.end( + layout( + 'incidents', + `

Incident notebook

${counts.map((x) => `${x.status}: ${x.n}`).join(' · ') || 'No incidents yet.'}

${rows.map((x) => `${esc(x.title)}

${esc(x.symptom)}

${x.status} ${esc(x.project)} · ${esc(x.detected_at)}
`).join('')}`, + ), + ); + return; + } + if (req.method === 'GET' && u.pathname === '/incidents/new') { + res.end(layout('new incident', '

Record what happened

' + incidentForm())); + return; + } + if (req.method === 'POST' && u.pathname === '/incidents/new') { + const f = await form(req), + id = crypto.randomUUID(), + now = new Date().toISOString(); + db.prepare( + `INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`, + ).run( + id, + f.title, + f.project || '', + f.status, + f.severity, + f.detected_at, + f.symptom, + ...fields.slice(0, 2).map((x) => f[x] || ''), + f.confidence || 'unknown', + ...fields.slice(2).map((x) => f[x] || ''), + now, + now, + ); + redirect(res, '/incidents/' + id); + return; + } + const match = u.pathname.match(/^\/incidents\/([^/]+)$/); + if (match) { + const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]); + if (!x) { + res.writeHead(404).end(); + return; + } + if (req.method === 'POST') { + const f = await form(req); + db.prepare( + `UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map((x) => x + '=?').join(',')},confidence=?,updated_at=? WHERE id=?`, + ).run( + f.title, + f.project || '', + f.status, + f.severity, + f.detected_at, + f.symptom, + ...fields.map((x) => f[x] || ''), + f.confidence || 'unknown', + new Date().toISOString(), + x.id, + ); + redirect(res, u.pathname); + return; + } + const timeline = db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id), + hyps = db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id), + commits = db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id); + res.end( + layout( + x.title, + `

${esc(x.title)}

${incidentForm(x)}

Timeline

${timeline.map((t) => `

${esc(t.kind)} ${esc(t.at)}
${esc(t.body)}

`).join('')}

Hypotheses

${hyps.map((h) => `

${esc(h.statement)} ${h.status}
${esc(h.evidence)}

`).join('')}

Linked commits

${commits.map((c) => `

${c.sha.slice(0, 7)} ${esc(c.subject)}

`).join('') || '

None.

'}
`, + ), + ); + return; + } + const sub = u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/); + if (req.method === 'POST' && sub) { + const f = await form(req), + id = sub[1]; + if (sub[2] === 'timeline') + db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run( + id, + f.at, + f.kind, + f.body, + ); + if (sub[2] === 'hypotheses') + db.prepare( + 'INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)', + ).run(id, f.statement, f.status, f.evidence); + if (sub[2] === 'export') { + const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(id); + const slug = x.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const md = `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`; + writeFileSync(join('data/exports', slug + '.md'), md); + res.end( + layout( + 'export', + `

Public draft exported

data/exports/${esc(slug)}.md

${esc(md)}
`, + ), + ); + return; + } + redirect(res, '/incidents/' + id); + return; + } + if (u.pathname === '/candidates' && req.method === 'POST') { + await scan(); + redirect(res, '/candidates'); + return; + } + if (u.pathname === '/candidates') { + const rows = db + .prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC") + .all(); + res.end( + layout( + 'git inbox', + `

Git inbox

${rows.map((c) => `
${esc(c.subject)}

${esc(c.repo)} · ${c.sha.slice(0, 10)} · ${esc(c.committed_at)}

changed paths
${esc(c.paths)}
`).join('')}`, + ), + ); + return; + } + const link = u.pathname.match(/^\/candidates\/(\d+)\/link$/); + if (link && req.method === 'POST') { + const f = await form(req); + db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run( + f.incident_id, + link[1], + ); + redirect(res, '/candidates'); + return; + } + if (u.pathname === '/api/receipts' && req.method === 'POST') { + let raw = ''; + for await (const c of req) raw += c; + const x = JSON.parse(raw); + db.prepare( + 'INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)', + ).run(x.project, x.commit, x.deployedAt, x.environment || '', x.health || '', raw); + res.writeHead(201, { 'content-type': 'application/json' }).end('{"ok":true}'); + return; + } + res.writeHead(404).end(layout('not found', '

Not found

')); +} -async function publicContent(req,res,path){ - if(req.method==='GET'&&path==='/api/v1/public/failures'){ - const rows=db.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC").all(); - res.writeHead(200,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(JSON.stringify({schemaVersion:1,generatedAt:new Date().toISOString(),failures:rows.map(publicIncident)}));return true; +async function publicContent(req, res, path) { + if (req.method === 'GET' && path === '/api/v1/public/failures') { + const rows = db + .prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC") + .all(); + res + .writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=60', + }) + .end( + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + failures: rows.map(publicIncident), + }), + ); + return true; + } + const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/); + if (req.method === 'GET' && md) { + const x = db + .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") + .get(decodeURIComponent(md[1])); + if (!x) { + res.writeHead(404).end('Not found'); + return true; + } + res + .writeHead(200, { + 'content-type': 'text/markdown; charset=utf-8', + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=60', + }) + .end(publicMarkdown(x)); + return true; + } + const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/); + if (req.method === 'GET' && detail) { + const x = db + .prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''") + .get(decodeURIComponent(detail[1])); + if (!x) { + res + .writeHead(404, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }) + .end('{"error":"not_found"}'); + return true; + } + res + .writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + 'cache-control': 'public, max-age=60', + }) + .end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) })); + return true; } - const md=path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/); - if(req.method==='GET'&&md){const x=db.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''").get(decodeURIComponent(md[1]));if(!x){res.writeHead(404).end('Not found');return true;}res.writeHead(200,{'content-type':'text/markdown; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(publicMarkdown(x));return true;} - const detail=path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/); - if(req.method==='GET'&&detail){const x=db.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''").get(decodeURIComponent(detail[1]));if(!x){res.writeHead(404,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*'}).end('{"error":"not_found"}');return true;}res.writeHead(200,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(JSON.stringify({schemaVersion:1,failure:publicDetail(x)}));return true;} return false; } -async function publicationAction(req,res,path){ - const match=path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/);if(!match||req.method!=='POST')return false;if(!auth(req,res))return true; - const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);if(!x){res.writeHead(404).end();return true;} - if(match[2]==='unpublish')db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run(new Date().toISOString(),x.id); - else {let slug=x.public_slug||slugify(x.title);const collision=db.prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?').get(slug,x.id);if(collision)slug=`${slug}-${x.id.slice(0,8)}`;const now=new Date().toISOString();db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run(slug,now,now,x.id);} - redirect(res,`/incidents/${x.id}`);return true; +async function publicationAction(req, res, path) { + const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/); + if (!match || req.method !== 'POST') return false; + if (!auth(req, res)) return true; + const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]); + if (!x) { + res.writeHead(404).end(); + return true; + } + if (match[2] === 'unpublish') + db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run( + new Date().toISOString(), + x.id, + ); + else { + let slug = x.public_slug || slugify(x.title); + const collision = db + .prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?') + .get(slug, x.id); + if (collision) slug = `${slug}-${x.id.slice(0, 8)}`; + const now = new Date().toISOString(); + db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run( + slug, + now, + now, + x.id, + ); + } + redirect(res, `/incidents/${x.id}`); + return true; } -http.createServer(async(req,res)=>{try{const path=new URL(req.url,'http://x').pathname;if(await publicContent(req,res,path))return;if(await publicationAction(req,res,path))return;if(path==='/events'){if(auth(req,res))res.end(renderEventsPage(db));return;}const handled=await receiptStore.handle(req,res,path);if(handled===false)await handler(req,res);}catch(e){console.error(e);if(!res.headersSent)res.writeHead(500);res.end('Internal error');}}).listen(port,()=>console.log(`trace listening on ${port}`)); +http + .createServer(async (req, res) => { + try { + const path = new URL(req.url, 'http://x').pathname; + if (await publicContent(req, res, path)) return; + if (await publicationAction(req, res, path)) return; + if (path === '/events') { + if (auth(req, res)) res.end(renderEventsPage(db)); + return; + } + const handled = await receiptStore.handle(req, res, path); + if (handled === false) await handler(req, res); + } catch (e) { + console.error(e); + if (!res.headersSent) res.writeHead(500); + res.end('Internal error'); + } + }) + .listen(port, () => console.log(`trace listening on ${port}`)); diff --git a/test/gitea.test.mjs b/test/gitea.test.mjs index e973691..d2f1e97 100644 --- a/test/gitea.test.mjs +++ b/test/gitea.test.mjs @@ -1,3 +1,48 @@ -import test from'node:test';import assert from'node:assert/strict';import{scanGiteaCandidates}from'../src/gitea.mjs'; -test('finds subject fixes, paginates safely, and ignores body-only matches',async()=>{const calls=[],fetchImpl=async url=>{calls.push(url);if(url.includes('/git/commits/abc'))return new Response(JSON.stringify({files:[{filename:'web/verify.go'}]}));return new Response(JSON.stringify([{sha:'abc',commit:{message:'Fix DTLS verification\nDetailed body',committer:{date:'2026-07-20T00:00:00Z'}}},{sha:'def',commit:{message:'Add feature\nfix mentioned only in body',committer:{date:'2026-07-19T00:00:00Z'}}}]))};const out=await scanGiteaCandidates({baseUrl:'https://gitea.example',repositories:['explewd/flit'],fetchImpl});assert.equal(out.candidates.length,1);assert.equal(out.candidates[0].sha,'abc');assert.equal(out.candidates[0].paths,'web/verify.go');assert.equal(out.errors.length,0);assert.ok(calls[0].includes('limit=50&page=1'))}); -test('isolates invalid and failed repositories',async()=>{const out=await scanGiteaCandidates({baseUrl:'https://gitea.example',repositories:['bad','explewd/missing'],fetchImpl:async()=>new Response('',{status:404})});assert.equal(out.candidates.length,0);assert.equal(out.errors.length,2)}); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { scanGiteaCandidates } from '../src/gitea.mjs'; +test('finds subject fixes, paginates safely, and ignores body-only matches', async () => { + const calls = [], + fetchImpl = async (url) => { + calls.push(url); + if (url.includes('/git/commits/abc')) + return new Response(JSON.stringify({ files: [{ filename: 'web/verify.go' }] })); + return new Response( + JSON.stringify([ + { + sha: 'abc', + commit: { + message: 'Fix DTLS verification\nDetailed body', + committer: { date: '2026-07-20T00:00:00Z' }, + }, + }, + { + sha: 'def', + commit: { + message: 'Add feature\nfix mentioned only in body', + committer: { date: '2026-07-19T00:00:00Z' }, + }, + }, + ]), + ); + }; + const out = await scanGiteaCandidates({ + baseUrl: 'https://gitea.example', + repositories: ['explewd/flit'], + fetchImpl, + }); + assert.equal(out.candidates.length, 1); + assert.equal(out.candidates[0].sha, 'abc'); + assert.equal(out.candidates[0].paths, 'web/verify.go'); + assert.equal(out.errors.length, 0); + assert.ok(calls[0].includes('limit=50&page=1')); +}); +test('isolates invalid and failed repositories', async () => { + const out = await scanGiteaCandidates({ + baseUrl: 'https://gitea.example', + repositories: ['bad', 'explewd/missing'], + fetchImpl: async () => new Response('', { status: 404 }), + }); + assert.equal(out.candidates.length, 0); + assert.equal(out.errors.length, 2); +}); diff --git a/test/receipts.test.mjs b/test/receipts.test.mjs index a2a85f6..1d2ba65 100644 --- a/test/receipts.test.mjs +++ b/test/receipts.test.mjs @@ -1,4 +1,52 @@ -import test from 'node:test';import assert from 'node:assert/strict';import {DatabaseSync}from'node:sqlite';import{createReceiptStore,validateReceipt}from'../src/receipts.mjs'; -const valid={schemaVersion:1,eventType:'deployment',eventId:'goonk-prod-1',project:'goonk',occurredAt:'2026-07-20T20:00:00Z',result:'success',source:{commit:'577a62e'},verification:{health:'healthy'}}; -test('validates v1',()=>{assert.deepEqual(validateReceipt(valid),[]);assert.ok(validateReceipt({}).length)}); -test('is idempotent and public projection is allowlisted',async()=>{const db=new DatabaseSync(':memory:'),store=createReceiptStore(db,'goonk:secret');const req=()=>({method:'POST',headers:{authorization:'Bearer secret'},async *[Symbol.asyncIterator](){yield JSON.stringify(valid)}});const res=()=>{const x={status:0,body:''};x.writeHead=s=>{x.status=s;return x};x.end=b=>{x.body=b;return x};return x};let r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(r.status,201);r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(JSON.parse(r.body).duplicate,true);r=res();await store.handle({method:'GET',headers:{}},r,'/api/v1/public/projects/goonk/latest');const out=JSON.parse(r.body);assert.equal(out.deployment.commit,'577a62e');assert.equal(out.deployment.payload,undefined)}); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { createReceiptStore, validateReceipt } from '../src/receipts.mjs'; +const valid = { + schemaVersion: 1, + eventType: 'deployment', + eventId: 'goonk-prod-1', + project: 'goonk', + occurredAt: '2026-07-20T20:00:00Z', + result: 'success', + source: { commit: '577a62e' }, + verification: { health: 'healthy' }, +}; +test('validates v1', () => { + assert.deepEqual(validateReceipt(valid), []); + assert.ok(validateReceipt({}).length); +}); +test('is idempotent and public projection is allowlisted', async () => { + const db = new DatabaseSync(':memory:'), + store = createReceiptStore(db, 'goonk:secret'); + const req = () => ({ + method: 'POST', + headers: { authorization: 'Bearer secret' }, + async *[Symbol.asyncIterator]() { + yield JSON.stringify(valid); + }, + }); + const res = () => { + const x = { status: 0, body: '' }; + x.writeHead = (s) => { + x.status = s; + return x; + }; + x.end = (b) => { + x.body = b; + return x; + }; + return x; + }; + let r = res(); + await store.handle(req(), r, '/api/v1/events'); + assert.equal(r.status, 201); + r = res(); + await store.handle(req(), r, '/api/v1/events'); + assert.equal(JSON.parse(r.body).duplicate, true); + r = res(); + await store.handle({ method: 'GET', headers: {} }, r, '/api/v1/public/projects/goonk/latest'); + const out = JSON.parse(r.body); + assert.equal(out.deployment.commit, '577a62e'); + assert.equal(out.deployment.payload, undefined); +});