hazo_audit
v2.0.1
Published
Field-level audit trail for hazo_connect-backed apps. Attach to any adapter via wrapWithAudit() to auto-capture writes; ships worker, Next.js routes, and React UI primitives.
Maintainers
Readme
hazo_audit
Field-level audit trail for hazo_connect-backed apps. Attach to any adapter
with wrapWithAudit() and every write to a captured table auto-produces an
outbox row, drained by an in-process worker into per-field events the UI can
render via <FieldAuditIcon />.
Zero changes to hazo_connect. Zero per-route boilerplate. Add a new
audited table with an INI block, not a code change.
npm i hazo_audit
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URLWhat it does
// instrumentation.ts
import { wrapWithAudit, startAuditWorker } from 'hazo_audit/server'
import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup'
const base = getHazoConnectSingleton()
const adapter = wrapWithAudit(base, { resolvers: { wp: wpResolver } })
startAuditWorker({ app_adapter: base })// middleware (or request handler)
import { runWithAuditContext } from 'hazo_audit/server'
await runWithAuditContext(
{ actor_kind: 'user', actor_user_id: session.user.id, scope_id: session.scope_id },
() => next(),
)// any field render site
import { FieldAuditIcon } from 'hazo_audit/client'
<FieldAuditIcon subjectKind="client_folder" subjectId={folder.id} fieldPath="client_name" />That's it. Insert/update/delete on any app_* or hazo_* table is captured,
diffed by microdiff for JSONB columns, and made queryable at /api/audit/events.
Architecture
caller writes
│
▼
wrapWithAudit(adapter) ─── reads INI ───► capture decision
│ ├─ pipeline tables → skip
│ ├─ exclude_* matches → skip
│ ├─ include_* matches → capture
│ └─ default → skip
│
▼ (captured)
hazo_audit_outbox ◄──────────── claimRows() ────► startAuditWorker
│
│ worker resolves before_row (caller hint, or chain prior outbox)
│ microdiff(before, after) for JSONB columns
│
▼
hazo_audit_field ◄──────────── /api/audit/events ────► <FieldAuditTrail>
(scope filter, mask sensitive)Entries
| Import | What |
|---|---|
| hazo_audit | Client-safe types and permission constants (AUDIT_VIEW_HISTORY, etc.). |
| hazo_audit/server | wrapWithAudit, startAuditWorker, runWithAuditContext, createAuditedCrudService, emitIntentEvent. Server-only. |
| hazo_audit/nextjs | createAuditEventsRoutes — Next.js GET + reveal POST handlers. |
| hazo_audit/client | <FieldAuditIcon />, <FieldAuditTrail />. Safe in client components. |
Tailwind v4 setup (required for the client components)
The client components use Tailwind utility classes. With Tailwind v4's JIT,
classes inside node_modules/ are invisible unless you opt in. Add this to
your globals.css (or wherever you @import "tailwindcss";):
@import "tailwindcss";
@source "../node_modules/hazo_audit/dist";Without the @source line, the popover and trail will render without
styling.
Capture rules — config/hazo_audit_config.ini
INI is the primary source of truth. The only TypeScript escape hatch is
resolvers: {...} on wrapWithAudit (for project-specific JSONB path
canonicalisation).
[capture]
include_table_prefixes = app_, hazo_
exclude_table_prefixes = _
exclude_tables = hazo_chat_typing_indicators
[overrides.app_clients]
subject_kind = client
sensitive_columns = ssn, dob
[overrides.app_client_folders]
subject_kind = client_folder
jsonb_columns = working_paper
jsonb_path_exclude = working_paper.__prefill_data
resolver = wpCapture decision (deterministic, PRD §6.1): pipeline tables → exclude → include → default deny.
Public API (R1)
wrapWithAudit(adapter, opts?) → HazoConnectAdapter
Proxies query() and claimRows(). For writes to captured tables, enqueues
an outbox row after the underlying call succeeds. Best-effort: any audit
failure fires opts.onAuditFailure(err, ctx) but never throws into the user
write path.
wrapWithAudit(base, {
resolvers: { wp: wpResolver },
onAuditFailure: (err, ctx) => log.error('audit_failed', { ...ctx, err }),
})startAuditWorker({ app_adapter, ... }) → WorkerHandle
Drains the outbox via claimRows() (concurrency-safe), runs microdiff,
inserts field events, and dead-letters after max_attempts failures.
const worker = startAuditWorker({ app_adapter: base, poll_ms: 2000 })
// later
await worker.stop()
// or for tests
const { processed, failed } = await worker.drainOnce()runWithAuditContext(ctx, fn)
AsyncLocalStorage-backed actor identity. The wrap stamps these fields onto
each outbox row. Missing context → actor_kind: 'background_job'.
createAuditedCrudService(adapter, table) → AuditedCrudService<T>
Drop-in for hazo_connect's createCrudService plus an opts.audit hook
for supplying before_row (so the worker doesn't have to chain) and
piggybacking a Layer-1 intent_event onto the write.
await svc.updateById(folderId, patch, {
audit: {
before_row: previousRow,
intent_event: 'folder_field_edited',
intent_payload: { field: 'client_name' },
},
})emitIntentEvent({ event_name, payload?, subject_kind?, subject_id? })
Direct Layer-1 intent emission (no piggyback). withAudit(name, payload, fn)
ergonomic is Phase 2.
createAuditEventsRoutes({ audit_adapter, getAuth }) → { GET, reveal: { POST } }
Next.js route factory.
GET /api/audit/events— requiresview_audit_history. Default-filters by caller'sscope_idunless they holdview_audit_across_scopes. Sensitive rows' values masked tonullunless caller holdsview_sensitive_audit.POST /api/audit/events/[id]/reveal— additionally requiresview_sensitive_audit. Side effect: writes anaudit_revealintent row.
createAuditEventsRoutes({
audit_adapter,
getAuth: async (req) => {
const a = await hazo_get_tenant_auth(req)
if (!a.authenticated) return null
return {
user_id: a.user.id,
scope_id: a.selected_scope_id,
permissions: a.permissions,
user_label: a.user.full_name,
}
},
})<FieldAuditIcon subjectKind subjectId fieldPath />
Renders a clock icon that opens a popover with the field's audit trail. Self-positioning, no Radix dep.
<FieldAuditTrail subjectKind subjectId fieldPath? />
Renders the trail directly (use in a sidebar, modal, etc.).
Schema
Three tables (see migrations/001_init.sql for both Postgres + SQLite):
hazo_audit_outbox— pre-drain queue.hazo_audit_field— per-leaf field events (UI reads from this).hazo_audit_intent— Layer-1 named events.
CLI — hazo-audit-migrate
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
npx hazo-audit-migrate --adapter=sqlite --path=./data/app.db
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL --dry-runPostgREST cannot run DDL; use --adapter=pg against the same Postgres
instance.
Limitations (R1)
| Shape | Behavior |
|---|---|
| Single-row insert/update/delete with eq-on-id WHERE | ✅ captured |
| Bulk insert | skipped + warning (opt-in fanout_bulk_writes) |
| Bulk update / delete (non-id WHERE) | skipped + warning |
| Composite primary key | skipped + warning (Phase 2) |
| Insert without Prefer: return=representation | skipped + warning |
| Raw writes (adapter.rawQuery) | pass-through, never audited |
See PRD §15 for the deferred-feature list.
Test-app
The test-app/ directory contains a Next.js demo:
npm run dev:test-app # http://localhost:3017Sidebar walks through:
- Capture & Drain — insert/update/delete a client, watch the outbox grow.
- Worker — manual
drainOnce(), see field events appear, retry counts. - History UI —
<FieldAuditIcon />and<FieldAuditTrail />in action. - Sensitive + Reveal — SSN masking and the reveal-is-itself-audited flow.
- Skipped Writes — bulk inserts that get skipped with a warning.
- Permissions — toggle cookies to exercise 401/403/masking.
Backed by SQLite (sql.js); the demo DB lives at test-app/data/demo.db.
What's new in v2.0.0
auditIntent(adapter, name, callback) — group multi-write transactions
import { auditIntent, runWithAuditContext } from 'hazo_audit/server';
await runWithAuditContext({ actor_kind: 'user', actor_user_id: 'u1' }, () =>
auditIntent(adapter, 'person.merge', async () => {
await crud.update(personA.id, { ...mergedFields });
await crud.delete(personB.id);
}, { metadata: { reason: 'manual merge by admin' } }),
);All captured writes inside the callback share a correlation_id. An opening
hazo_audit_intent row is written when the callback starts; a closing row
(person.merge.completed or person.merge.failed) lands on completion or
throw. Nested calls reuse the outer correlation_id.
snapshot(adapter, { table, recordId, asOf })
import { snapshot } from 'hazo_audit/server';
const snap = await snapshot(adapter, {
table: 'app_persons',
recordId: 'p1',
asOf: new Date('2026-05-01T10:00:00Z'),
});
// → { state: { name: 'Alice', email: '[email protected]' }, deleted: false, exists: true }Replays hazo_audit_field events for the subject up to asOf and returns the
computed field state.
revert(app_adapter, { table, recordId, toTimestamp, reason })
import { revert, RecordNotFoundError } from 'hazo_audit/server';
try {
const result = await revert(wrapped, {
audit_adapter: base, // optional; falls back to app_adapter when omitted
table: 'app_persons',
recordId: 'p1',
toTimestamp: new Date('2026-05-01T10:00:00Z'),
actorUserId: 'admin1',
reason: 'user-requested rollback',
});
// result.applied = 'insert' | 'update' | 'delete' | 'noop'
} catch (e) {
if (e instanceof RecordNotFoundError) { /* no audit history exists */ }
}Computes the target snapshot, diffs against the current row, and applies the
necessary PATCH/INSERT/DELETE — all wrapped in an audit.revert intent.
<AuditViewer /> (R3) — cross-record admin viewer
import { AuditViewer } from 'hazo_audit/client';
<AuditViewer
api_base="/api/audit"
initialFilters={{ subject_kind: 'person' }}
/>Filterable, grouped-by-correlation_id list of all audit events with CSV export.
<RevertConfirmDialog />
Modal helper for confirming a revert with a typed reason.
Capture default exclusions widened (R2)
The sample INI now ships with hazo_logs, hazo_notifications in exclude_tables
by default. The capture matrix is documented inline in config/hazo_audit_config.ini.sample.
Related
hazo_connect— provides the adapter interface andclaimRows()primitive.hazo_auth— typical source of session + permissions forgetAuth.[email protected]+— auto-wires<FieldAuditIcon />viafield_auditprop.
See design/PRD.md for the full design rationale and decision log.
