feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
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:
2026-05-07 02:03:32 +03:00
parent 134fe22fde
commit 0405ecd9ce
27 changed files with 2190 additions and 84 deletions
+52
View File
@@ -120,6 +120,16 @@
"projectDetail": {
"webhookTitle": "Project webhook",
"webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.",
"outgoingWebhookTitle": "Outgoing webhook (project)",
"outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.",
"outgoingFallbackGlobal": "the global integrations setting",
"notificationUrlLabel": "Outgoing webhook URL",
"notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.",
"stageNotificationUrlLabel": "Outgoing webhook URL (this stage)",
"stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.",
"stageOutgoingTitle": "Outgoing webhook (stage)",
"stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.",
"stageFallbackLabel": "the project or global settings",
"deleteProject": "Delete Project",
"envVars": "Environment Variables",
"volumes": "Volume Mounts",
@@ -618,6 +628,11 @@
"sites": {
"webhookTitle": "Site webhook",
"webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.",
"outgoingUrlTitle": "Outgoing webhook URL (this site)",
"outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.",
"outgoingWebhookTitle": "Outgoing webhook (site)",
"outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.",
"outgoingFallbackGlobal": "the global integrations setting",
"title": "Static Sites",
"addSite": "New Site",
"newSite": "New Static Site",
@@ -1168,6 +1183,43 @@
"confirmYes": "Regenerate",
"confirmNo": "Cancel"
},
"outgoingWebhook": {
"signingOn": "Signed",
"signingOff": "Unsigned",
"signingSecret": "HMAC signing secret",
"noSecret": "No signing secret — outgoing events are not signed.",
"reveal": "Reveal",
"generate": "Generate",
"copy": "Copy",
"copied": "Signing secret copied to clipboard",
"copyFailed": "Failed to copy to clipboard",
"loadFailed": "Failed to load signing secret",
"regenerate": "Regenerate",
"regenerated": "Signing secret regenerated",
"regenerateFailed": "Failed to regenerate signing secret",
"confirmRegenerateTitle": "Rotate signing secret?",
"confirmRegenerate": "The current secret is invalidated immediately. Every receiver verifying it must be updated in lock-step or it will start rejecting events.",
"confirmDisableTitle": "Disable HMAC signing?",
"confirmDisable": "Future events go out without the X-Hub-Signature-256 header. Receivers that require signatures will reject them.",
"confirmYes": "Confirm",
"confirmNo": "Cancel",
"disable": "Disable signing",
"disabled": "Signing disabled",
"disableFailed": "Failed to disable signing",
"sendTestTitle": "Send a test event",
"sendTestHelp": "Fires a synthetic \"test\" event to the resolved URL using the current secret.",
"sendTest": "Send test",
"sending": "Sending…",
"testFailed": "Failed to send test event",
"tier": "Tier",
"signed": "Signed",
"unsigned": "Unsigned",
"deliveryId": "Delivery",
"responseBody": "Response body",
"networkError": "Network error",
"fallbackTo": "No URL set on this tier — events will fall through to {label}.",
"noUrlConfigured": "No URL set. Configure one above before sending a test."
},
"settingsMaintenance": {
"title": "Maintenance",
"thresholds": "Thresholds",