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:
+23
-8
@@ -138,6 +138,15 @@ func (s *Store) runMigrations() error {
|
||||
// retention in hours. 0 in either disables collection.
|
||||
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
||||
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
||||
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
|
||||
// tokens (matches the inbound webhook_secret pattern). Empty = no
|
||||
// signing; existing rows stay unsigned on upgrade for back-compat.
|
||||
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
@@ -284,7 +293,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
healthcheck TEXT NOT NULL DEFAULT '',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
volumes TEXT NOT NULL DEFAULT '{}',
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -299,8 +310,9 @@ CREATE TABLE IF NOT EXISTS stages (
|
||||
confirm INTEGER NOT NULL DEFAULT 0,
|
||||
enable_proxy INTEGER NOT NULL DEFAULT 1,
|
||||
promote_from TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
cpu_limit REAL NOT NULL DEFAULT 0,
|
||||
memory_limit INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
@@ -326,6 +338,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
network TEXT NOT NULL DEFAULT 'tinyforge',
|
||||
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
npm_url TEXT NOT NULL DEFAULT '',
|
||||
npm_email TEXT NOT NULL DEFAULT '',
|
||||
npm_password TEXT NOT NULL DEFAULT '',
|
||||
@@ -487,11 +500,13 @@ CREATE TABLE IF NOT EXISTS static_sites (
|
||||
container_id TEXT NOT NULL DEFAULT '',
|
||||
proxy_route_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_site_secrets (
|
||||
|
||||
Reference in New Issue
Block a user