package api import ( "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "sync" "time" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" "github.com/alexei/tinyforge/internal/workload/plugin" ) // fireInFlight tracks trigger IDs that have a fire-now request actively // running so a runaway script or rapid double-click doesn't queue // duplicate deploys. Keyed by trigger ID; entries are added under the // mutex and removed by the handler's defer. Sufficient for an admin // gate — a real rate limiter belongs at the middleware layer, not here. var ( fireInFlightMu sync.Mutex fireInFlight = map[string]struct{}{} ) // triggerView is the response shape for /api/triggers. Webhook secrets // are never serialized — read them via the dedicated /webhook subresource // where the canonical URL is composed. type triggerView struct { ID string `json:"id"` Kind string `json:"kind"` Name string `json:"name"` Config json.RawMessage `json:"config"` WebhookEnabled bool `json:"webhook_enabled"` WebhookRequireSignature bool `json:"webhook_require_signature"` BindingCount int `json:"binding_count"` // LastFiredAt is the RFC3339 wall-clock the scheduler last // dispatched this trigger. Always present in the response shape; // empty for triggers that have never fired or are not scheduler- // driven. The detail page renders it as "last fired" on schedule // triggers; other kinds ignore it. LastFiredAt string `json:"last_fired_at"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func (s *Server) toTriggerView(t store.Trigger) triggerView { count, err := s.store.CountBindingsForTrigger(t.ID) if err != nil { slog.Warn("triggerView: count bindings", "trigger", t.ID, "error", err) } return triggerView{ ID: t.ID, Kind: t.Kind, Name: t.Name, Config: json.RawMessage(t.Config), WebhookEnabled: t.WebhookSecret != "", WebhookRequireSignature: t.WebhookRequireSignature, BindingCount: count, LastFiredAt: t.LastFiredAt, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, } } // toTriggerViewWithCount is the join-aware variant used by listTriggers // to avoid one COUNT(*) per row. Kept distinct from toTriggerView so // single-row paths (get/create/update) keep the simple call shape. func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView { return triggerView{ ID: row.ID, Kind: row.Kind, Name: row.Name, Config: json.RawMessage(row.Config), WebhookEnabled: row.WebhookSecret != "", WebhookRequireSignature: row.WebhookRequireSignature, BindingCount: row.BindingCount, LastFiredAt: row.LastFiredAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } // triggerRequest is the create/update body. Config is opaque per kind. // Auto-generates a webhook secret on create when WebhookEnabled is true; // the secret is exposed only via the /webhook subresource. type triggerRequest struct { Kind string `json:"kind"` Name string `json:"name"` Config json.RawMessage `json:"config"` WebhookEnabled bool `json:"webhook_enabled"` WebhookRequireSignature bool `json:"webhook_require_signature"` } // Same per-blob caps used on the workload pluginWorkloadRequest path — // triggers and workload trigger configs share the same plugin Validate() // call, so the byte budget should match. const maxTriggerStandaloneConfigBytes = 16 << 10 func (s *Server) listTriggers(w http.ResponseWriter, r *http.Request) { kind := r.URL.Query().Get("kind") rows, err := s.store.ListTriggersWithBindingCount(kind) if err != nil { slog.Error("list triggers", "error", err) respondError(w, http.StatusInternalServerError, "list triggers") return } out := make([]triggerView, 0, len(rows)) for _, t := range rows { out = append(out, toTriggerViewWithCount(t)) } respondJSON(w, http.StatusOK, out) } func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") t, err := s.store.GetTriggerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } respondJSON(w, http.StatusOK, s.toTriggerView(t)) } // buildTriggerFromRequest assembles a store.Trigger ready for insert. // Centralized so the standalone create endpoint and the inline-bind // endpoint cannot drift on secret-generation defaults. func buildTriggerFromRequest(req triggerRequest) store.Trigger { t := store.Trigger{ Kind: req.Kind, Name: strings.TrimSpace(req.Name), Config: string(req.Config), WebhookRequireSignature: req.WebhookRequireSignature, } if req.WebhookEnabled { t.WebhookSecret = generateWebhookSecret() t.WebhookSigningSecret = generateWebhookSecret() } return t } func (s *Server) createTrigger(w http.ResponseWriter, r *http.Request) { var req triggerRequest if !decodeJSONStrict(w, r, &req) { return } if err := validateTriggerRequest(req); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } created, err := s.store.CreateTrigger(buildTriggerFromRequest(req)) if err != nil { slog.Error("create trigger", "error", err) // UNIQUE name collision is the most common user-facing failure. if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "a trigger with this name already exists") return } respondError(w, http.StatusInternalServerError, "create trigger") return } respondJSON(w, http.StatusCreated, s.toTriggerView(created)) } func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") existing, err := s.store.GetTriggerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } var req triggerRequest if !decodeJSONStrict(w, r, &req) { return } // Kind is immutable on update. Mirror the value from the existing // row so validateTriggerRequest can still verify the config blob. req.Kind = existing.Kind if err := validateTriggerRequest(req); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } if req.Name != "" { existing.Name = strings.TrimSpace(req.Name) } if len(req.Config) > 0 { existing.Config = string(req.Config) } existing.WebhookRequireSignature = req.WebhookRequireSignature wasEnabled := existing.WebhookSecret != "" if req.WebhookEnabled && !wasEnabled { // false→true transition: rotate both secrets so re-enabling // after a disable does not silently revive an old leaked URL. existing.WebhookSecret = generateWebhookSecret() existing.WebhookSigningSecret = generateWebhookSecret() } if !req.WebhookEnabled { existing.WebhookSecret = "" existing.WebhookSigningSecret = "" } if err := s.store.UpdateTrigger(existing); err != nil { slog.Error("update trigger", "error", err) if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "a trigger with this name already exists") return } respondError(w, http.StatusInternalServerError, "update trigger") return } respondJSON(w, http.StatusOK, s.toTriggerView(existing)) } func (s *Server) deleteTrigger(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if err := s.store.DeleteTrigger(id); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "delete trigger") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) } // triggerWebhookView surfaces the inbound URL for a trigger. Returns // empty path / secret when the trigger has webhook ingress disabled. type triggerWebhookView struct { URL string `json:"url"` Secret string `json:"secret"` WebhookRequireSignature bool `json:"webhook_require_signature"` } func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") t, err := s.store.GetTriggerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } view := triggerWebhookView{ Secret: t.WebhookSecret, WebhookRequireSignature: t.WebhookRequireSignature, } if t.WebhookSecret != "" { view.URL = "/api/webhook/triggers/" + t.WebhookSecret } respondJSON(w, http.StatusOK, view) } // fireTriggerNow dispatches a trigger immediately without waiting for // its next natural fire window. Used by the /triggers/[id] "Fire now" // button so an operator can re-test a fixed broken deploy without // waiting one full schedule interval. // // Scope: schedule triggers only. Other kinds (registry / git / manual) // already have their own dispatch paths — registry/git fire on real // inbound events, manual fires from the workload Deploy button. Adding // "fire-now" for those would duplicate those flows without adding new // capability. // // Side effect: updates last_fired_at to "now" (same persist-before- // dispatch ordering the scheduler uses) so the natural next-fire // window shifts forward by exactly the interval. This is the // principle-of-least-surprise behavior — an operator who fires now // is intentionally resetting the cadence. func (s *Server) fireTriggerNow(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") // Per-trigger in-flight guard. AdminOnly + UI throttle is the only // gate against rapid double-clicks; without this guard a runaway // script could queue parallel fans-out of the same schedule, each // holding up to maxTriggerFanOutConcurrency deployer slots. // Returning 429 lets the client distinguish "already running" from // a real validation error. fireInFlightMu.Lock() if _, busy := fireInFlight[id]; busy { fireInFlightMu.Unlock() respondError(w, http.StatusTooManyRequests, "a fire is already in progress for this trigger") return } fireInFlight[id] = struct{}{} fireInFlightMu.Unlock() defer func() { fireInFlightMu.Lock() delete(fireInFlight, id) fireInFlightMu.Unlock() }() trg, err := s.store.GetTriggerByID(id) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "failed to load trigger") return } if trg.Kind != "schedule" { respondError(w, http.StatusBadRequest, "fire-now is only supported for schedule triggers") return } // AdminOnly middleware guarantees claims; treat their absence as a // boot-time wiring bug rather than fall back to an unattributable // "manual" string that collides with the `manual` trigger kind in // audit logs. claims, ok := auth.ClaimsFromContext(r.Context()) if !ok || claims.Username == "" { slog.Error("fire-now: missing claims under AdminOnly", "trigger", trg.Name) respondError(w, http.StatusInternalServerError, "missing auth context") return } actor := claims.Username now := time.Now().UTC() if err := s.store.SetTriggerLastFired(trg.ID, now.Format(time.RFC3339)); err != nil { respondError(w, http.StatusInternalServerError, "persist last_fired_at") return } evt := plugin.InboundEvent{ Kind: "schedule", Schedule: &plugin.ScheduleEvent{FiredAt: now}, } results, err := s.webhook.FanOutForTrigger(r.Context(), trg, evt) if err != nil { slog.Warn("fire-now: fan-out failed", "trigger", trg.Name, "actor", actor, "error", err) // Don't expose the raw error — it can carry registry-auth or // compose-stdout bytes (matches the manual-deploy handler). respondError(w, http.StatusInternalServerError, "fire failed; see server logs") return } var deployed, errored int for _, b := range results { switch { case b.Deployed: deployed++ case b.Reason == webhook.ReasonBindingDisabled, b.Reason == webhook.ReasonNoMatch: // silent default: errored++ } } // Empty fan-out (no bindings) is almost certainly an operator // mistake — the UI button is gated on binding_count>0, but the // counts can change between page load and click. Warn so the // no-op shows up in audit logs. if len(results) == 0 { slog.Warn("fire-now: no bindings to fire", "trigger", trg.Name, "actor", actor) } else { slog.Info("fire-now dispatched", "trigger", trg.Name, "actor", actor, "bindings", len(results), "deployed", deployed, "errored", errored) } respondJSON(w, http.StatusAccepted, map[string]any{ "trigger": trg.Name, "fired_at": now.Format(time.RFC3339), "bindings": len(results), "deployed": deployed, "errored": errored, }) } func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") secret := generateWebhookSecret() if err := s.store.SetTriggerWebhookSecret(id, secret); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "rotate webhook secret") return } respondJSON(w, http.StatusOK, map[string]string{ "secret": secret, "url": "/api/webhook/triggers/" + secret, }) } // maxBindingConfigBytes caps a per-binding override blob. Smaller than // the full trigger config — bindings should be lightweight tweaks // (tag pattern, branch filter), not whole replacement configs. const maxBindingConfigBytes = 8 << 10 // validateBindingConfig enforces the size cap and runs the trigger // plugin's Validate() against the merged (trigger.config + binding) // shape so a malformed override is caught at write time instead of // silently breaking webhook fan-out at deploy time. func validateBindingConfig(trg store.Trigger, bindingConfig json.RawMessage) error { if len(bindingConfig) > maxBindingConfigBytes { return fmt.Errorf("binding_config exceeds %d bytes", maxBindingConfigBytes) } merged, err := plugin.MergeJSONConfig(json.RawMessage(trg.Config), bindingConfig) if err != nil { return fmt.Errorf("binding_config: %w", err) } tp, err := plugin.GetTrigger(trg.Kind) if err != nil { return err } return tp.Validate(merged) } // validateTriggerRequest type-checks the trigger via the registered // plugin. Accepts an empty config only when the plugin allows it (e.g. // the manual trigger). func validateTriggerRequest(req triggerRequest) error { if strings.TrimSpace(req.Kind) == "" { return fmt.Errorf("kind is required") } if strings.TrimSpace(req.Name) == "" { return fmt.Errorf("name is required") } if len(req.Config) > maxTriggerStandaloneConfigBytes { return fmt.Errorf("config exceeds %d bytes", maxTriggerStandaloneConfigBytes) } tp, err := plugin.GetTrigger(req.Kind) if err != nil { return err } return tp.Validate(req.Config) } // bindingView shapes one binding for the /api/triggers/{id}/bindings // listing. Includes the workload's name to avoid an N+1 round-trip on // the frontend. type bindingView struct { ID string `json:"id"` WorkloadID string `json:"workload_id"` WorkloadName string `json:"workload_name"` TriggerID string `json:"trigger_id"` BindingConfig json.RawMessage `json:"binding_config"` Enabled bool `json:"enabled"` SortOrder int `json:"sort_order"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func (s *Server) toBindingView(b store.WorkloadTriggerBinding) bindingView { name := "" if w, err := s.store.GetWorkloadByID(b.WorkloadID); err == nil { name = w.Name } return bindingView{ ID: b.ID, WorkloadID: b.WorkloadID, WorkloadName: name, TriggerID: b.TriggerID, BindingConfig: json.RawMessage(b.BindingConfig), Enabled: b.Enabled, SortOrder: b.SortOrder, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, } } func (s *Server) listBindingsForTrigger(w http.ResponseWriter, r *http.Request) { tid := chi.URLParam(r, "id") if _, err := s.store.GetTriggerByID(tid); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } rows, err := s.store.ListBindingsForTriggerWithNames(tid) if err != nil { respondError(w, http.StatusInternalServerError, "list bindings") return } out := make([]bindingView, 0, len(rows)) for _, b := range rows { out = append(out, bindingView{ ID: b.ID, WorkloadID: b.WorkloadID, WorkloadName: b.WorkloadName, TriggerID: b.TriggerID, BindingConfig: json.RawMessage(b.BindingConfig), Enabled: b.Enabled, SortOrder: b.SortOrder, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, }) } respondJSON(w, http.StatusOK, out) } // bindingRequest is shared by trigger-side bind (POST .../bindings) and // workload-side bind (POST workloads/{id}/triggers). type bindingRequest struct { WorkloadID string `json:"workload_id"` TriggerID string `json:"trigger_id"` BindingConfig json.RawMessage `json:"binding_config"` Enabled *bool `json:"enabled"` SortOrder int `json:"sort_order"` } func (s *Server) bindWorkloadToTrigger(w http.ResponseWriter, r *http.Request) { tid := chi.URLParam(r, "id") var req bindingRequest if !decodeJSONStrict(w, r, &req) { return } if req.WorkloadID == "" { respondError(w, http.StatusBadRequest, "workload_id is required") return } trg, err := s.store.GetTriggerByID(tid) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } if _, err := s.store.GetWorkloadByID(req.WorkloadID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } if err := validateBindingConfig(trg, req.BindingConfig); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } b := store.WorkloadTriggerBinding{ WorkloadID: req.WorkloadID, TriggerID: tid, BindingConfig: string(req.BindingConfig), Enabled: enabled, SortOrder: req.SortOrder, } created, err := s.store.CreateBinding(b) if err != nil { if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "this workload is already bound to this trigger") return } slog.Error("create binding", "error", err) respondError(w, http.StatusInternalServerError, "create binding") return } respondJSON(w, http.StatusCreated, s.toBindingView(created)) } func (s *Server) updateBinding(w http.ResponseWriter, r *http.Request) { bid := chi.URLParam(r, "bid") existing, err := s.store.GetBindingByID(bid) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "binding") return } respondError(w, http.StatusInternalServerError, "get binding") return } var req bindingRequest if !decodeJSONStrict(w, r, &req) { return } if len(req.BindingConfig) > 0 { trg, terr := s.store.GetTriggerByID(existing.TriggerID) if terr != nil { slog.Error("update binding: trigger lookup", "trigger", existing.TriggerID, "error", terr) respondError(w, http.StatusInternalServerError, "trigger lookup") return } if err := validateBindingConfig(trg, req.BindingConfig); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } existing.BindingConfig = string(req.BindingConfig) } if req.Enabled != nil { existing.Enabled = *req.Enabled } existing.SortOrder = req.SortOrder if err := s.store.UpdateBinding(existing); err != nil { respondError(w, http.StatusInternalServerError, "update binding") return } respondJSON(w, http.StatusOK, s.toBindingView(existing)) } // listBindingsForWorkload is the workload-side mirror of // listBindingsForTrigger. Returns every trigger bound to the workload // in sort_order so the detail page can render them inline. func (s *Server) listBindingsForWorkload(w http.ResponseWriter, r *http.Request) { wid := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(wid); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } rows, err := s.store.ListBindingsForWorkloadWithNames(wid) if err != nil { respondError(w, http.StatusInternalServerError, "list bindings") return } type item struct { bindingView TriggerKind string `json:"trigger_kind"` TriggerName string `json:"trigger_name"` } out := make([]item, 0, len(rows)) for _, b := range rows { out = append(out, item{ bindingView: bindingView{ ID: b.ID, WorkloadID: b.WorkloadID, TriggerID: b.TriggerID, BindingConfig: json.RawMessage(b.BindingConfig), Enabled: b.Enabled, SortOrder: b.SortOrder, CreatedAt: b.CreatedAt, UpdatedAt: b.UpdatedAt, }, TriggerKind: b.TriggerKind, TriggerName: b.TriggerName, }) } respondJSON(w, http.StatusOK, out) } // workloadBindRequest covers the two UX flows: bind an existing trigger // (TriggerID present) or inline-create one in the same call (TriggerID // empty + Inline populated). The inline form keeps the 1:1 case feeling // unchanged from the embedded-trigger era. type workloadBindRequest struct { TriggerID string `json:"trigger_id"` BindingConfig json.RawMessage `json:"binding_config"` Enabled *bool `json:"enabled"` SortOrder int `json:"sort_order"` Inline *triggerRequest `json:"inline"` } func (s *Server) bindTriggerToWorkload(w http.ResponseWriter, r *http.Request) { wid := chi.URLParam(r, "id") if _, err := s.store.GetWorkloadByID(wid); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "workload") return } respondError(w, http.StatusInternalServerError, "get workload") return } var req workloadBindRequest if !decodeJSONStrict(w, r, &req) { return } if req.TriggerID == "" && req.Inline == nil { respondError(w, http.StatusBadRequest, "either trigger_id or inline trigger is required") return } enabled := true if req.Enabled != nil { enabled = *req.Enabled } // Inline path: create trigger + binding atomically so a binding // failure cannot leak a half-built trigger row. if req.TriggerID == "" { if err := validateTriggerRequest(*req.Inline); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } _, b, err := s.store.CreateTriggerWithBindingTx( buildTriggerFromRequest(*req.Inline), store.WorkloadTriggerBinding{ WorkloadID: wid, BindingConfig: string(req.BindingConfig), Enabled: enabled, SortOrder: req.SortOrder, }, ) if err != nil { if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "a trigger with this name already exists") return } slog.Error("inline trigger+binding tx", "error", err) respondError(w, http.StatusInternalServerError, "create inline trigger+binding") return } respondJSON(w, http.StatusCreated, s.toBindingView(b)) return } // Existing-trigger path: just bind. trg, err := s.store.GetTriggerByID(req.TriggerID) if err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "trigger") return } respondError(w, http.StatusInternalServerError, "get trigger") return } if err := validateBindingConfig(trg, req.BindingConfig); err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } b, err := s.store.CreateBinding(store.WorkloadTriggerBinding{ WorkloadID: wid, TriggerID: req.TriggerID, BindingConfig: string(req.BindingConfig), Enabled: enabled, SortOrder: req.SortOrder, }) if err != nil { if errors.Is(err, store.ErrUnique) { respondError(w, http.StatusConflict, "this workload is already bound to this trigger") return } slog.Error("create binding from workload side", "error", err) respondError(w, http.StatusInternalServerError, "create binding") return } respondJSON(w, http.StatusCreated, s.toBindingView(b)) } func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) { bid := chi.URLParam(r, "bid") if err := s.store.DeleteBinding(bid); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "binding") return } respondError(w, http.StatusInternalServerError, "delete binding") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": bid}) }