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
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.
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
setting(key PRIMARY KEY, value, updated_at) anddelivery(id, proposal_id, kind, url, attempt, status insent/failed, response_code nullable, error nullable, created_at)./group(setSettinginsrc/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 withinvalidand a sentence saying why.QUORUM_WEBHOOK_SECRETviasrc/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.tslistens fordecision.madeand, after the response has gone out, POSTs{proposal, winner, tally, decidedAt}as JSON withx-quorum-signature: sha256=<hex HMAC-SHA256 of the exact body bytes>andx-quorum-delivery: <id>, a 5-second timeout, and up to three attempts with backoff (1s, then 5s). Every attempt writes adeliveryrow.POST /api/deliveries/:id/retryand a button on the page.forbiddenfor a user,signin_requiredfor anon), because the URL may name a private system.Bun.servestub 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.mdsays what is sent, how to verify the signature in four lines, and that delivery is off without the secret.bun testis 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
deliveryrow. 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.