diff --git a/apps/admin/package.json b/apps/admin/package.json index 1a415ed9..5b339758 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "Admin UI for Pace", "license": "AGPL-3.0", diff --git a/apps/api/README.md b/apps/api/README.md index bfc3f8c2..db7084ca 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -767,6 +767,12 @@ The chart endpoints — `analytics/`, `saved-analytic-view/`, `default-analytics The seven routes under `webhooks/` and `webhook-logs/`. Admin only, at the workspace level. +### They have their own package + +They live in `internal/webhooks` rather than in `internal/project`, which is where they were first written. The charter boundary in this repo is by application rather than by url prefix — same session, same error vocabulary, so this is still the session API — but `internal/project` is that API's **work-item** surface, and these routes touch neither a work item nor a project: the `webhooks` and `webhook_logs` models are theirs alone, nothing outside the file referenced anything it declared, and the three allowlists that decide which urls a workspace may be told to call (`WEBHOOK_ALLOWED_IPS`, `WEBHOOK_ALLOWED_HOSTS`, `WEBHOOK_DISALLOWED_DOMAINS`) are read by nothing else in it. The move is a rename plus a package clause; every path, status and body is the one Django answered. + +The kit the handlers need — `authenticated`, `requireWorkspaceRole` and its role lookup, `invalidDetail`, `internalError`, `isUniqueViolation`, `newUUID` — is **copied** rather than imported. That is deliberate and it is what every handler package here already does: `authenticated`, `internalError`, `newUUID` and `decodeJSON` each exist five times over across `internal/space`, `internal/workspace`, `internal/externalapi`, `internal/project` and `internal/user`, and the `file_assets` model seven times. Ninety duplicated lines is the price of a package that imports nothing from a 37k-line one, and the copies are kept byte-identical so diffing the two shows nothing. + ### The secret is shown twice and no more often On creation and on a regenerate. Everywhere else it is dropped, and the **only** thing dropping it is a context flag. diff --git a/apps/api/internal/project/handler.go b/apps/api/internal/project/handler.go index 409650c8..c287da0f 100644 --- a/apps/api/internal/project/handler.go +++ b/apps/api/internal/project/handler.go @@ -15,7 +15,6 @@ import ( "github.com/yldm-tech/pace/apps/api/internal/projects" "github.com/yldm-tech/pace/apps/api/internal/storage" "gorm.io/gorm" - "net/netip" ) const ( @@ -47,10 +46,6 @@ type Settings struct { Environment map[string]string // LLMBaseURL is where the assistant's completions are asked for. The python client points at OpenAI unless it is told otherwise. LLMBaseURL string - // The three webhook settings, which together decide which urls a workspace may be told to call. - WebhookAllowedIPs []netip.Prefix - WebhookAllowedHosts []string - WebhookDisallowedDomains []string // SecretKey is what an encrypted configuration value is read back with. SecretKey string } @@ -194,7 +189,6 @@ func (handler *Handler) Register(router gin.IRouter) { handler.registerIssueSearchRoutes(router) handler.registerGlobalSearchRoutes(router) handler.registerEntitySearchRoutes(router) - handler.registerWebhookRoutes(router) handler.registerAnalyticViewRoutes(router) handler.registerAnalyticsRoutes(router) handler.registerAnalyticsSummaryRoutes(router) diff --git a/apps/api/internal/project/handler_test.go b/apps/api/internal/project/handler_test.go index 4c6334f4..b6753368 100644 --- a/apps/api/internal/project/handler_test.go +++ b/apps/api/internal/project/handler_test.go @@ -128,7 +128,6 @@ func TestProjectRouteInventory(t *testing.T) { "GET /api/workspaces/:slug/projects/:id/search-issues/": true, "GET /api/workspaces/:slug/search/": true, "GET /api/workspaces/:slug/entity-search/": true, - "GET /api/workspaces/:slug/webhooks/": true, "GET /api/workspaces/:slug/analytic-view/": true, "GET /api/workspaces/:slug/analytics/": true, "GET /api/workspaces/:slug/default-analytics/": true, @@ -139,12 +138,6 @@ func TestProjectRouteInventory(t *testing.T) { "PATCH /api/workspaces/:slug/analytic-view/:view/": true, "DELETE /api/workspaces/:slug/analytic-view/:view/": true, "POST /api/workspaces/:slug/export-analytics/": true, - "POST /api/workspaces/:slug/webhooks/": true, - "GET /api/workspaces/:slug/webhooks/:webhook/": true, - "PATCH /api/workspaces/:slug/webhooks/:webhook/": true, - "DELETE /api/workspaces/:slug/webhooks/:webhook/": true, - "POST /api/workspaces/:slug/webhooks/:webhook/regenerate/": true, - "GET /api/workspaces/:slug/webhook-logs/:webhook/": true, "GET /api/workspaces/:slug/projects/:id/intake-issues/": true, "POST /api/workspaces/:slug/projects/:id/intake-issues/": true, "GET /api/workspaces/:slug/projects/:id/intake-issues/:issue/": true, diff --git a/apps/api/internal/server/router.go b/apps/api/internal/server/router.go index aa8eea50..ce359f82 100644 --- a/apps/api/internal/server/router.go +++ b/apps/api/internal/server/router.go @@ -14,6 +14,7 @@ import ( spaceapi "github.com/yldm-tech/pace/apps/api/internal/space" "github.com/yldm-tech/pace/apps/api/internal/storage" userapi "github.com/yldm-tech/pace/apps/api/internal/user" + webhooksapi "github.com/yldm-tech/pace/apps/api/internal/webhooks" workspaceapi "github.com/yldm-tech/pace/apps/api/internal/workspace" "gorm.io/gorm" ) @@ -142,16 +143,13 @@ func NewRouter(dependencies Dependencies) *gin.Engine { } workspaceHandler.Register(router) projectHandler := projectapi.NewHandler(dependencies.Database, sessions, projectapi.Settings{ - AppBaseURL: dependencies.AuthSettings.AppBaseURL, - WebURL: dependencies.AuthSettings.WebURL, - FileSizeLimit: dependencies.AuthSettings.FileSizeLimit, - WebhookAllowedIPs: dependencies.AuthSettings.WebhookAllowedIPs, - WebhookAllowedHosts: dependencies.AuthSettings.WebhookAllowedHosts, - WebhookDisallowedDomains: dependencies.AuthSettings.WebhookDisallowedDomains, - SecretKey: dependencies.AuthSettings.SecretKey, - SkipEnvironmentConfig: dependencies.AuthSkipEnvironmentConfig, - Environment: dependencies.AuthSettings.Environment, - LLMBaseURL: dependencies.LLMBaseURL, + AppBaseURL: dependencies.AuthSettings.AppBaseURL, + WebURL: dependencies.AuthSettings.WebURL, + FileSizeLimit: dependencies.AuthSettings.FileSizeLimit, + SecretKey: dependencies.AuthSettings.SecretKey, + SkipEnvironmentConfig: dependencies.AuthSkipEnvironmentConfig, + Environment: dependencies.AuthSettings.Environment, + LLMBaseURL: dependencies.LLMBaseURL, }) if err == nil { projectHandler.SetStorage(attachmentStore) @@ -163,6 +161,13 @@ func NewRouter(dependencies Dependencies) *gin.Engine { projectHandler.SetCache(auth.NewRedisCacheInvalidator(dependencies.AuthRedis)) } projectHandler.Register(router) + // The webhook routes are session-authenticated like the project ones and read the same auth settings, but they share nothing else with the work-item surface, so they are their own package. + webhooksHandler := webhooksapi.NewHandler(dependencies.Database, sessions, webhooksapi.Settings{ + WebhookAllowedIPs: dependencies.AuthSettings.WebhookAllowedIPs, + WebhookAllowedHosts: dependencies.AuthSettings.WebhookAllowedHosts, + WebhookDisallowedDomains: dependencies.AuthSettings.WebhookDisallowedDomains, + }) + webhooksHandler.Register(router) spaceHandler := spaceapi.NewHandler(dependencies.Database) spaceHandler.SetSessions(sessions) spaceHandler.SetFileSizeLimit(dependencies.AuthSettings.FileSizeLimit) diff --git a/apps/api/internal/webhooks/handler.go b/apps/api/internal/webhooks/handler.go new file mode 100644 index 00000000..a8095b35 --- /dev/null +++ b/apps/api/internal/webhooks/handler.go @@ -0,0 +1,116 @@ +// Package webhooks serves the seven routes under `webhooks/` and `webhook-logs/`: where a workspace wants to be told about things, and which things. +// +// It is part of the session API rather than a third application — same cookie, same error vocabulary — but it is its own package because it shares nothing with the work-item surface `internal/project` owns. Its two models are its own, it answers no project-scoped path, and the three allowlists that decide which urls a workspace may be told to call are read by nothing else. The delivery side of the same chain lives in `internal/worker`; this is only the registration surface. +// +// The small kit below — authenticated, the role constants, workspaceRole, requireWorkspaceRole, invalidDetail, internalError, isUniqueViolation and newUUID — is copied from `internal/project` rather than imported from it. That is this repo's pattern for a handler package and not an oversight: `authenticated`, `internalError`, `newUUID` and `decodeJSON` each already exist five times over in internal/space, internal/workspace, internal/externalapi, internal/project and internal/user, and the alternative — exporting them from a 37k-line package — would couple the two packages together for the sake of ninety lines. Keep the copies byte-identical so a reader diffing the two sees no difference. +package webhooks + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "net/netip" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/yldm-tech/pace/apps/api/internal/auth" + "gorm.io/gorm" +) + +// roleAdmin is WorkspaceMember.Role's administrator value. Every route here is admin only, so it is the only one of the three this package has a use for. +const roleAdmin = 20 + +type Settings struct { + // The three webhook settings, which together decide which urls a workspace may be told to call. + WebhookAllowedIPs []netip.Prefix + WebhookAllowedHosts []string + WebhookDisallowedDomains []string +} + +type Handler struct { + db *gorm.DB + sessions *auth.SessionManager + settings Settings + clock func() time.Time +} + +func NewHandler(db *gorm.DB, sessions *auth.SessionManager, settings Settings) *Handler { + return &Handler{db: db, sessions: sessions, settings: settings, clock: time.Now} +} + +func (handler *Handler) Register(router gin.IRouter) { + handler.registerWebhookRoutes(router) +} + +func (handler *Handler) authenticated(next func(*gin.Context, *auth.User)) gin.HandlerFunc { + return func(c *gin.Context) { + if handler.sessions == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Authentication credentials were not provided."}) + return + } + user, _, err := handler.sessions.Authenticate(c.Request.Context(), c.Request, c.Writer) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Authentication credentials were not provided."}) + return + } + next(c, user) + } +} + +func (handler *Handler) workspaceRole(ctx context.Context, slug, userID string) (int, error) { + var role *int + err := handler.db.WithContext(ctx).Table("workspace_members wm"). + Joins("JOIN workspaces w ON w.id = wm.workspace_id"). + Where("w.slug = ? AND wm.member_id = ? AND wm.is_active = TRUE AND wm.deleted_at IS NULL", slug, userID). + Select("wm.role").Limit(1).Scan(&role).Error + if err != nil { + return 0, err + } + if role == nil { + return 0, nil + } + return *role, nil +} + +func (handler *Handler) requireWorkspaceRole(c *gin.Context, user *auth.User, allowed ...int) bool { + role, err := handler.workspaceRole(c.Request.Context(), c.Param("slug"), user.ID) + if err != nil { + handler.internalError(c, err) + return false + } + for _, candidate := range allowed { + if role == candidate { + return true + } + } + c.JSON(http.StatusForbidden, gin.H{"error": "You don't have the required permissions."}) + return false +} + +func (handler *Handler) invalidDetail(c *gin.Context) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Please provide valid detail"}) +} + +func (handler *Handler) internalError(c *gin.Context, err error) { + if err != nil { + c.Error(err) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong please try again later"}) +} + +func isUniqueViolation(err error) bool { + lowered := strings.ToLower(err.Error()) + return strings.Contains(lowered, "duplicate key") || strings.Contains(lowered, "unique constraint") +} + +func newUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil +} diff --git a/apps/api/internal/webhooks/handler_test.go b/apps/api/internal/webhooks/handler_test.go new file mode 100644 index 00000000..e8badbc7 --- /dev/null +++ b/apps/api/internal/webhooks/handler_test.go @@ -0,0 +1,32 @@ +package webhooks + +import ( + "testing" + + "github.com/gin-gonic/gin" +) + +func TestWebhookRouteInventory(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + NewHandler(nil, nil, Settings{}).Register(router) + expected := map[string]bool{ + "GET /api/workspaces/:slug/webhooks/": true, + "POST /api/workspaces/:slug/webhooks/": true, + "GET /api/workspaces/:slug/webhooks/:webhook/": true, + "PATCH /api/workspaces/:slug/webhooks/:webhook/": true, + "DELETE /api/workspaces/:slug/webhooks/:webhook/": true, + "POST /api/workspaces/:slug/webhooks/:webhook/regenerate/": true, + "GET /api/workspaces/:slug/webhook-logs/:webhook/": true, + } + for _, route := range router.Routes() { + key := route.Method + " " + route.Path + if !expected[key] { + t.Fatalf("unexpected webhook route %s", key) + } + delete(expected, key) + } + if len(expected) != 0 { + t.Fatalf("missing webhook routes: %#v", expected) + } +} diff --git a/apps/api/internal/project/webhook_handler.go b/apps/api/internal/webhooks/webhook_handler.go similarity index 99% rename from apps/api/internal/project/webhook_handler.go rename to apps/api/internal/webhooks/webhook_handler.go index 2436954f..1e738e12 100644 --- a/apps/api/internal/project/webhook_handler.go +++ b/apps/api/internal/webhooks/webhook_handler.go @@ -1,4 +1,4 @@ -package project +package webhooks import ( "crypto/rand" diff --git a/apps/api/internal/project/webhook_handler_test.go b/apps/api/internal/webhooks/webhook_handler_test.go similarity index 99% rename from apps/api/internal/project/webhook_handler_test.go rename to apps/api/internal/webhooks/webhook_handler_test.go index be36815b..04f0bfcd 100644 --- a/apps/api/internal/project/webhook_handler_test.go +++ b/apps/api/internal/webhooks/webhook_handler_test.go @@ -1,4 +1,4 @@ -package project +package webhooks import ( "strings" diff --git a/apps/space/package.json b/apps/space/package.json index 20139853..bcfff121 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 976e0d6b..c010bcee 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/package.json b/package.json index 17b03a5d..40dd2525 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pace", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "Project management that keeps pace with the work", "license": "AGPL-3.0", diff --git a/packages/codemods/package.json b/packages/codemods/package.json index 759f42a7..45ea3f64 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -1,6 +1,6 @@ { "name": "@pace/codemods", - "version": "1.4.35", + "version": "1.4.36", "private": true, "scripts": { "check:format": "oxfmt --check .", diff --git a/packages/constants/package.json b/packages/constants/package.json index d82b7b66..76a427eb 100644 --- a/packages/constants/package.json +++ b/packages/constants/package.json @@ -1,6 +1,6 @@ { "name": "@pace/constants", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/editor/package.json b/packages/editor/package.json index f79902bd..6f2b7a04 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@pace/editor", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "Core Editor that powers Pace", "keywords": [ diff --git a/packages/hooks/package.json b/packages/hooks/package.json index da746818..46ed80c9 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@pace/hooks", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "React hooks that are shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 8ddc7a6a..fae25017 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@pace/i18n", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "I18n shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/propel/package.json b/packages/propel/package.json index d49584d1..a8a98136 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -1,6 +1,6 @@ { "name": "@pace/propel", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/services/package.json b/packages/services/package.json index 5be54051..691d598f 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -1,6 +1,6 @@ { "name": "@pace/services", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 33674806..96720a0a 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -1,6 +1,6 @@ { "name": "@pace/shared-state", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "Shared state shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index 728d404c..4ce960a2 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -1,6 +1,6 @@ { "name": "@pace/tailwind-config", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "common tailwind configuration across monorepo", "license": "AGPL-3.0", diff --git a/packages/types/package.json b/packages/types/package.json index 0f9f9bf3..6d538abe 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@pace/types", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "type": "module", diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 66c97bcd..e16decc6 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@pace/typescript-config", - "version": "1.4.35", + "version": "1.4.36", "private": true, "license": "AGPL-3.0", "files": [ diff --git a/packages/ui/package.json b/packages/ui/package.json index 0279cb5a..82c69db0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@pace/ui", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "UI components shared across multiple apps internally", "license": "AGPL-3.0", diff --git a/packages/utils/package.json b/packages/utils/package.json index 7b0ef7b6..0193eb31 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@pace/utils", - "version": "1.4.35", + "version": "1.4.36", "private": true, "description": "Helper functions shared across multiple apps internally", "license": "AGPL-3.0",