Skip to content

fix(dashboard): three pages read fields the API does not return - #75

Open
ExposureGuard wants to merge 2 commits into
mainfrom
fix/dashboard-webhooks
Open

ExposureGuard wants to merge 2 commits into
mainfrom
fix/dashboard-webhooks

Conversation

@ExposureGuard

Copy link
Copy Markdown
Owner

Independent of #73 and #74 — branched from main.

What was wrong

The dashboard's Webhooks page has never worked. Three faults stacked, each hiding the next:

  1. The store had no delete. WebhookManager had register, fire, list_webhooks, rotate_secret — and no way to remove an endpoint.
  2. DELETE /v1/webhooks/<id> did not exist — but the page's Delete button has always called it, so it 404'd and the row stayed.
  3. /v1/admin/overview never set the webhooks.webhooks key the page renders from. _webhooks() returns counts (registered_count, deliveries_24h), not a list, so rows was always [] and the page said "No webhooks registered" however many there were.

And underneath all three: the table read w.id / w.event / w.deliveries, while the API answers webhook_id / events / fire_count. Even once the list arrived, every cell but the URL would have been empty.

The fix

  • WebhookManager.unregister(webhook_id, tenant_id) — tenant-scoped; refreshes the in-memory registry so the next event doesn't go to a receiver the operator just removed. Deliberately does not rely on the DELETE's row count: psycopg2's execute() returns None where sqlite3 returns a cursor, so a successful delete would read as a miss on Postgres.
  • DELETE /v1/webhooks/<int:webhook_id> — webhooks:write, audit-logged, and a miss is the same 404 whether the id belongs to another tenant or to nobody. Status codes must not answer "does tenant B have webhook id 7".
  • haldir_admin._webhooks() now carries the endpoint list, read straight from the table rather than the manager's in-memory registry — production runs two workers, and a webhook registered on one is not in the other's list. success_rate is None, not 1.0, for an endpoint nothing has ever reached.
  • dashboard.js reads the real field names.

Delivery history is kept on purpose — webhook_deliveries records what was sent, not what is configured.

Tests — tests/test_webhook_delete.py, 8 new

Store removal, tenant scoping (both directions), the route end-to-end, delete-twice-is-404, the overview payload, and a contract test that parses dashboard.js's actual template (function loadWebhooks … // ── Approvals page) and asserts every w.<field> it reads is a field the overview returns. That drift was the bug; it now fails in CI instead of emptying a table.

Verification

  • 1099 tests pass, flake8 clean, mypy clean across 29 files
  • openapi.json regenerated for the new route (its guard caught the drift, as designed)

Note for whoever merges #73 first: both branches touch openapi.json — a generated file. Whichever lands second needs a regen; the committed-spec test will say so.

🤖 Generated with Claude Code

Three faults stacked on each other, each hiding the next:

  * the store had no way to remove an endpoint at all;
  * `DELETE /v1/webhooks/<id>` did not exist, though the dashboard's Delete
    button has always called it — the button 404'd and the row stayed;
  * `/v1/admin/overview` never set the `webhooks.webhooks` key the page
    renders from, so the page said "No webhooks registered" however many
    were registered.

And the table read `id` / `event` / `deliveries` — names no surface has ever
returned — so even once the list arrived, every cell but the URL would have
rendered empty.

Fixed in all four places, with the rule the rest of the webhook surface
follows: a miss is the same 404 whether the id belongs to another tenant or
to nobody, so the route cannot be used as an existence oracle. Delivery
history is kept on purpose — it records what was sent, not what is
configured.

The list is read straight from the table rather than the manager's in-memory
registry: production runs two workers, and a webhook registered on one is not
in the other's list.

The contract test parses `dashboard.js`'s real template and asserts every
field it reads is one the overview returns. The two drifted silently once;
that drift was the whole bug.

`openapi.json` regenerated for the new route.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The webhooks fix was one instance of a class. Sweeping the other loaders
against the real payloads found two more:

**Approvals.** The table read `r.session_id`, `r.requested_by` and
`r.requested_at`. An approval row carries `agent_id`, `tool`, `action`,
`amount`, `reason` and `created_at` — and neither of the first two exists, so
half of every row rendered blank and "Requested at" showed nothing at all.
The headers now say what the columns are.

**Compliance.** `next_due` is per schedule and the response carries no
top-level one, so reading `schedules.next_due` left "Next pack due" showing
"—" forever, however many schedules there were. The soonest is now taken from
the rows.

The rest were checked and are correct: account, quotas, sessions, audit and
settings all read fields the API returns. So does the older `/dashboard`
(dashboard.html), which is a separate self-contained implementation — the
newer page is the one with the mismatches.

`tests/test_dashboard_contract.py` generalises the webhook contract test to
all three, parsing the real template in dashboard.js and resolving each
`x.field` access against the live payload. Checked in both directions: with
the old field names restored, the approvals test fails.

Its session is minted through the Gate rather than `POST /v1/sessions` —
that route enforces the tenant's agent cap, which a full-suite run has usually
reached, and that is exactly how this test failed the first time it ran in
the suite.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@ExposureGuard

Copy link
Copy Markdown
Owner Author

Second pass added (commit 54b1b9a) — the webhooks page was one instance of a class, so I swept the other loaders against the real payloads:

Approvals was broken the same way. The table read r.session_id, r.requested_by and r.requested_at; an approval row carries agent_id, tool, action, amount, reason, created_at — and neither of the first two exists anywhere. Half of every row rendered blank and "Requested at" showed nothing.

Compliance's "Next pack due" was permanently "—". next_due is per schedule; the response has no top-level one. It now takes the soonest from the rows.

The rest were checked and are clean — account, quotas, sessions, audit and settings all read real fields. So does the older /dashboard (dashboard.html), a separate self-contained implementation; the newer page is the one with the mismatches.

tests/test_dashboard_contract.py now covers all three pages by parsing the real template in dashboard.js and resolving every field access against the live payload — checked in both directions (old names restored → the approvals test fails). One trap worth knowing: the test mints its session through the Gate, not POST /v1/sessions, because that route enforces the tenant agent cap a full-suite run has usually reached. That is how it failed the first time it ran in the suite.

@ExposureGuard ExposureGuard changed the title fix(dashboard): the webhooks page could not list or delete anything fix(dashboard): three pages read fields the API does not return Oct 4, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant