Public: anyone can read this; the crew can change it.

A decision fires a signed webhook, with a delivery log an admin can read and retry #7

open not picked up yet opened this
opened this

When a proposal is decided, the group's webhook hears about it once, signed, and every attempt is on the record.

What done looks like

  • [ ] A migration creates setting (key PRIMARY KEY, value, updated_at) and delivery (id, proposal_id, kind, url, attempt, status in sent/failed, response_code nullable, error nullable, created_at).
  • [ ] An admin sets the webhook URL on /group (setSetting in src/actions.ts, admin only). The URL is validated in the action: https: only, no user-info in it, and a host that is not loopback or private — localhost, 127., ::1, 10., 172.16–31., 192.168., 169.254.*, anything ending .local — each refused with invalid and a sentence saying why.
  • [ ] The signing secret comes from the environment (QUORUM_WEBHOOK_SECRET via src/env.ts), never the database and never the repository. With no secret set, the page says delivery is off and names the variable that turns it on (Settings → App → Environment), and nothing is sent.
  • [ ] src/deliver.ts listens for decision.made and, after the response has gone out, POSTs {proposal, winner, tally, decidedAt} as JSON with x-quorum-signature: sha256=<hex HMAC-SHA256 of the exact body bytes> and x-quorum-delivery: <id>, a 5-second timeout, and up to three attempts with backoff (1s, then 5s). Every attempt writes a delivery row.
  • [ ] A decision delivers once: the same proposal never fires twice on its own, and only an admin's retry sends again — POST /api/deliveries/:id/retry and a button on the page.
  • [ ] The delivery log is admin-only at every door (forbidden for a user, signin_required for anon), because the URL may name a private system.
  • [ ] The outbound request is built from the stored URL alone: nothing a person typed reaches the URL, the method or a header, and no shell is involved. Redirects are refused rather than followed.
  • [ ] Tests, against a Bun.serve stub inside the test rather than the network: the signature equals a hand-computed HMAC over the body; an endpoint that fails records three attempts and stops; http: and a private address are refused; a user cannot read or retry the log; a slow endpoint delays no vote (the vote's response returns while delivery is still in flight).
  • [ ] README.md says what is sent, how to verify the signature in four lines, and that delivery is off without the secret. bun test is green.

Files I expect to change: src/db.ts, src/env.ts, src/actions.ts, src/deliver.ts (new), src/deliver.test.ts (new), src/routes.ts, src/pages/group.ts, src/pages/proposal.ts, src/server.test.ts, README.md.

Risks: this is the app making requests to the outside, so SSRF is the whole risk and the URL check is the whole defence — test it as carefully as the happy path. Never log the secret and never put it in a delivery row. Worth asking Sentinel to review before it merges.

Fit: Hearth — delivery with a supervisor, a log you can read, and honest about what is switched off. A person assigns; I do not.

commented

Amendment from the planner — this issue is split, and the half that is left is the half that must be right.

This issue keeps the sending: the setting and delivery migrations, the admin-only URL setting with its https:-only, no-user-info, no-private-host validation, the secret read from the environment, src/deliver.ts signing the exact body bytes and POSTing after the response has gone out, three attempts with backoff, a row per attempt, and a decision that fires exactly once on its own. Its tests are the signature against a hand-computed HMAC, the refused URLs, the three-attempt stop, and a slow endpoint delaying no vote.

Moved to #55, which waits on this one: /deliveries, listDeliveries, the retry door and the button, the legible failure sentences, and the line on a decided proposal. An SSRF defence and an admin panel in one pull is two runs, and the panel is the half that can wait — a signed delivery that is never read is still a correct delivery.

#8 (a decision opens a forge issue) now waits on #55 rather than on this issue alone, because the retry path and the append-only log are what it will reuse to deliver exactly once.

Asking Sentinel to review before it merges still stands, and applies to this half.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs