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.
Files changed (67) hide show
  1. package/CHANGE_LOG.md +53 -0
  2. package/README.md +269 -0
  3. package/SETUP_CHECKLIST.md +186 -0
  4. package/bin/hazo-audit-migrate.js +169 -0
  5. package/config/hazo_audit_config.ini.sample +71 -0
  6. package/dist/client/FieldAuditIcon.d.ts +11 -0
  7. package/dist/client/FieldAuditIcon.d.ts.map +1 -0
  8. package/dist/client/FieldAuditIcon.js +46 -0
  9. package/dist/client/FieldAuditIcon.js.map +1 -0
  10. package/dist/client/FieldAuditTrail.d.ts +8 -0
  11. package/dist/client/FieldAuditTrail.d.ts.map +1 -0
  12. package/dist/client/FieldAuditTrail.js +136 -0
  13. package/dist/client/FieldAuditTrail.js.map +1 -0
  14. package/dist/client/cn.d.ts +3 -0
  15. package/dist/client/cn.d.ts.map +1 -0
  16. package/dist/client/cn.js +6 -0
  17. package/dist/client/cn.js.map +1 -0
  18. package/dist/client/index.d.ts +11 -0
  19. package/dist/client/index.d.ts.map +1 -0
  20. package/dist/client/index.js +9 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/types.d.ts +19 -0
  23. package/dist/client/types.d.ts.map +1 -0
  24. package/dist/client/types.js +2 -0
  25. package/dist/client/types.js.map +1 -0
  26. package/dist/index.d.ts +76 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +20 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/nextjs/index.d.ts +78 -0
  31. package/dist/nextjs/index.d.ts.map +1 -0
  32. package/dist/nextjs/index.js +229 -0
  33. package/dist/nextjs/index.js.map +1 -0
  34. package/dist/server/audited_crud.d.ts +42 -0
  35. package/dist/server/audited_crud.d.ts.map +1 -0
  36. package/dist/server/audited_crud.js +57 -0
  37. package/dist/server/audited_crud.js.map +1 -0
  38. package/dist/server/config.d.ts +70 -0
  39. package/dist/server/config.d.ts.map +1 -0
  40. package/dist/server/config.js +203 -0
  41. package/dist/server/config.js.map +1 -0
  42. package/dist/server/context.d.ts +28 -0
  43. package/dist/server/context.d.ts.map +1 -0
  44. package/dist/server/context.js +49 -0
  45. package/dist/server/context.js.map +1 -0
  46. package/dist/server/diff.d.ts +32 -0
  47. package/dist/server/diff.d.ts.map +1 -0
  48. package/dist/server/diff.js +152 -0
  49. package/dist/server/diff.js.map +1 -0
  50. package/dist/server/index.d.ts +23 -0
  51. package/dist/server/index.d.ts.map +1 -0
  52. package/dist/server/index.js +26 -0
  53. package/dist/server/index.js.map +1 -0
  54. package/dist/server/outbox.d.ts +29 -0
  55. package/dist/server/outbox.d.ts.map +1 -0
  56. package/dist/server/outbox.js +53 -0
  57. package/dist/server/outbox.js.map +1 -0
  58. package/dist/server/worker.d.ts +43 -0
  59. package/dist/server/worker.d.ts.map +1 -0
  60. package/dist/server/worker.js +248 -0
  61. package/dist/server/worker.js.map +1 -0
  62. package/dist/server/wrap.d.ts +32 -0
  63. package/dist/server/wrap.d.ts.map +1 -0
  64. package/dist/server/wrap.js +232 -0
  65. package/dist/server/wrap.js.map +1 -0
  66. package/migrations/001_init.sql +149 -0
  67. 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
+ });