Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 0 additions & 6 deletions apps/api/internal/project/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 0 additions & 7 deletions apps/api/internal/project/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
25 changes: 15 additions & 10 deletions apps/api/internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions apps/api/internal/webhooks/handler.go
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions apps/api/internal/webhooks/handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project
package webhooks

import (
"crypto/rand"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project
package webhooks

import (
"strings"
Expand Down
2 changes: 1 addition & 1 deletion apps/space/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/codemods/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/codemods",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"scripts": {
"check:format": "oxfmt --check .",
Expand Down
2 changes: 1 addition & 1 deletion packages/constants/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/constants",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/editor",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"description": "Core Editor that powers Pace",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion packages/hooks/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/i18n/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/propel",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/services/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/services",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/shared-state/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/tailwind-config/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/types/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/types",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/typescript-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pace/typescript-config",
"version": "1.4.35",
"version": "1.4.36",
"private": true,
"license": "AGPL-3.0",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading