hazo_audit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +53 -0
- package/README.md +269 -0
- package/SETUP_CHECKLIST.md +186 -0
- package/bin/hazo-audit-migrate.js +169 -0
- package/config/hazo_audit_config.ini.sample +71 -0
- package/dist/client/FieldAuditIcon.d.ts +11 -0
- package/dist/client/FieldAuditIcon.d.ts.map +1 -0
- package/dist/client/FieldAuditIcon.js +46 -0
- package/dist/client/FieldAuditIcon.js.map +1 -0
- package/dist/client/FieldAuditTrail.d.ts +8 -0
- package/dist/client/FieldAuditTrail.d.ts.map +1 -0
- package/dist/client/FieldAuditTrail.js +136 -0
- package/dist/client/FieldAuditTrail.js.map +1 -0
- package/dist/client/cn.d.ts +3 -0
- package/dist/client/cn.d.ts.map +1 -0
- package/dist/client/cn.js +6 -0
- package/dist/client/cn.js.map +1 -0
- package/dist/client/index.d.ts +11 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +9 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +19 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +2 -0
- package/dist/client/types.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/nextjs/index.d.ts +78 -0
- package/dist/nextjs/index.d.ts.map +1 -0
- package/dist/nextjs/index.js +229 -0
- package/dist/nextjs/index.js.map +1 -0
- package/dist/server/audited_crud.d.ts +42 -0
- package/dist/server/audited_crud.d.ts.map +1 -0
- package/dist/server/audited_crud.js +57 -0
- package/dist/server/audited_crud.js.map +1 -0
- package/dist/server/config.d.ts +70 -0
- package/dist/server/config.d.ts.map +1 -0
- package/dist/server/config.js +203 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/context.d.ts +28 -0
- package/dist/server/context.d.ts.map +1 -0
- package/dist/server/context.js +49 -0
- package/dist/server/context.js.map +1 -0
- package/dist/server/diff.d.ts +32 -0
- package/dist/server/diff.d.ts.map +1 -0
- package/dist/server/diff.js +152 -0
- package/dist/server/diff.js.map +1 -0
- package/dist/server/index.d.ts +23 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +26 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/outbox.d.ts +29 -0
- package/dist/server/outbox.d.ts.map +1 -0
- package/dist/server/outbox.js +53 -0
- package/dist/server/outbox.js.map +1 -0
- package/dist/server/worker.d.ts +43 -0
- package/dist/server/worker.d.ts.map +1 -0
- package/dist/server/worker.js +248 -0
- package/dist/server/worker.js.map +1 -0
- package/dist/server/wrap.d.ts +32 -0
- package/dist/server/wrap.d.ts.map +1 -0
- package/dist/server/wrap.js +232 -0
- package/dist/server/wrap.js.map +1 -0
- package/migrations/001_init.sql +149 -0
- package/package.json +101 -0
package/CHANGE_LOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Change log
|
|
2
|
+
|
|
3
|
+
## v1.0.0 — 2026-05-20
|
|
4
|
+
|
|
5
|
+
Initial release. Implements PRD `design/PRD.md` end-to-end.
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
|
|
9
|
+
- **`wrapWithAudit(adapter)`** — adapter proxy that captures writes to
|
|
10
|
+
configured tables. Zero source changes to `hazo_connect`. Best-effort:
|
|
11
|
+
audit failures never break user writes.
|
|
12
|
+
- **`startAuditWorker({ app_adapter })`** — in-process worker that drains
|
|
13
|
+
the outbox via hazo_connect's `claimRows()`, runs microdiff for JSONB
|
|
14
|
+
columns, retries with backoff, dead-letters after `max_attempts`.
|
|
15
|
+
- **`createAuditEventsRoutes({ audit_adapter, getAuth })`** — Next.js GET +
|
|
16
|
+
reveal POST handlers with scope filtering and sensitive-column masking.
|
|
17
|
+
Reveal is itself audited via an `audit_reveal` intent row.
|
|
18
|
+
- **`<FieldAuditIcon />` + `<FieldAuditTrail />`** — client components.
|
|
19
|
+
Zero heavy UI deps (no Radix, no hazo_ui peer required).
|
|
20
|
+
- **INI-primary configuration** at `config/hazo_audit_config.ini`. Only TS
|
|
21
|
+
escape hatch is `resolvers: {...}` on `wrapWithAudit`.
|
|
22
|
+
- **`runWithAuditContext`** — AsyncLocalStorage-backed actor identity.
|
|
23
|
+
- **`createAuditedCrudService`** — drop-in for `createCrudService` plus
|
|
24
|
+
`{ audit: { before_row, intent_event, intent_payload } }` per-call hints.
|
|
25
|
+
- **`emitIntentEvent`** — direct Layer-1 intent emission.
|
|
26
|
+
- **`bin/hazo-audit-migrate`** — idempotent schema CLI (Postgres + SQLite).
|
|
27
|
+
- **`test-app/`** — Next.js demo: capture, drain, history popover,
|
|
28
|
+
sensitive masking + reveal, skipped bulk writes, permission toggling.
|
|
29
|
+
|
|
30
|
+
### Decisions log
|
|
31
|
+
|
|
32
|
+
22 design decisions from the interview that produced this PRD are captured
|
|
33
|
+
in `design/PRD.md` §16. Notable ones:
|
|
34
|
+
|
|
35
|
+
- Audit lives in its own package, not in `hazo_connect`.
|
|
36
|
+
- Adapter is wrapped from outside (no producer code changes).
|
|
37
|
+
- INI primary config; the only TS shape is `resolvers`.
|
|
38
|
+
- Default-deny capture with include/exclude prefix + exact name lists.
|
|
39
|
+
- Worker uses `claimRows()` for concurrency safety.
|
|
40
|
+
- Sensitive handling: store raw, mask at read, reveal writes its own intent.
|
|
41
|
+
- R1 supports single-row writes only; bulk + composite PK + non-id WHERE
|
|
42
|
+
are skipped with a structured warning.
|
|
43
|
+
|
|
44
|
+
### Known limitations (R1)
|
|
45
|
+
|
|
46
|
+
See PRD §10 and §15.
|
|
47
|
+
|
|
48
|
+
- Bulk writes skip by default; opt-in via `fanout_bulk_writes` per table.
|
|
49
|
+
Pre-write GET fan-out is acknowledged but not implemented in R1.
|
|
50
|
+
- Composite primary keys not supported.
|
|
51
|
+
- `withAudit(name, payload, () => writes)` ergonomic deferred to Phase 2.
|
|
52
|
+
- No standalone worker CLI (`bin/hazo-audit-worker`) yet.
|
|
53
|
+
- No CSV export / cold archival.
|
package/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# hazo_audit
|
|
2
|
+
|
|
3
|
+
Field-level audit trail for `hazo_connect`-backed apps. Attach to any adapter
|
|
4
|
+
with `wrapWithAudit()` and every write to a captured table auto-produces an
|
|
5
|
+
outbox row, drained by an in-process worker into per-field events the UI can
|
|
6
|
+
render via `<FieldAuditIcon />`.
|
|
7
|
+
|
|
8
|
+
Zero changes to `hazo_connect`. Zero per-route boilerplate. Add a new
|
|
9
|
+
audited table with an INI block, not a code change.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm i hazo_audit
|
|
13
|
+
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// instrumentation.ts
|
|
20
|
+
import { wrapWithAudit, startAuditWorker } from 'hazo_audit/server'
|
|
21
|
+
import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup'
|
|
22
|
+
|
|
23
|
+
const base = getHazoConnectSingleton()
|
|
24
|
+
const adapter = wrapWithAudit(base, { resolvers: { wp: wpResolver } })
|
|
25
|
+
startAuditWorker({ app_adapter: base })
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// middleware (or request handler)
|
|
30
|
+
import { runWithAuditContext } from 'hazo_audit/server'
|
|
31
|
+
|
|
32
|
+
await runWithAuditContext(
|
|
33
|
+
{ actor_kind: 'user', actor_user_id: session.user.id, scope_id: session.scope_id },
|
|
34
|
+
() => next(),
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// any field render site
|
|
40
|
+
import { FieldAuditIcon } from 'hazo_audit/client'
|
|
41
|
+
|
|
42
|
+
<FieldAuditIcon subjectKind="client_folder" subjectId={folder.id} fieldPath="client_name" />
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That's it. Insert/update/delete on any `app_*` or `hazo_*` table is captured,
|
|
46
|
+
diffed by microdiff for JSONB columns, and made queryable at `/api/audit/events`.
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
caller writes
|
|
52
|
+
│
|
|
53
|
+
▼
|
|
54
|
+
wrapWithAudit(adapter) ─── reads INI ───► capture decision
|
|
55
|
+
│ ├─ pipeline tables → skip
|
|
56
|
+
│ ├─ exclude_* matches → skip
|
|
57
|
+
│ ├─ include_* matches → capture
|
|
58
|
+
│ └─ default → skip
|
|
59
|
+
│
|
|
60
|
+
▼ (captured)
|
|
61
|
+
hazo_audit_outbox ◄──────────── claimRows() ────► startAuditWorker
|
|
62
|
+
│
|
|
63
|
+
│ worker resolves before_row (caller hint, or chain prior outbox)
|
|
64
|
+
│ microdiff(before, after) for JSONB columns
|
|
65
|
+
│
|
|
66
|
+
▼
|
|
67
|
+
hazo_audit_field ◄──────────── /api/audit/events ────► <FieldAuditTrail>
|
|
68
|
+
(scope filter, mask sensitive)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Entries
|
|
72
|
+
|
|
73
|
+
| Import | What |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `hazo_audit` | Client-safe types and permission constants (`AUDIT_VIEW_HISTORY`, etc.). |
|
|
76
|
+
| `hazo_audit/server` | `wrapWithAudit`, `startAuditWorker`, `runWithAuditContext`, `createAuditedCrudService`, `emitIntentEvent`. Server-only. |
|
|
77
|
+
| `hazo_audit/nextjs` | `createAuditEventsRoutes` — Next.js GET + reveal POST handlers. |
|
|
78
|
+
| `hazo_audit/client` | `<FieldAuditIcon />`, `<FieldAuditTrail />`. Safe in client components. |
|
|
79
|
+
|
|
80
|
+
### Tailwind v4 setup (required for the client components)
|
|
81
|
+
|
|
82
|
+
The client components use Tailwind utility classes. With Tailwind v4's JIT,
|
|
83
|
+
classes inside `node_modules/` are invisible unless you opt in. Add this to
|
|
84
|
+
your `globals.css` (or wherever you `@import "tailwindcss";`):
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
@import "tailwindcss";
|
|
88
|
+
@source "../node_modules/hazo_audit/dist";
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Without the `@source` line, the popover and trail will render without
|
|
92
|
+
styling.
|
|
93
|
+
|
|
94
|
+
## Capture rules — `config/hazo_audit_config.ini`
|
|
95
|
+
|
|
96
|
+
INI is the **primary** source of truth. The only TypeScript escape hatch is
|
|
97
|
+
`resolvers: {...}` on `wrapWithAudit` (for project-specific JSONB path
|
|
98
|
+
canonicalisation).
|
|
99
|
+
|
|
100
|
+
```ini
|
|
101
|
+
[capture]
|
|
102
|
+
include_table_prefixes = app_, hazo_
|
|
103
|
+
exclude_table_prefixes = _
|
|
104
|
+
exclude_tables = hazo_chat_typing_indicators
|
|
105
|
+
|
|
106
|
+
[overrides.app_clients]
|
|
107
|
+
subject_kind = client
|
|
108
|
+
sensitive_columns = ssn, dob
|
|
109
|
+
|
|
110
|
+
[overrides.app_client_folders]
|
|
111
|
+
subject_kind = client_folder
|
|
112
|
+
jsonb_columns = working_paper
|
|
113
|
+
jsonb_path_exclude = working_paper.__prefill_data
|
|
114
|
+
resolver = wp
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Capture decision (deterministic, PRD §6.1): pipeline tables → exclude →
|
|
118
|
+
include → default deny.
|
|
119
|
+
|
|
120
|
+
## Public API (R1)
|
|
121
|
+
|
|
122
|
+
### `wrapWithAudit(adapter, opts?)` → `HazoConnectAdapter`
|
|
123
|
+
|
|
124
|
+
Proxies `query()` and `claimRows()`. For writes to captured tables, enqueues
|
|
125
|
+
an outbox row after the underlying call succeeds. Best-effort: any audit
|
|
126
|
+
failure fires `opts.onAuditFailure(err, ctx)` but never throws into the user
|
|
127
|
+
write path.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
wrapWithAudit(base, {
|
|
131
|
+
resolvers: { wp: wpResolver },
|
|
132
|
+
onAuditFailure: (err, ctx) => log.error('audit_failed', { ...ctx, err }),
|
|
133
|
+
})
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `startAuditWorker({ app_adapter, ... })` → `WorkerHandle`
|
|
137
|
+
|
|
138
|
+
Drains the outbox via `claimRows()` (concurrency-safe), runs microdiff,
|
|
139
|
+
inserts field events, and dead-letters after `max_attempts` failures.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const worker = startAuditWorker({ app_adapter: base, poll_ms: 2000 })
|
|
143
|
+
// later
|
|
144
|
+
await worker.stop()
|
|
145
|
+
// or for tests
|
|
146
|
+
const { processed, failed } = await worker.drainOnce()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `runWithAuditContext(ctx, fn)`
|
|
150
|
+
|
|
151
|
+
AsyncLocalStorage-backed actor identity. The wrap stamps these fields onto
|
|
152
|
+
each outbox row. Missing context → `actor_kind: 'background_job'`.
|
|
153
|
+
|
|
154
|
+
### `createAuditedCrudService(adapter, table)` → `AuditedCrudService<T>`
|
|
155
|
+
|
|
156
|
+
Drop-in for `hazo_connect`'s `createCrudService` plus an `opts.audit` hook
|
|
157
|
+
for supplying `before_row` (so the worker doesn't have to chain) and
|
|
158
|
+
piggybacking a Layer-1 `intent_event` onto the write.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
await svc.updateById(folderId, patch, {
|
|
162
|
+
audit: {
|
|
163
|
+
before_row: previousRow,
|
|
164
|
+
intent_event: 'folder_field_edited',
|
|
165
|
+
intent_payload: { field: 'client_name' },
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### `emitIntentEvent({ event_name, payload?, subject_kind?, subject_id? })`
|
|
171
|
+
|
|
172
|
+
Direct Layer-1 intent emission (no piggyback). `withAudit(name, payload, fn)`
|
|
173
|
+
ergonomic is Phase 2.
|
|
174
|
+
|
|
175
|
+
### `createAuditEventsRoutes({ audit_adapter, getAuth })` → `{ GET, reveal: { POST } }`
|
|
176
|
+
|
|
177
|
+
Next.js route factory.
|
|
178
|
+
|
|
179
|
+
- `GET /api/audit/events` — requires `view_audit_history`. Default-filters
|
|
180
|
+
by caller's `scope_id` unless they hold `view_audit_across_scopes`.
|
|
181
|
+
Sensitive rows' values masked to `null` unless caller holds
|
|
182
|
+
`view_sensitive_audit`.
|
|
183
|
+
- `POST /api/audit/events/[id]/reveal` — additionally requires
|
|
184
|
+
`view_sensitive_audit`. Side effect: writes an `audit_reveal` intent row.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
createAuditEventsRoutes({
|
|
188
|
+
audit_adapter,
|
|
189
|
+
getAuth: async (req) => {
|
|
190
|
+
const a = await hazo_get_tenant_auth(req)
|
|
191
|
+
if (!a.authenticated) return null
|
|
192
|
+
return {
|
|
193
|
+
user_id: a.user.id,
|
|
194
|
+
scope_id: a.selected_scope_id,
|
|
195
|
+
permissions: a.permissions,
|
|
196
|
+
user_label: a.user.full_name,
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `<FieldAuditIcon subjectKind subjectId fieldPath />`
|
|
203
|
+
|
|
204
|
+
Renders a clock icon that opens a popover with the field's audit trail.
|
|
205
|
+
Self-positioning, no Radix dep.
|
|
206
|
+
|
|
207
|
+
### `<FieldAuditTrail subjectKind subjectId fieldPath? />`
|
|
208
|
+
|
|
209
|
+
Renders the trail directly (use in a sidebar, modal, etc.).
|
|
210
|
+
|
|
211
|
+
## Schema
|
|
212
|
+
|
|
213
|
+
Three tables (see `migrations/001_init.sql` for both Postgres + SQLite):
|
|
214
|
+
|
|
215
|
+
- `hazo_audit_outbox` — pre-drain queue.
|
|
216
|
+
- `hazo_audit_field` — per-leaf field events (UI reads from this).
|
|
217
|
+
- `hazo_audit_intent` — Layer-1 named events.
|
|
218
|
+
|
|
219
|
+
## CLI — `hazo-audit-migrate`
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
|
|
223
|
+
npx hazo-audit-migrate --adapter=sqlite --path=./data/app.db
|
|
224
|
+
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL --dry-run
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
PostgREST cannot run DDL; use `--adapter=pg` against the same Postgres
|
|
228
|
+
instance.
|
|
229
|
+
|
|
230
|
+
## Limitations (R1)
|
|
231
|
+
|
|
232
|
+
| Shape | Behavior |
|
|
233
|
+
|---|---|
|
|
234
|
+
| Single-row insert/update/delete with eq-on-id WHERE | ✅ captured |
|
|
235
|
+
| Bulk insert | skipped + warning (opt-in `fanout_bulk_writes`) |
|
|
236
|
+
| Bulk update / delete (non-id WHERE) | skipped + warning |
|
|
237
|
+
| Composite primary key | skipped + warning (Phase 2) |
|
|
238
|
+
| Insert without `Prefer: return=representation` | skipped + warning |
|
|
239
|
+
| Raw writes (`adapter.rawQuery`) | pass-through, never audited |
|
|
240
|
+
|
|
241
|
+
See PRD §15 for the deferred-feature list.
|
|
242
|
+
|
|
243
|
+
## Test-app
|
|
244
|
+
|
|
245
|
+
The `test-app/` directory contains a Next.js demo:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npm run dev:test-app # http://localhost:3017
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Sidebar walks through:
|
|
252
|
+
|
|
253
|
+
- Capture & Drain — insert/update/delete a client, watch the outbox grow.
|
|
254
|
+
- Worker — manual `drainOnce()`, see field events appear, retry counts.
|
|
255
|
+
- History UI — `<FieldAuditIcon />` and `<FieldAuditTrail />` in action.
|
|
256
|
+
- Sensitive + Reveal — SSN masking and the reveal-is-itself-audited flow.
|
|
257
|
+
- Skipped Writes — bulk inserts that get skipped with a warning.
|
|
258
|
+
- Permissions — toggle cookies to exercise 401/403/masking.
|
|
259
|
+
|
|
260
|
+
Backed by SQLite (sql.js); the demo DB lives at `test-app/data/demo.db`.
|
|
261
|
+
|
|
262
|
+
## Related
|
|
263
|
+
|
|
264
|
+
- `hazo_connect` — provides the adapter interface and `claimRows()` primitive.
|
|
265
|
+
- `hazo_auth` — typical source of session + permissions for `getAuth`.
|
|
266
|
+
- `hazo_collab_forms@5.10.0+` — auto-wires `<FieldAuditIcon />` via
|
|
267
|
+
`field_audit` prop.
|
|
268
|
+
|
|
269
|
+
See `design/PRD.md` for the full design rationale and decision log.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SETUP_CHECKLIST — `hazo_audit`
|
|
2
|
+
|
|
3
|
+
Step-by-step setup for a consuming app. Assumes `hazo_connect` is already
|
|
4
|
+
configured.
|
|
5
|
+
|
|
6
|
+
## 1. Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i hazo_audit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Optional but typical peers:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i hazo_auth # session + permissions for the routes
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 2. Apply the schema
|
|
19
|
+
|
|
20
|
+
Choose the adapter that matches your production DB. PostgREST users still
|
|
21
|
+
need a direct Postgres connection for DDL — point `--connection-string` at
|
|
22
|
+
the underlying Postgres URL.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Postgres
|
|
26
|
+
npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
|
|
27
|
+
|
|
28
|
+
# SQLite (local dev)
|
|
29
|
+
npx hazo-audit-migrate --adapter=sqlite --path=./data/app.db
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Adds three tables: `hazo_audit_outbox`, `hazo_audit_field`,
|
|
33
|
+
`hazo_audit_intent`. Idempotent — safe to re-run.
|
|
34
|
+
|
|
35
|
+
## 3. Drop in the capture config
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
mkdir -p config
|
|
39
|
+
cp node_modules/hazo_audit/config/hazo_audit_config.ini.sample config/hazo_audit_config.ini
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Edit to taste. Defaults capture every `app_*` and `hazo_*` table except
|
|
43
|
+
pipeline tables. Per-table overrides for `sensitive_columns`,
|
|
44
|
+
`jsonb_columns`, and resolvers go here too.
|
|
45
|
+
|
|
46
|
+
## 4. Wrap your adapter (`instrumentation.ts`)
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// instrumentation.ts
|
|
50
|
+
import { wrapWithAudit, startAuditWorker } from 'hazo_audit/server'
|
|
51
|
+
import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup'
|
|
52
|
+
|
|
53
|
+
let registered = false
|
|
54
|
+
|
|
55
|
+
export function register() {
|
|
56
|
+
if (registered) return
|
|
57
|
+
registered = true
|
|
58
|
+
const base = getHazoConnectSingleton()
|
|
59
|
+
const adapter = wrapWithAudit(base, {
|
|
60
|
+
resolvers: { /* wp: wpResolver, ... */ },
|
|
61
|
+
onAuditFailure: (err, ctx) => console.error('[audit]', ctx, err.message),
|
|
62
|
+
})
|
|
63
|
+
// Export `adapter` instead of `base` from wherever your app gets its adapter.
|
|
64
|
+
;(globalThis as any).__hazo_adapter = adapter
|
|
65
|
+
startAuditWorker({ app_adapter: base })
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Make sure your app imports the **wrapped** adapter (the one returned by
|
|
70
|
+
`wrapWithAudit`) wherever it would have imported the base adapter. The
|
|
71
|
+
worker uses the base adapter (the wrap is not needed for outbox drain).
|
|
72
|
+
|
|
73
|
+
## 5. Stamp actor context per request
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// middleware.ts (or your auth wrapper)
|
|
77
|
+
import { runWithAuditContext } from 'hazo_audit/server'
|
|
78
|
+
|
|
79
|
+
await runWithAuditContext(
|
|
80
|
+
{
|
|
81
|
+
actor_kind: 'user',
|
|
82
|
+
actor_user_id: session.user.id,
|
|
83
|
+
actor_label: session.user.full_name,
|
|
84
|
+
scope_id: session.scope_id,
|
|
85
|
+
},
|
|
86
|
+
() => handler(),
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Without this, every captured write defaults to
|
|
91
|
+
`actor_kind: 'background_job'`.
|
|
92
|
+
|
|
93
|
+
## 6. Mount the routes (Next.js App Router)
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// app/api/audit/events/route.ts
|
|
97
|
+
import { createAuditEventsRoutes } from 'hazo_audit/nextjs'
|
|
98
|
+
import { hazo_get_tenant_auth } from 'hazo_auth/lib/auth/hazo_get_tenant_auth.server'
|
|
99
|
+
import { getHazoConnectSingleton } from 'hazo_connect/nextjs/setup'
|
|
100
|
+
|
|
101
|
+
const routes = createAuditEventsRoutes({
|
|
102
|
+
audit_adapter: getHazoConnectSingleton(),
|
|
103
|
+
getAuth: async (req) => {
|
|
104
|
+
const a = await hazo_get_tenant_auth(req as any)
|
|
105
|
+
if (!a.authenticated) return null
|
|
106
|
+
return {
|
|
107
|
+
user_id: a.user.id,
|
|
108
|
+
scope_id: a.selected_scope_id,
|
|
109
|
+
permissions: a.permissions,
|
|
110
|
+
user_label: a.user.full_name,
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export const GET = routes.GET
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// app/api/audit/events/[id]/reveal/route.ts
|
|
120
|
+
import { /* same as above */ } from '...'
|
|
121
|
+
|
|
122
|
+
const routes = createAuditEventsRoutes(/* same opts */)
|
|
123
|
+
|
|
124
|
+
export const POST = routes.reveal.POST
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## 7. Grant the audit permissions
|
|
128
|
+
|
|
129
|
+
The routes check three permission strings (constants exported from
|
|
130
|
+
`hazo_audit`):
|
|
131
|
+
|
|
132
|
+
| Constant | Effect when missing |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `view_audit_history` | 403 on `/audit/events` and `/reveal`. |
|
|
135
|
+
| `view_sensitive_audit` | Sensitive `before_value`/`after_value` returned as `null`. `/reveal` 403. |
|
|
136
|
+
| `view_audit_across_scopes` | Results filtered to caller's `scope_id`. |
|
|
137
|
+
|
|
138
|
+
Assign via your hazo_auth role config (e.g.
|
|
139
|
+
`hazo_auth/role_permissions.json`).
|
|
140
|
+
|
|
141
|
+
## 8. Render the UI
|
|
142
|
+
|
|
143
|
+
Either inline:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { FieldAuditIcon } from 'hazo_audit/client'
|
|
147
|
+
|
|
148
|
+
<FieldAuditIcon subjectKind="client_folder" subjectId={folder.id} fieldPath="client_name" />
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
…or via `hazo_collab_forms@5.10.0+`:
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
<HazoDataForm
|
|
155
|
+
field_audit={{ subjectKind: 'client_folder', subjectId: folder.id }}
|
|
156
|
+
{/* ... */}
|
|
157
|
+
/>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`hazo_collab_forms` injects the icon into
|
|
161
|
+
`field_action_components.row_end` for every field.
|
|
162
|
+
|
|
163
|
+
## 9. Smoke test
|
|
164
|
+
|
|
165
|
+
1. Edit any field that lands in a captured table.
|
|
166
|
+
2. Check `hazo_audit_outbox` — should have one row with your write.
|
|
167
|
+
3. Wait ~3s (or the configured `worker.poll_ms`) for drain.
|
|
168
|
+
4. Check `hazo_audit_field` — should have one row per changed scalar / JSONB
|
|
169
|
+
leaf.
|
|
170
|
+
5. Hit `/api/audit/events?subject_kind=...&subject_id=...` — should return
|
|
171
|
+
the field events with `actor_user_id` set to the logged-in user.
|
|
172
|
+
6. Click the field's audit icon — popover shows the history.
|
|
173
|
+
|
|
174
|
+
If the popover is empty for a known-captured field, check the dev console
|
|
175
|
+
for `audit.skipped_unsupported_shape` warnings (most common cause: the
|
|
176
|
+
adapter didn't return rows on insert because `Prefer: return=representation`
|
|
177
|
+
wasn't set).
|
|
178
|
+
|
|
179
|
+
## 10. Tune the INI (optional)
|
|
180
|
+
|
|
181
|
+
| Section | What to consider |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `[capture]` | Add `include_tables` for any table not matching a prefix; `exclude_tables` for noisy ones. |
|
|
184
|
+
| `[retention]` | Default 30d for drained outbox, 90d for dead-lettered. Bump if you need longer auditability. |
|
|
185
|
+
| `[worker]` | Bump `poll_ms` down for snappier history (CPU cost: minimal at single-pod scale). |
|
|
186
|
+
| `[overrides.<table>]` | `sensitive_columns`, `jsonb_columns`, `jsonb_path_exclude`, `resolver`, `fanout_bulk_writes`. |
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hazo-audit-migrate
|
|
4
|
+
*
|
|
5
|
+
* Idempotently applies the hazo_audit schema migrations to a configured
|
|
6
|
+
* database. Reads connection info from CLI flags or env vars.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx hazo-audit-migrate --adapter=pg --connection-string=$DATABASE_URL
|
|
10
|
+
* npx hazo-audit-migrate --adapter=sqlite --path=./data/app.db
|
|
11
|
+
*
|
|
12
|
+
* Env vars (used when flags omitted):
|
|
13
|
+
* DATABASE_URL (pg)
|
|
14
|
+
* SQLITE_PATH (sqlite)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { dirname, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const MIGRATION_FILE = resolve(__dirname, '../migrations/001_init.sql');
|
|
23
|
+
|
|
24
|
+
function parseArgs(argv) {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const arg of argv) {
|
|
27
|
+
if (!arg.startsWith('--')) continue;
|
|
28
|
+
const eq = arg.indexOf('=');
|
|
29
|
+
if (eq === -1) {
|
|
30
|
+
out[arg.slice(2)] = true;
|
|
31
|
+
} else {
|
|
32
|
+
out[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function help() {
|
|
39
|
+
console.error(`hazo-audit-migrate — apply hazo_audit schema migrations.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
hazo-audit-migrate --adapter=<pg|sqlite> [flags...]
|
|
43
|
+
|
|
44
|
+
Adapters:
|
|
45
|
+
--adapter=pg --connection-string=URL
|
|
46
|
+
--adapter=sqlite --path=FILE
|
|
47
|
+
|
|
48
|
+
Other:
|
|
49
|
+
--sql=FILE Override the SQL file (defaults to bundled 001_init.sql).
|
|
50
|
+
--dry-run Print SQL without applying.
|
|
51
|
+
|
|
52
|
+
Note: PostgREST cannot run DDL; use --adapter=pg pointed at the same Postgres
|
|
53
|
+
instance to apply migrations, then point your app at PostgREST as usual.
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function splitStatements(sql) {
|
|
58
|
+
const lines = sql.split('\n').filter((l) => !l.trim().startsWith('--'));
|
|
59
|
+
const joined = lines.join('\n');
|
|
60
|
+
return joined
|
|
61
|
+
.split(/;\s*(?:\r?\n|$)/)
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter((s) => s.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runPg(args, statements) {
|
|
67
|
+
const connStr = args['connection-string'] || process.env.DATABASE_URL;
|
|
68
|
+
if (!connStr) {
|
|
69
|
+
throw new Error('pg: --connection-string=... or DATABASE_URL required');
|
|
70
|
+
}
|
|
71
|
+
const mod = await import('postgres').catch(() => {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'pg adapter needs the `postgres` npm package. Install in your app: `npm i postgres`.',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
const sql = mod.default(connStr);
|
|
77
|
+
try {
|
|
78
|
+
for (const stmt of statements) {
|
|
79
|
+
await sql.unsafe(stmt);
|
|
80
|
+
}
|
|
81
|
+
console.log(`Applied ${statements.length} statement(s).`);
|
|
82
|
+
} finally {
|
|
83
|
+
await sql.end({ timeout: 5 });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function runSqlite(args) {
|
|
88
|
+
const path = args.path || process.env.SQLITE_PATH;
|
|
89
|
+
if (!path) throw new Error('sqlite: --path=... or SQLITE_PATH required');
|
|
90
|
+
const mod = await import('better-sqlite3').catch(() => {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'sqlite adapter needs `better-sqlite3`. Install in your app: `npm i better-sqlite3`.',
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
const Database = mod.default;
|
|
96
|
+
const db = new Database(path);
|
|
97
|
+
try {
|
|
98
|
+
const sqliteStatements = extractSqliteBlock(
|
|
99
|
+
readFileSync(MIGRATION_FILE, 'utf-8'),
|
|
100
|
+
);
|
|
101
|
+
const tx = db.transaction(() => {
|
|
102
|
+
for (const stmt of sqliteStatements) {
|
|
103
|
+
db.prepare(stmt).run();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
tx();
|
|
107
|
+
console.log(
|
|
108
|
+
`Applied ${sqliteStatements.length} SQLite statement(s) to ${path}.`,
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
db.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract the commented SQLite block from the bundled SQL, stripping the
|
|
117
|
+
* leading "-- " from each line.
|
|
118
|
+
*/
|
|
119
|
+
function extractSqliteBlock(sql) {
|
|
120
|
+
const start = sql.indexOf('-- SQLite version');
|
|
121
|
+
const end = sql.indexOf('-- PostgreSQL version');
|
|
122
|
+
if (start === -1 || end === -1) return [];
|
|
123
|
+
const block = sql
|
|
124
|
+
.slice(start, end)
|
|
125
|
+
.split('\n')
|
|
126
|
+
.map((l) => l.replace(/^-- ?/, '').trim())
|
|
127
|
+
.filter((l) => l.length > 0 && !l.startsWith('SQLite version'))
|
|
128
|
+
.join('\n');
|
|
129
|
+
return splitStatements(block);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const args = parseArgs(process.argv.slice(2));
|
|
134
|
+
if (args.help || args.h) {
|
|
135
|
+
help();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
const adapter = args.adapter;
|
|
139
|
+
if (!adapter) {
|
|
140
|
+
help();
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (args['dry-run']) {
|
|
145
|
+
const sqlFile = args.sql ? resolve(process.cwd(), args.sql) : MIGRATION_FILE;
|
|
146
|
+
const sql = readFileSync(sqlFile, 'utf-8');
|
|
147
|
+
const statements =
|
|
148
|
+
adapter === 'sqlite' ? extractSqliteBlock(sql) : splitStatements(sql);
|
|
149
|
+
console.log(`-- Would apply ${statements.length} statement(s) from ${sqlFile}:`);
|
|
150
|
+
for (const [i, s] of statements.entries()) {
|
|
151
|
+
console.log(`-- [${i + 1}/${statements.length}]`);
|
|
152
|
+
console.log(s + ';');
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (adapter === 'pg') {
|
|
158
|
+
const sqlFile = args.sql ? resolve(process.cwd(), args.sql) : MIGRATION_FILE;
|
|
159
|
+
const sql = readFileSync(sqlFile, 'utf-8');
|
|
160
|
+
return runPg(args, splitStatements(sql));
|
|
161
|
+
}
|
|
162
|
+
if (adapter === 'sqlite') return runSqlite(args);
|
|
163
|
+
throw new Error(`Unknown adapter: ${adapter}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
main().catch((err) => {
|
|
167
|
+
console.error('hazo-audit-migrate failed:', err.message);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|