igneum/site/api/log.mjs
igneum-labs 91b23edb62 Log intake: accepts LOG_INTAKE_KEY_NEXT during a key rotation (round 4, X23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 18:25:33 +00:00

66 lines
2.9 KiB
JavaScript

// Igneum miner log intake. POST /api/log with header x-igneum-key and a JSON body
// {label, machine, run_id, lines}. Stores one row in Neon table miner_logs.
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
// Read back on the Mac with `node tools/logs.mjs`.
const MAX_LINES = 256 * 1024;
function neon() {
const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is not set');
const host = new URL(url).hostname.replace('-pooler', '');
return async (query, params = []) => {
const r = await fetch(`https://${host}/sql`, {
method: 'POST',
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, params }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
return j;
};
}
async function readBody(req) {
if (req.body !== undefined && req.body !== null) {
if (typeof req.body === 'string') return JSON.parse(req.body);
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'));
return req.body;
}
const chunks = [];
for await (const c of req) chunks.push(c);
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}
const str = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max);
export default async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ ok: false, error: 'method not allowed' });
}
// Two keys during a rotation (4 October 2026, round 4 X23): the current key and, while apps are moving to a new
// build, LOG_INTAKE_KEY_NEXT. Drop the old value from LOG_INTAKE_KEY once every machine reports with the new one.
const keys = [process.env.LOG_INTAKE_KEY, process.env.LOG_INTAKE_KEY_NEXT].filter(k => typeof k === 'string' && k.length >= 16);
const given = req.headers['x-igneum-key'];
if (!keys.length || typeof given !== 'string' || !keys.includes(given)) {
return res.status(401).json({ ok: false, error: 'bad key' });
}
let body;
try { body = await readBody(req); } catch { return res.status(400).json({ ok: false, error: 'bad json' }); }
let lines = str(body.lines, Infinity);
const buf = Buffer.from(lines, 'utf8');
if (buf.length > MAX_LINES) lines = buf.subarray(buf.length - MAX_LINES).toString('utf8');
const bytes = Buffer.byteLength(lines, 'utf8');
try {
const sql = neon();
const r = await sql(
'INSERT INTO miner_logs (label, machine, run_id, lines, bytes) VALUES ($1, $2, $3, $4, $5) RETURNING id',
[str(body.label, 200), str(body.machine, 200), str(body.run_id, 200), lines, bytes],
);
return res.status(200).json({ ok: true, id: Number(r.rows[0].id), bytes });
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) });
}
}