feat(flows): operator-authored flows HTTP API + audit log (NF-7) #363

Merged
code-lead merged 1 commit from feat/328-nf7-operator-api into main 2026-04-24 16:18:55 +00:00
Collaborator

Summary

  • Adds the operator-authored mutation surface on top of NF-4's read-only flow endpoints (create / update / delete / enable / disable / dry-run / revert) with a full version history.
  • Every mutation goes through the existing guardMutating auth gate and writes an atomic audit row alongside the live table via a new withFlowTransaction helper.
  • Default-flow protection is enforced uniformly: every mutation on source="default" rows 409s.

New endpoints

Method Path Behaviour
POST /flows Create flow (version = 1, source="operator") → 201
PATCH /flows/:id Replace body, bump version. Default flows 409.
DELETE /flows/:id Remove flow. Default flows 409.
POST /flows/:id/enable / disable Toggle + version bump (no-op when unchanged)
POST /flows/:id/dry-run Ephemeral executor run (mutationMode: "dry-run") — returns { run_id: "ephemeral", nodes: [...] }. Not persisted to flow_runs.
POST /flows/:id/revert?version=N Restore a historical body; bumps version forward.
GET /flows/:id/versions Per-version summary with nodes added / removed / modified counts.
GET /flows/:id/versions/:version Full historical body snapshot.

Design notes

  • Validation: extracted into apps/server/src/domain/flows/flow-validation.ts. Surface-level checks (id regex ^[a-z0-9-]+$, version === 1, on.triggerTRIGGER_KINDS, optional priority/mutex_group) run first; then defaultRegistry().compile(body) covers cycles, port-type mismatches, and unknown node types. Any failure returns 422 with { error, detail: { nodes, edges, global } } — mirrors NF-UI-3's FlowValidationError so the dashboard can render without translation.
  • Audit log: new flow_audit SQLite table with version_before/version_after + body_before/body_after + operator + action. Writes happen inside withFlowTransaction so the log can never drift from the live table. Survives flow deletion so history stays navigable post-delete.
  • Auth shape: handlers take the resolved operator as a string argument. main.ts extracts it from the Hono context after guardMutating has already validated it matches auth.operator_user. Pre-M18-8 deployments without an auth block fall back to a static "operator" label so the flow_audit.operator column stays NOT NULL.
  • Atomicity: delete writes the audit row BEFORE removing the flow so the txn rolls back cleanly on either-side failure.
  • just flows-apply <file.json>: probes GET /flows/:id and decides POST vs PATCH. curl + jq only — no Bun dependency in the recipe itself. Respects CLAUDE_HOOKS_URL + CLAUDE_HOOKS_OPERATOR env.

Test plan

  • bun test src/http/flows-routes.test.ts → 63 pass (17 NF-4 + 46 NF-7)
  • bun test src/http/auth.test.ts → 49 pass (adds 14 new route-level auth assertions covering every NF-7 mutation + the two read-only GETs)
  • bun x tsc --noEmit clean
  • bun x biome check . clean
  • Full suite (bun test): 1790 pass, 4 pre-existing unrelated failures (sweeper.test.ts, handlers/foreman.test.ts) confirmed against main before my branch

Covered behaviours

  • CRUD happy paths for create / update / delete / enable / disable
  • 403 from handleRequest on every NF-7 mutation without Remote-User
  • 422 on: non-object body, bad id regex, invalid trigger, non-array nodes/edges, unknown node type, cycle, non-number priority, non-string mutex_group
  • 409 on editing / deleting / toggling / reverting a default flow
  • 409 on duplicate id
  • 422 when PATCH body's .id ≠ URL path id
  • 400 on invalid JSON + missing trigger + non-integer version query-param
  • Dry-run: runs executor, returns run_id: "ephemeral", does NOT create a flow_runs row
  • Version bumping: create = 1, update = 2, disable = 3, enable = 4 (all emit distinct audit rows)
  • Revert: v1 → v3 bump, re-writes live body, reverted_from_version in response, action="revert" audit row
  • GET /flows/:id/versions with node-diff counts; 200 after delete (history outlives the row)
  • Audit-log canonical JSON body

Out of scope

  • UI work (NF-UI-* track).
  • Named dry-run fixture registry — the endpoint accepts { trigger } today and 422s on { fixture } with a clear marker.
  • Partial-body patches (PATCH replaces wholesale).
  • 401-vs-403 split — this repo's checkOperatorAuth returns 403 uniformly (used by every other auth-gated route); aligning would be a cross-cutting change.

Closes #328

🤖 Generated with Claude Code

## Summary - Adds the operator-authored mutation surface on top of NF-4's read-only flow endpoints (create / update / delete / enable / disable / dry-run / revert) with a full version history. - Every mutation goes through the existing `guardMutating` auth gate and writes an atomic audit row alongside the live table via a new `withFlowTransaction` helper. - Default-flow protection is enforced uniformly: every mutation on `source="default"` rows 409s. ## New endpoints | Method | Path | Behaviour | |---|---|---| | `POST` | `/flows` | Create flow (version = 1, `source="operator"`) → 201 | | `PATCH` | `/flows/:id` | Replace body, bump version. Default flows 409. | | `DELETE` | `/flows/:id` | Remove flow. Default flows 409. | | `POST` | `/flows/:id/enable` / `disable` | Toggle + version bump (no-op when unchanged) | | `POST` | `/flows/:id/dry-run` | Ephemeral executor run (`mutationMode: "dry-run"`) — returns `{ run_id: "ephemeral", nodes: [...] }`. **Not** persisted to `flow_runs`. | | `POST` | `/flows/:id/revert?version=N` | Restore a historical body; bumps version forward. | | `GET` | `/flows/:id/versions` | Per-version summary with nodes added / removed / modified counts. | | `GET` | `/flows/:id/versions/:version` | Full historical body snapshot. | ## Design notes - **Validation**: extracted into `apps/server/src/domain/flows/flow-validation.ts`. Surface-level checks (id regex `^[a-z0-9-]+$`, `version === 1`, `on.trigger` ∈ `TRIGGER_KINDS`, optional `priority`/`mutex_group`) run first; then `defaultRegistry().compile(body)` covers cycles, port-type mismatches, and unknown node types. Any failure returns 422 with `{ error, detail: { nodes, edges, global } }` — mirrors NF-UI-3's `FlowValidationError` so the dashboard can render without translation. - **Audit log**: new `flow_audit` SQLite table with `version_before`/`version_after` + `body_before`/`body_after` + `operator` + `action`. Writes happen inside `withFlowTransaction` so the log can never drift from the live table. Survives flow deletion so history stays navigable post-delete. - **Auth shape**: handlers take the resolved operator as a string argument. `main.ts` extracts it from the Hono context after `guardMutating` has already validated it matches `auth.operator_user`. Pre-M18-8 deployments without an `auth` block fall back to a static `"operator"` label so the `flow_audit.operator` column stays NOT NULL. - **Atomicity**: delete writes the audit row BEFORE removing the flow so the txn rolls back cleanly on either-side failure. - **`just flows-apply <file.json>`**: probes `GET /flows/:id` and decides POST vs PATCH. `curl` + `jq` only — no Bun dependency in the recipe itself. Respects `CLAUDE_HOOKS_URL` + `CLAUDE_HOOKS_OPERATOR` env. ## Test plan - [x] `bun test src/http/flows-routes.test.ts` → 63 pass (17 NF-4 + 46 NF-7) - [x] `bun test src/http/auth.test.ts` → 49 pass (adds 14 new route-level auth assertions covering every NF-7 mutation + the two read-only GETs) - [x] `bun x tsc --noEmit` clean - [x] `bun x biome check .` clean - [x] Full suite (`bun test`): 1790 pass, 4 pre-existing unrelated failures (`sweeper.test.ts`, `handlers/foreman.test.ts`) confirmed against `main` before my branch ### Covered behaviours - CRUD happy paths for create / update / delete / enable / disable - 403 from `handleRequest` on every NF-7 mutation without `Remote-User` - 422 on: non-object body, bad id regex, invalid trigger, non-array nodes/edges, unknown node type, cycle, non-number `priority`, non-string `mutex_group` - 409 on editing / deleting / toggling / reverting a default flow - 409 on duplicate id - 422 when PATCH body's `.id` ≠ URL path id - 400 on invalid JSON + missing trigger + non-integer version query-param - Dry-run: runs executor, returns `run_id: "ephemeral"`, does NOT create a `flow_runs` row - Version bumping: create = 1, update = 2, disable = 3, enable = 4 (all emit distinct audit rows) - Revert: v1 → v3 bump, re-writes live body, `reverted_from_version` in response, `action="revert"` audit row - `GET /flows/:id/versions` with node-diff counts; 200 after delete (history outlives the row) - Audit-log canonical JSON body ## Out of scope - UI work (NF-UI-* track). - Named dry-run fixture registry — the endpoint accepts `{ trigger }` today and 422s on `{ fixture }` with a clear marker. - Partial-body patches (PATCH replaces wholesale). - 401-vs-403 split — this repo's `checkOperatorAuth` returns 403 uniformly (used by every other auth-gated route); aligning would be a cross-cutting change. Closes #328 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(flows): operator-authored flows HTTP API + audit log (NF-7)
All checks were successful
qa / qa (pull_request) Successful in 5m8s
qa / dockerfile (pull_request) Successful in 7s
a0c0490cbc
Adds the operator-authored mutation surface on top of NF-4's read-only
flows endpoints. Every write lives behind the existing `guardMutating`
auth gate; reads stay public at the LAN boundary.

New endpoints:
- POST   /flows                       create an operator flow (version = 1)
- PATCH  /flows/:id                   replace body, bump version
- DELETE /flows/:id                   remove flow (default flows 409)
- POST   /flows/:id/enable|disable    toggle + version bump
- POST   /flows/:id/dry-run           ephemeral executor run in dry-run
                                      mutationMode; no flow_runs row
- POST   /flows/:id/revert?version=N  restore historical body
- GET    /flows/:id/versions          audit summary (nodes added / removed
                                      / modified per row)
- GET    /flows/:id/versions/:version historical body snapshot

Validation goes through `defaultRegistry().compile(body)`; failures return
a 422 with a `detail: { nodes, edges, global }` envelope that mirrors
NF-UI-3's FlowValidationError shape. Default flows (source="default")
are read-only through every mutation path.

New SQLite table `flow_audit` captures before/after body + version +
operator per mutation. Writes go through `withFlowTransaction` so the
audit log can never drift from the live table.

`just flows-apply <file.json>` probes GET /flows/:id and decides
POST vs. PATCH automatically, driven by curl + jq so the recipe has
no runtime dependency on Bun.

Tests: +60 (46 handler / +14 route-level auth). Covers CRUD, auth gate
via handleRequest (401→403 path), validation errors with structured
body, version bumping, default-flow protection, dry-run ephemerality,
audit log write/read, revert semantics, and the GET /flows/:id/versions
node-diff counts.

Closes #328

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
code-lead deleted branch feat/328-nf7-operator-api 2026-04-24 16:18:56 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!363
No description provided.