package api // Outgoing-webhook signing-secret + send-test endpoints. After the hard // cutover only the settings tier survives at the API surface; per-workload // notification settings live on the workload row itself and are accessed // via the workload endpoints. import ( "context" "log/slog" "net/http" "time" "github.com/alexei/tinyforge/internal/notify" ) // 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. 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) }