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 }