diff --git a/.env.example b/.env.example index ba6c7a8..0b6f308 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ PORT=3082 TRACE_PASSWORD=replace-me +# Project-scoped ingestion credentials: project:token pairs. +TRACE_RECEIPT_TOKENS=goonk:replace-with-a-long-random-token # Comma-separated id:absolute-path pairs. Mount paths read-only in Docker. TRACE_REPOS=goonk:/repos/goonk,flit:/repos/flit diff --git a/README.md b/README.md index f6a836f..f025557 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,7 @@ npm start ``` Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSWORD`; configure read-only repositories through `TRACE_REPOS`. Nothing publishes automatically: export creates a draft under `data/exports/` for manual review. + +## Deployment receipts v1 + +`POST /api/v1/events` accepts versioned build/deployment receipts using a project-scoped bearer token configured as `TRACE_RECEIPT_TOKENS=goonk:token,retur:other-token`. Event IDs are idempotent. `GET /api/v1/public/projects/:project/latest` returns an explicit allowlisted projection; raw payloads remain private. diff --git a/src/receipts.mjs b/src/receipts.mjs new file mode 100644 index 0000000..80b579d --- /dev/null +++ b/src/receipts.mjs @@ -0,0 +1,15 @@ +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/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}; +} diff --git a/src/server.mjs b/src/server.mjs index 4de93c9..436a6c5 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -5,6 +5,7 @@ import { promisify } from 'node:util'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import crypto from 'node:crypto'; +import { createReceiptStore } from './receipts.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 }); @@ -15,6 +16,7 @@ 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));`); +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 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));}; @@ -34,4 +36,4 @@ if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/ 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

'));} -http.createServer((req,res)=>handler(req,res).catch(e=>{console.error(e);res.writeHead(500).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;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/receipts.test.mjs b/test/receipts.test.mjs new file mode 100644 index 0000000..a2a85f6 --- /dev/null +++ b/test/receipts.test.mjs @@ -0,0 +1,4 @@ +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)});