lilith-platform.live/codebase/@features/edge-purge/src/server.ts

101 lines
3.4 KiB
TypeScript

import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { verifyPurgeToken } from './verify';
import { purgePath } from './purge';
// ---------------------------------------------------------------------------
// Boot guard — fail loud if secret is absent or too short
// ---------------------------------------------------------------------------
const secret = process.env['EDGE_PURGE_TOKEN'] ?? '';
if (!secret) {
process.stderr.write('FATAL: EDGE_PURGE_TOKEN is not set\n');
process.exit(1);
}
// "32 bytes" = 32 raw bytes = 64 hex chars minimum
if (!/^[0-9a-fA-F]{64,}$/.test(secret)) {
process.stderr.write(
'FATAL: EDGE_PURGE_TOKEN must be at least 64 hex characters (32 bytes)\n'
);
process.exit(1);
}
const port = Number(process.env['EDGE_PURGE_PORT'] ?? 9099);
const cacheDir = process.env['EDGE_PURGE_CACHE_DIR'] ?? '/var/cache/nginx/pseo';
function logLine(fields: Record<string, unknown>): void {
process.stderr.write(
JSON.stringify({ ts: Math.floor(Date.now() / 1000), ...fields }) + '\n'
);
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
const app = new Hono();
// auth_request-style verifier — side-effect free, kept for nginx subrequest use.
app.get('/verify', (c) => {
const uri = c.req.header('X-Original-URI');
const token = c.req.header('X-Purge-Token');
const tsRaw = c.req.header('X-Purge-Ts');
if (typeof uri !== 'string' || typeof token !== 'string' || typeof tsRaw !== 'string') {
logLine({ ok: false, reason: 'missing-headers' });
return c.body(null, 403);
}
const result = verifyPurgeToken({ uri, token, tsRaw, secret });
logLine({ ok: result.ok, ...(result.reason !== undefined ? { reason: result.reason } : {}) });
return result.ok ? c.body(null, 204) : c.body(null, 403);
});
// Purge endpoint — verifies the HMAC over the *target* path (the client signs
// path + "\n" + ts; see @features/api/src/lib/edge-purge.ts), then unlinks
// matching pseo_cache entries. Idempotent: purging an uncached path succeeds
// with purged=0.
app.post('/__purge', async (c) => {
const path = c.req.query('path');
const token = c.req.header('X-Purge-Token');
const tsRaw = c.req.header('X-Purge-Ts');
if (typeof path !== 'string' || typeof token !== 'string' || typeof tsRaw !== 'string') {
logLine({ ok: false, event: 'purge', reason: 'missing-params' });
return c.json({ ok: false }, 403);
}
if (!path.startsWith('/') || path.includes('\0') || path.length > 2048) {
logLine({ ok: false, event: 'purge', reason: 'bad-path' });
return c.json({ ok: false }, 400);
}
const result = verifyPurgeToken({ uri: path, token, tsRaw, secret });
if (!result.ok) {
logLine({
ok: false,
event: 'purge',
path,
...(result.reason !== undefined ? { reason: result.reason } : {}),
});
return c.json({ ok: false }, 403);
}
const { scanned, purged } = await purgePath(cacheDir, path);
logLine({ ok: true, event: 'purge', path, scanned, purged });
return c.json({ ok: true, purged });
});
// ---------------------------------------------------------------------------
// Server — 127.0.0.1 only
// ---------------------------------------------------------------------------
serve(
{
fetch: app.fetch,
hostname: '127.0.0.1',
port,
},
(info) => {
logLine({ ok: true, reason: 'listening', port: info.port });
}
);