0405ecd9ce
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.
404 lines
15 KiB
Go
404 lines
15 KiB
Go
package api
|
|
|
|
// Outgoing-webhook signing-secret + send-test endpoints. There are four
|
|
// tiers — settings, project, stage, site — each exposing the same three
|
|
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
|
|
// event. Returning a 200 from "send test" doesn't mean the receiver
|
|
// processed the event correctly — only that it answered with 2xx. The UI
|
|
// surfaces the receiver's status code + body preview so operators can
|
|
// distinguish "wired" from "wired and accepted".
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/notify"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// notificationSecretResponse is what the GET / regenerate endpoints return.
|
|
// The secret is revealed in cleartext exactly once per request — UI is
|
|
// expected to copy or hash it for display, not store it long-term.
|
|
type notificationSecretResponse struct {
|
|
Secret string `json:"secret"`
|
|
HasSecret bool `json:"has_secret"`
|
|
}
|
|
|
|
// testEventTimeout caps how long we wait for the receiver before declaring
|
|
// the test failed. Mirrors the production notifier's per-request timeout
|
|
// (10s) so test results are predictive of real send behaviour.
|
|
const testEventTimeout = 10 * time.Second
|
|
|
|
// buildTestEvent constructs the synthetic payload used by every "send
|
|
// test" endpoint. Marking it as type "test" prevents a misconfigured
|
|
// receiver from mistaking a wiring check for a real deploy event.
|
|
func buildTestEvent(project, stage string) notify.Event {
|
|
return notify.Event{
|
|
Type: "test",
|
|
Project: project,
|
|
Stage: stage,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Global / settings tier
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// getSettingsNotificationSecret handles GET /api/settings/notification-secret.
|
|
// Lazily generates a secret if one was never set (typical for sites
|
|
// upgrading from a pre-signing build).
|
|
func (s *Server) getSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
secret, err := s.store.EnsureSettingsNotificationSecret()
|
|
if err != nil {
|
|
slog.Error("get settings notification secret", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
}
|
|
|
|
// regenerateSettingsNotificationSecret handles POST
|
|
// /api/settings/notification-secret/regenerate. Replaces the existing
|
|
// secret with a fresh one, invalidating signatures verified against the
|
|
// old secret.
|
|
func (s *Server) regenerateSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetSettingsNotificationSecret(secret); err != nil {
|
|
slog.Error("regenerate settings notification secret", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
return
|
|
}
|
|
slog.Info("settings notification secret rotated")
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
}
|
|
|
|
// disableSettingsNotificationSigning handles POST
|
|
// /api/settings/notification-secret/disable. Clears the secret so further
|
|
// outgoing notifications are unsigned. Useful for receivers that don't
|
|
// support HMAC verification.
|
|
func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.SetSettingsNotificationSecret(""); err != nil {
|
|
slog.Error("disable settings notification signing", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
}
|
|
|
|
// settingsNotificationTest handles POST /api/settings/notification-test.
|
|
// Sends a synthetic test event to the global webhook URL using the global
|
|
// secret. No tier resolution — that's the whole point: each tier's test
|
|
// button proves *that* tier is wired correctly.
|
|
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
return
|
|
}
|
|
if settings.NotificationURL == "" {
|
|
respondError(w, http.StatusBadRequest, "no global notification URL configured")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
defer cancel()
|
|
result := s.notifier.SendSyncForTest(
|
|
ctx, settings.NotificationURL, settings.NotificationSecret, notify.TierSettings,
|
|
buildTestEvent("__tinyforge__", ""),
|
|
)
|
|
respondJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project tier
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
secret, err := s.store.EnsureProjectNotificationSecret(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("get project notification secret", "project", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
}
|
|
|
|
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
return
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
|
|
slog.Error("regenerate project notification secret", "project", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
return
|
|
}
|
|
slog.Info("project notification secret rotated", "project", id)
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
}
|
|
|
|
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
}
|
|
|
|
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
project, err := s.store.GetProjectByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
return
|
|
}
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
return
|
|
}
|
|
url, secret, tier := resolveProjectTestTarget(project, settings)
|
|
if url == "" {
|
|
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
defer cancel()
|
|
result := s.notifier.SendSyncForTest(
|
|
ctx, url, secret, tier,
|
|
buildTestEvent(project.Name, ""),
|
|
)
|
|
respondJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
|
|
// resolution but without a stage in scope. Used by the project-level test
|
|
// button so the operator sees exactly what a project-only event would do.
|
|
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
|
if project.NotificationURL != "" {
|
|
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
|
}
|
|
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stage tier
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
stageID := chi.URLParam(r, "stage")
|
|
secret, err := s.store.EnsureStageNotificationSecret(stageID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
slog.Error("get stage notification secret", "stage", stageID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
}
|
|
|
|
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
stageID := chi.URLParam(r, "stage")
|
|
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
|
return
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
|
|
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
return
|
|
}
|
|
slog.Info("stage notification secret rotated", "stage", stageID)
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
}
|
|
|
|
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
stageID := chi.URLParam(r, "stage")
|
|
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
}
|
|
|
|
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
stageID := chi.URLParam(r, "stage")
|
|
stage, err := s.store.GetStageByID(stageID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "stage")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
|
return
|
|
}
|
|
project, err := s.store.GetProjectByID(projectID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
return
|
|
}
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
return
|
|
}
|
|
// Reuse the production resolver so the test button exercises the exact
|
|
// fall-through logic a real deploy would.
|
|
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
|
if url == "" {
|
|
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
defer cancel()
|
|
result := s.notifier.SendSyncForTest(
|
|
ctx, url, secret, tier,
|
|
buildTestEvent(project.Name, stage.Name),
|
|
)
|
|
respondJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
|
|
// than imported to avoid an api → deployer dependency cycle and to keep the
|
|
// test-endpoint code self-contained. If divergence becomes a risk we can
|
|
// move this into a shared internal/notify subpackage.
|
|
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
|
if stage.NotificationURL != "" {
|
|
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
|
|
}
|
|
if project.NotificationURL != "" {
|
|
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
|
}
|
|
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static-site tier
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
slog.Error("get static site notification secret", "site", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
}
|
|
|
|
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
|
return
|
|
}
|
|
secret := generateWebhookSecret()
|
|
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
|
slog.Error("regenerate static site notification secret", "site", id, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
return
|
|
}
|
|
slog.Info("static site notification secret rotated", "site", id)
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
}
|
|
|
|
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
}
|
|
|
|
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
site, err := s.store.GetStaticSiteByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "static site")
|
|
return
|
|
}
|
|
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
|
return
|
|
}
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
return
|
|
}
|
|
url, secret, tier := resolveSiteTestTarget(site, settings)
|
|
if url == "" {
|
|
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
defer cancel()
|
|
result := s.notifier.SendSyncForTest(
|
|
ctx, url, secret, tier,
|
|
buildTestEvent(site.Name, ""),
|
|
)
|
|
respondJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
|
if site.NotificationURL != "" {
|
|
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
|
}
|
|
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
}
|