feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# Outgoing webhooks
|
||||
|
||||
Tinyforge posts JSON events to a configured URL when deploys and static-site
|
||||
syncs finish. Receivers can verify each request was sent by Tinyforge and
|
||||
not tampered with by checking the **HMAC-SHA256** signature on the body.
|
||||
|
||||
## Tiers and resolution
|
||||
|
||||
A single global URL is rarely enough — different teams own different
|
||||
projects, and operators often want to route prod failures somewhere noisier
|
||||
than dev failures. Tinyforge supports four tiers:
|
||||
|
||||
| Tier | Where set | Used for |
|
||||
|-----------|----------------------------------------|-------------------------|
|
||||
| `stage` | Stage edit form | Per-stage deploys |
|
||||
| `project` | Project edit form | All stages of a project |
|
||||
| `site` | Static-site detail page | Static-site sync events |
|
||||
| `settings`| Settings → Integrations | Global fallback |
|
||||
|
||||
Resolution order:
|
||||
|
||||
* **Deploys**: `stage → project → settings`
|
||||
* **Sites**: `site → settings`
|
||||
|
||||
The most-specific tier with a non-empty URL wins. The signing secret
|
||||
travels with the URL that sourced it: a stage can sign even when the
|
||||
project and global URLs are unsigned.
|
||||
|
||||
## Signature scheme
|
||||
|
||||
Every request includes:
|
||||
|
||||
```
|
||||
POST /your/handler HTTP/1.1
|
||||
Content-Type: application/json
|
||||
User-Agent: Tinyforge-Webhook/1
|
||||
X-Hub-Signature-256: sha256=<hex>
|
||||
X-Tinyforge-Event: deploy_success
|
||||
X-Tinyforge-Delivery: 0f3a…-uuid
|
||||
X-Tinyforge-Timestamp: 2026-05-07T12:34:56Z
|
||||
X-Tinyforge-Tier: stage
|
||||
```
|
||||
|
||||
The signature is `HMAC-SHA256(secret, raw_body)`, hex-encoded, with the
|
||||
`sha256=` prefix — the GitHub `X-Hub-Signature-256` format. Receivers
|
||||
already built for GitHub-style webhooks (Gitea, Forgejo, n8n, Hookdeck,
|
||||
the service-to-notification-bridge generic webhook provider) verify it
|
||||
without modification.
|
||||
|
||||
When no signing secret is configured for the resolved tier, the
|
||||
signature header is omitted and the request goes out unsigned. This is
|
||||
intentional back-compat for receivers that don't speak HMAC.
|
||||
|
||||
## Receiver requirements
|
||||
|
||||
A correct verifier:
|
||||
|
||||
1. **Reads the raw body bytes** before any JSON parse / re-serialise.
|
||||
2. Computes `HMAC-SHA256(secret, body)` and compares to the value after
|
||||
`sha256=` in `X-Hub-Signature-256`.
|
||||
3. Uses a **constant-time** comparator (`hmac.compare_digest` /
|
||||
`crypto.timingSafeEqual` / `hmac.Equal`).
|
||||
4. Returns 401/403 on mismatch — Tinyforge surfaces the receiver's
|
||||
status code in the UI when the operator clicks **Send test**.
|
||||
|
||||
### Node / TypeScript
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
export function verify(secret: string, rawBody: Buffer, header: string): boolean {
|
||||
const got = header.startsWith('sha256=') ? header.slice(7) : header;
|
||||
const want = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
if (got.length !== want.length) return false;
|
||||
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex'));
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify(secret: str, raw_body: bytes, header: str) -> bool:
|
||||
got = header[7:] if header.startswith("sha256=") else header
|
||||
want = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(got, want)
|
||||
```
|
||||
|
||||
### Go
|
||||
|
||||
```go
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func verify(secret string, body []byte, header string) bool {
|
||||
got := strings.TrimPrefix(header, "sha256=")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
want := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(got), []byte(want))
|
||||
}
|
||||
```
|
||||
|
||||
## Event payload
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "deploy_success",
|
||||
"project": "demo-app",
|
||||
"stage": "prod",
|
||||
"image_tag": "v1.4.2",
|
||||
"subdomain": "stage-prod-demo-app",
|
||||
"url": "https://stage-prod-demo-app.example.com",
|
||||
"timestamp": "2026-05-07T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
`type` values:
|
||||
- `deploy_success`, `deploy_failure` — sent by the deployer.
|
||||
- `site_sync_success`, `site_sync_failure` — sent by the static-site manager.
|
||||
Use the `project` field as the site name; `stage` and `image_tag` are empty.
|
||||
- `test` — sent by the **Send test** button. Treat it as a no-op or
|
||||
surface it in your operator log; never as a real deploy event.
|
||||
|
||||
## Configuring the service-to-notification-bridge
|
||||
|
||||
If you're sending Tinyforge events to the
|
||||
[service-to-notification-bridge](https://github.com/) generic webhook
|
||||
provider:
|
||||
|
||||
1. Create a **Generic Webhook** provider.
|
||||
2. Set `auth_mode = hmac_sha256`.
|
||||
3. Paste the **same secret** Tinyforge generated (revealed via the
|
||||
Outgoing webhook panel).
|
||||
4. Set `event_type_path = type` so deploys and site syncs map to
|
||||
distinct event types in the bridge.
|
||||
5. Add `payload_mappings` for `project`, `stage`, `image_tag`, `url`,
|
||||
`error` and reference them as `{{ extra.project }}` in your
|
||||
notification templates.
|
||||
|
||||
The bridge accepts `X-Hub-Signature-256` natively (no header rename
|
||||
needed) and reads the raw body before parsing, so step 1 of the
|
||||
receiver requirements is already met.
|
||||
|
||||
## Rotating secrets
|
||||
|
||||
Click **Regenerate** in the Outgoing webhook panel to rotate. The old
|
||||
secret is invalidated immediately — update receivers in lock-step or
|
||||
expect a brief window of 401s. There is no soft rollover today.
|
||||
|
||||
To send unsigned events to a legacy receiver that can't verify, click
|
||||
**Disable signing**. Tinyforge will keep dispatching events without the
|
||||
`X-Hub-Signature-256` header until you regenerate.
|
||||
Reference in New Issue
Block a user