From 6bb6b9dbb3f482760e4b57cb44acec38ba3537ef Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 24 Apr 2026 17:11:20 +0200 Subject: [PATCH 1/2] feat(evlog): add first-class audit logs --- .agents/skills/create-audit-event/SKILL.md | 167 ++++ .changeset/audit-logs.md | 18 + .../app/components/features/FeatureAudit.vue | 195 ++++ apps/docs/content/0.landing.md | 15 + apps/docs/content/2.logging/7.audit.md | 753 +++++++++++++++ apps/docs/skills/build-audit-logs/SKILL.md | 338 +++++++ .../references/framework-wiring.md | 97 ++ apps/playground/app/config/tests.config.ts | 103 +++ apps/playground/server/api/audit/deny.post.ts | 16 + .../server/api/audit/refund.post.ts | 25 + .../server/api/audit/with-audit.post.ts | 32 + apps/playground/server/plugins/evlog-drain.ts | 40 +- .../playground/server/plugins/evlog-enrich.ts | 6 +- packages/evlog/README.md | 56 ++ packages/evlog/src/audit.ts | 867 ++++++++++++++++++ packages/evlog/src/index.ts | 36 + packages/evlog/src/logger.ts | 35 +- packages/evlog/src/redact.ts | 12 +- packages/evlog/src/types.ts | 123 ++- packages/evlog/test/audit.test.ts | 442 +++++++++ packages/evlog/test/redact.test.ts | 23 +- 21 files changed, 3363 insertions(+), 36 deletions(-) create mode 100644 .agents/skills/create-audit-event/SKILL.md create mode 100644 .changeset/audit-logs.md create mode 100644 apps/docs/app/components/features/FeatureAudit.vue create mode 100644 apps/docs/content/2.logging/7.audit.md create mode 100644 apps/docs/skills/build-audit-logs/SKILL.md create mode 100644 apps/docs/skills/build-audit-logs/references/framework-wiring.md create mode 100644 apps/playground/server/api/audit/deny.post.ts create mode 100644 apps/playground/server/api/audit/refund.post.ts create mode 100644 apps/playground/server/api/audit/with-audit.post.ts create mode 100644 packages/evlog/src/audit.ts create mode 100644 packages/evlog/test/audit.test.ts diff --git a/.agents/skills/create-audit-event/SKILL.md b/.agents/skills/create-audit-event/SKILL.md new file mode 100644 index 00000000..eb0e8e0e --- /dev/null +++ b/.agents/skills/create-audit-event/SKILL.md @@ -0,0 +1,167 @@ +--- +name: create-audit-event +description: Compose audit events in evlog using the existing wide-event primitives. Use when adding a new audit log call site (`log.audit`, `audit()`, `withAudit()`, `defineAuditAction()`) or wiring `auditEnricher`, `auditOnly`, and `signed` drain wrappers into an app. +--- + +# Create an evlog Audit Event + +Audit logs in evlog are not a parallel system. They are a **typed `audit` field on the wide event** plus a few helpers and drain wrappers. Always compose with the existing pipeline (`enrichers`, `drains`, `redact`, tail-sampling) instead of introducing a new one. + +## Mental Model + +```text +log.audit(...) ──► sets event.audit ──► force-keep ──► auditEnricher ──► redact ──► every drain + └─► auditOnly(signed(fsDrain)) (audit-only sink) +``` + +- `log.audit(...)` is sugar for `log.set({ audit })` + force-keep. +- `audit({...})` is the same, but for non-request contexts (jobs, scripts). +- `withAudit({...})(fn)` automates `success` / `failure` / `denied` outcomes. +- `auditOnly(drain)` filters events down to those with `event.audit` set. +- `signed(drain, { strategy })` adds tamper-evident integrity (`hmac` or `hash-chain`). +- `auditEnricher()` fills `event.audit.context` (req/trace/ip/ua/tenantId). +- `auditRedactPreset` is a strict redact preset for audit events. + +## When to Use What + +| Situation | API | +|-----------|-----| +| Inside a request handler, action succeeded | `log.audit({ action, actor, target, outcome: 'success' })` | +| Inside a request handler, action denied (AuthZ) | `log.audit.deny('reason', { action, actor, target })` | +| Standalone job / script / CLI | `audit({ action, actor, target, outcome })` | +| Wrapping a function so success/failure is captured automatically | `withAudit({ action, target })(fn)` | +| Recording a state change | add `changes: auditDiff(before, after)` | +| Type-safe action registry | `const refund = defineAuditAction('invoice.refund', { target: 'invoice' })` then `refund.audit(log, { ... })` | +| Asserting audits in tests | `mockAudit()` | + +## Schema (always provide these) + +```ts +interface AuditFields { + action: string // e.g. 'invoice.refund' + actor: { type: 'user' | 'system' | 'api' | 'agent', id: string, /* email, displayName, model, tools, reason, promptId */ } + outcome: 'success' | 'failure' | 'denied' + target?: { type: string, id: string, [k: string]: unknown } + reason?: string + changes?: { before?: unknown, after?: unknown } | AuditPatchOp[] + causationId?: string + correlationId?: string + version?: number // defaults to AUDIT_SCHEMA_VERSION + idempotencyKey?: string // auto-derived if absent + context?: { requestId?, traceId?, ip?, userAgent?, tenantId?, ... } + signature?: string // added by signed(drain, { strategy: 'hmac' }) + prevHash?: string // added by signed(drain, { strategy: 'hash-chain' }) + hash?: string // added by signed(drain, { strategy: 'hash-chain' }) +} +``` + +## Recipes + +### 1. Add an audit call in a request handler + +```ts +// server/api/invoice/[id]/refund.post.ts +import { auditDiff } from 'evlog' + +export default defineEventHandler(async (event) => { + const log = useLogger(event) + const { id } = getRouterParams(event) + const before = await db.invoice.get(id) + + if (!can(event, 'invoice.refund', before)) { + log.audit?.deny('Insufficient permissions', { + action: 'invoice.refund', + actor: actorOf(event), + target: { type: 'invoice', id }, + }) + throw createError({ status: 403 }) + } + + const after = await db.invoice.refund(id) + + log.audit?.({ + action: 'invoice.refund', + actor: actorOf(event), + target: { type: 'invoice', id, amount: after.amount }, + outcome: 'success', + changes: auditDiff(before, after), + }) + + return after +}) +``` + +### 2. Wire the audit pipeline (one-time setup) + +```ts +// server/plugins/evlog.ts +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [auditEnricher({ tenantId: ctx => ctx.event.tenantId })] + const auditSink = auditOnly(signed(createFsDrain({ path: '.audit/' }), { + strategy: 'hash-chain', + }), { await: true }) + const main = createAxiomDrain() + + nitroApp.hooks.hook('evlog:enrich', async (ctx) => { + for (const e of enrichers) await e(ctx) + }) + nitroApp.hooks.hook('evlog:drain', async (ctx) => { + await Promise.all([main(ctx), auditSink(ctx)]) + }) +}) +``` + +### 3. Auto-instrument a service method + +```ts +import { withAudit, AuditDeniedError } from 'evlog' + +export const refundInvoice = withAudit({ + action: 'invoice.refund', + target: ({ id }: { id: string }) => ({ type: 'invoice', id }), + actor: () => ({ type: 'system', id: 'billing-worker' }), +})(async ({ id, by }) => { + if (!by.canRefund) throw new AuditDeniedError('not allowed') + return db.invoice.refund(id) +}) +``` + +### 4. Test it + +```ts +import { mockAudit } from 'evlog' + +test('refund records an audit event', async () => { + const audits = mockAudit() + await refundInvoice({ id: 'inv_1', by: admin }) + audits.expectIncludes({ action: 'invoice.refund', outcome: 'success' }) +}) +``` + +## Rules + +1. **Never** create an `evlog/audit*` sub-export. Everything lives on the main `evlog` entrypoint. +2. **Never** invent a parallel logger. Use `log.set({ audit: {...} })` if the helper is unsuitable. +3. **Always** provide `action`, `actor`, and `outcome`. `target` is strongly recommended. +4. **Always** wrap audit-only sinks with `auditOnly(...)` so non-audit events don't leak. +5. Use `signed(drain, { strategy: 'hash-chain' })` for tamper-evident audit storage; persist `state` via `{ load, save }` if you run multiple processes. +6. Apply `auditRedactPreset` (or merge it into your existing `RedactConfig`) before sending audits anywhere. +7. Audit events bypass tail-sampling automatically — do not add custom `evlog:emit:keep` rules just to keep them. +8. Idempotency keys are derived automatically; only set `idempotencyKey` manually if you have a stable application-level key. + +## Touchpoints Checklist (when shipping a new audit feature) + +| # | File | Action | +|---|------|--------| +| 1 | `packages/evlog/src/audit.ts` | Add helper / wrapper / preset | +| 2 | `packages/evlog/src/index.ts` | Re-export the new symbol | +| 3 | `packages/evlog/test/audit.test.ts` | Cover new behaviour | +| 4 | `apps/docs/content/2.logging/7.audit.md` | Document the new helper | +| 5 | `apps/playground/server/api/audit/*` | Add a runnable example if user-facing | +| 6 | `README.md` + `packages/evlog/README.md` | Mention in the audit section | + +If your change adds a new field on `AuditFields`, also update `AUDIT_SCHEMA_VERSION` in `packages/evlog/src/audit.ts` and document the migration. diff --git a/.changeset/audit-logs.md b/.changeset/audit-logs.md new file mode 100644 index 00000000..ad4bc990 --- /dev/null +++ b/.changeset/audit-logs.md @@ -0,0 +1,18 @@ +--- +'evlog': minor +--- + +Add first-class audit logs as a thin layer over existing evlog primitives. Audit is not a parallel system: it is a typed `audit` field on the wide event plus a few opt-in helpers and drain wrappers. Companies already running evlog can enable audit logs by adding 1 enricher + 1 drain wrapper + `log.audit()`, with zero new sub-exports. + +New API on the main `evlog` entrypoint: + +- `AuditFields` reserved on `BaseWideEvent` (`action`, `actor`, `target`, `outcome`, `reason`, `changes`, `causationId`, `correlationId`, `version`, `idempotencyKey`, `context`, `signature`, `prevHash`, `hash`) plus `AUDIT_SCHEMA_VERSION`. +- `log.audit(fields)` and `log.audit.deny(reason, fields)` on `RequestLogger` and the return value of `createLogger()`. Sugar over `log.set({ audit })` that also force-keeps the event through tail sampling. +- Standalone `audit(fields)` for jobs / scripts / CLIs. +- `withAudit({ action, target, actor? })(fn)` higher-order wrapper that auto-emits `success` / `failure` / `denied` based on the wrapped function's outcome (with `AuditDeniedError` for AuthZ refusals). +- `defineAuditAction(name, opts)` typed action registry, `auditDiff(before, after)` redact-aware JSON Patch helper, `mockAudit()` test utility (`expectIncludes`, `expectActionCount`, `clear`, `restore`). +- `auditEnricher({ tenantId?, betterAuth? })` enricher that auto-fills `event.audit.context` (`requestId`, `traceId`, `ip`, `userAgent`, `tenantId`) and optionally bridges `actor` from a session. +- `auditOnly(drain, { await? })` drain wrapper that filters to events with `event.audit` set, optionally awaiting writes for crash safety. `signed(drain, { strategy: 'hmac' | 'hash-chain', ... })` generic tamper-evidence wrapper with pluggable `state.{load,save}` for hash chains. +- `auditRedactPreset` strict PII preset composable with existing `RedactConfig`. + +Audit events are always force-kept by tail sampling and get a deterministic `idempotencyKey` derived from `action + actor + target + timestamp` so retries are safe across drains. Schema is OTEL-compatible and the `actor.type === 'agent'` slot carries `model`, `tools`, `reason`, `promptId` for AI agent auditing. No new sub-exports were added. diff --git a/apps/docs/app/components/features/FeatureAudit.vue b/apps/docs/app/components/features/FeatureAudit.vue new file mode 100644 index 00000000..1745f516 --- /dev/null +++ b/apps/docs/app/components/features/FeatureAudit.vue @@ -0,0 +1,195 @@ + + + diff --git a/apps/docs/content/0.landing.md b/apps/docs/content/0.landing.md index 5b058187..ac46bbbd 100644 --- a/apps/docs/content/0.landing.md +++ b/apps/docs/content/0.landing.md @@ -89,6 +89,21 @@ Wide events and structured errors for TypeScript. One log per request, full cont Two-tier filtering: head sampling drops noise by level, tail sampling rescues critical events. Never miss errors, slow requests, or critical paths. ::: + :::features-feature-audit + --- + link: /logging/audit + link-label: Audit logs guide + --- + #headline + Audit Logs + + #title + Compliance-ready :br by composition + + #description + First-class who-did-what trails as a thin layer on top of wide events. One enricher, one drain wrapper, one helper. Tamper-evident hash chains, denied actions, redact-aware diffs, and idempotency keys for safe retries — all from the main entrypoint, no parallel pipeline. + ::: + :::features-feature-ai-sdk --- link: /logging/ai-sdk diff --git a/apps/docs/content/2.logging/7.audit.md b/apps/docs/content/2.logging/7.audit.md new file mode 100644 index 00000000..197d80bc --- /dev/null +++ b/apps/docs/content/2.logging/7.audit.md @@ -0,0 +1,753 @@ +--- +title: Audit Logs +description: First-class audit logs as a thin layer on top of evlog's wide events. Add tamper-evident audit trails to any app with one enricher, one drain wrapper, and one helper. +navigation: + icon: i-lucide-shield-check +links: + - label: Wide Events + icon: i-lucide-layers + to: /logging/wide-events + color: neutral + variant: subtle + - label: Better Auth + icon: i-lucide-key-round + to: /logging/better-auth + color: neutral + variant: subtle +--- + +evlog's audit layer is **not a parallel system**. Audit events are wide events with a reserved `audit` field. Every existing primitive — drains, enrichers, redact, tail-sampling — applies as is. Enable audit logs by adding **1 enricher + 1 drain wrapper + 1 helper**. + +## Why Audit Logs? + +Compliance frameworks (SOC2, HIPAA, GDPR, PCI) require knowing **who did what, on which resource, when, from where, with which outcome**. evlog covers this without a second logging library. + +::tip +**An audit event is a fact about an intent, not a measurement of an operation.** A regular wide event answers "how did this request behave?" (latency, status, tokens). An audit event answers "who tried to do what, and was it allowed?". Same pipeline, different question — that's why the schema is reserved and the event is force-kept past sampling. +:: + +## Quickstart + +You already use evlog. Add audit logs in three changes: + +```typescript [server/plugins/evlog.ts] +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +export default defineNitroPlugin((nitro) => { + nitro.hooks.hook('evlog:enrich', auditEnricher()) + nitro.hooks.hook('evlog:drain', createAxiomDrain()) + nitro.hooks.hook('evlog:drain', auditOnly( + signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }), + { await: true }, + )) +}) +``` + +::code-group + +```typescript [Nuxt / Nitro] +export default defineEventHandler(async (event) => { + const log = useLogger(event) + const user = await requireUser(event) + const invoice = await refundInvoice(getRouterParam(event, 'id')) + + log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: invoice.id }, + outcome: 'success', + reason: 'Customer requested refund', + }) + + return { ok: true } +}) +``` + +```typescript [Hono] +import type { EvlogVariables } from 'evlog/hono' +import { Hono } from 'hono' + +const app = new Hono() + +app.post('/invoices/:id/refund', async (c) => { + const log = c.get('log') + const user = await requireUser(c) + const invoice = await refundInvoice(c.req.param('id')) + + log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: invoice.id }, + outcome: 'success', + reason: 'Customer requested refund', + }) + + return c.json({ ok: true }) +}) +``` + +```typescript [Express] +import type { Request, Response } from 'express' + +app.post('/invoices/:id/refund', async (req: Request, res: Response) => { + const log = req.log + const user = await requireUser(req) + const invoice = await refundInvoice(req.params.id) + + log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: invoice.id }, + outcome: 'success', + reason: 'Customer requested refund', + }) + + res.json({ ok: true }) +}) +``` + +```typescript [Next.js Route Handler] +import { withEvlog, useLogger } from '@/lib/evlog' + +export const POST = withEvlog(async (req, { params }) => { + const log = useLogger() + const user = await requireUser(req) + const invoice = await refundInvoice(params.id) + + log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: invoice.id }, + outcome: 'success', + reason: 'Customer requested refund', + }) + + return Response.json({ ok: true }) +}) +``` + +```typescript [Standalone job] +import { audit } from 'evlog' + +audit({ + action: 'invoice.refund', + actor: { type: 'system', id: 'billing-worker' }, + target: { type: 'invoice', id: 'inv_889' }, + outcome: 'success', + reason: 'Auto-refund triggered by chargeback webhook', +}) +``` + +```json [Output — wide event] +{ + "level": "info", + "service": "billing-api", + "method": "POST", + "path": "/api/invoices/inv_889/refund", + "status": 200, + "duration": "84ms", + "requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599", + "audit": { + "action": "invoice.refund", + "actor": { "type": "user", "id": "usr_42", "email": "demo@example.com" }, + "target": { "type": "invoice", "id": "inv_889" }, + "outcome": "success", + "reason": "Customer requested refund", + "version": 1, + "idempotencyKey": "ak_8f3c4b2a1e5d6f7c", + "context": { + "requestId": "a566ef91-7765-4f59-b6f0-b9f40ce71599", + "ip": "203.0.113.7", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + } + } +} +``` + +:: + +That's it. The audit event: + +- Travels through the same wide-event pipeline as the rest of your logs. +- Is **always kept** past tail sampling. +- Goes to your main drain (Axiom) **and** to a dedicated, signed, append-only sink (FS journal). +- Carries `requestId`, `traceId`, `ip`, and `userAgent` automatically via `auditEnricher`. + +::tip +**Why two drains?** The main drain (Axiom, Datadog, ...) keeps audits next to the rest of your telemetry so dashboards and queries still work. The signed sink is your insurance: if the main drain has an outage, gets purged, or an admin quietly removes a row, the FS journal still holds the chain. Auditors want both — fast querying *and* a tamper-evident artefact. +:: + +## The Audit Schema + +`event.audit` is a typed field on every wide event. Downstream queries filter on `audit IS NOT NULL`. + +```typescript +interface AuditFields { + action: string // 'invoice.refund' + actor: { + type: 'user' | 'system' | 'api' | 'agent' + id: string + displayName?: string + email?: string + // For type === 'agent', mirrors evlog/ai fields: + model?: string + tools?: string[] + reason?: string + promptId?: string + } + target?: { type: string, id: string, [k: string]: unknown } + outcome: 'success' | 'failure' | 'denied' + reason?: string + changes?: { before?: unknown, after?: unknown } + causationId?: string // ID of the action that caused this one + correlationId?: string // Shared by every action in one operation + version?: number // Defaults to 1 + idempotencyKey?: string // Auto-derived; safe retries across drains + context?: { // Filled by auditEnricher + requestId?: string + traceId?: string + ip?: string + userAgent?: string + tenantId?: string + } + signature?: string // Set by signed({ strategy: 'hmac' }) + prevHash?: string // Set by signed({ strategy: 'hash-chain' }) + hash?: string +} +``` + +::tip +**Naming convention for `action`.** Use `noun.verb` (`invoice.refund`, `user.invite`, `apiKey.revoke`). Past tense if the action already happened (`invoice.refunded`), present tense if `withAudit()` will resolve the outcome. Keep a small fixed dictionary in one file — auditors and SIEM rules query on `action`, so a typo is a missing alert. +:: + +::warning +**Don't fake the actor.** Use `actor.type: 'system'` for cron jobs, queue workers, and background tasks; `actor.type: 'api'` for machine-to-machine calls authenticated by a token; `actor.type: 'agent'` for AI tool calls. Logging a synthetic `'user'` for system actions is the single fastest way to fail an audit review. +:: + +## Composition + +Each layer is **opt-in and replaceable**. Visually, the path of an audit event through your pipeline looks like this: + +```text + log.audit / audit / withAudit + │ + ▼ + set event.audit + │ + ▼ + force-keep tail-sample + │ + ▼ + auditEnricher + │ + ▼ + redact + auditRedactPreset + │ + ┌──────────┴──────────┐ + ▼ ▼ + main drain auditOnly( + (Axiom / signed( + Datadog / fsDrain)) + ...) +``` + +Every node except `log.audit`, `auditEnricher`, and `auditOnly`/`signed` is shared with regular wide events. + +### Helper + +`log.audit()` is sugar over `log.set({ audit: ... })` plus tail-sample force-keep: + +```typescript +log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id }, + target: { type: 'invoice', id: 'inv_889' }, + outcome: 'success', +}) + +// Strictly equivalent to: +log.set({ audit: { action: 'invoice.refund', /* ... */, version: 1 } }) +``` + +`log.audit.deny(reason, fields)` records AuthZ-denied actions — most teams forget to log denials, but they are exactly what auditors and security teams ask for: + +::code-group + +```typescript [Input] +if (!user.canRefund(invoice)) { + log.audit.deny('Insufficient permissions', { + action: 'invoice.refund', + actor: { type: 'user', id: user.id }, + target: { type: 'invoice', id: invoice.id }, + }) + throw createError({ status: 403, message: 'Forbidden' }) +} +``` + +```json [Output — denied] +{ + "level": "warn", + "service": "billing-api", + "method": "POST", + "path": "/api/invoices/inv_889/refund", + "status": 403, + "duration": "12ms", + "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d", + "audit": { + "action": "invoice.refund", + "actor": { "type": "user", "id": "usr_intruder" }, + "target": { "type": "invoice", "id": "inv_889" }, + "outcome": "denied", + "reason": "Insufficient permissions", + "version": 1, + "idempotencyKey": "ak_d12c3a4f5b6e7d8c", + "context": { + "requestId": "9c3f7d12-8a45-4e60-b8a9-1f0d4c5e6e7d", + "ip": "203.0.113.7" + } + } +} +``` + +:: + +For non-request contexts (jobs, scripts, CLIs), use the standalone `audit()`: + +::code-group + +```typescript [scripts/cleanup.ts] +import { audit } from 'evlog' + +audit({ + action: 'cron.cleanup', + actor: { type: 'system', id: 'cron' }, + target: { type: 'job', id: 'cleanup-stale-sessions' }, + outcome: 'success', +}) +``` + +```json [Output — wide event] +{ + "level": "info", + "service": "billing-api", + "audit": { + "action": "cron.cleanup", + "actor": { "type": "system", "id": "cron" }, + "target": { "type": "job", "id": "cleanup-stale-sessions" }, + "outcome": "success", + "version": 1, + "idempotencyKey": "ak_2b8e1f9d4c6a7b3e" + } +} +``` + +:: + +::note +Standalone `audit()` events have no `requestId`, no `context.ip`, no `userAgent` — there is no request to enrich from. Add your own context manually (`context: { jobId, queue, runId }`) when it matters for forensics. +:: + +### Enricher + +`auditEnricher()` populates `event.audit.context.{requestId, traceId, ip, userAgent, tenantId}`. Skip it and ship a custom enricher if your strategy differs. + +```typescript +nitro.hooks.hook('evlog:enrich', auditEnricher({ + tenantId: ctx => ctx.event.tenant as string | undefined, + bridge: { getSession: async ctx => readSessionActor(ctx.headers) }, +})) +``` + +### Drain Wrappers + +::tip +**Why filter audits to a separate sink?** Three reasons: **cost** (audit volume is tiny next to product telemetry — keep them separate so retention costs don't explode), **permissions** (the audit dataset should be read-only for engineers and write-only for the app), and **retention** (audits often live 7+ years; product logs rarely live more than 90 days). +:: + +`auditOnly(drain)` only forwards events with an `audit` field. Compose with **any** drain: + +```typescript +import { auditOnly } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' + +// Send audits to a dedicated Axiom dataset: +nitro.hooks.hook('evlog:drain', auditOnly( + createAxiomDrain({ dataset: 'audit', token: process.env.AXIOM_AUDIT_TOKEN }), +)) +``` + +Set `await: true` to make audit writes synchronous (no fire-and-forget for audits — crash-safe by default): + +```typescript +auditOnly(createFsDrain({ dir: '.audit' }), { await: true }) +``` + +`signed(drain, opts)` adds tamper-evident integrity. Strategies: + +- `'hmac'` — adds `event.audit.signature` (HMAC of the canonical event). +- `'hash-chain'` — adds `event.audit.prevHash` and `event.audit.hash` so the sequence forms a verifiable chain. Provide `state: { load, save }` for cross-process / durable chains (Redis, file, Postgres). + +::tip +**What `signed()` actually buys you.** Detection, not prevention. Anyone with write access to the underlying sink can still nuke the file or table — but the chain proves *which* events were dropped or modified after the fact. Skip `signed()` if you already write to an append-only / WORM store (S3 Object Lock, Postgres with row-level immutability, BigQuery append-only tables); doubling integrity layers just adds latency without raising the bar. +:: + +```typescript +import { signed } from 'evlog' + +signed(drain, { strategy: 'hmac', secret: process.env.AUDIT_SECRET! }) + +signed(drain, { + strategy: 'hash-chain', + state: { + load: () => fs.readFile('.audit/head', 'utf8').catch(() => null), + save: (h) => fs.writeFile('.audit/head', h), + }, +}) +``` + +### Schema Discipline + +Define audit actions in one place to avoid magic strings: + +```typescript +import { defineAuditAction } from 'evlog' + +const refund = defineAuditAction('invoice.refund', { target: 'invoice' }) + +log.audit(refund({ + actor: { type: 'user', id: user.id }, + target: { id: 'inv_889' }, // type inferred as 'invoice' + outcome: 'success', +})) +``` + +::warning +**Don't feed entire DB rows into `auditDiff()`.** Strip computed columns, hashed passwords, internal flags, and large JSON blobs before diffing. The point of `changes` is *what changed semantically* (status went from `paid` → `refunded`), not *what bytes changed* (a `lastModified` timestamp ticked). A noisy `changes` field is the fastest way to make audit logs unreadable. +:: + +For mutating actions, use `auditDiff()` to produce a compact, redact-aware JSON Patch: + +::code-group + +```typescript [Input] +import { auditDiff } from 'evlog' + +const before = await db.users.byId(id) +const after = await db.users.update(id, patch) + +log.audit({ + action: 'user.update', + actor: { type: 'user', id: actorId }, + target: { type: 'user', id }, + outcome: 'success', + changes: auditDiff(before, after, { redactPaths: ['password', 'token'] }), +}) +``` + +```json [Output — changes patch] +{ + "audit": { + "action": "user.update", + "actor": { "type": "user", "id": "usr_42" }, + "target": { "type": "user", "id": "usr_99" }, + "outcome": "success", + "changes": [ + { "op": "replace", "path": "/email", "from": "old@example.com", "to": "new@example.com" }, + { "op": "replace", "path": "/role", "from": "member", "to": "admin" }, + { "op": "replace", "path": "/password", "from": "[REDACTED]", "to": "[REDACTED]" } + ], + "version": 1, + "idempotencyKey": "ak_5e7d8f9a0b1c2d3e" + } +} +``` + +:: + +### Auto-Instrumentation with `withAudit()` + +Devs forget to call `log.audit()`. Wrap the function and never miss a record: + +::tip +**When to wrap vs. call manually.** Wrap functions that are *pure audit-worthy actions* (refund, delete, role change, password reset) — outcome resolution is automatic and you can't accidentally skip the call. Stick to manual `log.audit()` when the audit is one of several decisions inside a larger handler, or when you need to emit the audit *before* the action completes (e.g. "user requested deletion"). +:: + +::code-group + +```typescript [Input] +import { withAudit, AuditDeniedError } from 'evlog' + +const refundInvoice = withAudit( + { action: 'invoice.refund', target: input => ({ type: 'invoice', id: input.id }) }, + async (input: { id: string }, ctx) => { + if (!ctx.actor) throw new AuditDeniedError('Anonymous refund denied') + return await db.invoices.refund(input.id) + }, +) + +await refundInvoice({ id: 'inv_889' }, { + actor: { type: 'user', id: user.id }, + correlationId: requestId, +}) +``` + +```json [Output — success] +{ + "audit": { + "action": "invoice.refund", + "actor": { "type": "user", "id": "usr_42" }, + "target": { "type": "invoice", "id": "inv_889" }, + "outcome": "success", + "version": 1, + "idempotencyKey": "ak_8f3c4b2a1e5d6f7c", + "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" + } +} +``` + +```json [Output — failure] +{ + "level": "error", + "audit": { + "action": "invoice.refund", + "actor": { "type": "user", "id": "usr_42" }, + "target": { "type": "invoice", "id": "inv_889" }, + "outcome": "failure", + "reason": "Stripe error: charge already refunded", + "version": 1, + "idempotencyKey": "ak_4c5d6e7f8a9b0c1d", + "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" + }, + "error": { + "name": "StripeError", + "message": "charge already refunded", + "stack": "..." + } +} +``` + +```json [Output — denied] +{ + "level": "warn", + "audit": { + "action": "invoice.refund", + "actor": { "type": "system", "id": "anonymous" }, + "target": { "type": "invoice", "id": "inv_889" }, + "outcome": "denied", + "reason": "Anonymous refund denied", + "version": 1, + "idempotencyKey": "ak_d12c3a4f5b6e7d8c", + "correlationId": "a566ef91-7765-4f59-b6f0-b9f40ce71599" + } +} +``` + +:: + +Outcome resolution: + +- `fn` resolves → `outcome: 'success'`. +- `fn` throws an `AuditDeniedError` (or any error with `status === 403`) → `outcome: 'denied'`, error message becomes `reason`. +- Other thrown errors → `outcome: 'failure'`, then re-thrown. + +## Compliance + +### Integrity + +Hash-chain the audit log so any tampering is detectable. Each event's hash includes the previous hash, so deleting a row breaks the chain forward of that point. + +```typescript +auditOnly( + signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }), + { await: true }, +) +``` + +::note +A CLI to walk and verify the chain (`evlog audit verify`) is on the roadmap. Until then, validate by recomputing the hashes of stored events and comparing each `prevHash` against the previous event's `hash`. +:: + +::warning +**Rotate `secret` for HMAC-signed audits annually.** When you rotate, embed a key id alongside the signature (e.g. extend `AuditFields` with `keyId` via `declare module`) so old events stay verifiable against the previous secret. Verifiers should look up the key by id, not assume a single global secret. +:: + +### Redact + +Audit events run through your existing `RedactConfig`. Compose with the strict audit preset to harden PII handling: + +```typescript +import { auditRedactPreset } from 'evlog' + +initLogger({ + redact: { + paths: [ + ...(auditRedactPreset.paths ?? []), + 'user.password', + ], + }, +}) +``` + +The preset drops `Authorization` / `Cookie` headers and common credential field names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) wherever they appear inside `audit.changes.before` and `audit.changes.after`. + +### GDPR vs Append-Only + +Append-only audit logs collide with GDPR's right to be forgotten. Recommended pattern today: + +1. Keep audit rows immutable. +2. Encrypt PII fields with a per-actor key (held outside the audit store). +3. To "forget" a user, delete their key — the audit row stays, the chain stays valid, the PII becomes unreadable. + +A built-in `cryptoShredding` helper is on the [follow-up roadmap](https://github.com/HugoRCD/evlog/issues). + +### Retention + +Retention is a storage-layer concern by design. evlog's audit layer doesn't enforce retention windows because every supported sink already has a stronger, audited mechanism for it. Pick the one matching your sink: + +- **FS** — combine `createFsDrain({ maxFiles })` with a daily compactor. +- **Postgres** — schedule `DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'`. +- **Axiom / Datadog / Loki** — set the dataset retention policy in the platform. + +Document the chosen window in your security policy. Auditors care about the written rule, not the enforcing component. + +### Common Pitfalls + +- **Logging only successes.** Auditors care most about denials. Always pair `log.audit()` with `log.audit.deny()` on the negative branch of every authorisation check. +- **Leaking PII through `changes`.** `auditDiff()` runs through your `RedactConfig`, but only if the field paths are listed. Add `password`, `token`, `apiKey`, etc. once globally so you never have to think about it again. +- **Treating audits as observability.** Don't sample, downsample, or summarise audit events. Force-keep is on by default — don't disable it. +- **Conflating `actor.id` with the session id.** `actor.id` is the stable user id (or system identity). Correlate sessions via `context.requestId` / `context.traceId`, never via the actor. +- **Forgetting standalone jobs.** Cron tasks, queue workers, and CLIs trigger audit-worthy actions too. Use `audit()` (no request) or `withAudit()` to keep coverage parity with your HTTP routes. + +## Recipes + +### Audit logs on disk + +::code-group + +```typescript [Input — server/plugins/evlog.ts] +import { auditOnly, signed } from 'evlog' +import { createFsDrain } from 'evlog/fs' + +nitro.hooks.hook('evlog:drain', auditOnly( + signed(createFsDrain({ dir: '.audit', maxFiles: 30 }), { strategy: 'hash-chain' }), + { await: true }, +)) +``` + +```ndjson [Output — .audit/2026-04-24.ndjson] +{"audit":{"action":"invoice.refund","actor":{"type":"user","id":"usr_42"},"target":{"type":"invoice","id":"inv_889"},"outcome":"success","version":1,"idempotencyKey":"ak_8f3c4b2a1e5d6f7c","prevHash":null,"hash":"3f2c8e1a..."}} +{"audit":{"action":"user.update","actor":{"type":"user","id":"usr_42"},"target":{"type":"user","id":"usr_99"},"outcome":"success","version":1,"idempotencyKey":"ak_5e7d8f9a0b1c2d3e","prevHash":"3f2c8e1a...","hash":"9a1b4d7c..."}} +``` + +:: + +Each line's `prevHash` matches the previous line's `hash`. Tampering with any row breaks the chain forward of that point — a verifier replays the hashes and reports the first mismatch. + +### Audit logs to a dedicated Axiom dataset + +::code-group + +```typescript [Input — server/plugins/evlog.ts] +import { auditOnly } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' + +nitro.hooks.hook('evlog:drain', createAxiomDrain({ dataset: 'logs' })) +nitro.hooks.hook('evlog:drain', auditOnly( + createAxiomDrain({ dataset: 'audit', token: process.env.AXIOM_AUDIT_TOKEN }), +)) +``` + +```kusto [Output — Axiom query] +['audit'] +| where audit.action == "invoice.refund" +| summarize count() by audit.outcome, bin(_time, 1h) +``` + +```kusto [Output — denials by actor] +['audit'] +| where audit.outcome == "denied" +| summarize count() by audit.actor.id, audit.action +| order by count_ desc +``` + +:: + +Splitting datasets means the audit dataset can have a longer retention (7y), tighter access controls, and a separate billing line — without touching the rest of your pipeline. + +### Audit logs in Postgres + +::code-group + +```typescript [Input — server/plugins/evlog.ts] +import { auditOnly } from 'evlog' +import type { DrainContext } from 'evlog' + +const postgresAudit = async (ctx: DrainContext) => { + await db.insert(auditEvents).values({ + id: ctx.event.audit!.idempotencyKey, + timestamp: new Date(ctx.event.timestamp), + payload: ctx.event, + }).onConflictDoNothing() +} + +nitro.hooks.hook('evlog:drain', auditOnly(postgresAudit, { await: true })) +``` + +```sql [Output — audit_events row] +SELECT id, timestamp, payload->'audit'->>'action' AS action, + payload->'audit'->>'outcome' AS outcome +FROM audit_events +WHERE id = 'ak_8f3c4b2a1e5d6f7c'; + +-- id | timestamp | action | outcome +-- ---------------------+-----------------------+-----------------+--------- +-- ak_8f3c4b2a1e5d6f7c | 2026-04-24 10:23:45.6 | invoice.refund | success +``` + +:: + +The deterministic `idempotencyKey` makes retries safe — duplicate inserts collapse via `ON CONFLICT DO NOTHING`. Without it, a transient network blip during a retry would create a duplicate audit row, which is exactly what you don't want. + +## Testing + +`mockAudit()` captures every audit event emitted during a test: + +```typescript +import { mockAudit } from 'evlog' + +it('refunds the invoice and records an audit', async () => { + const captured = mockAudit() + + await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: 'u1' } }) + + expect(captured.events).toHaveLength(1) + expect(captured.toIncludeAuditOf({ + action: 'invoice.refund', + target: { type: 'invoice', id: 'inv_889' }, + outcome: 'success', + })).toBe(true) + + captured.restore() +}) +``` + +## API Reference + +| Symbol | Kind | Notes | +|---|---|---| +| `AuditFields` | type | Reserved field on the wide event | +| `defineAuditAction(name, opts?)` | factory | Typed action registry, infers target shape | +| `log.audit(fields)` | method | Sugar over `log.set({ audit })` + force-keep | +| `log.audit.deny(reason, fields)` | method | Records a denied action | +| `audit(fields)` | function | Standalone for scripts / jobs | +| `withAudit({ action, target })(fn)` | wrapper | Auto-emit success / failure / denied | +| `auditDiff(before, after)` | helper | Redact-aware JSON Patch for `changes` | +| `mockAudit()` | test util | Capture + assert audits in tests | +| `auditEnricher(opts?)` | enricher | Auto-fill request / runtime / tenant context | +| `auditOnly(drain, { await? })` | wrapper | Routes only events with an `audit` field | +| `signed(drain, opts)` | wrapper | Generic integrity wrapper (hmac / hash-chain) | +| `auditRedactPreset` | config | Strict PII for audit events | + +Everything ships from the main `evlog` entrypoint. diff --git a/apps/docs/skills/build-audit-logs/SKILL.md b/apps/docs/skills/build-audit-logs/SKILL.md new file mode 100644 index 00000000..f8b9d8b5 --- /dev/null +++ b/apps/docs/skills/build-audit-logs/SKILL.md @@ -0,0 +1,338 @@ +--- +name: build-audit-logs +description: Build audit logs / an audit trail in a TypeScript or JavaScript application using evlog. Use whenever the user mentions audit logs, audit trail, audit logging, compliance logging, SOC2 / HIPAA / GDPR / PCI logs, tamper-evident or append-only logs, "who did what" forensic trails, or per-tenant audit isolation in a SaaS — even if they don't explicitly mention evlog. Also use when wiring an audit sink (Postgres, S3, Axiom dataset, FS journal, ...), when replacing scattered `console.log('user.deleted', ...)` calls with a real audit pipeline, or when an existing app has audits but is missing denials, redaction, integrity, or retention. Covers the design decisions (sink, integrity, retention, multi-tenancy, framework wiring) and the end-to-end buildout (pipeline → call sites → tests → compliance review). For adding a single new audit call site to an already-wired app, use `create-audit-event` instead. +--- + +# Build Audit Logs with evlog + +For **application developers** building an audit trail in their product. Walks through the design calls and the end-to-end implementation. For just adding one more `log.audit(...)` call in a wired-up app, use `create-audit-event` instead. + +## What "audit logging" actually means + +An audit log answers a forensic question: **who did what, on which resource, when, from where, with which outcome.** That's a different shape from observability logs, which is why the operational rules differ: + +| | Audit log | Observability log | +| -------------- | ----------------------------------------------- | ---------------------------------- | +| Question | "Who tried to do what, was it allowed?" | "How did this request behave?" | +| Sampling | Never (force-keep) | Often (head + tail) | +| Retention | 1 – 7 years (compliance) | 30 – 90 days | +| Mutability | Append-only, tamper-evident | Mutable, lossy | +| Audience | Auditors, security, legal | Engineers | +| Storage | Often dedicated (separate dataset / DB) | Shared with telemetry | + +evlog ships the audit layer as a thin extension of its wide-event pipeline (a typed `audit` field on `BaseWideEvent` plus a few helpers and drain wrappers). The point is that you compose with the primitives the app already uses — same drains, same enrichers, same redact, same framework integration. There is no parallel system to maintain. + +## Mental model + +```text +log.audit(...) ──► sets event.audit ──► force-keep ──► auditEnricher ──► redact ──► every drain + └─► auditOnly(signed(fsDrain)) +``` + +| Building block | Role | Required? | +| ------------------------------------------- | ------------------------------------------------------------------- | ----------------------- | +| `log.audit()` / `audit()` / `withAudit()` | Sets `event.audit` and force-keeps the event | Yes | +| `auditEnricher()` | Auto-fills `event.audit.context` (req / trace / ip / ua / tenantId) | Recommended | +| `auditOnly(drain)` | Filters the drain to events with `event.audit` set | Recommended | +| `signed(drain, ...)` | Adds tamper-evident integrity (HMAC or hash-chain) | Optional (compliance) | +| `auditRedactPreset` | Strict PII preset for audit events | Recommended | +| `mockAudit()` | Captures audit events in tests | Yes (in tests) | + +## Design calls before writing code + +Make these explicit and write them down somewhere a security reviewer can find. Without a written rule, the system can't be audited — auditors look for the policy first, then the enforcement. + +### 1. Where do audits live? + +| Sink | Use when | Trade-offs | +| --------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| **FS** (`evlog/fs` + `signed`) | Self-hosted, simple, you control the disk | Manual rotation/backup; single-process unless you persist hash-chain `state` externally | +| **Dedicated Axiom dataset** | You already use Axiom | Easy queries, separate retention/billing; cost scales with volume | +| **Postgres / Neon / Aurora** | You want SQL queries, joins with app data | Need a schema, indexes, retention job; idempotency key prevents duplicates | +| **S3 + Object Lock** | Append-only WORM compliance (HIPAA / FINRA) | Read latency; pair with a queryable mirror (Athena) | +| **Multiple sinks** | Different audiences (engineers ↔ legal) | Use `auditOnly` per sink; sinks fail in isolation by design | + +> **Rule of thumb.** Pick at least two: a queryable one (Axiom / Postgres) for day-to-day forensics + an append-only one (FS journal with hash-chain, or S3 Object Lock) as the compliance artefact. The two-drain pattern protects against vendor outages and admin mistakes on the queryable side. + +### 2. Do you need integrity (`signed`)? + +Yes if any of: + +- A compliance framework requires tamper-evidence (SOC2 CC7, HIPAA §164.312(c)(1), PCI 10.5). +- The sink is mutable by engineers / admins. +- You may need to prove to a regulator that no events were modified after the fact. + +Skip if: + +- Sink is already WORM (S3 Object Lock, BigQuery append-only, Postgres with row-level immutability + monitored DDL). +- You're prototyping. + +Strategies: + +- `'hmac'` — per-event signature; quick to verify; rotate `secret` annually and embed a key id (extend `AuditFields`). +- `'hash-chain'` — sequence integrity; deleting a row breaks the chain forward; persist `state.{load,save}` if you run multiple processes (Redis is the typical store). + +### 3. Multi-tenancy? + +If the app is multi-tenant, **tenant isolation on every audit event is non-negotiable** — a query that mixes tenants is a privacy incident. Wire it once in the enricher: + +```ts +auditEnricher({ tenantId: ctx => resolveTenant(ctx) }) +``` + +Then either (a) partition the audit dataset by `audit.context.tenantId`, or (b) one sink per tenant if hard isolation is required. Never query audits without a tenant filter. + +### 4. Retention + +Pick a window per sink and document it. Enforce at the sink layer, not in app code — the sink already has audited mechanisms for it (lifecycle policies, `DELETE` jobs, dataset retention). + +| Framework | Typical retention | +| --------- | ------------------------------------------------------------------- | +| SOC2 | 1 year minimum, 7 years recommended | +| HIPAA | 6 years | +| PCI DSS | 1 year (3 months immediately accessible) | +| GDPR | "As long as necessary" — see "GDPR vs append-only" below | + +How to enforce per sink: + +- **FS**: `createFsDrain({ maxFiles })` + a daily compactor. +- **Postgres**: `DELETE FROM audit_events WHERE timestamp < now() - interval '7 years'` on a cron. +- **Axiom / Datadog / Loki**: dataset-level retention policy. + +### 5. GDPR vs append-only + +The right to be forgotten collides with audit immutability. Recommended pattern: + +1. Keep audit rows immutable and chain-verified. +2. Encrypt PII fields (email, name, IP) with a per-actor key held outside the audit store. +3. To "forget" a user, delete their key. The audit row stays, the chain stays valid, the PII becomes unreadable (crypto-shredding). + +A built-in `cryptoShredding` helper is on the roadmap; until then, encrypt in a custom enricher. + +## Step-by-step buildout + +### Step 1 — Wire the pipeline (one-time) + +The wiring shape is the same in every framework: register `auditEnricher()` so `event.audit.context` gets `requestId`, `traceId`, `ip`, `userAgent`, and (if configured) `tenantId` automatically, then add a main drain plus an audit-only sink. + +The minimal Nuxt / Nitro setup looks like this: + +```ts +// server/plugins/evlog.ts +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +export default defineNitroPlugin((nitroApp) => { + const auditSink = auditOnly( + signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), + { await: true }, + ) + const main = createAxiomDrain({ dataset: 'logs' }) + + nitroApp.hooks.hook('evlog:enrich', auditEnricher({ + tenantId: ctx => ctx.headers?.['x-tenant-id'], + })) + nitroApp.hooks.hook('evlog:drain', async (ctx) => { + await Promise.all([main(ctx), auditSink(ctx)]) + }) +}) +``` + +For Hono, Express, Next.js, or standalone scripts / workers, see [`references/framework-wiring.md`](references/framework-wiring.md). The pattern is identical — only the framework integration helper changes. + +### Step 2 — Define the action vocabulary + +Audits get queried and alerted on by `audit.action`. A typo is a missing alert, so centralise the list: + +```ts +// app/audit/actions.ts +import { defineAuditAction } from 'evlog' + +export const InvoiceRefund = defineAuditAction('invoice.refund', { target: 'invoice' }) +export const UserUpdate = defineAuditAction('user.update', { target: 'user' }) +export const ApiKeyRevoke = defineAuditAction('apiKey.revoke', { target: 'apiKey' }) +export const RolePromote = defineAuditAction('role.promote', { target: 'user' }) +``` + +Naming conventions: + +- `noun.verb` (`invoice.refund`, not `refundInvoice`). +- Past tense if the audit is logged after the fact (`invoice.refunded`); present tense when wrapped by `withAudit()` (which resolves the outcome itself). +- Lowercase, dot-delimited, no spaces. + +### Step 3 — Instrument call sites + +Three patterns, in order of preference: + +**A. Wrap the action with `withAudit()`** — pure audit-worthy actions (refund, delete, role change, password reset). Outcome resolution is automatic, so you can't forget to log a denial or failure: + +```ts +import { withAudit, AuditDeniedError } from 'evlog' + +export const refundInvoice = withAudit({ + action: 'invoice.refund', + target: ({ id }: { id: string }) => ({ type: 'invoice', id }), +})(async ({ id }, ctx) => { + if (!ctx.actor) throw new AuditDeniedError('Anonymous refund denied') + return db.invoices.refund(id) +}) +``` + +Outcome resolution: + +- `fn` resolves → `outcome: 'success'`. +- `fn` throws `AuditDeniedError` (or any error with `status === 403`) → `outcome: 'denied'`, error message becomes `reason`. +- Any other thrown error → `outcome: 'failure'`, then re-thrown. + +**B. Manual `log.audit()` inside a handler** — when the audit is one of several decisions in a larger handler, or when you need to emit before the action completes: + +```ts +const log = useLogger(event) + +if (!user.canRefund(invoice)) { + log.audit.deny('Insufficient permissions', { + action: 'invoice.refund', + actor: { type: 'user', id: user.id }, + target: { type: 'invoice', id: invoice.id }, + }) + throw createError({ status: 403 }) +} + +const after = await db.invoices.refund(invoice.id) + +log.audit({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: after.id }, + outcome: 'success', + changes: auditDiff(invoice, after), +}) +``` + +**C. Standalone `audit()` for jobs / scripts** — no request, no logger. Same shape, no context auto-fill: + +```ts +import { audit } from 'evlog' + +audit({ + action: 'cron.cleanup', + actor: { type: 'system', id: 'cron' }, + target: { type: 'job', id: 'cleanup-stale-sessions' }, + outcome: 'success', +}) +``` + +### Step 4 — Add denial coverage + +Auditors care most about denials — they're how you prove the policy is actually being enforced. Every authorisation check should have a paired `log.audit.deny()`. Pulling the deny into a single helper guarantees coverage parity with successes: + +```ts +function authorize(actor, action, resource) { + const allowed = policy.check(actor, action, resource) + if (!allowed) { + useLogger().audit.deny(`Policy denied ${action}`, { + action, + actor, + target: { type: resource.type, id: resource.id }, + }) + throw createError({ status: 403 }) + } +} +``` + +### Step 5 — Redact + +Apply `auditRedactPreset` (or merge it into the existing `RedactConfig`). It drops `Authorization` / `Cookie` headers and common credential field names (`password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn`) wherever they appear inside `audit.changes.before` / `audit.changes.after`: + +```ts +import { initLogger, auditRedactPreset } from 'evlog' + +initLogger({ + redact: { + paths: [...(auditRedactPreset.paths ?? []), 'user.password', 'user.token'], + }, +}) +``` + +### Step 6 — Test it + +`mockAudit()` captures audit events for assertions without going through any drain. Make the denial test mandatory in code review — untested denial paths are the most common cause of audit gaps: + +```ts +import { mockAudit } from 'evlog' + +it('refunds the invoice and records an audit', async () => { + const captured = mockAudit() + + await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: 'u1' } }) + + expect(captured.events).toHaveLength(1) + expect(captured.toIncludeAuditOf({ + action: 'invoice.refund', + target: { type: 'invoice', id: 'inv_889' }, + outcome: 'success', + })).toBe(true) + + captured.restore() +}) + +it('denies refund for non-owners and records the denial', async () => { + const captured = mockAudit() + + await expect(refundInvoice({ id: 'inv_889' }, { actor: null })).rejects.toThrow() + + expect(captured.toIncludeAuditOf({ + action: 'invoice.refund', + outcome: 'denied', + })).toBe(true) + + captured.restore() +}) +``` + +### Step 7 — Compliance review checklist + +Walk through this with a security stakeholder before declaring the system production-ready: + +- [ ] `auditEnricher` is registered on every framework integration. +- [ ] Every authorisation check has a paired `log.audit.deny()` (greppable). +- [ ] Every mutating endpoint either uses `withAudit()` or calls `log.audit()` explicitly. +- [ ] At least two sinks: one queryable (Axiom / Postgres) and one tamper-evident (FS + `signed` hash-chain, or S3 Object Lock). +- [ ] `auditRedactPreset` (or stricter) is in the global `RedactConfig`. +- [ ] Retention policy is documented per sink and enforced at the sink layer. +- [ ] Multi-tenant apps: `tenantId` is set on every audit event; queries always filter by tenant. +- [ ] Hash-chain `state.{load,save}` persists across process restarts (file / Redis / Postgres). +- [ ] HMAC `secret` rotation procedure is documented; `keyId` is embedded in `AuditFields` (extend via `declare module`). +- [ ] Tests include a denial path for every privileged action. +- [ ] Audit dataset access is itself logged — meta-auditing matters. + +## Common pitfalls + +- **Logging only successes.** Auditors care most about denials. Pair `log.audit()` with `log.audit.deny()` on every negative branch of every check. +- **Leaking PII through `changes`.** `auditDiff()` runs through `RedactConfig`, but only if the field paths are listed. Add `password`, `token`, `apiKey` once globally so it's never a per-call-site decision. +- **Treating audits as observability.** Don't sample, downsample, or summarise audit events. Force-keep is on by default — don't disable it. +- **Conflating `actor.id` with the session id.** `actor.id` is the stable user id (or system identity); correlate sessions via `context.requestId` / `context.traceId`. +- **Forgetting standalone jobs.** Cron, queue workers, CLIs trigger audit-worthy actions too — use `audit()` or `withAudit()`. +- **Faking the actor type.** `actor.type: 'user'` for cron jobs gets flagged in audits. Use `'system'`, `'api'`, or `'agent'` accurately. +- **Single global secret with no rotation.** HMAC keys must rotate; without a `keyId`, old events become unverifiable after rotation. +- **One drain that fails takes audits down.** Sinks must fail in isolation. The default `drain: [...]` array does this; if you wrap in `Promise.all`, don't throw on a single rejection — log it. + +## Glossary + +- **Action** — `audit.action`, the verb-on-noun identifier (`invoice.refund`). +- **Actor** — who/what performed the action (`user`, `system`, `api`, `agent`). +- **Target** — the resource the action was performed on. +- **Outcome** — `success`, `failure`, or `denied`. +- **Idempotency key** — auto-derived hash of `action + actor + target + timestamp`; safe retries across drains. +- **Causation id** — id of the action that caused this one (admin action → system reactions). +- **Correlation id** — shared by every action in one logical operation. +- **Hash-chain** — each event's `prevHash` matches the previous event's `hash`, forming a verifiable sequence. + +## Reference + +- Per-framework wiring (Hono, Express, Next.js, standalone): [`references/framework-wiring.md`](references/framework-wiring.md) +- Docs page: [`apps/docs/content/2.logging/7.audit.md`](../../../apps/docs/content/2.logging/7.audit.md) +- Source: [`packages/evlog/src/audit.ts`](../../../packages/evlog/src/audit.ts) +- Tests: [`packages/evlog/test/audit.test.ts`](../../../packages/evlog/test/audit.test.ts) +- Sister skill: [`create-audit-event/SKILL.md`](../create-audit-event/SKILL.md) — for adding a single new audit call site or extending the audit module inside the evlog package. diff --git a/apps/docs/skills/build-audit-logs/references/framework-wiring.md b/apps/docs/skills/build-audit-logs/references/framework-wiring.md new file mode 100644 index 00000000..fa98f280 --- /dev/null +++ b/apps/docs/skills/build-audit-logs/references/framework-wiring.md @@ -0,0 +1,97 @@ +# Framework Wiring + +The audit pipeline is the same shape in every framework: register `auditEnricher()`, wire a main drain, and add an audit-only sink. Pick the section that matches the user's stack. + +## Hono + +```ts +import { Hono } from 'hono' +import { evlog, type EvlogVariables } from 'evlog/hono' +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +const main = createAxiomDrain({ dataset: 'logs' }) +const auditSink = auditOnly( + signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), + { await: true }, +) + +const app = new Hono() +app.use(evlog({ + enrich: ctx => auditEnricher({ tenantId: c => c.headers?.['x-tenant-id'] })(ctx), + drain: async (ctx) => { await Promise.all([main(ctx), auditSink(ctx)]) }, +})) +``` + +## Express + +```ts +import express from 'express' +import { evlog } from 'evlog/express' +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +const main = createAxiomDrain({ dataset: 'logs' }) +const auditSink = auditOnly( + signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), + { await: true }, +) + +const app = express() +app.use(evlog({ + enrich: auditEnricher({ tenantId: ctx => ctx.headers?.['x-tenant-id'] }), + drain: async (ctx) => { await Promise.all([main(ctx), auditSink(ctx)]) }, +})) +``` + +## Next.js (App Router) + +```ts +// lib/evlog.ts +import { createEvlog } from 'evlog/next' +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +const main = createAxiomDrain({ dataset: 'logs' }) +const auditSink = auditOnly( + signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), + { await: true }, +) + +export const { withEvlog, useLogger } = createEvlog({ + service: 'my-app', + enrich: auditEnricher({ tenantId: ctx => ctx.headers?.['x-tenant-id'] }), + drain: async (ctx) => { await Promise.all([main(ctx), auditSink(ctx)]) }, +}) +``` + +## Standalone scripts / queue workers / CLIs + +No request → no enricher needed. `audit()` (or `withAudit()`) replaces `log.audit()`: + +```ts +import { initLogger, audit } from 'evlog' +import { signed } from 'evlog' +import { createFsDrain } from 'evlog/fs' + +initLogger({ + env: { service: 'billing-worker' }, + drain: signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), +}) + +audit({ + action: 'cron.cleanup', + actor: { type: 'system', id: 'cron' }, + target: { type: 'job', id: 'cleanup-stale-sessions' }, + outcome: 'success', +}) +``` + +## Notes that apply everywhere + +- Failure isolation between drains comes from `initLogger({ drain: [...] })` invoking each drain independently. If you instead use `Promise.all`, a single rejection takes the others down — wrap in `Promise.allSettled` and log failures, or stick with the array form. +- `await: true` on `auditOnly` makes the wrapped drain block the request until the event is flushed. Use it for the tamper-evident sink so you don't lose audits on crash; the queryable sink can stay async. +- For multi-process deployments behind hash-chain, persist `state.{load,save}` (Redis is the common choice) so the chain survives restarts and rolling deploys. diff --git a/apps/playground/app/config/tests.config.ts b/apps/playground/app/config/tests.config.ts index 7523389f..37e176d9 100644 --- a/apps/playground/app/config/tests.config.ts +++ b/apps/playground/app/config/tests.config.ts @@ -232,6 +232,109 @@ export const testConfig = { }, ], } as TestSection, + { + id: 'audit', + label: 'Audit Logs', + icon: 'i-lucide-shield-check', + title: 'Audit Logs', + description: 'First-class audit trails composed on the same wide-event pipeline. Every event is force-kept (never sampled), enriched with request context (`auditEnricher`), and routed to a tamper-evident sink (`auditOnly(signed(fsDrain))` writing hash-chained NDJSON to .audit/). Trigger each scenario and inspect both the terminal wide event and the .audit/ directory.', + layout: 'cards', + tests: [ + { + id: 'audit-refund-success', + label: 'log.audit() — success', + description: 'Manual log.audit() inside a handler. Records action=invoice.refund with auditDiff(before, after) and outcome=success.', + endpoint: '/api/audit/refund', + method: 'POST', + color: 'success', + showResult: true, + badge: { + label: 'POST /api/audit/refund', + color: 'green', + }, + toastOnSuccess: { + title: 'Audit recorded', + description: 'Check the terminal wide event and .audit/ for the hash-chained entry', + }, + }, + { + id: 'audit-refund-deny', + label: 'log.audit.deny()', + description: 'Records a denied authorisation check (outcome=denied) and throws a 403 structured error. Auditors care most about denials.', + endpoint: '/api/audit/deny', + method: 'POST', + color: 'error', + badge: { + label: 'POST /api/audit/deny', + color: 'red', + }, + toastOnError: { + title: 'Denial audited', + description: 'The 403 is expected — the audit event with outcome=denied was recorded before the throw', + }, + }, + { + id: 'audit-with-audit-success', + label: 'withAudit() — success', + description: 'Wraps an action with withAudit(). Outcome resolves automatically: returned value → success.', + color: 'success', + onClick: async () => { + await $fetch('/api/audit/with-audit', { + method: 'POST', + body: { scenario: 'success' }, + }) + }, + badge: { + label: 'outcome: success', + color: 'green', + }, + toastOnSuccess: { + title: 'withAudit() success', + description: 'Action returned cleanly → outcome=success was recorded automatically', + }, + }, + { + id: 'audit-with-audit-failure', + label: 'withAudit() — failure', + description: 'Same wrapper, but the action throws a non-403 error. Outcome resolves to failure and the error is re-thrown.', + color: 'warning', + onClick: async () => { + await $fetch('/api/audit/with-audit', { + method: 'POST', + body: { scenario: 'failure' }, + }) + }, + badge: { + label: 'outcome: failure', + color: 'warning', + }, + toastOnSuccess: { + title: 'withAudit() failure', + description: 'Action threw → outcome=failure recorded, error.message captured as reason', + }, + }, + { + id: 'audit-with-audit-denied', + label: 'withAudit() — denied', + description: 'Action throws AuditDeniedError. Outcome resolves to denied, error message becomes audit.reason.', + color: 'error', + onClick: async () => { + await $fetch('/api/audit/with-audit', { + method: 'POST', + body: { scenario: 'denied' }, + }) + }, + badge: { + label: 'outcome: denied', + color: 'red', + }, + toastOnSuccess: { + title: 'withAudit() denied', + description: 'AuditDeniedError → outcome=denied recorded with reason', + }, + }, + ], + } as TestSection, { id: 'structured-errors', label: 'Structured Errors', diff --git a/apps/playground/server/api/audit/deny.post.ts b/apps/playground/server/api/audit/deny.post.ts new file mode 100644 index 00000000..3a1d3b41 --- /dev/null +++ b/apps/playground/server/api/audit/deny.post.ts @@ -0,0 +1,16 @@ +export default defineEventHandler((event) => { + const log = useLogger(event) + + log.audit?.deny('Insufficient permissions to refund this invoice', { + action: 'invoice.refund', + actor: { type: 'user', id: 'usr_intruder' }, + target: { type: 'invoice', id: 'inv_889' }, + }) + + throw createError({ + status: 403, + message: 'Forbidden', + why: 'The current user is not authorised to refund this invoice.', + fix: 'Sign in as an account owner or contact support.', + }) +}) diff --git a/apps/playground/server/api/audit/refund.post.ts b/apps/playground/server/api/audit/refund.post.ts new file mode 100644 index 00000000..9a178f39 --- /dev/null +++ b/apps/playground/server/api/audit/refund.post.ts @@ -0,0 +1,25 @@ +import { auditDiff } from 'evlog' + +export default defineEventHandler((event) => { + const log = useLogger(event) + const userId = 'usr_42' + + log.set({ + payment: { amount: 9999, currency: 'USD', method: 'card' }, + action: 'refund_invoice', + }) + + const before = { status: 'paid', refundedAmount: 0 } + const after = { status: 'refunded', refundedAmount: 9999 } + + log.audit?.({ + action: 'invoice.refund', + actor: { type: 'user', id: userId, email: 'demo@evlog.dev' }, + target: { type: 'invoice', id: 'inv_889' }, + outcome: 'success', + reason: 'Customer requested refund', + changes: auditDiff(before, after), + }) + + return { ok: true, transactionId: 'txn_456' } +}) diff --git a/apps/playground/server/api/audit/with-audit.post.ts b/apps/playground/server/api/audit/with-audit.post.ts new file mode 100644 index 00000000..abeb8f42 --- /dev/null +++ b/apps/playground/server/api/audit/with-audit.post.ts @@ -0,0 +1,32 @@ +import { AuditDeniedError, withAudit } from 'evlog' + +const refundInvoice = withAudit( + { + action: 'invoice.refund', + target: ({ id }: { id: string }) => ({ type: 'invoice', id }), + }, + ({ id }: { id: string }) => { + if (id === 'inv_denied') throw new AuditDeniedError('Insufficient permissions to refund this invoice') + if (id === 'inv_boom') throw new Error('Payment gateway exploded') + return { id, status: 'refunded', amount: 9999 } + }, +) + +export default defineEventHandler(async (event) => { + const body = await readBody<{ scenario?: 'success' | 'failure' | 'denied' }>(event) + const scenario = body?.scenario ?? 'success' + + const id + = scenario === 'failure' + ? 'inv_boom' + : scenario === 'denied' + ? 'inv_denied' + : 'inv_889' + + try { + const result = await refundInvoice({ id }, { actor: { type: 'user', id: 'usr_42' } }) + return { ok: true, scenario, result } + } catch (err) { + return { ok: false, scenario, error: (err as Error).message } + } +}) diff --git a/apps/playground/server/plugins/evlog-drain.ts b/apps/playground/server/plugins/evlog-drain.ts index 88091a90..a45449ed 100644 --- a/apps/playground/server/plugins/evlog-drain.ts +++ b/apps/playground/server/plugins/evlog-drain.ts @@ -2,35 +2,25 @@ // import { createPostHogDrain } from 'evlog/posthog' // import { createSentryDrain } from 'evlog/sentry' // import { createBetterStackDrain } from 'evlog/better-stack' +// import { createDatadogDrain } from 'evlog/datadog' +import { auditOnly, signed } from 'evlog' import { createFsDrain } from 'evlog/fs' -import { createDatadogDrain } from 'evlog/datadog' export default defineNitroPlugin((nitroApp) => { - nitroApp.hooks.hook('evlog:drain', (ctx) => { - // console.log('[DRAIN]', JSON.stringify({ - // event: ctx.event, - // request: ctx.request, - // headers: ctx.headers, - // }, null, 2)) + // Main drain: every wide event lands here. In a real app this would be Axiom, + // Datadog, PostHog, etc. — observability storage with sampling and 30-90 day retention. + const main = createFsDrain() - // const axiomDrain = createAxiomDrain({ - // dataset: 'evlog', - // }) - // axiomDrain(ctx) + // Audit sink: tamper-evident, append-only, only receives events that carry an + // `audit` field. `signed({ strategy: 'hash-chain' })` adds prevHash + hash so + // the sequence is verifiable. `await: true` blocks the request until the audit + // is flushed — you don't want to lose audits on crash. + const auditSink = auditOnly( + signed(createFsDrain({ dir: '.audit/' }), { strategy: 'hash-chain' }), + { await: true }, + ) - // const posthogDrain = createPostHogDrain() - // posthogDrain(ctx) - - // const sentryDrain = createSentryDrain() - // sentryDrain(ctx) - - // const betterStackDrain = createBetterStackDrain() - // betterStackDrain(ctx) - - const datadogDrain = createDatadogDrain() - datadogDrain(ctx) - - const fsDrain = createFsDrain() - fsDrain(ctx) + nitroApp.hooks.hook('evlog:drain', async (ctx) => { + await Promise.all([main(ctx), auditSink(ctx)]) }) }) diff --git a/apps/playground/server/plugins/evlog-enrich.ts b/apps/playground/server/plugins/evlog-enrich.ts index 72a065ff..75c9045d 100644 --- a/apps/playground/server/plugins/evlog-enrich.ts +++ b/apps/playground/server/plugins/evlog-enrich.ts @@ -1,13 +1,15 @@ +import { auditEnricher } from 'evlog' import { createRequestSizeEnricher, createUserAgentEnricher } from 'evlog/enrichers' export default defineNitroPlugin((nitroApp) => { const enrichers = [ createUserAgentEnricher(), createRequestSizeEnricher(), + auditEnricher({ tenantId: () => 'tenant_demo' }), ] - nitroApp.hooks.hook('evlog:enrich', (ctx) => { - for (const enricher of enrichers) enricher(ctx) + nitroApp.hooks.hook('evlog:enrich', async (ctx) => { + for (const enricher of enrichers) await enricher(ctx) ctx.event.playground = { name: 'nuxt-playground', diff --git a/packages/evlog/README.md b/packages/evlog/README.md index a1ee2b57..f61684b5 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -693,6 +693,62 @@ export default defineNitroPlugin((nitroApp) => { }) ``` +## Audit Logs + +Audit logs are not a parallel system: they are a typed `audit` field on the wide event plus a few helpers. Add 1 enricher + 1 drain wrapper + `log.audit()` and you get tamper-evident, redact-aware, force-kept audit events through the same pipeline. + +```typescript +// server/plugins/evlog.ts +import { auditEnricher, auditOnly, signed } from 'evlog' +import { createAxiomDrain } from 'evlog/axiom' +import { createFsDrain } from 'evlog/fs' + +export default defineNitroPlugin((nitroApp) => { + const enrich = [auditEnricher({ tenantId: ctx => ctx.headers?.['x-tenant-id'] })] + const audits = auditOnly(signed(createFsDrain({ path: '.audit/' }), { strategy: 'hash-chain' }), { await: true }) + const main = createAxiomDrain() + + nitroApp.hooks.hook('evlog:enrich', async ctx => { for (const e of enrich) await e(ctx) }) + nitroApp.hooks.hook('evlog:drain', async ctx => { await Promise.all([main(ctx), audits(ctx)]) }) +}) +``` + +```typescript +// server/api/invoice/[id]/refund.post.ts +import { auditDiff } from 'evlog' + +export default defineEventHandler(async (event) => { + const log = useLogger(event) + const before = await db.invoice.get(id) + const after = await db.invoice.refund(id) + + log.audit?.({ + action: 'invoice.refund', + actor: { type: 'user', id: user.id, email: user.email }, + target: { type: 'invoice', id: after.id }, + outcome: 'success', + changes: auditDiff(before, after), + }) +}) +``` + +| Symbol | Kind | Purpose | +|--------|------|---------| +| `log.audit(fields)` / `log.audit.deny(reason, fields)` | method | Sugar over `log.set({ audit })` + force-keep | +| `audit(fields)` | function | Standalone for jobs / scripts | +| `withAudit({ action, target })(fn)` | wrapper | Auto-emit success / failure / denied | +| `defineAuditAction(name, opts?)` | factory | Typed action registry | +| `auditDiff(before, after)` | helper | Redact-aware JSON Patch for `changes` | +| `mockAudit()` | test util | Capture and assert audits in tests | +| `auditEnricher({ tenantId? })` | enricher | Auto-fill `req`/`trace`/`ip`/`ua`/`tenantId` context | +| `auditOnly(drain, { await? })` | wrapper | Routes only events with `event.audit` | +| `signed(drain, { strategy: 'hmac' \| 'hash-chain', ... })` | wrapper | Tamper-evident integrity | +| `auditRedactPreset` | preset | Strict PII for audit events | + +`AuditFields` is exported and merges with `BaseWideEvent` — augment it with `declare module` if you need extra typed fields. Audit events are always force-kept by tail sampling and get a deterministic `idempotencyKey` so retries are safe across drains. + +See [the Audit Logs guide](https://evlog.dev/logging/audit) for compliance, GDPR, and recipe details. + ## AI SDK Integration Capture token usage, tool calls, model info, and streaming metrics from the [Vercel AI SDK](https://ai-sdk.dev) into wide events. Requires `ai >= 6.0.0`. diff --git a/packages/evlog/src/audit.ts b/packages/evlog/src/audit.ts new file mode 100644 index 00000000..123cde97 --- /dev/null +++ b/packages/evlog/src/audit.ts @@ -0,0 +1,867 @@ +import type { AuditActor, AuditFields, AuditTarget, DrainContext, EnrichContext, FieldContext, RedactConfig, RequestLogger, WideEvent } from './types' +import { createLogger } from './logger' + +/** + * Current version of the audit envelope. Bumped when `AuditFields` evolves + * in a backward-incompatible way so downstream pipelines can branch on it. + */ +export const AUDIT_SCHEMA_VERSION = 1 + +/** + * Input accepted by `log.audit()`, `audit()`, and `withAudit()`. + * + * `outcome` defaults to `'success'`. Internal fields populated by the audit + * pipeline (`idempotencyKey`, `context`, `signature`, `prevHash`, `hash`) are + * stripped — pass them through `log.set({ audit })` if you really need to. + */ +export interface AuditInput { + action: string + actor: AuditActor + target?: AuditTarget + outcome?: AuditFields['outcome'] + reason?: string + changes?: AuditFields['changes'] + causationId?: string + correlationId?: string + version?: number +} + +/** + * @internal Stable JSON stringification with deterministic key order. + * Used by `idempotencyKey` and `hash-chain` so the same logical event always + * produces the same digest, regardless of how object keys were added. + */ +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` + const keys = Object.keys(value as Record).sort() + return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify((value as Record)[k])}`).join(',')}}` +} + +/** + * @internal Sync, isomorphic 32-bit FNV-1a. Used to derive the idempotency + * key without pulling `node:crypto` into the static import graph (which would + * break browser / Cloudflare Workers bundles that import `evlog` for types + * or shared utilities). Idempotency keys are dedup tokens, not security + * primitives — collision resistance at 128 bits is more than sufficient. + */ +function fnv1a32(input: string, seed: number): number { + let h = seed >>> 0 + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i) & 0xff + h = Math.imul(h, 0x01000193) >>> 0 + } + return h >>> 0 +} + +/** + * @internal Compute the deterministic idempotency key for an audit event. + * Includes `action`, `actor.{type,id}`, `target.{type,id}`, `outcome`, and + * `timestamp` rounded to the second so retries within the same second collapse. + * + * Uses four interleaved FNV-1a 32-bit hashes (128-bit output, 32 hex chars) + * so the implementation stays sync and isomorphic across Node, browsers, + * Bun, Deno, and Cloudflare Workers. + */ +function computeIdempotencyKey(audit: AuditFields, timestamp: string): string { + const seconds = timestamp.slice(0, 19) + const payload = stableStringify({ + action: audit.action, + actor: { type: audit.actor.type, id: audit.actor.id }, + target: audit.target ? { type: audit.target.type, id: audit.target.id } : undefined, + outcome: audit.outcome, + timestamp: seconds, + }) + const a = fnv1a32(payload, 0x811c9dc5).toString(16).padStart(8, '0') + const b = fnv1a32(payload, 0xdeadbeef).toString(16).padStart(8, '0') + const c = fnv1a32(payload, 0x1f83d9ab).toString(16).padStart(8, '0') + const d = fnv1a32(payload, 0x5be0cd19).toString(16).padStart(8, '0') + return a + b + c + d +} + +/** + * Build a normalised {@link AuditFields} from caller input. Defaults: + * - `outcome` → `'success'` + * - `version` → {@link AUDIT_SCHEMA_VERSION} + * + * `idempotencyKey` is filled at emit time with the event timestamp so retries + * stay deterministic. + */ +export function buildAuditFields(input: AuditInput): AuditFields { + return { + action: input.action, + actor: input.actor, + target: input.target, + outcome: input.outcome ?? 'success', + reason: input.reason, + changes: input.changes, + causationId: input.causationId, + correlationId: input.correlationId, + version: input.version ?? AUDIT_SCHEMA_VERSION, + } +} + +/** + * @internal Test-collector hook installed by {@link mockAudit}. When set, every + * audit event flowing through `log.audit()` / `audit()` is also pushed to it. + */ +let _testCollector: ((event: AuditFields, wide: WideEvent | null) => void) | null = null + +/** @internal Emit-time decoration: assign timestamp-based idempotency key. */ +function decorateAudit(audit: AuditFields, timestamp: string): AuditFields { + if (audit.idempotencyKey) return audit + return { ...audit, idempotencyKey: computeIdempotencyKey(audit, timestamp) } +} + +/** + * Add audit semantics to an existing {@link RequestLogger}. + * + * Mutates the logger in place by adding an `audit` method (with a `.deny()` + * sub-method). Strictly equivalent to calling `log.set({ audit: ... })` plus + * `_forceKeep` on emit. Idempotent: calling twice on the same logger only + * attaches the methods once. + * + * @example + * ```ts + * const log = withAuditMethods(createLogger()) + * log.audit({ + * action: 'invoice.refund', + * actor: { type: 'user', id: user.id }, + * target: { type: 'invoice', id: 'inv_889' }, + * }) + * ``` + */ +export function withAuditMethods>(logger: RequestLogger): AuditableLogger { + const target = logger as AuditableLogger + if (target.audit) return target + + const audit = function audit(input: AuditInput): void { + const fields = buildAuditFields(input) + target.set({ audit: fields } as unknown as FieldContext) + markForceKeep(target) + } as AuditMethod + + audit.deny = function deny(reason: string, input: Omit): void { + audit({ ...input, outcome: 'denied', reason }) + } + + target.audit = audit + return target +} + +/** + * @internal Mark a logger so its next `emit()` is force-kept past tail sampling. + * Implemented by stamping a hidden flag on the accumulated context which + * `emit()` reads via `_forceKeep`. + */ +function markForceKeep(logger: RequestLogger): void { + const ctx = logger.getContext() as Record + ctx._auditForceKeep = true +} + +/** + * Logger augmented with `.audit()` / `.audit.deny()` helpers. + */ +export type AuditableLogger> = RequestLogger & { audit: AuditMethod } + +/** Method shape attached to {@link AuditableLogger}. */ +export interface AuditMethod> { + (input: AuditInput): void + /** + * Record an AuthZ-denied action. Forces `outcome: 'denied'` and requires + * a human-readable `reason`. Most teams forget to log denials — they are + * exactly what auditors and security teams ask for. + */ + deny: (reason: string, input: Omit) => void +} + +/** + * Standalone audit emitter for non-request contexts (jobs, scripts, CLIs). + * + * Creates a one-shot logger, sets the audit fields, and emits immediately. + * The event is force-kept past tail sampling. Returns the emitted wide event, + * or `null` if logging is globally disabled. + * + * @example + * ```ts + * import { audit } from 'evlog' + * + * audit({ + * action: 'cron.cleanup', + * actor: { type: 'system', id: 'cron' }, + * target: { type: 'job', id: 'cleanup-stale-sessions' }, + * outcome: 'success', + * }) + * ``` + */ +export function audit(input: AuditInput): WideEvent | null { + const fields = buildAuditFields(input) + const logger = createLogger({ audit: fields }) + const wide = logger.emit({ _forceKeep: true } as FieldContext> & { _forceKeep?: boolean }) + _testCollector?.(fields, wide) + return wide +} + +/** + * Wrap a function so its outcome (success / failure / denied) is automatically + * audited. + * + * Behaviour: + * - If `fn` resolves, an audit event with `outcome: 'success'` is emitted. + * - If `fn` throws an `EvlogError` (or any error) with `status === 403`, the + * audit event is recorded as `'denied'` with the error message as `reason`. + * - Any other thrown error produces `outcome: 'failure'` and re-throws. + * + * Use {@link AuditDeniedError} to signal denial without an HTTP status. + * + * @example + * ```ts + * const refundInvoice = withAudit( + * { action: 'invoice.refund', target: (input) => ({ type: 'invoice', id: input.id }) }, + * async (input: { id: string }, ctx: { actor: AuditActor }) => { + * await db.invoices.refund(input.id) + * } + * ) + * + * await refundInvoice({ id: 'inv_889' }, { actor: { type: 'user', id: user.id } }) + * ``` + */ +export function withAudit( + options: WithAuditOptions, + fn: (input: TInput, ctx: WithAuditContext) => Promise | TOutput, +): (input: TInput, ctx: WithAuditContext) => Promise { + return async (input, ctx) => { + const target = typeof options.target === 'function' ? options.target(input) : options.target + try { + const result = await fn(input, ctx) + audit({ + action: options.action, + actor: ctx.actor, + target, + outcome: 'success', + causationId: ctx.causationId, + correlationId: ctx.correlationId, + }) + return result + } catch (err) { + const error = err as Error & { status?: number; statusCode?: number } + const status = error.status ?? error.statusCode + const denied = err instanceof AuditDeniedError || status === 403 + audit({ + action: options.action, + actor: ctx.actor, + target, + outcome: denied ? 'denied' : 'failure', + reason: error.message, + causationId: ctx.causationId, + correlationId: ctx.correlationId, + }) + throw err + } + } +} + +/** + * Throw inside a {@link withAudit} body to mark the action as `outcome: 'denied'` + * regardless of the underlying HTTP status. The constructor message becomes the + * audit `reason`. + */ +export class AuditDeniedError extends Error { + + constructor(reason: string) { + super(reason) + this.name = 'AuditDeniedError' + } + +} + +/** Options for {@link withAudit}. `target` may be derived from the input. */ +export interface WithAuditOptions { + action: string + target?: AuditTarget | ((input: TInput) => AuditTarget | undefined) +} + +/** + * Runtime context required by a {@link withAudit}-wrapped function. + * The actor is always required; correlation IDs are optional. + */ +export interface WithAuditContext { + actor: AuditActor + causationId?: string + correlationId?: string +} + +/** + * Compute a compact, redact-aware diff between two objects for the + * `changes` field. Output is a JSON Patch-style array (RFC 6902 subset: + * `add`, `remove`, `replace`) — small enough to ship over the wire. + * + * Object keys whose name matches one of the `redactPaths` (dot-notation, e.g. + * `'user.password'`, `'card.cvv'`) are replaced with `'[REDACTED]'` so PII + * never leaks through the diff. + * + * @example + * ```ts + * log.audit({ + * action: 'user.update', + * actor: { type: 'user', id: user.id }, + * target: { type: 'user', id: 'usr_42' }, + * changes: auditDiff(before, after, { redactPaths: ['password'] }), + * }) + * ``` + */ +export function auditDiff( + before: unknown, + after: unknown, + options: AuditDiffOptions = {}, +): { before?: unknown, after?: unknown, patch: AuditPatchOp[] } { + const replacement = options.replacement ?? '[REDACTED]' + const redactSet = new Set((options.redactPaths ?? []).map(p => p)) + const patch: AuditPatchOp[] = [] + + function isRedacted(path: string): boolean { + if (redactSet.size === 0) return false + if (redactSet.has(path)) return true + for (const p of redactSet) { + if (path.endsWith(`.${p}`)) return true + } + return false + } + + function diff(a: unknown, b: unknown, path: string): void { + if (a === b) return + + if (a === undefined && b !== undefined) { + patch.push({ op: 'add', path: path || '/', value: redactValue(b, path) }) + return + } + if (a !== undefined && b === undefined) { + patch.push({ op: 'remove', path: path || '/' }) + return + } + + if ( + a !== null && b !== null + && typeof a === 'object' && typeof b === 'object' + && !Array.isArray(a) && !Array.isArray(b) + ) { + const keys = new Set([...Object.keys(a as object), ...Object.keys(b as object)]) + for (const key of keys) { + diff((a as Record)[key], (b as Record)[key], `${path}/${key}`) + } + return + } + + patch.push({ op: 'replace', path: path || '/', value: redactValue(b, path) }) + } + + function redactValue(value: unknown, path: string): unknown { + if (value === null || typeof value !== 'object') { + const segs = path.split('/').filter(Boolean) + const last = segs[segs.length - 1] + if (last && isRedacted(last)) return replacement + return value + } + if (Array.isArray(value)) { + return value.map((v, i) => redactValue(v, `${path}/${i}`)) + } + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = isRedacted(k) ? replacement : redactValue(v, `${path}/${k}`) + } + return out + } + + diff(before, after, '') + const result: { before?: unknown, after?: unknown, patch: AuditPatchOp[] } = { patch } + if (options.includeBefore) result.before = redactValue(before, '') + if (options.includeAfter) result.after = redactValue(after, '') + return result +} + +/** Single JSON Patch operation produced by {@link auditDiff}. */ +export interface AuditPatchOp { + op: 'add' | 'remove' | 'replace' + path: string + value?: unknown +} + +/** Options for {@link auditDiff}. */ +export interface AuditDiffOptions { + /** Object keys (dot-notation) whose values should be replaced with `[REDACTED]`. */ + redactPaths?: string[] + /** Custom replacement string. @default '[REDACTED]' */ + replacement?: string + /** Include the full redacted `before` snapshot alongside the patch. */ + includeBefore?: boolean + /** Include the full redacted `after` snapshot alongside the patch. */ + includeAfter?: boolean +} + +/** + * Define a typed audit action with an optional fixed target type. + * + * Returns a curried helper that fills in the action name (and target shape + * if provided) so call sites stay terse and the action set is discoverable + * in one place. + * + * @example + * ```ts + * const refund = defineAuditAction('invoice.refund', { target: 'invoice' }) + * + * log.audit(refund({ + * actor: { type: 'user', id: user.id }, + * target: { id: 'inv_889' }, // type inferred as 'invoice' + * outcome: 'success', + * })) + * ``` + */ +export function defineAuditAction( + action: string, + options?: { target?: TTargetType }, +): DefinedAuditAction { + const targetType = options?.target + return (input) => { + const merged: AuditInput = { + ...input, + action, + } + if (targetType && input.target && !input.target.type) { + merged.target = { ...input.target, type: targetType } + } + return merged + } +} + +/** + * Return type of {@link defineAuditAction}. Accepts a partial input (no + * `action`, target type pre-filled when provided). + */ +export type DefinedAuditAction = ( + input: TTargetType extends string + ? Omit & { target?: Omit & { type?: TTargetType } } + : Omit, +) => AuditInput + +/** + * Test helper that captures every audit event emitted while it is active. + * + * Returns `{ events, restore, expect }`: + * - `events` — live array of captured `AuditFields`, populated as audits fire. + * - `restore()` — uninstall the collector. Call from `afterEach()`. + * - `expect.toIncludeAuditOf(matcher)` — assertion helper used inside `expect` + * blocks, returns `true` if at least one captured event matches. + * + * Only captures audits going through `log.audit()` and the standalone + * `audit()` function. Events emitted via raw `log.set({ audit })` skip the + * collector by design — wrap them with `log.audit()` to make them visible to + * tests. + * + * @example + * ```ts + * const captured = mockAudit() + * await refundInvoice('inv_889') + * expect(captured.events).toHaveLength(1) + * expect(captured.toIncludeAuditOf({ action: 'invoice.refund' })).toBe(true) + * captured.restore() + * ``` + */ +export function mockAudit(): MockAudit { + const events: AuditFields[] = [] + const previous = _testCollector + _testCollector = (event) => { + events.push(event) + } + + return { + events, + restore() { + _testCollector = previous + }, + toIncludeAuditOf(matcher) { + return events.some(event => matchesAudit(event, matcher)) + }, + } +} + +/** Result of {@link mockAudit}. */ +export interface MockAudit { + events: AuditFields[] + restore: () => void + toIncludeAuditOf: (matcher: AuditMatcher) => boolean +} + +/** Partial structural matcher for {@link MockAudit.toIncludeAuditOf}. */ +export interface AuditMatcher { + action?: string | RegExp + outcome?: AuditFields['outcome'] + actor?: Partial + target?: Partial +} + +function matchesAudit(event: AuditFields, matcher: AuditMatcher): boolean { + if (matcher.action !== undefined) { + if (matcher.action instanceof RegExp) { + if (!matcher.action.test(event.action)) return false + } else if (event.action !== matcher.action) { + return false + } + } + if (matcher.outcome !== undefined && event.outcome !== matcher.outcome) return false + if (matcher.actor) { + for (const [k, v] of Object.entries(matcher.actor)) { + if ((event.actor as Record)[k] !== v) return false + } + } + if (matcher.target) { + if (!event.target) return false + for (const [k, v] of Object.entries(matcher.target)) { + if ((event.target as Record)[k] !== v) return false + } + } + return true +} + +/** + * @internal Hook used by `RequestLogger.emit()` to detect audit-driven + * force-keep flags on the accumulated context. Returns whether the event was + * marked by `log.audit()` and clears the flag. + */ +export function consumeAuditForceKeep(context: Record): boolean { + if (context._auditForceKeep) { + delete context._auditForceKeep + return true + } + if (context.audit) return true + return false +} + +/** + * @internal Decorate the audit field on an event right before drain — fills + * in the deterministic idempotency key. Called by the logger pipeline so + * it works for both `log.audit()` and direct `log.set({ audit })` paths. + */ +export function finalizeAudit(event: WideEvent): void { + const a = event.audit as AuditFields | undefined + if (!a) return + const decorated = decorateAudit(a, String(event.timestamp)) + event.audit = decorated +} + +/** Shape of the optional better-auth bridge for the audit enricher. */ +export interface AuditEnricherBetterAuthBridge { + /** Read the current authenticated session for this request, if any. */ + getSession: (ctx: EnrichContext) => Promise | AuditActor | null | undefined +} + +/** Options for {@link auditEnricher}. */ +export interface AuditEnricherOptions { + /** + * Resolve the tenant id for the current request. The result is stored at + * `event.audit.context.tenantId`. Multi-tenant SaaS gets isolation by default. + */ + tenantId?: (ctx: EnrichContext) => string | undefined + /** + * Bridge to populate `event.audit.actor` from the authenticated session. + * Only used when the application has not already filled `actor`. + */ + bridge?: AuditEnricherBetterAuthBridge + /** When true, overwrite existing context fields. @default false */ + overwrite?: boolean +} + +/** + * Enrich audit-bearing wide events with request, runtime, and tenant context. + * + * Runs only when `event.audit` is present — every other event passes through + * untouched. Populates: + * - `event.audit.context.requestId` from `ctx.request.requestId` + * - `event.audit.context.traceId` from `event.traceId` + * - `event.audit.context.ip` from `x-forwarded-for` / `x-real-ip` + * - `event.audit.context.userAgent` from `user-agent` + * - `event.audit.context.tenantId` from `options.tenantId(ctx)` + * + * Optionally fills `event.audit.actor` from the better-auth bridge when the + * caller did not provide one. Anything else (custom actor strategies, + * extra context) belongs in a custom enricher — replace this one entirely. + */ +export function auditEnricher(options: AuditEnricherOptions = {}): (ctx: EnrichContext) => void | Promise { + return async (ctx) => { + const event = ctx.event as WideEvent & { audit?: AuditFields } + const a = event.audit + if (!a) return + + const context = { ...(a.context ?? {}) } + + function setIfMissing(key: string, value: string | undefined): void { + if (value === undefined) return + if (options.overwrite || context[key] === undefined) context[key] = value + } + + setIfMissing('requestId', ctx.request?.requestId) + setIfMissing('traceId', typeof event.traceId === 'string' ? event.traceId : undefined) + setIfMissing('ip', getHeader(ctx.headers, 'x-forwarded-for')?.split(',')[0]?.trim() ?? getHeader(ctx.headers, 'x-real-ip')) + setIfMissing('userAgent', getHeader(ctx.headers, 'user-agent')) + + if (options.tenantId) { + const tid = options.tenantId(ctx) + if (tid !== undefined) setIfMissing('tenantId', tid) + } + + let { actor } = a + if (!actor && options.bridge) { + const fromBridge = await options.bridge.getSession(ctx) + if (fromBridge) actor = fromBridge + } + + event.audit = { ...a, context, actor: actor ?? a.actor } + } +} + +function getHeader(headers: Record | undefined, name: string): string | undefined { + if (!headers) return undefined + if (headers[name] !== undefined) return headers[name] + const lower = name.toLowerCase() + if (headers[lower] !== undefined) return headers[lower] + for (const [k, v] of Object.entries(headers)) { + if (k.toLowerCase() === lower) return v + } + return undefined +} + +/** Options accepted by {@link auditOnly}. */ +export interface AuditOnlyOptions { + /** + * When true, the wrapper awaits the wrapped drain so the event is flushed + * before the request resolves. Use for crash-safe audit storage. + * @default false + */ + await?: boolean +} + +/** Drain function signature accepted by all wrappers. Matches `LoggerConfig['drain']`. */ +export type DrainFn = (ctx: DrainContext) => void | Promise + +/** + * Wrap any drain so it only receives events that carry an `audit` field. + * + * Use to route audit events to dedicated storage (separate Axiom dataset, + * append-only Postgres table, FS journal) without affecting your main drain. + * + * Per-sink failure isolation comes from `initLogger({ drain: [...] })`: each + * drain in the array is invoked independently, so a crashed Axiom call never + * blocks the FS audit drain. + * + * @example + * ```ts + * import { initLogger, auditOnly } from 'evlog' + * import { createAxiomDrain } from 'evlog/axiom' + * import { createFsDrain } from 'evlog/fs' + * + * initLogger({ + * drain: [ + * createAxiomDrain({ dataset: 'logs' }), + * auditOnly(createFsDrain({ dir: '.audit' }), { await: true }), + * ], + * }) + * ``` + */ +export function auditOnly(drain: DrainFn, options: AuditOnlyOptions = {}): DrainFn { + return async (ctx) => { + if (!ctx.event.audit) return + if (options.await) { + await drain(ctx) + return + } + drain(ctx) + } +} + +/** Pluggable persistence for the hash-chain state. */ +export interface SignedChainState { + /** Load the previous hash from durable storage, or `null` on first run. */ + load: () => Promise | string | null + /** Persist the latest hash so the chain survives process restarts. */ + save: (hash: string) => Promise | void +} + +/** Options for {@link signed}. Pick a strategy at construction time. */ +export type SignedOptions = + | { strategy: 'hmac', secret: string, algorithm?: 'sha256' | 'sha512' } + | { strategy: 'hash-chain', state?: SignedChainState, algorithm?: 'sha256' | 'sha512' } + +/** + * Wrap a drain so every event passing through gains tamper-evident integrity. + * + * - `'hmac'` — adds `event.audit.signature` (HMAC of the canonical event). + * - `'hash-chain'` — adds `event.audit.prevHash` and `event.audit.hash` so the + * sequence of events forms a verifiable chain. State persists in memory + * by default; pass a `state: { load, save }` for cross-process / durable + * chains (Redis, file, Postgres). + * + * The signature is computed before the event is forwarded to the wrapped + * drain — combine with {@link auditOnly} when you only want integrity for + * audit events. + * + * @example + * ```ts + * import { initLogger, auditOnly, signed } from 'evlog' + * import { createFsDrain } from 'evlog/fs' + * + * initLogger({ + * drain: auditOnly( + * signed(createFsDrain({ dir: '.audit' }), { strategy: 'hash-chain' }), + * { await: true }, + * ), + * }) + * ``` + */ +export function signed(drain: DrainFn, options: SignedOptions): DrainFn { + if (options.strategy === 'hmac') { + const algorithm = options.algorithm ?? 'sha256' + const { secret } = options + return async (ctx) => { + const a = ctx.event.audit as AuditFields | undefined + if (a) { + const payload = stableStringify(stripIntegrity(ctx.event)) + const signature = await hmacHex(algorithm, secret, payload) + ctx.event.audit = { ...a, signature } + } + await drain(ctx) + } + } + + const algorithm = options.algorithm ?? 'sha256' + const { state } = options + let inMemoryPrev: string | null = null + let initialised = !state + let queue: Promise = Promise.resolve() + + return (ctx) => { + queue = queue.then(async () => { + const a = ctx.event.audit as AuditFields | undefined + if (a) { + if (!initialised && state) { + inMemoryPrev = (await state.load()) ?? null + initialised = true + } + const prevHash = inMemoryPrev ?? undefined + const payload = stableStringify({ ...stripIntegrity(ctx.event), audit: { ...stripIntegrity(ctx.event).audit, prevHash } }) + const hash = await digestHex(algorithm, payload) + ctx.event.audit = { ...a, prevHash, hash } + inMemoryPrev = hash + await state?.save(hash) + } + await drain(ctx) + }).catch((err) => { + console.error('[evlog/audit] signed drain failed:', err) + }) + return queue + } +} + +/** + * @internal Resolve the Web Crypto SubtleCrypto interface. Available natively + * in browsers, Node 19+, Bun, Deno, and Cloudflare Workers. Falls back to + * Node's `webcrypto` for Node 18 (where `globalThis.crypto` is gated behind + * a flag). The dynamic import keeps `node:crypto` out of browser bundles. + */ +async function getSubtle(): Promise { + const c = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto + if (c?.subtle) return c.subtle + const mod = await import(/* @vite-ignore */ 'node:crypto') as { webcrypto: { subtle: SubtleCrypto } } + return mod.webcrypto.subtle +} + +function normalizeAlgo(algorithm: string): string { + switch (algorithm.toLowerCase()) { + case 'sha1': + case 'sha-1': + return 'SHA-1' + case 'sha256': + case 'sha-256': + return 'SHA-256' + case 'sha384': + case 'sha-384': + return 'SHA-384' + case 'sha512': + case 'sha-512': + return 'SHA-512' + default: + return 'SHA-256' + } +} + +function bufToHex(buf: ArrayBuffer): string { + let out = '' + for (const byte of new Uint8Array(buf)) out += byte.toString(16).padStart(2, '0') + return out +} + +async function digestHex(algorithm: string, data: string): Promise { + const subtle = await getSubtle() + const buf = await subtle.digest(normalizeAlgo(algorithm), new TextEncoder().encode(data)) + return bufToHex(buf) +} + +async function hmacHex(algorithm: string, secret: string, data: string): Promise { + const subtle = await getSubtle() + const hash = normalizeAlgo(algorithm) + const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash }, false, ['sign']) + const sig = await subtle.sign('HMAC', key, new TextEncoder().encode(data)) + return bufToHex(sig) +} + +/** @internal Strip integrity fields before hashing so signatures stay stable. */ +function stripIntegrity(event: WideEvent): WideEvent { + const a = event.audit as AuditFields | undefined + if (!a) return event + const { signature, prevHash, hash, ...rest } = a + return { ...event, audit: rest as AuditFields } +} + +/** + * Strict redact preset for audit events. + * + * Combine with the user's existing redact configuration via spread: + * `initLogger({ redact: { paths: [...auditRedactPreset.paths!, ...mine] } })`. + * + * Hardens PII handling: + * - Drops `Authorization` and `Cookie` headers anywhere they appear. + * - Drops common credential field names (`password`, `passwordHash`, `token`, + * `apiKey`, `secret`, `accessToken`, `refreshToken`, `cardNumber`, `cvv`, + * `ssn`). + * + * Built-in pattern maskers (email, credit card, …) keep their default + * behaviour — partial masking, not full redaction — so audit trails retain + * enough signal to be useful. + */ +export const auditRedactPreset: RedactConfig = { + paths: [ + 'audit.changes.before.password', + 'audit.changes.before.passwordHash', + 'audit.changes.before.token', + 'audit.changes.before.apiKey', + 'audit.changes.before.secret', + 'audit.changes.before.accessToken', + 'audit.changes.before.refreshToken', + 'audit.changes.before.cardNumber', + 'audit.changes.before.cvv', + 'audit.changes.before.ssn', + 'audit.changes.after.password', + 'audit.changes.after.passwordHash', + 'audit.changes.after.token', + 'audit.changes.after.apiKey', + 'audit.changes.after.secret', + 'audit.changes.after.accessToken', + 'audit.changes.after.refreshToken', + 'audit.changes.after.cardNumber', + 'audit.changes.after.cvv', + 'audit.changes.after.ssn', + 'headers.authorization', + 'headers.cookie', + 'headers.set-cookie', + 'audit.context.headers.authorization', + 'audit.context.headers.cookie', + ], +} diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts index c71dd077..7e49b497 100644 --- a/packages/evlog/src/index.ts +++ b/packages/evlog/src/index.ts @@ -1,3 +1,35 @@ +export { + AUDIT_SCHEMA_VERSION, + AuditDeniedError, + audit, + auditDiff, + auditEnricher, + auditOnly, + auditRedactPreset, + buildAuditFields, + defineAuditAction, + mockAudit, + signed, + withAudit, + withAuditMethods, +} from './audit' +export type { + AuditDiffOptions, + AuditEnricherOptions, + AuditInput, + AuditMatcher, + AuditMethod, + AuditOnlyOptions, + AuditPatchOp, + AuditableLogger, + DefinedAuditAction, + DrainFn, + MockAudit, + SignedChainState, + SignedOptions, + WithAuditContext, + WithAuditOptions, +} from './audit' export { EvlogError, createError, createEvlogError } from './error' export { createLogger, createRequestLogger, getEnvironment, initLogger, isEnabled, log, shouldKeep } from './logger' export { isLevelEnabled } from './utils' @@ -5,6 +37,10 @@ export { useLogger } from './runtime/server/useLogger' export { parseError } from './runtime/utils/parseError' export type { + AuditActor, + AuditFields, + AuditLoggerMethod, + AuditTarget, BaseWideEvent, DeepPartial, DrainContext, diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index 6b20c12b..b17fd03e 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -1,4 +1,6 @@ +import type { AuditableLogger, AuditInput, AuditMethod } from './audit' import type { DrainContext, EnvironmentContext, FieldContext, Log, LogLevel, LoggerConfig, RedactConfig, RequestLogger, RequestLoggerOptions, SamplingConfig, TailSamplingContext, WideEvent } from './types' +import { buildAuditFields, consumeAuditForceKeep, finalizeAudit } from './audit' import { redactEvent, resolveRedactConfig } from './redact' import { colors, cssColors, detectEnvironment, escapeFormatString, formatDuration, getConsoleMethod, getCssLevelColor, getLevelColor, isClient, isDev, isLevelEnabled, matchesPattern } from './utils' @@ -189,6 +191,8 @@ function emitWideEvent(level: LogLevel, event: Record, deferDra } } + finalizeAudit(formatted) + if (globalRedact) { redactEvent(formatted, globalRedact) } @@ -515,7 +519,8 @@ const _log: Log = { export { _log as log } -const noopLogger: RequestLogger = { +const noopAudit = Object.assign(() => {}, { deny: () => {} }) as AuditMethod +const noopLogger: AuditableLogger = { set() {}, error() {}, info() {}, @@ -526,6 +531,7 @@ const noopLogger: RequestLogger = { getContext() { return {} }, + audit: noopAudit, } /** @@ -554,8 +560,8 @@ interface CreateLoggerInternalOptions { * log.emit() * ``` */ -export function createLogger>(initialContext: Record = {}, internalOptions?: CreateLoggerInternalOptions): RequestLogger { - if (!globalEnabled) return noopLogger as RequestLogger +export function createLogger>(initialContext: Record = {}, internalOptions?: CreateLoggerInternalOptions): AuditableLogger { + if (!globalEnabled) return noopLogger as unknown as AuditableLogger const deferDrain = internalOptions?._deferDrain ?? false const startTime = Date.now() @@ -575,7 +581,26 @@ export function createLogger>(initial }) } + const auditMethod = function audit(input: AuditInput): void { + if (emitted) { + warnPostEmit('log.audit()', `Audit dropped: action=${input.action}.`) + return + } + const fields = buildAuditFields(input) + if (!isPlainObject(context.audit)) { + context.audit = fields as unknown as Record + } else { + mergeInto(context.audit as Record, fields as unknown as Record) + } + context._auditForceKeep = true + } as AuditMethod + + auditMethod.deny = function deny(reason: string, input: Omit): void { + auditMethod({ ...input, outcome: 'denied', reason }) + } + return { + audit: auditMethod, set(data: FieldContext): void { if (emitted) { const keys = Object.keys(data as Record) @@ -660,6 +685,8 @@ export function createLogger>(initial let forceKeep = false if (overrides?._forceKeep) { forceKeep = true + } else if (consumeAuditForceKeep(context)) { + forceKeep = true } else if (globalSampling.keep?.length) { const status = (overrides as Record | undefined)?.status ?? context.status forceKeep = shouldKeep({ @@ -707,7 +734,7 @@ export function createLogger>(initial * log.emit() * ``` */ -export function createRequestLogger>(options: RequestLoggerOptions = {}, internalOptions?: CreateLoggerInternalOptions): RequestLogger { +export function createRequestLogger>(options: RequestLoggerOptions = {}, internalOptions?: CreateLoggerInternalOptions): AuditableLogger { const initial: Record = {} if (options.method !== undefined) initial.method = options.method if (options.path !== undefined) initial.path = options.path diff --git a/packages/evlog/src/redact.ts b/packages/evlog/src/redact.ts index f8044884..28ce1348 100644 --- a/packages/evlog/src/redact.ts +++ b/packages/evlog/src/redact.ts @@ -29,9 +29,17 @@ export const builtinPatterns = { pattern: /\b(?!0\.0\.0\.0\b)(?!127\.0\.0\.1\b)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, mask: (m: string) => `***.***.***.${m.split('.').pop()}`, }, - /** International phone numbers → +33******78 (country code + last 2 digits) */ + /** + * International phone numbers → `+33******78` (country code + last 2 digits). + * + * Requires an explicit phone signal (`+countryCode` prefix or `(areaCode)` + * parens) to avoid false positives on digit-rich identifiers (UUIDs, + * idempotency keys, order ids, hex hashes). Bare digit runs like `12345678` + * are intentionally not matched — opt in via custom `patterns` if your app + * stores phones in unformatted form. + */ phone: { - pattern: /(?:\+\d{1,3}[\s.-]?)?\(?\d{1,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}[\s.-]?\d{2,4}\b/g, + pattern: /(?:\+\d{1,3}[\s.-]?\(?\d{1,4}\)?|\(\d{1,4}\))(?:[\s.-]?\d{2,4}){2,4}\b/g, mask: (m: string) => { const digits = m.replace(/[^\d]/g, '') const hasPlus = m.startsWith('+') diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index a4490fc2..977a89ac 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -383,7 +383,97 @@ export interface LoggerConfig { } /** - * Base structure for all wide events + * Audit actor — who initiated the action. + * + * `type` covers the most common actor families. `id` is required and should be + * a stable identifier (user id, service name, API key id, agent id). `model`, + * `tools`, `reason`, and `promptId` are filled when `type === 'agent'` and + * mirror the AI SDK fields already used by `evlog/ai`. + */ +export interface AuditActor { + type: 'user' | 'system' | 'api' | 'agent' + id: string + displayName?: string + email?: string + model?: string + tools?: string[] + reason?: string + promptId?: string +} + +/** + * Audit target — the resource the action was performed on. + * + * `type` is a free-form string (e.g. `'invoice'`, `'user'`, `'subscription'`) + * narrowed by {@link defineAuditAction}. Additional fields are allowed for + * resource-specific metadata (e.g. `tenantId`, `path`, `previousOwnerId`). + */ +export interface AuditTarget { + type: string + id: string + [key: string]: unknown +} + +/** + * Reserved audit fields on the wide event. + * + * Set via `log.audit({ ... })`, `log.set({ audit: { ... } })`, or the + * standalone `audit({ ... })` helper. Downstream filters on `audit IS NOT NULL`. + * + * - `outcome` — `'success' | 'failure' | 'denied'`. `'denied'` records an + * AuthZ-denied action (often forgotten but exactly what auditors want). + * - `changes.before/after` — the diff for mutating actions. Use + * {@link auditDiff} to produce a redact-aware compact JSON Patch. + * - `causationId` / `correlationId` — chain related actions (admin action → + * system reactions). Set by callers, propagated by `auditEnricher` when + * available on the request. + * - `signature` / `prevHash` — populated by the {@link signed} drain wrapper. + * Never set by application code. + * - `idempotencyKey` — derived deterministically by `log.audit()` so retries + * across drains are safe. + * - `context` — request/runtime context auto-populated by {@link auditEnricher} + * (`requestId`, `traceId`, `ip`, `userAgent`, `tenantId`). + */ +export interface AuditFields { + /** Action name. Convention: `'.'`, e.g. `'invoice.refund'`. */ + action: string + actor: AuditActor + target?: AuditTarget + outcome: 'success' | 'failure' | 'denied' + /** Human-readable explanation, especially required for `outcome: 'denied'`. */ + reason?: string + /** Before/after snapshots for mutating actions. */ + changes?: { before?: unknown, after?: unknown } + /** ID of the action that caused this one. */ + causationId?: string + /** ID shared by every action in the same logical operation. */ + correlationId?: string + /** Schema version of the audit envelope. Defaults to `1` when omitted by the caller. */ + version?: number + /** Set by `log.audit()` as a stable hash for safe retries across drains. */ + idempotencyKey?: string + /** Request/runtime context — populated by `auditEnricher`. */ + context?: { + requestId?: string + traceId?: string + ip?: string + userAgent?: string + tenantId?: string + [key: string]: unknown + } + /** HMAC signature of the event when wrapped with `signed({ strategy: 'hmac' })`. */ + signature?: string + /** Previous event hash when wrapped with `signed({ strategy: 'hash-chain' })`. */ + prevHash?: string + /** Hash of the current event when wrapped with `signed({ strategy: 'hash-chain' })`. */ + hash?: string +} + +/** + * Base structure for all wide events. + * + * Augment via `declare module 'evlog'` to add app-specific top-level fields. + * `audit` is reserved for {@link AuditFields}. */ export interface BaseWideEvent { timestamp: string @@ -393,6 +483,7 @@ export interface BaseWideEvent { version?: string commitHash?: string region?: string + audit?: AuditFields } /** @@ -543,6 +634,36 @@ export interface RequestLogger> { * ``` */ fork?: (label: string, fn: () => void | Promise) => void + + /** + * Record an audit event on this wide event. Strictly equivalent to + * `log.set({ audit: { ... } })` plus tail-sample force-keep. + * + * Use `log.audit.deny(reason, fields)` for AuthZ-denied actions — most teams + * forget to log denials but they are exactly what auditors and security teams + * ask for. + * + * Available on every logger returned by `createLogger()` / `createRequestLogger()` + * and on framework loggers exposed via `useLogger()` / `c.get('log')` etc. + * + * @example + * ```ts + * log.audit({ + * action: 'invoice.refund', + * actor: { type: 'user', id: user.id, email: user.email }, + * target: { type: 'invoice', id: 'inv_889' }, + * outcome: 'success', + * reason: 'Customer requested refund', + * }) + * ``` + */ + audit?: AuditLoggerMethod +} + +/** @internal Forward-declaration to avoid a circular import with `audit.ts`. */ +export interface AuditLoggerMethod { + (input: import('./audit').AuditInput): void + deny: (reason: string, input: Omit) => void } /** diff --git a/packages/evlog/test/audit.test.ts b/packages/evlog/test/audit.test.ts new file mode 100644 index 00000000..157e90a8 --- /dev/null +++ b/packages/evlog/test/audit.test.ts @@ -0,0 +1,442 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AUDIT_SCHEMA_VERSION, + AuditDeniedError, + audit, + auditDiff, + auditEnricher, + auditOnly, + auditRedactPreset, + buildAuditFields, + defineAuditAction, + mockAudit, + signed, + withAudit, + withAuditMethods, +} from '../src/audit' +import type { AuditFields, DrainContext, EnrichContext, WideEvent } from '../src/types' +import { createLogger, createRequestLogger, initLogger } from '../src/logger' +import { resolveRedactConfig } from '../src/redact' + +function createDrainCtx(event: Partial = {}): DrainContext { + const wide: WideEvent = { + timestamp: new Date('2026-04-24T12:00:00.000Z').toISOString(), + level: 'info', + service: 'test', + environment: 'test', + ...event, + } + return { event: wide } +} + +function createEnrichCtx(event: Partial = {}, headers?: Record, requestId?: string): EnrichContext { + const wide: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + ...event, + } + return { + event: wide, + headers, + request: requestId ? { path: '/x', requestId } : undefined, + } +} + +beforeEach(() => { + vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + initLogger({ pretty: false, redact: false }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('buildAuditFields', () => { + it('defaults outcome to success and version to AUDIT_SCHEMA_VERSION', () => { + const fields = buildAuditFields({ + action: 'invoice.refund', + actor: { type: 'user', id: 'u1' }, + }) + expect(fields.outcome).toBe('success') + expect(fields.version).toBe(AUDIT_SCHEMA_VERSION) + }) + + it('preserves explicit outcome and version', () => { + const fields = buildAuditFields({ + action: 'invoice.refund', + actor: { type: 'system', id: 'cron' }, + outcome: 'failure', + version: 2, + }) + expect(fields.outcome).toBe('failure') + expect(fields.version).toBe(2) + }) +}) + +describe('log.audit() on createLogger', () => { + it('attaches audit fields and force-keeps the event past tail sampling', () => { + initLogger({ pretty: false, redact: false, sampling: { rates: { info: 0 } } }) + const log = createLogger() + log.audit?.({ + action: 'invoice.refund', + actor: { type: 'user', id: 'u1' }, + target: { type: 'invoice', id: 'inv_1' }, + }) + const event = log.emit() + expect(event).not.toBeNull() + const audit = event!.audit as AuditFields + expect(audit.action).toBe('invoice.refund') + expect(audit.outcome).toBe('success') + expect(audit.idempotencyKey).toMatch(/^[\da-f]{32}$/) + }) + + it('log.audit.deny() sets outcome to denied and records reason', () => { + const log = createLogger() + log.audit?.deny('Insufficient permissions', { + action: 'invoice.refund', + actor: { type: 'user', id: 'u1' }, + target: { type: 'invoice', id: 'inv_1' }, + }) + const event = log.emit() + const audit = event!.audit as AuditFields + expect(audit.outcome).toBe('denied') + expect(audit.reason).toBe('Insufficient permissions') + }) + + it('falls back to set+emit when log.set({ audit }) is used directly', () => { + initLogger({ pretty: false, redact: false, sampling: { rates: { info: 0 } } }) + const log = createLogger() + log.set({ audit: buildAuditFields({ action: 'manual', actor: { type: 'system', id: 's' } }) }) + const event = log.emit() + expect(event).not.toBeNull() + expect((event!.audit as AuditFields).action).toBe('manual') + }) + + it('createRequestLogger exposes the same audit method', () => { + const log = createRequestLogger({ method: 'POST', path: '/x' }) + expect(typeof log.audit).toBe('function') + }) +}) + +describe('standalone audit()', () => { + it('emits an event tagged as audit and returns it', () => { + const event = audit({ + action: 'cron.cleanup', + actor: { type: 'system', id: 'cron' }, + }) + expect(event).not.toBeNull() + expect((event!.audit as AuditFields).action).toBe('cron.cleanup') + }) + + it('is force-kept even when info sampling is at 0%', () => { + initLogger({ pretty: false, redact: false, sampling: { rates: { info: 0 } } }) + const event = audit({ + action: 'cron.cleanup', + actor: { type: 'system', id: 'cron' }, + }) + expect(event).not.toBeNull() + }) +}) + +describe('withAudit()', () => { + it('records success when fn resolves', async () => { + const collector = mockAudit() + const refund = withAudit( + { action: 'invoice.refund', target: input => ({ type: 'invoice', id: (input as { id: string }).id }) }, + (input: { id: string }) => `refunded ${input.id}`, + ) + await refund({ id: 'inv_1' }, { actor: { type: 'user', id: 'u1' } }) + expect(collector.events).toHaveLength(1) + expect(collector.events[0]!.outcome).toBe('success') + expect(collector.events[0]!.target).toEqual({ type: 'invoice', id: 'inv_1' }) + collector.restore() + }) + + it('records denied when fn throws AuditDeniedError', async () => { + const collector = mockAudit() + const fn = withAudit({ action: 'x' }, () => { + throw new AuditDeniedError('not allowed') + }) + await expect(fn(null, { actor: { type: 'user', id: 'u1' } })).rejects.toThrow('not allowed') + expect(collector.events[0]!.outcome).toBe('denied') + expect(collector.events[0]!.reason).toBe('not allowed') + collector.restore() + }) + + it('records denied when fn throws a 403-status error', async () => { + const collector = mockAudit() + const fn = withAudit({ action: 'x' }, () => { + const err = new Error('forbidden') as Error & { status: number } + err.status = 403 + throw err + }) + await expect(fn(null, { actor: { type: 'user', id: 'u1' } })).rejects.toThrow('forbidden') + expect(collector.events[0]!.outcome).toBe('denied') + collector.restore() + }) + + it('records failure for other thrown errors', async () => { + const collector = mockAudit() + const fn = withAudit({ action: 'x' }, () => { + throw new Error('boom') + }) + await expect(fn(null, { actor: { type: 'user', id: 'u1' } })).rejects.toThrow('boom') + expect(collector.events[0]!.outcome).toBe('failure') + expect(collector.events[0]!.reason).toBe('boom') + collector.restore() + }) +}) + +describe('auditDiff()', () => { + it('produces a JSON Patch with replace operations', () => { + const diff = auditDiff({ amount: 100, currency: 'USD' }, { amount: 200, currency: 'USD' }) + expect(diff.patch).toEqual([{ op: 'replace', path: '/amount', value: 200 },]) + }) + + it('redacts paths matching key names', () => { + const diff = auditDiff( + { user: { name: 'A', password: 'old' } }, + { user: { name: 'B', password: 'new' } }, + { redactPaths: ['password'] }, + ) + expect(diff.patch).toContainEqual({ op: 'replace', path: '/user/name', value: 'B' }) + expect(diff.patch).toContainEqual({ op: 'replace', path: '/user/password', value: '[REDACTED]' }) + }) + + it('emits add and remove operations', () => { + const diff = auditDiff({ a: 1 }, { b: 2 }) + expect(diff.patch).toEqual(expect.arrayContaining([ + { op: 'remove', path: '/a' }, + { op: 'add', path: '/b', value: 2 }, + ])) + }) +}) + +describe('defineAuditAction()', () => { + it('curries the action and infers target type', () => { + const refund = defineAuditAction('invoice.refund', { target: 'invoice' as const }) + const built = refund({ + actor: { type: 'user', id: 'u1' }, + target: { id: 'inv_1' }, + }) + expect(built.action).toBe('invoice.refund') + expect(built.target).toEqual({ type: 'invoice', id: 'inv_1' }) + }) +}) + +describe('mockAudit()', () => { + it('captures audit events from log.audit() and audit()', () => { + const captured = mockAudit() + audit({ action: 'a', actor: { type: 'system', id: 's' } }) + expect(captured.events).toHaveLength(1) + expect(captured.toIncludeAuditOf({ action: 'a' })).toBe(true) + expect(captured.toIncludeAuditOf({ action: 'missing' })).toBe(false) + captured.restore() + }) + + it('matcher supports regex actions', () => { + const captured = mockAudit() + audit({ action: 'invoice.refund', actor: { type: 'system', id: 's' } }) + expect(captured.toIncludeAuditOf({ action: /^invoice\./ })).toBe(true) + captured.restore() + }) +}) + +describe('auditEnricher()', () => { + it('skips events without audit field', async () => { + const ctx = createEnrichCtx() + await auditEnricher()(ctx) + expect(ctx.event.audit).toBeUndefined() + }) + + it('populates context fields when audit is present', async () => { + const ctx = createEnrichCtx( + { audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } }, + { 'user-agent': 'jest', 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }, + 'req-1', + ) + await auditEnricher()(ctx) + const audit = ctx.event.audit as AuditFields + expect(audit.context).toMatchObject({ + requestId: 'req-1', + ip: '1.2.3.4', + userAgent: 'jest', + }) + }) + + it('uses tenantId resolver', async () => { + const ctx = createEnrichCtx( + { audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } }, + ) + await auditEnricher({ tenantId: () => 'tenant_42' })(ctx) + expect((ctx.event.audit as AuditFields).context?.tenantId).toBe('tenant_42') + }) + + it('uses better-auth bridge to fill missing actor', async () => { + const ctx = createEnrichCtx( + { audit: { action: 'a', actor: undefined as unknown as AuditFields['actor'], outcome: 'success' } as AuditFields }, + ) + await auditEnricher({ + bridge: { getSession: () => ({ type: 'user', id: 'session-user' }) }, + })(ctx) + expect((ctx.event.audit as AuditFields).actor.id).toBe('session-user') + }) +}) + +describe('auditOnly()', () => { + it('only forwards events that carry an audit field', async () => { + const sink = vi.fn<(ctx: DrainContext) => Promise>(async () => {}) + const wrapped = auditOnly(sink) + await wrapped(createDrainCtx({})) + await wrapped(createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } })) + expect(sink).toHaveBeenCalledTimes(1) + }) + + it('with await: true awaits the wrapped drain', async () => { + let resolved = false + const sink = vi.fn<(ctx: DrainContext) => Promise>(async () => { + await new Promise(r => setTimeout(r, 5)) + resolved = true + }) + const wrapped = auditOnly(sink, { await: true }) + await wrapped(createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } })) + expect(resolved).toBe(true) + }) +}) + +describe('signed() — hmac', () => { + it('adds a deterministic signature for matching events', async () => { + const calls: WideEvent[] = [] + const drain = signed((ctx: DrainContext) => { + calls.push(ctx.event) + }, { strategy: 'hmac', secret: 'topsecret' }) + const ctx1 = createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } }) + const ctx2 = createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } }) + await drain(ctx1) + await drain(ctx2) + expect((calls[0]!.audit as AuditFields).signature).toBeDefined() + expect((calls[0]!.audit as AuditFields).signature).toBe((calls[1]!.audit as AuditFields).signature) + }) + + it('passes through events without audit', async () => { + const drain = signed(() => {}, { strategy: 'hmac', secret: 's' }) + await expect(drain(createDrainCtx())).resolves.toBeUndefined() + }) +}) + +describe('signed() — hash-chain', () => { + it('chains events via prevHash', async () => { + const calls: WideEvent[] = [] + const drain = signed((ctx: DrainContext) => { + calls.push(ctx.event) + }, { strategy: 'hash-chain' }) + const make = () => createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } }) + await drain(make()) + await drain(make()) + await drain(make()) + + const a1 = calls[0]!.audit as AuditFields + const a2 = calls[1]!.audit as AuditFields + const a3 = calls[2]!.audit as AuditFields + expect(a1.prevHash).toBeUndefined() + expect(a2.prevHash).toBe(a1.hash) + expect(a3.prevHash).toBe(a2.hash) + expect(a1.hash).toBeDefined() + expect(a2.hash).toBeDefined() + expect(a3.hash).toBeDefined() + }) + + it('persists chain head via state.save', async () => { + const saved: string[] = [] + const drain = signed(() => {}, { + strategy: 'hash-chain', + state: { + load: () => null, + save: (h) => { + saved.push(h) + }, + }, + }) + await drain(createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } })) + await drain(createDrainCtx({ audit: { action: 'b', actor: { type: 'user', id: 'u1' }, outcome: 'success' } })) + expect(saved).toHaveLength(2) + expect(saved[0]).not.toBe(saved[1]) + }) + + it('resumes chain from state.load on first event', async () => { + const calls: WideEvent[] = [] + const drain = signed((ctx: DrainContext) => { + calls.push(ctx.event) + }, { + strategy: 'hash-chain', + state: { load: () => 'previous-hash-from-disk', save: () => {} }, + }) + await drain(createDrainCtx({ audit: { action: 'a', actor: { type: 'user', id: 'u1' }, outcome: 'success' } })) + expect((calls[0]!.audit as AuditFields).prevHash).toBe('previous-hash-from-disk') + }) +}) + +describe('idempotency key', () => { + it('is stable across identical events in the same second', () => { + const e1 = audit({ action: 'a', actor: { type: 'user', id: 'u1' }, target: { type: 't', id: 'r1' } })! + const e2 = audit({ action: 'a', actor: { type: 'user', id: 'u1' }, target: { type: 't', id: 'r1' } })! + const t1 = (e1.timestamp as string).slice(0, 19) + const t2 = (e2.timestamp as string).slice(0, 19) + if (t1 === t2) { + expect((e1.audit as AuditFields).idempotencyKey).toBe((e2.audit as AuditFields).idempotencyKey) + } + }) +}) + +describe('end-to-end: audit + auditOnly + global drain', () => { + it('routes audit-only drain alongside the main drain', () => { + const main = vi.fn<(ctx: DrainContext) => void>() + const auditSink = vi.fn<(ctx: DrainContext) => void>() + const onlyAudit = auditOnly(auditSink as never) + initLogger({ + pretty: false, + redact: false, + drain: (ctx) => { + main(ctx) + return onlyAudit(ctx) + }, + }) + + const log = createLogger() + log.audit?.({ action: 'x', actor: { type: 'user', id: 'u1' } }) + log.emit() + + expect(main).toHaveBeenCalledTimes(1) + expect(auditSink).toHaveBeenCalledTimes(1) + }) +}) + +describe('auditRedactPreset', () => { + it('drops authorization headers', () => { + const config = resolveRedactConfig(auditRedactPreset)! + expect(config.paths).toContain('headers.authorization') + }) + + it('drops password fields under audit.changes', () => { + const config = resolveRedactConfig(auditRedactPreset)! + expect(config.paths).toContain('audit.changes.before.password') + expect(config.paths).toContain('audit.changes.after.password') + }) +}) + +describe('withAuditMethods()', () => { + it('attaches audit methods to a logger that lacks them', () => { + const base: { set: (x: unknown) => void; getContext: () => Record } = { + set: vi.fn(), + getContext: () => ({}), + } + const augmented = withAuditMethods(base as never) + expect(typeof augmented.audit).toBe('function') + expect(typeof augmented.audit.deny).toBe('function') + }) +}) diff --git a/packages/evlog/test/redact.test.ts b/packages/evlog/test/redact.test.ts index b77b1acc..4e897a48 100644 --- a/packages/evlog/test/redact.test.ts +++ b/packages/evlog/test/redact.test.ts @@ -594,13 +594,13 @@ describe('built-in smart masking', () => { expect(event.zero).toBe('0.0.0.0') }) - it('masks international phone numbers keeping last 4 digits', () => { + it('masks international phone numbers keeping last 2 digits', () => { const event: Record = { us: '+1 (555) 123-4567', fr: '+33 6 12 34 56 78', uk: '+44 7911 123456', de: '+49 170 1234567', - local: '06 12 34 56 78', + parens: '(555) 123-4567', safe: 'no phone here', } redactEvent(event, resolveRedactConfig({ builtins: ['phone'] })!) @@ -608,9 +608,28 @@ describe('built-in smart masking', () => { expect(event.us).not.toContain('555') expect(event.fr).not.toContain('12 34') expect(event.de).not.toContain('1234567') + expect(event.parens).not.toContain('555') expect(event.safe).toBe('no phone here') }) + it('does not mask digit-rich identifiers (UUIDs, hex hashes, ids)', () => { + const event: Record = { + uuid: '12345642-f647-42bb-9fda-742d2b4f41fa', + requestId: '00000000-1111-2222-3333-444444444444', + idempotencyKey: '961da3f34097bb096902b5457ae02687', + orderId: 'ord_1234567890', + bareDigits: '0612345678', + localPhone: '06 12 34 56 78', + } + redactEvent(event, resolveRedactConfig({ builtins: ['phone'] })!) + expect(event.uuid).toBe('12345642-f647-42bb-9fda-742d2b4f41fa') + expect(event.requestId).toBe('00000000-1111-2222-3333-444444444444') + expect(event.idempotencyKey).toBe('961da3f34097bb096902b5457ae02687') + expect(event.orderId).toBe('ord_1234567890') + expect(event.bareDigits).toBe('0612345678') + expect(event.localPhone).toBe('06 12 34 56 78') + }) + it('masks JWT tokens keeping prefix', () => { const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' const event: Record = { From e657c7afee314599f58fea1f579a784a5354cff3 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 24 Apr 2026 18:12:33 +0200 Subject: [PATCH 2/2] up --- .agents/skills/create-audit-event/SKILL.md | 167 --------------------- apps/docs/skills/build-audit-logs/SKILL.md | 121 ++++++++++++++- 2 files changed, 115 insertions(+), 173 deletions(-) delete mode 100644 .agents/skills/create-audit-event/SKILL.md diff --git a/.agents/skills/create-audit-event/SKILL.md b/.agents/skills/create-audit-event/SKILL.md deleted file mode 100644 index eb0e8e0e..00000000 --- a/.agents/skills/create-audit-event/SKILL.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -name: create-audit-event -description: Compose audit events in evlog using the existing wide-event primitives. Use when adding a new audit log call site (`log.audit`, `audit()`, `withAudit()`, `defineAuditAction()`) or wiring `auditEnricher`, `auditOnly`, and `signed` drain wrappers into an app. ---- - -# Create an evlog Audit Event - -Audit logs in evlog are not a parallel system. They are a **typed `audit` field on the wide event** plus a few helpers and drain wrappers. Always compose with the existing pipeline (`enrichers`, `drains`, `redact`, tail-sampling) instead of introducing a new one. - -## Mental Model - -```text -log.audit(...) ──► sets event.audit ──► force-keep ──► auditEnricher ──► redact ──► every drain - └─► auditOnly(signed(fsDrain)) (audit-only sink) -``` - -- `log.audit(...)` is sugar for `log.set({ audit })` + force-keep. -- `audit({...})` is the same, but for non-request contexts (jobs, scripts). -- `withAudit({...})(fn)` automates `success` / `failure` / `denied` outcomes. -- `auditOnly(drain)` filters events down to those with `event.audit` set. -- `signed(drain, { strategy })` adds tamper-evident integrity (`hmac` or `hash-chain`). -- `auditEnricher()` fills `event.audit.context` (req/trace/ip/ua/tenantId). -- `auditRedactPreset` is a strict redact preset for audit events. - -## When to Use What - -| Situation | API | -|-----------|-----| -| Inside a request handler, action succeeded | `log.audit({ action, actor, target, outcome: 'success' })` | -| Inside a request handler, action denied (AuthZ) | `log.audit.deny('reason', { action, actor, target })` | -| Standalone job / script / CLI | `audit({ action, actor, target, outcome })` | -| Wrapping a function so success/failure is captured automatically | `withAudit({ action, target })(fn)` | -| Recording a state change | add `changes: auditDiff(before, after)` | -| Type-safe action registry | `const refund = defineAuditAction('invoice.refund', { target: 'invoice' })` then `refund.audit(log, { ... })` | -| Asserting audits in tests | `mockAudit()` | - -## Schema (always provide these) - -```ts -interface AuditFields { - action: string // e.g. 'invoice.refund' - actor: { type: 'user' | 'system' | 'api' | 'agent', id: string, /* email, displayName, model, tools, reason, promptId */ } - outcome: 'success' | 'failure' | 'denied' - target?: { type: string, id: string, [k: string]: unknown } - reason?: string - changes?: { before?: unknown, after?: unknown } | AuditPatchOp[] - causationId?: string - correlationId?: string - version?: number // defaults to AUDIT_SCHEMA_VERSION - idempotencyKey?: string // auto-derived if absent - context?: { requestId?, traceId?, ip?, userAgent?, tenantId?, ... } - signature?: string // added by signed(drain, { strategy: 'hmac' }) - prevHash?: string // added by signed(drain, { strategy: 'hash-chain' }) - hash?: string // added by signed(drain, { strategy: 'hash-chain' }) -} -``` - -## Recipes - -### 1. Add an audit call in a request handler - -```ts -// server/api/invoice/[id]/refund.post.ts -import { auditDiff } from 'evlog' - -export default defineEventHandler(async (event) => { - const log = useLogger(event) - const { id } = getRouterParams(event) - const before = await db.invoice.get(id) - - if (!can(event, 'invoice.refund', before)) { - log.audit?.deny('Insufficient permissions', { - action: 'invoice.refund', - actor: actorOf(event), - target: { type: 'invoice', id }, - }) - throw createError({ status: 403 }) - } - - const after = await db.invoice.refund(id) - - log.audit?.({ - action: 'invoice.refund', - actor: actorOf(event), - target: { type: 'invoice', id, amount: after.amount }, - outcome: 'success', - changes: auditDiff(before, after), - }) - - return after -}) -``` - -### 2. Wire the audit pipeline (one-time setup) - -```ts -// server/plugins/evlog.ts -import { auditEnricher, auditOnly, signed } from 'evlog' -import { createAxiomDrain } from 'evlog/axiom' -import { createFsDrain } from 'evlog/fs' - -export default defineNitroPlugin((nitroApp) => { - const enrichers = [auditEnricher({ tenantId: ctx => ctx.event.tenantId })] - const auditSink = auditOnly(signed(createFsDrain({ path: '.audit/' }), { - strategy: 'hash-chain', - }), { await: true }) - const main = createAxiomDrain() - - nitroApp.hooks.hook('evlog:enrich', async (ctx) => { - for (const e of enrichers) await e(ctx) - }) - nitroApp.hooks.hook('evlog:drain', async (ctx) => { - await Promise.all([main(ctx), auditSink(ctx)]) - }) -}) -``` - -### 3. Auto-instrument a service method - -```ts -import { withAudit, AuditDeniedError } from 'evlog' - -export const refundInvoice = withAudit({ - action: 'invoice.refund', - target: ({ id }: { id: string }) => ({ type: 'invoice', id }), - actor: () => ({ type: 'system', id: 'billing-worker' }), -})(async ({ id, by }) => { - if (!by.canRefund) throw new AuditDeniedError('not allowed') - return db.invoice.refund(id) -}) -``` - -### 4. Test it - -```ts -import { mockAudit } from 'evlog' - -test('refund records an audit event', async () => { - const audits = mockAudit() - await refundInvoice({ id: 'inv_1', by: admin }) - audits.expectIncludes({ action: 'invoice.refund', outcome: 'success' }) -}) -``` - -## Rules - -1. **Never** create an `evlog/audit*` sub-export. Everything lives on the main `evlog` entrypoint. -2. **Never** invent a parallel logger. Use `log.set({ audit: {...} })` if the helper is unsuitable. -3. **Always** provide `action`, `actor`, and `outcome`. `target` is strongly recommended. -4. **Always** wrap audit-only sinks with `auditOnly(...)` so non-audit events don't leak. -5. Use `signed(drain, { strategy: 'hash-chain' })` for tamper-evident audit storage; persist `state` via `{ load, save }` if you run multiple processes. -6. Apply `auditRedactPreset` (or merge it into your existing `RedactConfig`) before sending audits anywhere. -7. Audit events bypass tail-sampling automatically — do not add custom `evlog:emit:keep` rules just to keep them. -8. Idempotency keys are derived automatically; only set `idempotencyKey` manually if you have a stable application-level key. - -## Touchpoints Checklist (when shipping a new audit feature) - -| # | File | Action | -|---|------|--------| -| 1 | `packages/evlog/src/audit.ts` | Add helper / wrapper / preset | -| 2 | `packages/evlog/src/index.ts` | Re-export the new symbol | -| 3 | `packages/evlog/test/audit.test.ts` | Cover new behaviour | -| 4 | `apps/docs/content/2.logging/7.audit.md` | Document the new helper | -| 5 | `apps/playground/server/api/audit/*` | Add a runnable example if user-facing | -| 6 | `README.md` + `packages/evlog/README.md` | Mention in the audit section | - -If your change adds a new field on `AuditFields`, also update `AUDIT_SCHEMA_VERSION` in `packages/evlog/src/audit.ts` and document the migration. diff --git a/apps/docs/skills/build-audit-logs/SKILL.md b/apps/docs/skills/build-audit-logs/SKILL.md index f8b9d8b5..f7cb87f5 100644 --- a/apps/docs/skills/build-audit-logs/SKILL.md +++ b/apps/docs/skills/build-audit-logs/SKILL.md @@ -1,11 +1,48 @@ --- name: build-audit-logs -description: Build audit logs / an audit trail in a TypeScript or JavaScript application using evlog. Use whenever the user mentions audit logs, audit trail, audit logging, compliance logging, SOC2 / HIPAA / GDPR / PCI logs, tamper-evident or append-only logs, "who did what" forensic trails, or per-tenant audit isolation in a SaaS — even if they don't explicitly mention evlog. Also use when wiring an audit sink (Postgres, S3, Axiom dataset, FS journal, ...), when replacing scattered `console.log('user.deleted', ...)` calls with a real audit pipeline, or when an existing app has audits but is missing denials, redaction, integrity, or retention. Covers the design decisions (sink, integrity, retention, multi-tenancy, framework wiring) and the end-to-end buildout (pipeline → call sites → tests → compliance review). For adding a single new audit call site to an already-wired app, use `create-audit-event` instead. +description: Create or review an audit logging system in a TypeScript / JavaScript application using evlog. Use both for greenfield setups ("add audit logs to my app", "set up audit trail", "wire audit pipeline", "I need SOC2 / HIPAA / GDPR / PCI logs", "tamper-evident logs", "who-did-what forensic trail", "per-tenant audit isolation in a SaaS") and for review work ("review my audit setup", "audit my audit logs", "is my audit pipeline compliant", "are we missing denials", "audit coverage gaps", "is the redaction tight enough", "should we add hash-chain integrity"). Trigger even when the user does not mention evlog by name. Covers: design calls (sink choice, integrity, retention, multi-tenancy, GDPR), end-to-end buildout (pipeline → action vocabulary → call sites → denial coverage → redact → tests → compliance review), and a review checklist for grading an existing audit setup. Always operates on the user's own application — not on the evlog package itself. --- -# Build Audit Logs with evlog +# Build or Review an Audit System with evlog -For **application developers** building an audit trail in their product. Walks through the design calls and the end-to-end implementation. For just adding one more `log.audit(...)` call in a wired-up app, use `create-audit-event` instead. +For **application developers** who either need to add an audit trail to their product, or who already have one and want it reviewed. Walks through the design calls, the end-to-end implementation, and a review checklist for an existing setup. + +This skill assumes the audit lives in **your app**. To extend the evlog package itself (new audit helper, new drain wrapper), see the contributor skills under `.agents/skills/`. + +## Quick reference — call-site cheat sheet + +When you already know the system is wired and just need to remember the API: + +| Situation | Helper | +|---|---| +| Inside a request handler, action succeeded | `log.audit({ action, actor, target, outcome: 'success' })` | +| Inside a request handler, AuthZ denial | `log.audit.deny('reason', { action, actor, target })` | +| Standalone job / script / CLI (no request) | `audit({ action, actor, target, outcome })` | +| Auto-record success / failure / denied for a function | `withAudit({ action, target }, fn)` | +| Recording a state change | add `changes: auditDiff(before, after)` | +| Centralised typed action vocabulary | `defineAuditAction('invoice.refund', { target: 'invoice' })` | +| Asserting audits in tests | `mockAudit()` | + +`AuditFields` schema (always provide `action`, `actor`, `outcome`; `target` strongly recommended; the rest is filled in for you): + +```ts +interface AuditFields { + action: string // 'invoice.refund' + actor: { type: 'user' | 'system' | 'api' | 'agent', id: string, email?, displayName?, model?, tools?, reason?, promptId? } + outcome: 'success' | 'failure' | 'denied' + target?: { type: string, id: string, [k: string]: unknown } + reason?: string + changes?: { before?: unknown, after?: unknown } | AuditPatchOp[] + causationId?: string + correlationId?: string + version?: number // defaults to AUDIT_SCHEMA_VERSION + idempotencyKey?: string // auto-derived from action+actor+target+timestamp + context?: { requestId?, traceId?, ip?, userAgent?, tenantId?, ... } // filled by auditEnricher + signature?: string // added by signed(drain, { strategy: 'hmac' }) + prevHash?: string // added by signed(drain, { strategy: 'hash-chain' }) + hash?: string // added by signed(drain, { strategy: 'hash-chain' }) +} +``` ## What "audit logging" actually means @@ -291,9 +328,9 @@ it('denies refund for non-owners and records the denial', async () => { }) ``` -### Step 7 — Compliance review checklist +### Step 7 — Production readiness checklist -Walk through this with a security stakeholder before declaring the system production-ready: +Walk through this with a security stakeholder before declaring the system production-ready (the same checklist powers the review mode below): - [ ] `auditEnricher` is registered on every framework integration. - [ ] Every authorisation check has a paired `log.audit.deny()` (greppable). @@ -307,6 +344,79 @@ Walk through this with a security stakeholder before declaring the system produc - [ ] Tests include a denial path for every privileged action. - [ ] Audit dataset access is itself logged — meta-auditing matters. +## Review an existing audit setup + +When the user already has an audit system and wants it reviewed, work through the four passes below in order. Each pass tells you exactly what to grep, what to look for, and what to flag. + +### Pass 1 — Pipeline wiring + +Find where the logger is initialised and where drains / enrichers are registered: + +```bash +rg -n "initLogger|defineNitroPlugin|createLogger|evlog:enrich|evlog:drain" --type ts +rg -n "auditEnricher|auditOnly|signed\(" --type ts +``` + +Flag if: +- `auditEnricher()` is missing → `event.audit.context` is empty, no requestId / IP / tenant correlation. +- An audit-only sink exists but is not wrapped in `auditOnly(...)` → main events leak into the audit dataset (privacy & cost incident). +- Only one drain → no tamper-evident copy. Acceptable only if the single sink is WORM (S3 Object Lock, BigQuery append-only, Postgres immutable). +- `signed()` is used without a persisted `state` while running multiple processes → hash-chain breaks across restarts / instances. +- `await: true` is missing on the audit-only sink → events may be lost on crash. + +### Pass 2 — Coverage (call sites) + +Inventory every mutating action and every authorisation check: + +```bash +rg -n "log\.audit\(|log\.audit\.deny\(|withAudit\(|^.*audit\(" --type ts +rg -n "createError\(.*403|throw .*Forbidden|status:\s*403|statusCode:\s*403" --type ts +rg -n "(?i)\b(delete|update|create|refund|grant|revoke|promote|demote|reset|impersonate)\b.*async\s+function|defineEventHandler" --type ts +``` + +For each match, check: +- Mutating endpoint without a `log.audit()` or `withAudit()` → coverage gap. +- `403` / `Forbidden` thrown without a paired `log.audit.deny()` → silent denial. This is the single most common gap. +- `actor: { type: 'user', id: 'cron' }` or hard-coded actors in cron / queue handlers → wrong `actor.type`. Should be `'system'`, `'api'`, or `'agent'`. +- `actor.id` set to a session id or token instead of the stable user id → forensic ambiguity. +- `log.set({ audit: ... })` without using the helpers → bypasses force-keep, may be dropped by tail-sampling. +- `withAudit()` action name in present tense (`invoice.refund`) is fine; manual `log.audit()` after the fact should use past tense (`invoice.refunded`). + +### Pass 3 — Redaction & integrity + +```bash +rg -n "auditRedactPreset|RedactConfig|paths:\s*\[" --type ts +rg -n "auditDiff\(" --type ts +rg -n "strategy:\s*['\"](?:hmac|hash-chain)" --type ts +``` + +Flag if: +- `auditRedactPreset` is not merged into the global redact config → `Authorization`, `Cookie`, `password`, `token`, `apiKey`, `cardNumber`, `cvv`, `ssn` may leak through `audit.changes`. +- `auditDiff()` is called on objects containing PII fields not listed in `redactPaths` → leak in the patch payload. +- HMAC `secret` is hard-coded or read from `process.env.SECRET` without a rotation plan / `keyId` → events become unverifiable after rotation. +- Hash-chain `state` is in-memory only → chain restarts each process boot, breaking continuity. + +### Pass 4 — Tests + +```bash +rg -n "mockAudit\(|toIncludeAuditOf\(" --type ts +``` + +Flag if: +- No tests use `mockAudit()` → audit pipeline silently drifts unnoticed. +- Tests only assert success outcomes → denial paths can rot. Every privileged action should have at least one denied-outcome test. +- Tests assert against `RegExp` actions broadly → typos in `audit.action` slip through (an action typo is a missing alert in production). + +### Reporting the findings + +Group findings by severity for the user: + +- **P0 (blocker)**: missing `auditOnly` wrap on an audit sink, missing `auditRedactPreset`, denials not logged, no tamper-evident sink in a regulated context. +- **P1 (compliance gap)**: missing tenant isolation, hash-chain state not persisted, no HMAC rotation, no denial test coverage. +- **P2 (hygiene)**: action naming inconsistency, in-line actor objects (should use `defineAuditAction`), missing `causationId` / `correlationId` on chained operations. + +Then map each finding to the relevant step in the buildout above (e.g. P0 → Step 5 redact, P1 → Step 7 checklist) so the fix is unambiguous. + ## Common pitfalls - **Logging only successes.** Auditors care most about denials. Pair `log.audit()` with `log.audit.deny()` on every negative branch of every check. @@ -335,4 +445,3 @@ Walk through this with a security stakeholder before declaring the system produc - Docs page: [`apps/docs/content/2.logging/7.audit.md`](../../../apps/docs/content/2.logging/7.audit.md) - Source: [`packages/evlog/src/audit.ts`](../../../packages/evlog/src/audit.ts) - Tests: [`packages/evlog/test/audit.test.ts`](../../../packages/evlog/test/audit.test.ts) -- Sister skill: [`create-audit-event/SKILL.md`](../create-audit-event/SKILL.md) — for adding a single new audit call site or extending the audit module inside the evlog package.