diff --git a/README.md b/README.md index 4f46060..0eb45ef 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ cp .env.example .env npm start ``` -Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`. Incidents remain private until a human explicitly publishes them. Published, sanitized Markdown is available through `GET /api/v1/public/failures` and `GET /api/v1/public/failures/:slug.md` for build-time consumers. +Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`. Incidents remain private until a human explicitly publishes them. Published projections are available through `GET /api/v1/public/failures`, `GET /api/v1/public/failures/:slug`, and `GET /api/v1/public/failures/:slug.md` for runtime and build-time consumers. Git candidate scanning is optional and disabled by default. If an installation deliberately mounts read-only repository mirrors, list their container paths in `TRACE_REPOS`; production receipt ingestion does not require repository access. diff --git a/src/server.mjs b/src/server.mjs index 54b087b..7fc5dca 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -31,6 +31,7 @@ const publicSeverity={annoyance:'paper-cut',degraded:'minor',unavailable:'signif 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}`)} @@ -52,6 +53,8 @@ async function publicContent(req,res,path){ } 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; }