diff --git a/ROADMAP2.md b/ROADMAP2.md index 5e2e9de..d102bf4 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -86,7 +86,7 @@ Legend: βœ… done Β· 🟑 partial/different Β· ❌ missing Β· βž– not applicable | Multi-tenant organisations + custom RBAC (per-resource CRUD, create-limits, read-scope) | 🟑 `is_admin` + `project_members` | Defer full RBAC; design PAT scopes role-compatible | | Team invite links/emails | 🟑 manual user create only | `POST /invites` β†’ link join | | User API keys β†’ full REST API | ❌ **the big gap** β€” session/JWT only | PATs (Phase A) | -| Outbound webhooks (collection events, HMAC, custom headers) | ❌ | `webhooks` table + delivery log | +| Outbound webhooks (collection events, HMAC, custom headers) | βœ… `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | β€” | | Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar | | White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` β€” **Containr remains the default brand** | | Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟑 `audit_logs` coarser | Enrich + project feed UI | @@ -278,9 +278,10 @@ and wired refs, one click or one CLI call. ## 8. Phase G β€” Platform polish -- [ ] **Outbound webhooks** β€” `webhooks(url, secret, events[], headers, - enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`; - events: `deployment.* service.* database.* security.* agent.*`. +- [x] **Outbound webhooks** β€” `outbound_webhooks(url, secret, events[], + headers, enabled)` + `webhook_deliveries` log; HMAC + `X-Containr-Signature`; wildcard events on every audited action; + CLI + MCP + `/settings/webhooks` page with delivery log + test ping. - [ ] **Activity feed** β€” enrich `audit_logs` with `severity category icon label metadata`; per-project timeline + global feed; TTL job. - [ ] **Banners** β€” `banners(title, body, level, scope, active)`; top @@ -345,8 +346,10 @@ and wired refs, one click or one CLI call. - [ ] **dflow importer** β€” `containr migrate dflow` via Payload REST export or Mongo dump: serversβ†’nodes, projects, services(+vars/ domains/volumes), databases, templates. Onboards dflow refugees. -- [ ] **`containr import compose`** β€” Phase-E parser straight to a - project (no template intermediate). +- [x] **`containr import compose`** β€” `POST /projects/:id/import-compose` + deploys a compose file straight into a project; CLI `import compose`, + MCP tool, workspace import modal; project delete cascades to + graph-provisioned managed databases (`database_services.project_id`). - [ ] **Template registry** β€” optional `CONTAINR_TEMPLATE_REGISTRY_URL` fetch/publish; bundled catalog works offline (dflow's self-contained principle β€” nothing phones home). diff --git a/app/backend/internal/api/audit.go b/app/backend/internal/api/audit.go index 64a43b2..ac3b0f2 100644 --- a/app/backend/internal/api/audit.go +++ b/app/backend/internal/api/audit.go @@ -52,6 +52,8 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in if err != nil { } + + emitWebhookEvent(userID, resource, resourceID, action, details) } func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) { @@ -79,6 +81,8 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de if err != nil { } + + emitWebhookEvent(userIDStr, resource, resourceID, action, details) } var auditDB *database.DB diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 1e56ecd..d4d2a40 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -23,6 +23,11 @@ import ( ) func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { + // Audit + outbound webhook delivery need a package-level handle for + // call sites that don't carry a gin context. + SetAuditDB(db) + StartWebhookDispatcher() + // Expose Better Auth through backend so frontend can use a single backend origin. setupAuthProxyRoutes(router, cfg, db) @@ -328,6 +333,14 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // WebSocket endpoint authed.GET("/ws", handleWebSocket) + // Outbound webhooks β€” signed event delivery to user HTTP endpoints + authed.GET("/webhooks", handleListOutboundWebhooks) + authed.POST("/webhooks", handleCreateOutboundWebhook) + authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook) + authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook) + authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries) + authed.POST("/webhooks/:id/test", handleTestOutboundWebhook) + // Owned-resource mutations β€” handlers scope to the caller and // admit admins. New projects land unapproved until an admin // publishes them. diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index eeb0e83..482685b 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -454,6 +454,10 @@ func handleCreateService(c *gin.Context) { } service.Domains = loadServiceDomains(db.(*database.DB), service.ID) + LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{ + "name": service.Name, "type": service.Type, "project_id": service.ProjectID, + }) + c.JSON(http.StatusCreated, gin.H{"service": service}) } @@ -876,5 +880,7 @@ func handleDeleteService(c *gin.Context) { return } + LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{}) + c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"}) } diff --git a/app/backend/internal/api/webhooks.go b/app/backend/internal/api/webhooks.go new file mode 100644 index 0000000..a29d417 --- /dev/null +++ b/app/backend/internal/api/webhooks.go @@ -0,0 +1,543 @@ +package api + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "containr/internal/database" + "containr/internal/database/sqlcdb" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" +) + +// Outbound webhooks: every audited action (LogAudit) emits an event named +// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an +// event list supporting "service.*" and "*" wildcards. Deliveries are +// HMAC-signed and retried up to 3 times with backoff; each attempt lands +// in webhook_deliveries. + +var ( + webhookQueue = make(chan webhookJob, 512) + webhookWorkers sync.Once +) + +type webhookJob struct { + Webhook sqlcdb.OutboundWebhook + Event string + Payload []byte +} + +type webhookEventPayload struct { + Event string `json:"event"` + Resource string `json:"resource"` + ResourceID string `json:"resource_id"` + Action string `json:"action"` + UserID string `json:"user_id,omitempty"` + Details map[string]interface{} `json:"details,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// emitWebhookEvent fans an audited action out to all enabled webhooks whose +// event list matches. Called from LogAudit β€” fire-and-forget. +func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) { + db := GetAuditDB() + if db == nil { + return + } + // Actions that already carry a resource prefix ("service.clone") pass + // through unchanged; bare actions ("create") get resource-qualified. + event := action + if !strings.Contains(action, ".") { + event = resource + "." + action + } + + queries := sqlcdb.New(db.DB) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + hooks, err := queries.ListEnabledOutboundWebhooks(ctx) + if err != nil || len(hooks) == 0 { + return + } + + payload, err := json.Marshal(webhookEventPayload{ + Event: event, Resource: resource, ResourceID: resourceID, + Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(), + }) + if err != nil { + return + } + + uid, uidErr := uuid.Parse(strings.TrimSpace(userID)) + for _, hook := range hooks { + if !webhookEventMatches(hook.Events, event) { + continue + } + // Webhooks only receive events for their owner β€” unless subscribed to "*" + // is deliberate admin reach; owner scoping keeps per-user isolation. + if uidErr != nil || hook.UserID != uid { + continue + } + select { + case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}: + default: + // Queue full: drop rather than block the request path. + } + } +} + +func webhookEventMatches(events json.RawMessage, event string) bool { + var subs []string + if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 { + return false + } + for _, sub := range subs { + sub = strings.TrimSpace(sub) + if sub == "*" || sub == event { + return true + } + if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) { + return true + } + } + return false +} + +// StartWebhookDispatcher launches delivery workers; call once at boot. +func StartWebhookDispatcher() { + webhookWorkers.Do(func() { + for i := 0; i < 4; i++ { + go webhookDeliveryWorker() + } + }) +} + +func webhookDeliveryWorker() { + for job := range webhookQueue { + deliverWebhook(job) + } +} + +var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func deliverWebhook(job webhookJob) { + db := GetAuditDB() + if db == nil { + return + } + queries := sqlcdb.New(db.DB) + ctx := context.Background() + + deliveryID := uuid.New() + _ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{ + ID: deliveryID, + WebhookID: job.Webhook.ID, + Event: job.Event, + Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true}, + Status: "pending", + }) + + var lastStatus string = "failed" + var respStatus sql.NullInt32 + var respBody sql.NullString + var durationMs sql.NullInt32 + var deliveredAt sql.NullTime + + for attempt := int32(1); attempt <= 3; attempt++ { + start := time.Now() + status, code, body := postWebhook(job, deliveryID, attempt) + durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true} + respStatus = code + respBody = body + if status == "success" { + lastStatus = "success" + deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true} + _ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{ + ID: deliveryID, + Status: lastStatus, + ResponseStatus: respStatus, + ResponseBody: respBody, + Attempts: attempt, + DurationMs: durationMs, + DeliveredAt: deliveredAt, + }) + return + } + if attempt < 3 { + time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second) + } + } + _ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{ + ID: deliveryID, + Status: lastStatus, + ResponseStatus: respStatus, + ResponseBody: respBody, + Attempts: 3, + DurationMs: durationMs, + DeliveredAt: deliveredAt, + }) +} + +func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) { + req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload)) + if err != nil { + return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true} + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Containr-Webhook/1.0") + req.Header.Set("X-Containr-Event", job.Event) + req.Header.Set("X-Containr-Delivery", deliveryID.String()) + if job.Webhook.Secret != "" { + mac := hmac.New(sha256.New, []byte(job.Webhook.Secret)) + mac.Write(job.Payload) + req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + } + if job.Webhook.Headers.Valid { + var custom map[string]string + if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil { + for k, v := range custom { + req.Header.Set(k, v) + } + } + } + + resp, err := webhookHTTPClient.Do(req) + if err != nil { + return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true} + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true} + bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0} + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return "success", code, bodyStr + } + return "failed", code, bodyStr +} + +// ---------- HTTP API ---------- + +type outboundWebhookResponse struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Secret string `json:"secret,omitempty"` + Events []string `json:"events"` + Headers map[string]string `json:"headers,omitempty"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse { + var events []string + _ = json.Unmarshal(row.Events, &events) + if events == nil { + events = []string{} + } + var headers map[string]string + if row.Headers.Valid { + _ = json.Unmarshal(row.Headers.RawMessage, &headers) + } + resp := outboundWebhookResponse{ + ID: row.ID.String(), + Name: row.Name, + URL: row.Url, + Events: events, + Headers: headers, + Enabled: row.Enabled, + } + if revealSecret { + resp.Secret = row.Secret + } + if row.CreatedAt.Valid { + resp.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + resp.UpdatedAt = row.UpdatedAt.Time + } + return resp +} + +func validWebhookURL(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errors.New("url must be a valid http(s) URL") + } + return nil +} + +type webhookMutationRequest struct { + Name string `json:"name"` + URL string `json:"url"` + Secret *string `json:"secret"` + Events []string `json:"events"` + Headers map[string]string `json:"headers"` + Enabled *bool `json:"enabled"` +} + +func handleListOutboundWebhooks(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"}) + return + } + out := make([]outboundWebhookResponse, 0, len(rows)) + for _, row := range rows { + out = append(out, outboundWebhookJSON(row, false)) + } + c.JSON(http.StatusOK, gin.H{"webhooks": out}) +} + +func handleCreateOutboundWebhook(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + var req webhookMutationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + respondError(c, http.StatusBadRequest, "VALIDATION", "name is required") + return + } + if err := validWebhookURL(req.URL); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + if len(req.Events) == 0 { + respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")") + return + } + + secret := "" + if req.Secret != nil { + secret = strings.TrimSpace(*req.Secret) + } + if secret == "" { + buf := make([]byte, 24) + _, _ = rand.Read(buf) + secret = hex.EncodeToString(buf) + } + eventsJSON, _ := json.Marshal(req.Events) + var headersJSON pqtype.NullRawMessage + if len(req.Headers) > 0 { + if raw, err := json.Marshal(req.Headers); err == nil { + headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true} + } + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + + db := c.MustGet("db").(*database.DB) + id := uuid.New() + err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{ + ID: id, + UserID: userID, + Name: req.Name, + Url: strings.TrimSpace(req.URL), + Secret: secret, + Events: eventsJSON, + Headers: headersJSON, + Enabled: enabled, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"}) + return + } + LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name}) + c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{ + ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret, + Events: eventsJSON, Headers: headersJSON, Enabled: enabled, + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }, true)) +} + +func handleUpdateOutboundWebhook(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + webhookID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id") + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{ + ID: webhookID, UserID: userID, + }) + if errors.Is(err, sql.ErrNoRows) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found") + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"}) + return + } + + var req webhookMutationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + name := row.Name + if req.Name != "" { + name = strings.TrimSpace(req.Name) + } + targetURL := row.Url + if req.URL != "" { + if err := validWebhookURL(req.URL); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + targetURL = strings.TrimSpace(req.URL) + } + secret := row.Secret + if req.Secret != nil { + secret = strings.TrimSpace(*req.Secret) + } + eventsJSON := row.Events + if req.Events != nil { + if len(req.Events) == 0 { + respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required") + return + } + eventsJSON, _ = json.Marshal(req.Events) + } + headersJSON := row.Headers + if req.Headers != nil { + if raw, err := json.Marshal(req.Headers); err == nil { + headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true} + } + } + enabled := row.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + + err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{ + ID: webhookID, UserID: userID, + Name: name, Url: targetURL, Secret: secret, + Events: eventsJSON, Headers: headersJSON, Enabled: enabled, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"}) + return + } + LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name}) + row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled + c.JSON(http.StatusOK, outboundWebhookJSON(row, true)) +} + +func handleDeleteOutboundWebhook(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + webhookID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id") + return + } + db := c.MustGet("db").(*database.DB) + err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{ + ID: webhookID, UserID: userID, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"}) + return + } + LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{}) + c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"}) +} + +func handleListOutboundWebhookDeliveries(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + webhookID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id") + return + } + db := c.MustGet("db").(*database.DB) + queries := sqlcdb.New(db.DB) + if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{ + ID: webhookID, UserID: userID, + }); errors.Is(err, sql.ErrNoRows) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found") + return + } + rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"}) + return + } + c.JSON(http.StatusOK, gin.H{"deliveries": rows}) +} + +func handleTestOutboundWebhook(c *gin.Context) { + userID, ok := requireAuthenticatedUserUUID(c) + if !ok { + return + } + webhookID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id") + return + } + db := c.MustGet("db").(*database.DB) + row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{ + ID: webhookID, UserID: userID, + }) + if errors.Is(err, sql.ErrNoRows) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found") + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"}) + return + } + if !row.Enabled { + respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled") + return + } + payload, _ := json.Marshal(webhookEventPayload{ + Event: "ping", Resource: "webhook", ResourceID: webhookID.String(), + Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(), + }) + select { + case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}: + c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"}) + default: + respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full") + } +} diff --git a/app/backend/internal/api/webhooks_test.go b/app/backend/internal/api/webhooks_test.go new file mode 100644 index 0000000..90a0a81 --- /dev/null +++ b/app/backend/internal/api/webhooks_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestWebhookEventMatches(t *testing.T) { + subs := func(list ...string) json.RawMessage { + raw, _ := json.Marshal(list) + return raw + } + cases := []struct { + events json.RawMessage + event string + want bool + }{ + {subs("service.*"), "service.deploy", true}, + {subs("service.*"), "database.create", false}, + {subs("*"), "service.deploy", true}, + {subs("deployment.fail", "service.*"), "deployment.fail", true}, + {subs("deployment.fail"), "deployment.success", false}, + {subs(" ping "), "ping", true}, + {subs(), "service.deploy", false}, + {json.RawMessage(`"not-a-list"`), "service.deploy", false}, + {json.RawMessage(`invalid`), "service.deploy", false}, + } + for i, tc := range cases { + if got := webhookEventMatches(tc.events, tc.event); got != tc.want { + t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want) + } + } +} diff --git a/app/backend/internal/cli/commands/webhooks.go b/app/backend/internal/cli/commands/webhooks.go new file mode 100644 index 0000000..b9322cb --- /dev/null +++ b/app/backend/internal/cli/commands/webhooks.go @@ -0,0 +1,216 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var webhookEvents []string +var webhookHeaders []string +var webhookSecret string +var webhookName string + +// WebhooksCmd manages outbound webhooks β€” signed HTTP event delivery. +var WebhooksCmd = &cobra.Command{ + Use: "webhooks", + Aliases: []string{"hooks"}, + Short: "Manage outbound webhooks (signed event delivery)", +} + +var webhookListCmd = &cobra.Command{ + Use: "list", + Short: "List webhooks", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/webhooks", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "webhooks") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, w := range items { + events := "" + if ev, ok := w["events"].([]interface{}); ok { + names := make([]string, 0, len(ev)) + for _, e := range ev { + if s, ok := e.(string); ok { + names = append(names, s) + } + } + events = strings.Join(names, ",") + } + rows = append(rows, []string{ + str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"), + }) + } + printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows) + return nil + }, +} + +func webhookBody(req map[string]interface{}) (map[string]interface{}, error) { + if len(webhookEvents) > 0 { + req["events"] = webhookEvents + } + if webhookSecret != "" { + req["secret"] = webhookSecret + } + if webhookName != "" { + req["name"] = webhookName + } + if len(webhookHeaders) > 0 { + hdrs, err := parseKVFlags(webhookHeaders) + if err != nil { + return nil, err + } + req["headers"] = hdrs + } + return req, nil +} + +var webhookCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a webhook (events repeat with --event; use \"*\" for all)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(webhookEvents) == 0 { + return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')") + } + c, err := client() + if err != nil { + return err + } + body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]}) + if err != nil { + return err + } + data, err := c.Do("POST", "/webhooks", body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var webhookUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + body, err := webhookBody(map[string]interface{}{}) + if err != nil { + return err + } + if url, _ := cmd.Flags().GetString("url"); url != "" { + body["url"] = url + } + if cmd.Flags().Changed("enabled") { + body["enabled"] = true + } + if cmd.Flags().Changed("disabled") { + body["enabled"] = false + } + data, err := c.Do("PATCH", "/webhooks/"+args[0], body) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var webhookDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a webhook", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !Confirm("Delete webhook " + args[0] + "?") { + return nil + } + c, err := client() + if err != nil { + return err + } + data, err := c.Do("DELETE", "/webhooks/"+args[0], nil) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +var webhookDeliveriesCmd = &cobra.Command{ + Use: "deliveries ", + Short: "Show recent delivery attempts for a webhook", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/webhooks/"+args[0]+"/deliveries", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "deliveries") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, d := range items { + rows = append(rows, []string{ + str(d, "id"), str(d, "event"), str(d, "status"), + str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"), + }) + } + printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows) + return nil + }, +} + +var webhookTestCmd = &cobra.Command{ + Use: "test ", + Short: "Queue a test ping delivery", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", "/webhooks/"+args[0]+"/test", map[string]interface{}{}) + if err != nil { + return err + } + PrintRaw(data) + return nil + }, +} + +func init() { + webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)") + webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)") + webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)") + webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name") + webhookUpdateCmd.Flags().String("url", "", "New URL") + webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)") + webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret") + webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)") + webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook") + webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook") + + WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd) +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index ad0dedb..770b41a 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -87,6 +87,7 @@ func init() { rootCmd.AddCommand(commands.GatewayCmd) rootCmd.AddCommand(commands.AdminCmd) rootCmd.AddCommand(commands.NotificationsCmd) + rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.UpCmd) } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index b7bd99d..86a3334 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -175,6 +175,19 @@ type Notification struct { CreatedAt sql.NullTime `json:"created_at"` } +type OutboundWebhook struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + Url string `json:"url"` + Secret string `json:"secret"` + Events json.RawMessage `json:"events"` + Headers pqtype.NullRawMessage `json:"headers"` + Enabled bool `json:"enabled"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + type Project struct { ID uuid.UUID `json:"id"` Name string `json:"name"` @@ -286,3 +299,17 @@ type UserToken struct { RevokedAt sql.NullTime `json:"revoked_at"` CreatedAt sql.NullTime `json:"created_at"` } + +type WebhookDelivery struct { + ID uuid.UUID `json:"id"` + WebhookID uuid.UUID `json:"webhook_id"` + Event string `json:"event"` + Payload pqtype.NullRawMessage `json:"payload"` + Status string `json:"status"` + ResponseStatus sql.NullInt32 `json:"response_status"` + ResponseBody sql.NullString `json:"response_body"` + Attempts int32 `json:"attempts"` + DurationMs sql.NullInt32 `json:"duration_ms"` + CreatedAt sql.NullTime `json:"created_at"` + DeliveredAt sql.NullTime `json:"delivered_at"` +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index c4ad8c1..0acb2b4 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -27,15 +27,18 @@ type Querier interface { CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error + CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) + CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error + DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) @@ -50,6 +53,8 @@ type Querier interface { GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) + GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) + GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) @@ -69,7 +74,9 @@ type Querier interface { ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) + ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) + ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingProjects(ctx context.Context) ([]Project, error) // Visibility model: a project is readable when it is approved for public @@ -79,6 +86,7 @@ type Querier interface { ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) + ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) @@ -102,8 +110,10 @@ type Querier interface { UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error + UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) + UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error } diff --git a/app/backend/internal/database/sqlcdb/webhooks.sql.go b/app/backend/internal/database/sqlcdb/webhooks.sql.go new file mode 100644 index 0000000..7989f88 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/webhooks.sql.go @@ -0,0 +1,329 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: webhooks.sql + +package sqlcdb + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" +) + +const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec +INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +` + +type CreateOutboundWebhookParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + Url string `json:"url"` + Secret string `json:"secret"` + Events json.RawMessage `json:"events"` + Headers pqtype.NullRawMessage `json:"headers"` + Enabled bool `json:"enabled"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error { + _, err := q.db.ExecContext(ctx, createOutboundWebhook, + arg.ID, + arg.UserID, + arg.Name, + arg.Url, + arg.Secret, + arg.Events, + arg.Headers, + arg.Enabled, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec +INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at) +VALUES ($1, $2, $3, $4, $5, $6) +` + +type CreateWebhookDeliveryParams struct { + ID uuid.UUID `json:"id"` + WebhookID uuid.UUID `json:"webhook_id"` + Event string `json:"event"` + Payload pqtype.NullRawMessage `json:"payload"` + Status string `json:"status"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error { + _, err := q.db.ExecContext(ctx, createWebhookDelivery, + arg.ID, + arg.WebhookID, + arg.Event, + arg.Payload, + arg.Status, + arg.CreatedAt, + ) + return err +} + +const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec +DELETE FROM outbound_webhooks +WHERE id = $1 AND user_id = $2 +` + +type DeleteOutboundWebhookByIDAndUserParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error { + _, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID) + return err +} + +const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one +SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at +FROM outbound_webhooks +WHERE id = $1 +` + +func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) { + row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id) + var i OutboundWebhook + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Url, + &i.Secret, + &i.Events, + &i.Headers, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one +SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at +FROM outbound_webhooks +WHERE id = $1 AND user_id = $2 +` + +type GetOutboundWebhookByIDAndUserParams struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) { + row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID) + var i OutboundWebhook + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Url, + &i.Secret, + &i.Events, + &i.Headers, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many +SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at +FROM outbound_webhooks +WHERE enabled +` + +func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) { + rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks) + if err != nil { + return nil, err + } + defer rows.Close() + items := []OutboundWebhook{} + for rows.Next() { + var i OutboundWebhook + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Url, + &i.Secret, + &i.Events, + &i.Headers, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many +SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at +FROM outbound_webhooks +WHERE user_id = $1 +ORDER BY created_at DESC +` + +func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) { + rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []OutboundWebhook{} + for rows.Next() { + var i OutboundWebhook + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Url, + &i.Secret, + &i.Events, + &i.Headers, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many +SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at +FROM webhook_deliveries +WHERE webhook_id = $1 +ORDER BY created_at DESC +LIMIT 100 +` + +func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) { + rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []WebhookDelivery{} + for rows.Next() { + var i WebhookDelivery + if err := rows.Scan( + &i.ID, + &i.WebhookID, + &i.Event, + &i.Payload, + &i.Status, + &i.ResponseStatus, + &i.ResponseBody, + &i.Attempts, + &i.DurationMs, + &i.CreatedAt, + &i.DeliveredAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec +UPDATE outbound_webhooks +SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7 +WHERE id = $8 AND user_id = $9 +` + +type UpdateOutboundWebhookParams struct { + Name string `json:"name"` + Url string `json:"url"` + Secret string `json:"secret"` + Events json.RawMessage `json:"events"` + Headers pqtype.NullRawMessage `json:"headers"` + Enabled bool `json:"enabled"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error { + _, err := q.db.ExecContext(ctx, updateOutboundWebhook, + arg.Name, + arg.Url, + arg.Secret, + arg.Events, + arg.Headers, + arg.Enabled, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + return err +} + +const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec +UPDATE webhook_deliveries +SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6 +WHERE id = $7 +` + +type UpdateWebhookDeliveryResultParams struct { + Status string `json:"status"` + ResponseStatus sql.NullInt32 `json:"response_status"` + ResponseBody sql.NullString `json:"response_body"` + Attempts int32 `json:"attempts"` + DurationMs sql.NullInt32 `json:"duration_ms"` + DeliveredAt sql.NullTime `json:"delivered_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error { + _, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult, + arg.Status, + arg.ResponseStatus, + arg.ResponseBody, + arg.Attempts, + arg.DurationMs, + arg.DeliveredAt, + arg.ID, + ) + return err +} diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index ec2297f..fa01263 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -140,6 +140,13 @@ func init() { register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false}) register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false}) + register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false}) + register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false}) + register(spec{"containr_webhooks_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false}) + register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true}) + register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false}) + register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false}) + // Nodes / agent tokens (admin) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false}) diff --git a/app/backend/migrations_goose/20261103000000_outbound_webhooks.sql b/app/backend/migrations_goose/20261103000000_outbound_webhooks.sql new file mode 100644 index 0000000..de50d97 --- /dev/null +++ b/app/backend/migrations_goose/20261103000000_outbound_webhooks.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Outbound webhooks: user-owned HTTP endpoints that receive signed +-- platform events (resource.action), with a delivery log. +CREATE TABLE IF NOT EXISTS outbound_webhooks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name varchar(255) NOT NULL, + url varchar(500) NOT NULL, + secret text NOT NULL DEFAULT '', + events jsonb NOT NULL DEFAULT '[]'::jsonb, + headers jsonb, + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + webhook_id uuid NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE, + event varchar(255) NOT NULL, + payload jsonb, + status varchar(50) NOT NULL DEFAULT 'pending', + response_status integer, + response_body text, + attempts integer NOT NULL DEFAULT 0, + duration_ms integer, + created_at timestamptz DEFAULT now(), + delivered_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_outbound_webhooks_user ON outbound_webhooks(user_id); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS outbound_webhooks; diff --git a/app/backend/sqlc/queries/webhooks.sql b/app/backend/sqlc/queries/webhooks.sql new file mode 100644 index 0000000..be96569 --- /dev/null +++ b/app/backend/sqlc/queries/webhooks.sql @@ -0,0 +1,49 @@ +-- name: ListOutboundWebhooksByUser :many +SELECT outbound_webhooks.* +FROM outbound_webhooks +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: GetOutboundWebhookByIDAndUser :one +SELECT outbound_webhooks.* +FROM outbound_webhooks +WHERE id = $1 AND user_id = $2; + +-- name: ListEnabledOutboundWebhooks :many +SELECT outbound_webhooks.* +FROM outbound_webhooks +WHERE enabled; + +-- name: CreateOutboundWebhook :exec +INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10); + +-- name: UpdateOutboundWebhook :exec +UPDATE outbound_webhooks +SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7 +WHERE id = $8 AND user_id = $9; + +-- name: DeleteOutboundWebhookByIDAndUser :exec +DELETE FROM outbound_webhooks +WHERE id = $1 AND user_id = $2; + +-- name: CreateWebhookDelivery :exec +INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at) +VALUES ($1, $2, $3, $4, $5, $6); + +-- name: UpdateWebhookDeliveryResult :exec +UPDATE webhook_deliveries +SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6 +WHERE id = $7; + +-- name: ListWebhookDeliveriesByWebhook :many +SELECT webhook_deliveries.* +FROM webhook_deliveries +WHERE webhook_id = $1 +ORDER BY created_at DESC +LIMIT 100; + +-- name: GetOutboundWebhookByID :one +SELECT outbound_webhooks.* +FROM outbound_webhooks +WHERE id = $1; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index fe6dc99..1ad7cb0 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -275,3 +275,30 @@ CREATE TABLE user_tokens ( revoked_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + +CREATE TABLE outbound_webhooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + url VARCHAR(500) NOT NULL, + secret TEXT NOT NULL DEFAULT '', + events JSONB NOT NULL DEFAULT '[]'::jsonb, + headers JSONB, + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + webhook_id UUID NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE, + event VARCHAR(255) NOT NULL, + payload JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + response_status INTEGER, + response_body TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + delivered_at TIMESTAMP WITH TIME ZONE +); diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 970e8dc..00c20b9 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -9,6 +9,7 @@ import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetrics import { BuildsPage } from '@/features/builds/pages/BuildsPage'; import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; +import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage'; import { AdminPage } from '@/features/admin/pages/AdminPage'; import { LandingPage } from '@/features/landing/LandingPage'; import { @@ -151,6 +152,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 64961e3..04c0cf4 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -27,6 +27,7 @@ import { UploadCloud, User, Users, + Webhook, } from 'lucide-react'; import { getCurrentUserProfile, @@ -67,6 +68,7 @@ const navSections: NavSection[] = [ items: [ { label: 'People', href: '/people', icon: Users, hint: 'Members & access' }, { label: 'Audit Logs', href: '/settings/audit-logs', icon: ScrollText, hint: 'Activity trail' }, + { label: 'Webhooks', href: '/settings/webhooks', icon: Webhook, hint: 'Signed event delivery' }, { label: 'Settings', href: '/settings', icon: Settings, hint: 'Platform preferences' }, { label: 'Docs', href: '/docs', icon: BookOpen, hint: 'Guides & reference' }, ], diff --git a/app/frontend/src/features/webhooks/pages/WebhooksPage.tsx b/app/frontend/src/features/webhooks/pages/WebhooksPage.tsx new file mode 100644 index 0000000..4d35350 --- /dev/null +++ b/app/frontend/src/features/webhooks/pages/WebhooksPage.tsx @@ -0,0 +1,363 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + createWebhook, + deleteWebhook, + listWebhookDeliveries, + listWebhooks, + testWebhook, + updateWebhook, + type OutboundWebhook, + type WebhookDelivery, +} from '@/lib/api-client'; +import { useDemoMode } from '@/lib/demo-mode'; +import { formatRelative } from '@/lib/time'; +import { DemoRestricted, useToast } from '@/shared/components'; +import { + Webhook, + Plus, + Trash2, + Send, + Loader2, + ChevronDown, + ChevronRight, + Copy, + X, +} from 'lucide-react'; + +const inputClass = + 'h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all'; + +const eventSuggestions = ['*', 'service.*', 'project.*', 'database.*', 'deployment.*', 'template.*']; + +function statusClass(status: WebhookDelivery['status']): string { + switch (status) { + case 'success': + return 'text-[var(--success)]'; + case 'failed': + return 'text-[var(--error)]'; + default: + return 'text-[var(--warning)]'; + } +} + +function WebhookRow({ + webhook, + onDelete, + onToggle, + onTest, + testing, +}: { + webhook: OutboundWebhook; + onDelete: () => void; + onToggle: () => void; + onTest: () => void; + testing: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const deliveriesQuery = useQuery({ + queryKey: ['webhook-deliveries', webhook.id], + queryFn: () => listWebhookDeliveries(webhook.id), + enabled: expanded, + refetchInterval: expanded ? 10000 : false, + }); + + return ( +
+
+ +
+
+ {webhook.name} + {!webhook.enabled && ( + + disabled + + )} +
+

{webhook.url}

+
+
+ {webhook.events.map((ev) => ( + + {ev} + + ))} +
+
+ + + +
+
+ {expanded && ( +
+

+ Recent deliveries +

+ {deliveriesQuery.isLoading ? ( + + ) : (deliveriesQuery.data ?? []).length === 0 ? ( +

No deliveries yet.

+ ) : ( +
+ {(deliveriesQuery.data ?? []).slice(0, 20).map((d) => ( +
+ {d.status} + {d.event} + {d.response_status != null && ( + HTTP {d.response_status} + )} + {d.attempts} tries + {d.duration_ms != null && ( + {d.duration_ms}ms + )} + {formatRelative(d.created_at)} +
+ ))} +
+ )} +
+ )} +
+ ); +} + +export function WebhooksPage() { + const isDemoMode = useDemoMode(); + const queryClient = useQueryClient(); + const toast = useToast(); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState({ name: '', url: '', events: [] as string[] }); + const [secret, setSecret] = useState(null); + const [testingId, setTestingId] = useState(null); + + const webhooksQuery = useQuery({ + queryKey: ['webhooks'], + queryFn: listWebhooks, + enabled: !isDemoMode, + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] }); + + const createMutation = useMutation({ + mutationFn: () => createWebhook(form), + onSuccess: (created) => { + invalidate(); + setCreating(false); + setForm({ name: '', url: '', events: [] }); + if (created.secret) { + setSecret(created.secret); + } + }, + onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Failed to create webhook', 'error'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteWebhook(id), + onSuccess: invalidate, + onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Delete failed', 'error'), + }); + + const toggleMutation = useMutation({ + mutationFn: (w: OutboundWebhook) => updateWebhook(w.id, { enabled: !w.enabled }), + onSuccess: invalidate, + }); + + const testMutation = useMutation({ + mutationFn: (id: string) => testWebhook(id), + onMutate: (id) => setTestingId(id), + onSettled: () => setTestingId(null), + onSuccess: () => toast.showToast('Test ping queued', 'info'), + onError: (err) => toast.showToast(err instanceof Error ? err.message : 'Test failed', 'error'), + }); + + if (isDemoMode) { + return ; + } + + const toggleEvent = (ev: string) => + setForm((f) => ({ + ...f, + events: f.events.includes(ev) ? f.events.filter((e) => e !== ev) : [...f.events, ev], + })); + + return ( +
+
+
+
+

Webhooks_

+

+ Signed HTTP delivery for every platform event β€” deploys, restarts, deletes +

+
+ +
+
+ +
+ {secret && ( +
+
+

Signing secret β€” shown once

+

+ Verify deliveries with X-Containr-Signature +

+ {secret} +
+ + +
+ )} + + {webhooksQuery.isLoading ? ( + + ) : (webhooksQuery.data ?? []).length === 0 ? ( +
+ +

No webhooks yet.

+

+ Subscribe an HTTP endpoint to platform events like service.* or{' '} + deployment.fail. +

+
+ ) : ( + (webhooksQuery.data ?? []).map((w) => ( + testMutation.mutate(w.id)} + onToggle={() => toggleMutation.mutate(w)} + onDelete={() => { + if (window.confirm(`Delete webhook "${w.name}"?`)) { + deleteMutation.mutate(w.id); + } + }} + /> + )) + )} +
+ + {creating && ( +
+
setCreating(false)} /> +
+

New webhook

+
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + className={`${inputClass} w-full`} + placeholder="deploy-alerts" + /> +
+
+ + setForm((f) => ({ ...f, url: e.target.value }))} + className={`${inputClass} w-full mono`} + placeholder="https://example.com/hooks/containr" + /> +
+
+ +
+ {eventSuggestions.map((ev) => ( + + ))} +
+

+ Events are resource.action β€” wildcards like{' '} + service.* match the whole resource. +

+
+
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index f154679..f2f3a2f 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3507,6 +3507,256 @@ export interface paths { patch?: never; trace?: never; }; + "/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List outbound webhooks */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhooks for the caller (secrets masked) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + webhooks?: components["schemas"]["OutboundWebhook"][]; + }; + }; + }; + }; + }; + put?: never; + /** + * Create an outbound webhook + * @description Receives signed POST deliveries for every audited action matching + * the event list β€” "resource.action" names, "resource.*" wildcards, + * or "*" for everything. The secret is returned once; deliveries + * carry X-Containr-Signature: sha256= over the raw body. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name: string; + url: string; + /** @description Signing secret (auto-generated when omitted) */ + secret?: string; + /** + * @example [ + * "service.*", + * "deployment.fail" + * ] + */ + events: string[]; + headers?: { + [key: string]: string; + }; + /** @default true */ + enabled?: boolean; + }; + }; + }; + responses: { + /** @description Webhook created (secret revealed once) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OutboundWebhook"]; + }; + }; + /** @description Invalid name, URL, or empty event list */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete a webhook */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; + options?: never; + head?: never; + /** Update a webhook */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + name?: string; + url?: string; + secret?: string; + events?: string[]; + headers?: { + [key: string]: string; + }; + enabled?: boolean; + }; + }; + }; + responses: { + /** @description Updated webhook */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OutboundWebhook"]; + }; + }; + /** @description Webhook not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + trace?: never; + }; + "/webhooks/{id}/deliveries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List delivery attempts for a webhook */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Recent deliveries (newest first, max 100) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + deliveries?: components["schemas"]["WebhookDelivery"][]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/{id}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Queue a test ping delivery */ + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Delivery queued */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -9923,6 +10173,43 @@ export interface components { notifications?: components["schemas"]["Notification"][]; unread?: number; }; + OutboundWebhook: { + /** Format: uuid */ + id?: string; + name?: string; + url?: string; + /** @description Only present on create/update responses */ + secret?: string; + events?: string[]; + headers?: { + [key: string]: string; + }; + enabled?: boolean; + /** Format: date-time */ + created_at?: string; + /** Format: date-time */ + updated_at?: string; + }; + WebhookDelivery: { + /** Format: uuid */ + id?: string; + /** Format: uuid */ + webhook_id?: string; + event?: string; + payload?: { + [key: string]: unknown; + }; + /** @enum {string} */ + status?: "pending" | "success" | "failed"; + response_status?: number | null; + response_body?: string | null; + attempts?: number; + duration_ms?: number | null; + /** Format: date-time */ + created_at?: string; + /** Format: date-time */ + delivered_at?: string | null; + }; AuditLogListResponse: { audit_logs?: components["schemas"]["AuditLog"][]; }; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 823aceb..508d40a 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/CLI.md", "section": "guides", "title": "Containr CLI", - "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance β€” the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings β†’ Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response β€” safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars β€” referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection β€” by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project β€”\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy β€” `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target β€” set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\nβ€” set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile β†’ railpack β†’ nixpacks).\n`static` builds assets in a node stage and serves them from nginx β€”\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and β€”\nwhen the service has domains β€” installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables β€” ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts β†’ 409\n```\n\nNote: cloned domains are verbatim copies β€” the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio β€” every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" + "markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance β€” the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings β†’ Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response β€” safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs [--follow] runtime logs (SSE follow)\nexec -- one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars β€” referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update --public/--public=false toggles it (container recreates, data persists)\ndatabases register register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection β€” by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose converts a compose file into\n a v2 graph config; deploy-graph \n deploys an ad-hoc service graph\nimport compose \n deploys a docker-compose file straight into a project β€”\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nwebhooks list|create|update|delete|deliveries|test\n outbound signed webhooks β€” create \n --event 'service.*' (repeatable, '*' = all);\n deliveries are HMAC-signed (X-Containr-Signature)\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project \ncontainr up --project --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles..project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy β€” `containr deploy ` to apply immediately.\n\n`volumes list` shows the node's docker volume inventory with in-use\nflags; `volumes delete ` removes an unused volume (admin scope).\n\n## Service domains and access\n\nA service can carry multiple hostnames; the default is mirrored to\n`services.domain`:\n\n```bash\ncontainr services domains add app.example.com --default\ncontainr services domains check # DNS preflight per domain\ncontainr services domains remove \n```\n\nDNS check compares each domain to the expected target β€” set\n`app_settings.public_ip` (or `PUBLIC_IP` env) so `wrong-target` is\nmeaningful; without it any resolvable domain reports `ok`.\n\nAccess gates apply via Traefik middlewares (no extra proxy):\n\n```bash\ncontainr services update --maintenance on\ncontainr services update --basic-auth admin:secret --basic-auth ops:pw2\n```\n\nMaintenance mode redirects all traffic to `/api/v1/maintenance`\nβ€” set `app_settings.base_url` or `BASE_URL`; without it the service is\ntaken offline with empty responses. Basic-auth credentials are stored\nbcrypt-hashed (htpasswd) and gate every request.\n\n## Build strategy and resources\n\nSource-built services pick a builder per service:\n\n```bash\ncontainr services update --builder railpack # auto|railpack|nixpacks|dockerfile|static\ncontainr services update --builder static --static-cmd \"npm run build\" --static-dir dist\n```\n\n`auto` detects from repo contents (Dockerfile β†’ railpack β†’ nixpacks).\n`static` builds assets in a node stage and serves them from nginx β€”\n`--static-cmd` runs inside the build stage (default\n`npm ci && npm run build`), `--static-dir` is copied into the nginx image\n(default `dist`, exposed on port 80).\n\nResource reservations complement the hard `cpu`/`memory` limits:\n\n```bash\ncontainr services update --cpu-reserve 0.25 --memory-reserve 128Mi\n```\n\n`cpu`/`memory` are hard caps (NanoCPUs/Memory); `cpu_reserve`/\n`memory_reserve` are soft hints (CPU shares, MemoryReservation). Empty\nstring clears. `app_settings.default_cpu`/`default_memory` change the\ninstance-level defaults used at service creation;\n`app_settings.capacity_policy=block` fails deployments whose requested\nmemory exceeds node capacity (default `warn` logs only).\n\n## Sleep mode (scale-to-zero)\n\nServices can scale to zero after an idle window and resume on traffic:\n\n```bash\ncontainr services update --sleep --sleep-idle 15 # enable, 15 min idle timeout\ncontainr services update --sleep=false # disable\ncontainr services sleep # sleep now\ncontainr services wake # resume now\ncontainr services env-check # flag empty vars + unresolved ${{refs}}\n```\n\nIdleness is measured as flat container network I/O over the configured\nwindow (swept every 30s). Sleeping removes the workload containers and β€”\nwhen the service has domains β€” installs a busybox placeholder that holds\nthe Traefik route at higher priority, serves a reloading \"waking\" page,\nand pings `GET /api/v1/internal/wake/:id` to reconcile the service back.\nThe API must be reachable from containers for the callback (containerized\ndeploys work via the shared network; bare-metal needs `HOST=0.0.0.0`).\n\n## Clone, move, registries\n\n`services clone` duplicates config, volumes, domains, access gates,\nbuilder settings, and variables β€” ciphertext secret values are copied\nverbatim (no decrypt/re-encrypt). The clone starts stopped:\n\n```bash\ncontainr services clone --name staging-api --project --environment preview\ncontainr services move # name conflicts β†’ 409\n```\n\nNote: cloned domains are verbatim copies β€” the same hostname attached to\ntwo services makes Traefik routing ambiguous. Re-domain or undeploy one.\n\n`registries` stores private-image pull credentials, owner-scoped and\nencrypted at rest. Matched by image host on every image-sourced deploy:\n\n```bash\ncontainr registries add ghcr ghcr.io --username --password \ncontainr registries add hub docker.io --username --password # Docker Hub\ncontainr registries update --username # empty --password keeps stored\ncontainr registries remove \n```\n\n## MCP server\n\n`containr-mcp` (`go build ./cmd/mcp`) exposes the same API as MCP tools\nover stdio β€” every command group above maps to a `containr_*` tool\n(~60 tools). Destructive tools require the caller to pass\n`confirm: true`; results are pretty JSON with `isError` on failures.\n\nClient configuration (Claude Code, Cursor, Devin, any stdio MCP host):\n\n```json\n{\n \"mcpServers\": {\n \"containr\": {\n \"command\": \"containr-mcp\",\n \"env\": {\n \"CONTAINR_API_URL\": \"https://containr.example.com\",\n \"CONTAINR_TOKEN\": \"cnp_...\"\n }\n }\n }\n}\n```\n\nScope the token deliberately: `read` is enough for inspection agents,\n`write` for deploy automation, `admin` only for platform operations.\n\nDeployments are serialized per service: a second `deploy` on a busy\nservice lands in `queued` state and runs when the active one finishes.\n`deployments cancel ` drops a queued deployment or aborts a running\none (builds/reconciles stop via context cancellation).\n" }, { "path": "guides/CLOUDFLARE_SETUP.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index aa6fe95..69e387c 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1767,6 +1767,66 @@ export async function importComposeIntoProject( return { created: payload.created ?? [], warnings: payload.warnings ?? [] }; } +export type OutboundWebhook = { + id: string; + name: string; + url: string; + secret?: string; + events: string[]; + headers?: Record; + enabled: boolean; + created_at: string; + updated_at: string; +}; + +export type WebhookDelivery = { + id: string; + webhook_id: string; + event: string; + status: 'pending' | 'success' | 'failed'; + response_status?: number | null; + response_body?: string | null; + attempts: number; + duration_ms?: number | null; + created_at: string; + delivered_at?: string | null; +}; + +export async function listWebhooks(): Promise { + const payload = await requestJson<{ webhooks?: OutboundWebhook[] }>('/webhooks'); + return payload.webhooks ?? []; +} + +export type WebhookInput = { + name: string; + url: string; + events: string[]; + secret?: string; + headers?: Record; + enabled?: boolean; +}; + +export async function createWebhook(input: WebhookInput): Promise { + return requestJson('/webhooks', { method: 'POST', body: JSON.stringify(input) }); +} + +export async function updateWebhook(id: string, input: Partial): Promise { + return requestJson(`/webhooks/${id}`, { method: 'PATCH', body: JSON.stringify(input) }); +} + +export async function deleteWebhook(id: string): Promise { + await requestJson(`/webhooks/${id}`, { method: 'DELETE' }); +} + +export async function listWebhookDeliveries(id: string): Promise { + const payload = await requestJson<{ deliveries?: WebhookDelivery[] }>(`/webhooks/${id}/deliveries`); + return payload.deliveries ?? []; +} + +export async function testWebhook(id: string): Promise { + await requestJson(`/webhooks/${id}/test`, { method: 'POST', body: JSON.stringify({}) }); +} + export function serviceStatusClass(status: ServiceStatus): string { switch (status) { case 'running': diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8c02af1..6bd7a7a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2350,6 +2350,189 @@ paths: schema: $ref: '#/components/schemas/MessageResponse' + # Outbound Webhooks + /webhooks: + get: + tags: + - Webhooks + summary: List outbound webhooks + security: + - bearerAuth: [] + responses: + '200': + description: Webhooks for the caller (secrets masked) + content: + application/json: + schema: + type: object + properties: + webhooks: + type: array + items: + $ref: '#/components/schemas/OutboundWebhook' + post: + tags: + - Webhooks + summary: Create an outbound webhook + description: | + Receives signed POST deliveries for every audited action matching + the event list β€” "resource.action" names, "resource.*" wildcards, + or "*" for everything. The secret is returned once; deliveries + carry X-Containr-Signature: sha256= over the raw body. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - url + - events + properties: + name: + type: string + url: + type: string + secret: + type: string + description: Signing secret (auto-generated when omitted) + events: + type: array + items: + type: string + example: ["service.*", "deployment.fail"] + headers: + type: object + additionalProperties: + type: string + enabled: + type: boolean + default: true + responses: + '201': + description: Webhook created (secret revealed once) + content: + application/json: + schema: + $ref: '#/components/schemas/OutboundWebhook' + '400': + description: Invalid name, URL, or empty event list + + /webhooks/{id}: + patch: + tags: + - Webhooks + summary: Update a webhook + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + url: + type: string + secret: + type: string + events: + type: array + items: + type: string + headers: + type: object + additionalProperties: + type: string + enabled: + type: boolean + responses: + '200': + description: Updated webhook + content: + application/json: + schema: + $ref: '#/components/schemas/OutboundWebhook' + '404': + description: Webhook not found + delete: + tags: + - Webhooks + summary: Delete a webhook + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + + /webhooks/{id}/deliveries: + get: + tags: + - Webhooks + summary: List delivery attempts for a webhook + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Recent deliveries (newest first, max 100) + content: + application/json: + schema: + type: object + properties: + deliveries: + type: array + items: + $ref: '#/components/schemas/WebhookDelivery' + + /webhooks/{id}/test: + post: + tags: + - Webhooks + summary: Queue a test ping delivery + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '202': + description: Delivery queued + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + # Audit Logs /audit-logs: get: @@ -6953,6 +7136,72 @@ components: unread: type: integer + OutboundWebhook: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + url: + type: string + secret: + type: string + description: Only present on create/update responses + events: + type: array + items: + type: string + headers: + type: object + additionalProperties: + type: string + enabled: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + WebhookDelivery: + type: object + properties: + id: + type: string + format: uuid + webhook_id: + type: string + format: uuid + event: + type: string + payload: + type: object + additionalProperties: true + status: + type: string + enum: [pending, success, failed] + response_status: + type: integer + nullable: true + response_body: + type: string + nullable: true + attempts: + type: integer + duration_ms: + type: integer + nullable: true + created_at: + type: string + format: date-time + delivered_at: + type: string + format: date-time + nullable: true + AuditLogListResponse: type: object properties: diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index bcf5a2f..123a808 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -78,6 +78,10 @@ security scan|vulnerabilities|metrics gateway services|keys|analytics (admin) admin overview|users|settings|audit-logs notifications list|read +webhooks list|create|update|delete|deliveries|test + outbound signed webhooks β€” create + --event 'service.*' (repeatable, '*' = all); + deliveries are HMAC-signed (X-Containr-Signature) up deploy the current directory ```