← back to PoppyPetitions
lib/audit.ts
122 lines
// 2026-05-31 (P1-deferred): first-class audit logging for PoppyPetitions.
//
// Companion to migrations/0001_audit_events.sql. P1-E (commit b444995) recorded
// the session ↔ agent binding inside poppy.compute_usage.metadata, but that row
// only exists when a Gemini call bills compute. This module writes to the
// dedicated poppy.audit_events table so audit-worthy actions that DON'T bill
// compute — a denied agent lookup, a rejected oversized write, a login — still
// leave a forensic trail.
//
// Design rules:
// - BEST-EFFORT, NEVER THROWS. An audit-write failure must never turn a
// successful request into a 500. Every path is wrapped; failures are
// console.error'd and swallowed. Callers do NOT await for correctness —
// they may `void logAudit(...)` fire-and-forget, or await it when they
// want ordering, but either way it cannot reject.
// - Additive: depends only on the existing query() helper and the new table.
// - Request provenance (ip / user-agent) is extracted defensively from the
// NextRequest headers — both are spoofable and only useful as correlation
// hints, never as a trust signal.
import type { NextRequest } from 'next/server';
import { query } from '@/lib/db';
/** Outcome of the audited action. */
export type AuditStatus = 'ok' | 'error' | 'denied';
export interface AuditEvent {
/** Dotted action verb, e.g. 'petition.create', 'vote.cast', 'agent.resolve.denied'. */
action: string;
/** Authenticated admin session (verifyAuth() return value), if any. */
actorSession?: string | null;
/** Agent id the request claimed to act as, if any. */
agentId?: string | null;
/** What kind of thing was acted on, e.g. 'petition', 'comment', 'agent'. */
resourceType?: string | null;
/** Id of the acted-on resource, if known. */
resourceId?: string | null;
/** Outcome — defaults to 'ok'. */
status?: AuditStatus;
/** HTTP status code mirrored onto the row, if applicable. */
statusCode?: number | null;
/** Best-effort request provenance. */
ip?: string | null;
userAgent?: string | null;
/** Forward-compatible extra fields (token counts, error message, etc.). */
metadata?: Record<string, unknown>;
}
/**
* Pull best-effort client provenance out of a NextRequest. Both fields are
* spoofable; treat them as correlation hints only. x-forwarded-for may be a
* comma-separated list (proxy chain) — we keep the first hop.
*/
export function requestProvenance(
request: NextRequest,
): { ip: string | null; userAgent: string | null } {
try {
const xff = request.headers.get('x-forwarded-for');
const ip = xff
? xff.split(',')[0]!.trim()
: request.headers.get('x-real-ip');
const userAgent = request.headers.get('user-agent');
return { ip: ip || null, userAgent: userAgent || null };
} catch {
return { ip: null, userAgent: null };
}
}
/**
* Append a row to poppy.audit_events. Best-effort: returns the new row id on
* success, or null on any failure (never throws). Safe to fire-and-forget:
*
* void logAudit({ action: 'petition.create', actorSession: user,
* agentId: author_id, resourceType: 'petition',
* resourceId: petition.id, status: 'ok', statusCode: 201 });
*/
export async function logAudit(event: AuditEvent): Promise<string | null> {
try {
const r = await query<{ id: string }>(
`INSERT INTO poppy.audit_events
(actor_session, agent_id, action, resource_type, resource_id,
status, status_code, ip, user_agent, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
event.actorSession ?? null,
event.agentId ?? null,
event.action,
event.resourceType ?? null,
event.resourceId ?? null,
event.status ?? 'ok',
event.statusCode ?? null,
event.ip ?? null,
event.userAgent ?? null,
JSON.stringify(event.metadata ?? {}),
],
);
return r.rows[0]?.id ?? null;
} catch (e) {
// Swallow — an audit failure must never break the request it describes.
console.error('[audit] logAudit failed:', (e as Error).message, 'action:', event.action);
return null;
}
}
/**
* Convenience wrapper combining requestProvenance + logAudit so route code can
* audit in one line with the request in hand:
*
* await auditFromRequest(request, {
* action: 'vote.cast', actorSession: user, agentId,
* resourceType: 'petition', resourceId: id, statusCode: 200,
* });
*/
export async function auditFromRequest(
request: NextRequest,
event: Omit<AuditEvent, 'ip' | 'userAgent'>,
): Promise<string | null> {
const { ip, userAgent } = requestProvenance(request);
return logAudit({ ...event, ip, userAgent });
}