feat: token-authenticated approval API + structured webhook payload #4

Merged
oc merged 2 commits from feat/approve-api into main 2026-08-21 10:12:10 -07:00
Collaborator

Enables a notifier (desktop popup, chat bot, phone) to approve or reject an action without driving the web UI or holding a login session. Groundwork for wiring approvals into desktop notifications on reptar.

The problem

Approve/reject were web-UI only, and both require a session cookie. The ?token= link only renders the approve page — its buttons still POST to a session-gated route. So the only way to approve programmatically was to script a login and store the admin password.

What this adds

POST /api/actions/:id/approve and POST /api/actions/:id/reject, authenticated by the per-action approve_token that already exists: a UUIDv4 minted at submit time, scoped to exactly one action, delivered only in the approval notification.

The agent API key is deliberately rejected on these routes. The agent submits actions, so letting it approve them would defeat the entire purpose of the service. Verified below.

Token may be sent in the JSON body or as ?token=, matching the existing link format.

Case Response
valid token, pending 200 + {id, status, result}
unknown id or bad token 401
no token supplied 400
action already resolved 409 + current status

The 409 matters for a notifier: it distinguishes "someone already handled this" from "this failed."

Webhook payload

Gains a structured action object. The text field is unchanged, so current consumers keep working:

{
  "text": "[ActionGateway] New action needs approval...",
  "mode": "now",
  "action": {
    "id": "b17760ab-...",
    "description": "webhook shape check",
    "command": "uptime",
    "run_as": "oc",
    "approve_url": "...?token=...",
    "approve_token": "9685ee08-...",
    "api_approve": ".../api/actions/<id>/approve",
    "api_reject": ".../api/actions/<id>/reject"
  }
}

Previously the id, command and approve URL were only recoverable by regexing the human-readable blob.

Refactor

Three copies of the actions SELECT and its 11-field row mapping collapse onto ACTION_COLUMNS + row_to_action. Execute and reject-chain logic move into helpers now shared by the web and API handlers, so the two paths cannot drift.

Those helpers also stop calling .unwrap() on DB errors while holding the connection mutex — that would poison it for the life of the process, leaving the service "active" under systemd but failing every subsequent request.

Verification

Ran against a throwaway instance on its own DB and port; the production gateway was untouched and healthy throughout.

  • wrong token → 401; no token → 400; agent API key alone → 400, cannot approve
  • correct token → 200, command executed, output returned
  • repeat approve → 409 with status: executed
  • reject via ?token=200; siblings in the same chain_id also rejected
  • approve a rejected action → 409
  • webhook payload captured and matches the shape above
  • web UI unaffected: with a session cookie approve still executes; without one it redirects to login and the action stays pending (command never runs)

cargo fmt --check, cargo clippy -- -D warnings, cargo test, and cargo build --release all pass.

Second commit

CI already failed on maincargo clippy -- -D warnings errored on four pre-existing issues (duplicated #[allow(dead_code)] in models.rs/config.rs, two print_literal in cli_main.rs). Fixed in a separate commit so it can be dropped or landed independently. No behaviour change; CLI output is byte-identical.

Not included

Two issues I noticed but left alone to keep this reviewable:

  • Command::output() runs in an async handler with no spawn_blocking, parking a tokio worker for the command's full duration. Matters more once remote runners add network latency.
  • :7878 binds 0.0.0.0 and answers plaintext HTTP on the LAN, while the session cookie sets http_only but neither Secure nor SameSite.
Enables a notifier (desktop popup, chat bot, phone) to approve or reject an action without driving the web UI or holding a login session. Groundwork for wiring approvals into desktop notifications on reptar. ## The problem Approve/reject were web-UI only, and both require a session cookie. The `?token=` link only *renders* the approve page — its buttons still POST to a session-gated route. So the only way to approve programmatically was to script a login and store the admin password. ## What this adds `POST /api/actions/:id/approve` and `POST /api/actions/:id/reject`, authenticated by the per-action `approve_token` that already exists: a UUIDv4 minted at submit time, scoped to exactly one action, delivered only in the approval notification. **The agent API key is deliberately rejected on these routes.** The agent submits actions, so letting it approve them would defeat the entire purpose of the service. Verified below. Token may be sent in the JSON body or as `?token=`, matching the existing link format. | Case | Response | |---|---| | valid token, pending | `200` + `{id, status, result}` | | unknown id or bad token | `401` | | no token supplied | `400` | | action already resolved | `409` + current status | The `409` matters for a notifier: it distinguishes "someone already handled this" from "this failed." ## Webhook payload Gains a structured `action` object. The `text` field is **unchanged**, so current consumers keep working: ```json { "text": "[ActionGateway] New action needs approval...", "mode": "now", "action": { "id": "b17760ab-...", "description": "webhook shape check", "command": "uptime", "run_as": "oc", "approve_url": "...?token=...", "approve_token": "9685ee08-...", "api_approve": ".../api/actions/<id>/approve", "api_reject": ".../api/actions/<id>/reject" } } ``` Previously the id, command and approve URL were only recoverable by regexing the human-readable blob. ## Refactor Three copies of the `actions` SELECT and its 11-field row mapping collapse onto `ACTION_COLUMNS` + `row_to_action`. Execute and reject-chain logic move into helpers now shared by the web and API handlers, so the two paths cannot drift. Those helpers also stop calling `.unwrap()` on DB errors *while holding the connection mutex* — that would poison it for the life of the process, leaving the service "active" under systemd but failing every subsequent request. ## Verification Ran against a throwaway instance on its own DB and port; the production gateway was untouched and healthy throughout. - wrong token → `401`; no token → `400`; **agent API key alone → `400`, cannot approve** - correct token → `200`, command executed, output returned - repeat approve → `409` with `status: executed` - reject via `?token=` → `200`; siblings in the same `chain_id` also rejected - approve a rejected action → `409` - webhook payload captured and matches the shape above - **web UI unaffected**: with a session cookie approve still executes; without one it redirects to login and the action stays `pending` (command never runs) `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`, and `cargo build --release` all pass. ## Second commit CI already failed on `main` — `cargo clippy -- -D warnings` errored on four pre-existing issues (duplicated `#[allow(dead_code)]` in `models.rs`/`config.rs`, two `print_literal` in `cli_main.rs`). Fixed in a **separate commit** so it can be dropped or landed independently. No behaviour change; CLI output is byte-identical. ## Not included Two issues I noticed but left alone to keep this reviewable: - `Command::output()` runs in an async handler with no `spawn_blocking`, parking a tokio worker for the command's full duration. Matters more once remote runners add network latency. - `:7878` binds `0.0.0.0` and answers plaintext HTTP on the LAN, while the session cookie sets `http_only` but neither `Secure` nor `SameSite`.
claude added 2 commits 2026-08-13 03:08:07 -07:00
Adds POST /api/actions/:id/approve and /reject so a notifier (desktop
popup, chat bot, phone) can resolve an action without driving the web UI
or holding a login session.

Auth is the per-action approve_token already minted at submit time: a
UUIDv4 scoped to exactly one action and delivered only in the approval
notification. The agent API key is deliberately NOT accepted on these
routes -- the agent submits actions, so letting it approve them would
defeat the human-in-the-loop guarantee. The token may be supplied in the
JSON body or as ?token=, matching the existing approve link.

Both endpoints return 401 for an unknown id/token and 409 (with the
current status) if the action is no longer pending, so a notifier can
tell "someone else already handled this" from "this failed".

The notification webhook gains a structured "action" object alongside the
existing "text" field, which is unchanged so current consumers keep
working. Previously the id, command and approve URL were only available
by regexing the human-readable blob.

Also collapses three copies of the actions SELECT and its row mapping
onto ACTION_COLUMNS/row_to_action, and extracts the execute and
reject-chain logic into helpers now shared by the web and API handlers.
Those helpers no longer .unwrap() DB errors while holding the connection
mutex, which would poison it for the life of the process.
CI runs `cargo clippy -- -D warnings`, which already failed on main with
four errors predating this branch:

  duplicated attribute            src/models.rs, src/config.rs
  literal with an empty format    src/cli_main.rs (x2)

Both structs carried #[allow(dead_code)] twice; dropped the duplicate.
The println! calls moved the literal into the format string as clippy
suggested. No behaviour change -- output is byte-identical.

Kept separate from the feature commit so it can be dropped or landed on
its own.
oc merged commit 7d20f6b817 into main 2026-08-21 10:12:10 -07:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: clawbot/action-gateway#4