# Webhooks (contract between workers) The global rules from the earlier interface docs apply: - No JS and no inline styles; no unwrap in non-test code. - Tests use `tempfile::TempDir`. - Never put cargo target dirs or big files in /tmp. - Every new crate is added at its **latest** version with `cargo add` and recorded in the root `[workspace.dependencies]`. ## Model - A repo webhook has: - a URL (https, or http only when the config allows it); - a secret, encrypted at rest with the instance key the same way TOTP secrets are; - a content type (always `application/json`); - an event list: `push`, `pull_request`, `issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `ping`; - an `active` flag. - Managing hooks needs Admin on the repo. - **Deliveries** are rows in the DB: - the request: headers and body. The headers are queued without `X-Forge-Delivery` or the signature; after each attempt the stored headers are replaced with the ones actually sent; - the response: status, the first 64 KiB of the body, and timing; - `attempt`, a state (`pending` | `ok` | `failed` | `gave_up`), `next_attempt_at`, and a GUID. - **Retries** use backoff 1m, 5m, 30m, 2h, 6h, then `gave_up`. "Redeliver" in the UI creates a new delivery with the same payload. - **Headers:** - `X-Forge-Event: `, `X-Forge-Delivery: `; - `X-Forge-Signature-256: sha256=`; - `User-Agent: forge-hookshot/`. - **Payloads** are our own JSON. We deliberately don't copy the GitHub or Gitea schemas, but a similar shape is fine: - `repository {id, owner, name, full_name, private, html_url, clone_url}`, `sender {id, username}`; - push: `ref, before, after, commits[{id, message, author{name,email}, timestamp, url}] (max 20), total_commits, compare_url`; - pull_request: `action (opened|closed|reopened|merged|synchronize|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), number, pull_request {title, body, state, merged, base, head, html_url}`; the label, assignee and milestone actions add `label {name, color}`, `assignee {username}` or `milestone {title}` at the top level, as the issues event does; - issues: `action (opened|closed|reopened|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), issue {number, title, body, state, html_url}`, plus `label {name, color}`, `assignee {username}` or `milestone {title}` for those actions; - issue_comment: `action (created|edited|deleted), issue {…}, comment {id, body, html_url}`; - pull_request_review: `action submitted, review {id, state, body}, pull_request {…}`. ## SSRF guard (mandatory) - Resolve the host once, check every resolved address, then connect to exactly that checked address. - In reqwest, use a custom `dns::Resolve` that filters addresses, so a DNS rebind can't bypass the check. - Reject: loopback, private (RFC1918, fc00::/7), link-local (169.254/16, fe80::/10), CGNAT 100.64/10, unspecified, multicast, broadcast, documentation ranges, IPv4-mapped IPv6 of any of these, and 0.0.0.0/8. - Config `[webhooks] allowed_hosts = []` (exact hostnames or CIDRs) whitelists internal targets; the default is empty. - **No redirects.** Timeouts: 10s connect, 20s total. Response bodies are read up to 64 KiB. - The URL must have no userinfo, and its port must be 1–65535. - `allow_http = false` by default. ## Delivery worker - A tokio task started at boot. Each loop it claims due deliveries (`state = 'pending' AND next_attempt_at <= now`), sends them with bounded concurrency (4), and records the result. - A 2xx response means `ok`. - It is woken by a `Notify` when a new delivery is queued, so the first attempt is immediate; otherwise it polls every 30s. - Deliveries older than 14 days are purged daily by the same task. ## Integration points - Enqueue through the sender-aware helpers in `crates/forge-web/src/webhooks/emit.rs`. Each one builds its event's payload, looks up the sender, and hands off to the generic `emit(state, repo, sender, event, action, payload)`. That function finds the active hooks subscribed to the event and inserts a delivery for each. Nothing here fails the caller; errors are logged. - `Sender` says who made the change. `lookup_sender(state, actor_id)` builds one; an empty id means a system change and is sent as `"sender": null`. - `emit_ref_update(…)`: `push`, plus `create`/`delete` for branch and tag creation and deletion. Only `refs/heads/*` and `refs/tags/*` produce events. - `emit_pull(…)`: `pull_request` (for example, on PR create in `pages/compare.rs`). - `emit_item_action(…)`: `pull_request` or `issues` action events, reloaded from the store. Used for close, reopen, merge and `synchronize`. - `emit_issue(…)`: `issues` create (`pages/issues_list.rs`). - `emit_label_changes(…)` and `emit_assignee_changes(…)`: one `labeled`/`unlabeled` or `assigned`/`unassigned` event per changed label (matched by name) or username. `pull` picks the `pull_request` event for a pull request and `issues` for an issue. - `emit_milestone_change(…)`: `demilestoned` for the milestone before and `milestoned` for the one after, given their titles; nothing when they match. Same `pull` choice. - `emit_comment(…)`: `issue_comment` create, edit and delete. - `emit_review(…)`: `pull_request_review` submit. - Call sites: `post_update.rs`, `pulls_sync.rs`, `pages/{compare,issue_view,issue_parts,issue_meta,issues_list,pulls_merge,pull_reviews}.rs`. The label, assignee and milestone saves in `pages/issue_meta.rs` run for issues and pull requests alike. - A "ping" event is sent by `emit_ping(state, hook)` when a hook is created and from the "Test" button. It ignores the hook's subscriptions. ## UI (repo settings, a "Webhooks" section) - List hooks with their last delivery status. - Create and edit form: URL, secret (write-only; leave it blank to keep the current one), event checkboxes, active. - Delete hooks. - Hook detail page: the last 50 deliveries, each expandable with `
` to show the request headers and body and the response; a Redeliver button and a Test (ping) button.