feat: phase 4 integrations — connectors, rybbit analytics, webhooks

- core.integrations (migration 00005) with AES-256-GCM-encrypted
  credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback);
  has_credentials serialization only — secrets never leave the server
- /projects/:id/integrations list/create/delete/test; health check hits
  the stored base URL with a 5s timeout and records status
- Rybbit connector: GET …/integrations/:id/analytics proxies
  /api/organizations + /api/sites/* server-side into a normalized shape
- core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and
  POST …/deploy-markers. Events: issue.created (new fingerprint on
  ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256
  over the raw body; in-process dispatcher, 3 attempts, quadratic
  backoff, 10s timeout, pending-scan on boot
- HTTPS enforced for public targets; HTTP allowed for loopback/private/
  self-hosted sinks
- Integrations page (connectors + webhooks tabs, inline analytics,
  delivery log, secret-shown-once), demo-mode stubs
- 12 new primora_* MCP tools
- workspace 0.4.0

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-16 18:47:31 +02:00
1 parent 8cfddf9184
commit e25eb92339
82 files changed
+5710 -61

No files matched your search

+29 -16
View File
@@ -24,17 +24,19 @@ import (
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/observability"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
"github.com/tdvorak/primora/apps/backend/internal/services"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
type App struct {
Config config.Config
Router *gin.Engine
Logger *slog.Logger
DB *pgxpool.Pool
Redis *redis.Client
DBX *dbx.Client
Config config.Config
Router *gin.Engine
Logger *slog.Logger
DB *pgxpool.Pool
Redis *redis.Client
DBX *dbx.Client
Platform *services.PlatformService
}
func Bootstrap(ctx context.Context) (*App, error) {
@@ -82,20 +84,27 @@ func Bootstrap(ctx context.Context) (*App, error) {
}
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
repo := repositories.NewCoreRepository(dbPool)
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg))
encryptor, err := secrets.NewEncryptor(cfg.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("encryption key: %w", err)
}
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, firstPartyDBSeeds(cfg), encryptor, logger)
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
if cfg.Env != "production" && os.Getenv("PRIMORA_ENCRYPTION_KEY") == "" {
logger.Warn("PRIMORA_ENCRYPTION_KEY unset — using the built-in development key")
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(middleware.RequestID())
router.Use(middleware.Logger(logger))
metrics := observability.NewMetrics()
router.Use(middleware.Metrics(metrics))
router.Use(middleware.Compression())
// CORS configuration - update AllowedOrigins for production
corsOrigins := []string{cfg.PublicURL}
if cfg.Env == "development" {
@@ -104,7 +113,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
router.Use(middleware.CORS(middleware.CORSConfig{
AllowedOrigins: corsOrigins,
}))
router.Use(middleware.AuthMiddleware{
Queries: repo,
Logger: logger,
@@ -149,12 +158,13 @@ func Bootstrap(ctx context.Context) (*App, error) {
handler.Register(router)
return &App{
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
DBX: dbxClient,
Config: cfg,
Router: router,
Logger: logger,
DB: dbPool,
Redis: redisClient,
DBX: dbxClient,
Platform: platform,
}, nil
}
@@ -196,6 +206,9 @@ func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
}
func (a *App) Close() error {
if a.Platform != nil {
a.Platform.Close()
}
if a.DBX != nil {
a.DBX.Close()
}
+15
View File
@@ -33,8 +33,15 @@ type Config struct {
SMTPUser string
SMTPPassword string
SMTPSecure bool
EncryptionKey string
}
// devEncryptionKey is the built-in credential key for local development only.
// Set PRIMORA_ENCRYPTION_KEY in any real deployment — credentials written with
// this key are readable by anyone with database access.
const devEncryptionKey = "3031323334353637383961626364656630313233343536373839616263646566"
func Load() (Config, error) {
cfg := Config{
Env: getenv("NODE_ENV", "development"),
@@ -55,6 +62,7 @@ func Load() (Config, error) {
SMTPHost: getenv("SMTP_HOST", "localhost"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
}
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
@@ -103,6 +111,13 @@ func Load() (Config, error) {
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
missing = append(missing, "RESEND_API_KEY or SMTP_HOST")
}
if cfg.EncryptionKey == "" {
if cfg.Env == "production" {
missing = append(missing, "PRIMORA_ENCRYPTION_KEY")
} else {
cfg.EncryptionKey = devEncryptionKey
}
}
if len(missing) > 0 {
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
}
@@ -0,0 +1,178 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: integrations.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createIntegration = `-- name: CreateIntegration :one
INSERT INTO core.integrations (
project_id,
type,
name,
base_url,
config,
credentials,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at
`
type CreateIntegrationParams struct {
ProjectID uuid.UUID `json:"project_id"`
Type string `json:"type"`
Name string `json:"name"`
BaseUrl string `json:"base_url"`
Config []byte `json:"config"`
Credentials []byte `json:"credentials"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) {
row := q.db.QueryRow(ctx, createIntegration,
arg.ProjectID,
arg.Type,
arg.Name,
arg.BaseUrl,
arg.Config,
arg.Credentials,
arg.CreatedByUserID,
)
var i CoreIntegration
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Type,
&i.Name,
&i.BaseUrl,
&i.Config,
&i.Credentials,
&i.Status,
&i.LastHealthAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteIntegration = `-- name: DeleteIntegration :one
DELETE FROM core.integrations
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at
`
type DeleteIntegrationParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) {
row := q.db.QueryRow(ctx, deleteIntegration, arg.ID, arg.ProjectID)
var i CoreIntegration
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Type,
&i.Name,
&i.BaseUrl,
&i.Config,
&i.Credentials,
&i.Status,
&i.LastHealthAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getIntegrationByID = `-- name: GetIntegrationByID :one
SELECT id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at FROM core.integrations
WHERE id = $1
`
func (q *Queries) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) {
row := q.db.QueryRow(ctx, getIntegrationByID, id)
var i CoreIntegration
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Type,
&i.Name,
&i.BaseUrl,
&i.Config,
&i.Credentials,
&i.Status,
&i.LastHealthAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listIntegrations = `-- name: ListIntegrations :many
SELECT id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at FROM core.integrations
WHERE project_id = $1
ORDER BY name ASC
`
func (q *Queries) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) {
rows, err := q.db.Query(ctx, listIntegrations, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreIntegration
for rows.Next() {
var i CoreIntegration
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Type,
&i.Name,
&i.BaseUrl,
&i.Config,
&i.Credentials,
&i.Status,
&i.LastHealthAt,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateIntegrationHealth = `-- name: UpdateIntegrationHealth :exec
UPDATE core.integrations
SET status = $2,
last_health_at = NOW(),
updated_at = NOW()
WHERE id = $1
`
type UpdateIntegrationHealthParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
}
func (q *Queries) UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error {
_, err := q.db.Exec(ctx, updateIntegrationHealth, arg.ID, arg.Status)
return err
}
@@ -242,6 +242,21 @@ type CoreEvent struct {
Ts pgtype.Timestamptz `json:"ts"`
}
type CoreIntegration struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Type string `json:"type"`
Name string `json:"name"`
BaseUrl string `json:"base_url"`
Config []byte `json:"config"`
Credentials []byte `json:"credentials"`
Status string `json:"status"`
LastHealthAt pgtype.Timestamptz `json:"last_health_at"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreOrganization struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
@@ -298,3 +313,29 @@ type CoreUser struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
}
type CoreWebhook struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreWebhookDelivery struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
EventType string `json:"event_type"`
Payload []byte `json:"payload"`
Status string `json:"status"`
Attempts int32 `json:"attempts"`
LastStatusCode *int32 `json:"last_status_code"`
LastError string `json:"last_error"`
DeliveredAt pgtype.Timestamptz `json:"delivered_at"`
NextRetryAt pgtype.Timestamptz `json:"next_retry_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
@@ -28,18 +28,22 @@ type Querier interface {
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error)
CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error)
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error)
@@ -52,6 +56,7 @@ type Querier interface {
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
@@ -60,7 +65,11 @@ type Querier interface {
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
GetUserByID(ctx context.Context, id uuid.UUID) (CoreUser, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error)
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error)
HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error)
InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error)
InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error)
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
@@ -72,12 +81,18 @@ type Querier interface {
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error)
ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error)
ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error)
MarkInvitationAccepted(ctx context.Context, id uuid.UUID) (CoreProjectInvitation, error)
MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
@@ -89,10 +104,12 @@ type Querier interface {
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
}
@@ -173,6 +173,25 @@ func (q *Queries) GetComponentByName(ctx context.Context, arg GetComponentByName
return i, err
}
const hasFingerprintSeen = `-- name: HasFingerprintSeen :one
SELECT EXISTS(
SELECT 1 FROM core.events
WHERE project_id = $1 AND fingerprint = $2 AND type = 'error'
) AS seen
`
type HasFingerprintSeenParams struct {
ProjectID uuid.UUID `json:"project_id"`
Fingerprint string `json:"fingerprint"`
}
func (q *Queries) HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) {
row := q.db.QueryRow(ctx, hasFingerprintSeen, arg.ProjectID, arg.Fingerprint)
var seen bool
err := row.Scan(&seen)
return seen, err
}
const insertEvent = `-- name: InsertEvent :one
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -0,0 +1,409 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: webhooks.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const createWebhook = `-- name: CreateWebhook :one
INSERT INTO core.webhooks (
project_id,
url,
secret,
events,
enabled,
created_by_user_id
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
`
type CreateWebhookParams struct {
ProjectID uuid.UUID `json:"project_id"`
Url string `json:"url"`
Secret []byte `json:"secret"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
}
func (q *Queries) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) {
row := q.db.QueryRow(ctx, createWebhook,
arg.ProjectID,
arg.Url,
arg.Secret,
arg.Events,
arg.Enabled,
arg.CreatedByUserID,
)
var i CoreWebhook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteWebhook = `-- name: DeleteWebhook :one
DELETE FROM core.webhooks
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
`
type DeleteWebhookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
}
func (q *Queries) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) {
row := q.db.QueryRow(ctx, deleteWebhook, arg.ID, arg.ProjectID)
var i CoreWebhook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getWebhookByID = `-- name: GetWebhookByID :one
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE id = $1
`
func (q *Queries) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) {
row := q.db.QueryRow(ctx, getWebhookByID, id)
var i CoreWebhook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getWebhookDeliveryByID = `-- name: GetWebhookDeliveryByID :one
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
WHERE id = $1
`
func (q *Queries) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) {
row := q.db.QueryRow(ctx, getWebhookDeliveryByID, id)
var i CoreWebhookDelivery
err := row.Scan(
&i.ID,
&i.WebhookID,
&i.EventType,
&i.Payload,
&i.Status,
&i.Attempts,
&i.LastStatusCode,
&i.LastError,
&i.DeliveredAt,
&i.NextRetryAt,
&i.CreatedAt,
)
return i, err
}
const insertWebhookDelivery = `-- name: InsertWebhookDelivery :one
INSERT INTO core.webhook_deliveries (
webhook_id,
event_type,
payload,
next_retry_at
) VALUES ($1, $2, $3, NOW())
RETURNING id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at
`
type InsertWebhookDeliveryParams struct {
WebhookID uuid.UUID `json:"webhook_id"`
EventType string `json:"event_type"`
Payload []byte `json:"payload"`
}
func (q *Queries) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) {
row := q.db.QueryRow(ctx, insertWebhookDelivery, arg.WebhookID, arg.EventType, arg.Payload)
var i CoreWebhookDelivery
err := row.Scan(
&i.ID,
&i.WebhookID,
&i.EventType,
&i.Payload,
&i.Status,
&i.Attempts,
&i.LastStatusCode,
&i.LastError,
&i.DeliveredAt,
&i.NextRetryAt,
&i.CreatedAt,
)
return i, err
}
const listPendingWebhookDeliveries = `-- name: ListPendingWebhookDeliveries :many
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
LIMIT $1
`
func (q *Queries) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) {
rows, err := q.db.Query(ctx, listPendingWebhookDeliveries, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreWebhookDelivery
for rows.Next() {
var i CoreWebhookDelivery
if err := rows.Scan(
&i.ID,
&i.WebhookID,
&i.EventType,
&i.Payload,
&i.Status,
&i.Attempts,
&i.LastStatusCode,
&i.LastError,
&i.DeliveredAt,
&i.NextRetryAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWebhookDeliveries = `-- name: ListWebhookDeliveries :many
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
WHERE webhook_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListWebhookDeliveriesParams struct {
WebhookID uuid.UUID `json:"webhook_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error) {
rows, err := q.db.Query(ctx, listWebhookDeliveries, arg.WebhookID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreWebhookDelivery
for rows.Next() {
var i CoreWebhookDelivery
if err := rows.Scan(
&i.ID,
&i.WebhookID,
&i.EventType,
&i.Payload,
&i.Status,
&i.Attempts,
&i.LastStatusCode,
&i.LastError,
&i.DeliveredAt,
&i.NextRetryAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWebhooks = `-- name: ListWebhooks :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1
ORDER BY created_at ASC
`
func (q *Queries) ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error) {
rows, err := q.db.Query(ctx, listWebhooks, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreWebhook
for rows.Next() {
var i CoreWebhook
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events))
`
type ListWebhooksForEventParams struct {
ProjectID uuid.UUID `json:"project_id"`
Column2 string `json:"column_2"`
}
func (q *Queries) ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error) {
rows, err := q.db.Query(ctx, listWebhooksForEvent, arg.ProjectID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []CoreWebhook
for rows.Next() {
var i CoreWebhook
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markWebhookDelivery = `-- name: MarkWebhookDelivery :exec
UPDATE core.webhook_deliveries
SET status = $2,
attempts = attempts + 1,
last_status_code = $3,
last_error = $4,
delivered_at = $5,
next_retry_at = $6
WHERE id = $1
`
type MarkWebhookDeliveryParams struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
LastStatusCode *int32 `json:"last_status_code"`
LastError string `json:"last_error"`
DeliveredAt pgtype.Timestamptz `json:"delivered_at"`
NextRetryAt pgtype.Timestamptz `json:"next_retry_at"`
}
func (q *Queries) MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error {
_, err := q.db.Exec(ctx, markWebhookDelivery,
arg.ID,
arg.Status,
arg.LastStatusCode,
arg.LastError,
arg.DeliveredAt,
arg.NextRetryAt,
)
return err
}
const updateWebhook = `-- name: UpdateWebhook :one
UPDATE core.webhooks
SET url = $3,
events = $4,
enabled = $5,
secret = CASE WHEN $6::boolean THEN $7 ELSE secret END,
updated_at = NOW()
WHERE id = $1
AND project_id = $2
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
`
type UpdateWebhookParams struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Url string `json:"url"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
Column6 bool `json:"column_6"`
Secret []byte `json:"secret"`
}
func (q *Queries) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) {
row := q.db.QueryRow(ctx, updateWebhook,
arg.ID,
arg.ProjectID,
arg.Url,
arg.Events,
arg.Enabled,
arg.Column6,
arg.Secret,
)
var i CoreWebhook
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Url,
&i.Secret,
&i.Events,
&i.Enabled,
&i.CreatedByUserID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+12
View File
@@ -91,6 +91,18 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys)
api.POST("/projects/:projectID/db-connections/:connectionID/query", h.dbxQuery)
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
api.GET("/projects/:projectID/integrations", h.listIntegrations)
api.POST("/projects/:projectID/integrations", h.createIntegration)
api.DELETE("/projects/:projectID/integrations/:integrationID", h.deleteIntegration)
api.POST("/projects/:projectID/integrations/:integrationID/test", h.testIntegration)
api.GET("/projects/:projectID/integrations/:integrationID/analytics", h.integrationAnalytics)
api.GET("/projects/:projectID/webhooks", h.listWebhooks)
api.POST("/projects/:projectID/webhooks", h.createWebhook)
api.PATCH("/projects/:projectID/webhooks/:webhookID", h.updateWebhook)
api.DELETE("/projects/:projectID/webhooks/:webhookID", h.deleteWebhook)
api.GET("/projects/:projectID/webhooks/:webhookID/deliveries", h.listWebhookDeliveries)
api.POST("/projects/:projectID/webhooks/:webhookID/test", h.testWebhook)
api.POST("/projects/:projectID/deploy-markers", h.createDeployMarker)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
@@ -0,0 +1,125 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// actorProjectIntegration resolves the actor, :projectID and :integrationID.
func (h *HTTPHandler) actorProjectIntegration(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
integrationID, ok := parseUUIDParam(c, "integrationID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, integrationID, true
}
func (h *HTTPHandler) integrationError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "integration not found")
return
}
msg := err.Error()
if strings.Contains(msg, "access denied") || strings.Contains(msg, "insufficient") || strings.Contains(msg, "authentication required") || strings.Contains(msg, "actor required") {
h.handleError(c, err)
return
}
if strings.Contains(msg, "rybbit") || strings.Contains(msg, "unreachable") {
apperrors.Abort(c, http.StatusBadGateway, "integration_error", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listIntegrations(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListIntegrations(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createIntegration(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateIntegrationInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateIntegration(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
if strings.Contains(err.Error(), "already exists") {
apperrors.Abort(c, http.StatusConflict, "name_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) deleteIntegration(c *gin.Context) {
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
if !ok {
return
}
if err := h.Platform.DeleteIntegration(c.Request.Context(), actor, projectID, integrationID, middleware.RequestIDFromContext(c)); err != nil {
h.integrationError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) testIntegration(c *gin.Context) {
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
if !ok {
return
}
result, err := h.Platform.TestIntegration(c.Request.Context(), actor, projectID, integrationID, middleware.RequestIDFromContext(c))
if err != nil {
h.integrationError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) integrationAnalytics(c *gin.Context) {
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
if !ok {
return
}
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
result, err := h.Platform.IntegrationAnalytics(c.Request.Context(), actor, projectID, integrationID,
strings.TrimSpace(c.Query("site")), days)
if err != nil {
if strings.Contains(err.Error(), "site") || strings.Contains(err.Error(), "does not provide") || strings.Contains(err.Error(), "no credentials") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error())
return
}
h.integrationError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
+152
View File
@@ -0,0 +1,152 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
// actorProjectWebhook resolves the actor, :projectID and :webhookID.
func (h *HTTPHandler) actorProjectWebhook(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
webhookID, ok := parseUUIDParam(c, "webhookID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, webhookID, true
}
func (h *HTTPHandler) webhookError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "webhook not found")
return
}
msg := err.Error()
if strings.Contains(msg, "already exists") {
apperrors.Abort(c, http.StatusConflict, "url_conflict", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listWebhooks(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListWebhooks(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createWebhook(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateWebhookInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateWebhook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.webhookError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) updateWebhook(c *gin.Context) {
actor, projectID, webhookID, ok := h.actorProjectWebhook(c)
if !ok {
return
}
var body services.UpdateWebhookInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.UpdateWebhook(c.Request.Context(), actor, projectID, webhookID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.webhookError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteWebhook(c *gin.Context) {
actor, projectID, webhookID, ok := h.actorProjectWebhook(c)
if !ok {
return
}
if err := h.Platform.DeleteWebhook(c.Request.Context(), actor, projectID, webhookID, middleware.RequestIDFromContext(c)); err != nil {
h.webhookError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) listWebhookDeliveries(c *gin.Context) {
actor, projectID, webhookID, ok := h.actorProjectWebhook(c)
if !ok {
return
}
limit, ok := parsePaginationQuery(c, "limit", 50, 1, 200)
if !ok {
return
}
result, err := h.Platform.ListWebhookDeliveries(c.Request.Context(), actor, projectID, webhookID, limit)
if err != nil {
h.webhookError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) testWebhook(c *gin.Context) {
actor, projectID, webhookID, ok := h.actorProjectWebhook(c)
if !ok {
return
}
result, err := h.Platform.TestWebhook(c.Request.Context(), actor, projectID, webhookID, middleware.RequestIDFromContext(c))
if err != nil {
h.webhookError(c, err)
return
}
c.JSON(http.StatusAccepted, result)
}
func (h *HTTPHandler) createDeployMarker(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateDeployMarkerInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateDeployMarker(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
if strings.Contains(err.Error(), "required") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
+76
View File
@@ -0,0 +1,76 @@
// Package secrets encrypts credential payloads at rest with AES-256-GCM.
// Ciphertext layout: nonce || ciphertext.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
)
type Encryptor struct {
aead cipher.AEAD
}
// NewEncryptor accepts a 32-byte key as hex or base64 (std or raw encoding).
func NewEncryptor(raw string) (*Encryptor, error) {
key, err := decodeKey(raw)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("init cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("init gcm: %w", err)
}
return &Encryptor{aead: aead}, nil
}
func decodeKey(raw string) ([]byte, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, errors.New("encryption key is empty")
}
for _, decode := range []func(string) ([]byte, error){
hex.DecodeString,
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
base64.URLEncoding.DecodeString,
base64.RawURLEncoding.DecodeString,
} {
key, err := decode(trimmed)
if err == nil && len(key) == 32 {
return key, nil
}
}
return nil, errors.New("encryption key must decode to 32 bytes (hex or base64)")
}
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
nonce := make([]byte, e.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
return e.aead.Seal(nonce, nonce, plaintext, nil), nil
}
func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) < e.aead.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, body := ciphertext[:e.aead.NonceSize()], ciphertext[e.aead.NonceSize():]
plain, err := e.aead.Open(nil, nonce, body, nil)
if err != nil {
return nil, fmt.Errorf("decrypt: %w", err)
}
return plain, nil
}
@@ -0,0 +1,85 @@
package secrets
import (
"encoding/base64"
"encoding/hex"
"testing"
)
const testKeyHex = "3031323334353637383961626364656630313233343536373839616263646566"
func TestRoundTrip(t *testing.T) {
enc, err := NewEncryptor(testKeyHex)
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
plain := []byte(`{"api_key":"ryb-secret-token"}`)
ciphertext, err := enc.Encrypt(plain)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if string(ciphertext) == string(plain) {
t.Fatal("ciphertext contains plaintext")
}
got, err := enc.Decrypt(ciphertext)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if string(got) != string(plain) {
t.Fatalf("round trip mismatch: got %q", got)
}
}
func TestKeyDecoding(t *testing.T) {
raw := make([]byte, 32)
for i := range raw {
raw[i] = byte(i)
}
for name, key := range map[string]string{
"hex": hex.EncodeToString(raw),
"base64": base64.StdEncoding.EncodeToString(raw),
"base64-raw": base64.RawStdEncoding.EncodeToString(raw),
"base64-url": base64.URLEncoding.EncodeToString(raw),
"whitespace": " " + hex.EncodeToString(raw) + "\n",
} {
if _, err := NewEncryptor(key); err != nil {
t.Errorf("%s: %v", name, err)
}
}
}
func TestRejectsBadKeys(t *testing.T) {
for _, key := range []string{
"",
"too-short-hex",
hex.EncodeToString([]byte("sixteen-byte-key")),
"!!!not-encoded!!!",
} {
if _, err := NewEncryptor(key); err == nil {
t.Errorf("expected error for key %q", key)
}
}
}
func TestDecryptRejectsGarbage(t *testing.T) {
enc, err := NewEncryptor(testKeyHex)
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
if _, err := enc.Decrypt([]byte("short")); err == nil {
t.Error("expected error for short ciphertext")
}
if _, err := enc.Decrypt(make([]byte, 64)); err == nil {
t.Error("expected error for unauthenticated ciphertext")
}
}
func TestCrossKeyFails(t *testing.T) {
a, _ := NewEncryptor(testKeyHex)
other := hex.EncodeToString([]byte("0123456789abcdef0123456789abcde0"))
b, _ := NewEncryptor(other)
ciphertext, _ := a.Encrypt([]byte("secret"))
if _, err := b.Decrypt(ciphertext); err == nil {
t.Error("expected decryption to fail with a different key")
}
}
@@ -0,0 +1,269 @@
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// IntegrationCredentials is the secret payload stored AES-256-GCM encrypted in
// core.integrations.credentials. It is never serialized to the client — see
// IntegrationSummary.
type IntegrationCredentials struct {
APIKey string `json:"api_key,omitempty"`
}
// IntegrationConfig holds non-secret connector settings (core.integrations.config).
type IntegrationConfig struct {
SiteID string `json:"site_id,omitempty"`
}
type CreateIntegrationInput struct {
Name string `json:"name" validate:"required,min=1"`
Type string `json:"type" validate:"required,oneof=rybbit"`
BaseURL string `json:"base_url" validate:"required,url"`
APIKey string `json:"api_key"`
SiteID string `json:"site_id"`
}
// IntegrationSummary is the client-facing shape — credentials reduced to a flag.
type IntegrationSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Type string `json:"type"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
SiteID string `json:"site_id"`
Status string `json:"status"`
LastHealthAt *time.Time `json:"last_health_at"`
HasCredentials bool `json:"has_credentials"`
CreatedAt time.Time `json:"created_at"`
}
var integrationTypes = map[string]bool{"rybbit": true}
func parseIntegrationConfig(raw []byte) IntegrationConfig {
var cfg IntegrationConfig
_ = json.Unmarshal(raw, &cfg)
return cfg
}
func toIntegrationSummary(row db.CoreIntegration) IntegrationSummary {
cfg := parseIntegrationConfig(row.Config)
out := IntegrationSummary{
ID: row.ID,
ProjectID: row.ProjectID,
Type: row.Type,
Name: row.Name,
BaseURL: row.BaseUrl,
SiteID: cfg.SiteID,
Status: row.Status,
HasCredentials: len(row.Credentials) > 0,
CreatedAt: row.CreatedAt.Time,
}
if row.LastHealthAt.Valid {
t := row.LastHealthAt.Time
out.LastHealthAt = &t
}
return out
}
func normalizeBaseURL(raw string) (string, error) {
trimmed := strings.TrimSpace(strings.TrimRight(raw, "/"))
u, err := url.Parse(trimmed)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return "", errors.New("base_url must be an http(s) URL")
}
return trimmed, nil
}
func (s *PlatformService) decryptCredentials(row db.CoreIntegration) (IntegrationCredentials, error) {
var creds IntegrationCredentials
if len(row.Credentials) == 0 {
return creds, nil
}
if s.enc == nil {
return creds, errors.New("encryption not configured")
}
plain, err := s.enc.Decrypt(row.Credentials)
if err != nil {
return creds, fmt.Errorf("credentials unreadable: %w", err)
}
if err := json.Unmarshal(plain, &creds); err != nil {
return creds, fmt.Errorf("credentials malformed: %w", err)
}
return creds, nil
}
func (s *PlatformService) integrationFor(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, write bool) (db.CoreIntegration, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreIntegration{}, err
}
row, err := s.repo.Queries().GetIntegrationByID(ctx, integrationID)
if err != nil {
return db.CoreIntegration{}, err
}
if row.ProjectID != projectID {
return db.CoreIntegration{}, fmt.Errorf("integration access denied")
}
return row, nil
}
func (s *PlatformService) ListIntegrations(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]IntegrationSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListIntegrations(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]IntegrationSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toIntegrationSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateIntegration(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateIntegrationInput, requestID string) (IntegrationSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return IntegrationSummary{}, err
}
if !integrationTypes[input.Type] {
return IntegrationSummary{}, fmt.Errorf("unsupported integration type %q", input.Type)
}
name := normalizeSlug(input.Name)
if name == "" {
return IntegrationSummary{}, errors.New("name must contain letters or digits")
}
baseURL, err := normalizeBaseURL(input.BaseURL)
if err != nil {
return IntegrationSummary{}, err
}
var credBytes []byte
if strings.TrimSpace(input.APIKey) != "" {
raw, _ := json.Marshal(IntegrationCredentials{APIKey: strings.TrimSpace(input.APIKey)})
credBytes, err = s.enc.Encrypt(raw)
if err != nil {
return IntegrationSummary{}, err
}
}
cfgBytes, _ := json.Marshal(IntegrationConfig{SiteID: strings.TrimSpace(input.SiteID)})
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
row, err := s.repo.Queries().CreateIntegration(ctx, db.CreateIntegrationParams{
ProjectID: projectID,
Type: input.Type,
Name: name,
BaseUrl: baseURL,
Config: cfgBytes,
Credentials: credBytes,
CreatedByUserID: createdBy,
})
if err != nil {
if strings.Contains(err.Error(), "integrations_project_id_name_key") {
return IntegrationSummary{}, fmt.Errorf("an integration named %s already exists", name)
}
return IntegrationSummary{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.created", "integration", row.ID.String(), map[string]any{
"name": row.Name, "type": row.Type, "base_url": row.BaseUrl,
}))
}
return toIntegrationSummary(row), nil
}
func (s *PlatformService) DeleteIntegration(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, requestID string) error {
row, err := s.integrationFor(ctx, actor, projectID, integrationID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteIntegration(ctx, db.DeleteIntegrationParams{
ID: integrationID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.deleted", "integration", integrationID.String(), map[string]any{
"name": row.Name, "type": row.Type,
}))
return nil
}
// integrationProbePath is the lightest authenticated endpoint per type; it
// verifies reachability and credentials in one request.
func integrationProbePath(integrationType string) string {
if integrationType == "rybbit" {
return "/api/organizations"
}
return "/"
}
func (s *PlatformService) TestIntegration(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, requestID string) (map[string]any, error) {
row, err := s.integrationFor(ctx, actor, projectID, integrationID, true)
if err != nil {
return nil, err
}
creds, err := s.decryptCredentials(row)
if err != nil {
return nil, err
}
status := "error"
result := map[string]any{"ok": false}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, row.BaseUrl+integrationProbePath(row.Type), nil)
if err == nil {
if creds.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+creds.APIKey)
}
client := &http.Client{Timeout: 5 * time.Second}
resp, reqErr := client.Do(req)
if reqErr != nil {
result["error"] = reqErr.Error()
} else {
defer resp.Body.Close()
result["status_code"] = resp.StatusCode
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
result["ok"] = true
status = "ok"
} else {
result["error"] = fmt.Sprintf("unexpected status %d", resp.StatusCode)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
result["error"] = "credentials rejected"
}
}
}
} else {
result["error"] = err.Error()
}
_ = s.repo.Queries().UpdateIntegrationHealth(ctx, db.UpdateIntegrationHealthParams{
ID: row.ID,
Status: status,
})
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.tested", "integration", integrationID.String(), map[string]any{
"name": row.Name, "status": status,
}))
}
return result, nil
}
@@ -0,0 +1,107 @@
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
)
func TestSignBodyMatchesReceiverVerification(t *testing.T) {
secret := "whsec_test_123"
body := []byte(`{"event":"issue.created","data":{"fingerprint":"abc"}}`)
got := signBody(secret, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if got != want {
t.Fatalf("signature mismatch: got %s want %s", got, want)
}
if len(got) != 64 {
t.Fatalf("expected 64-char hex digest, got %d", len(got))
}
if signBody(secret, []byte(`{"event":"issue.created"}`)) == got {
t.Error("different body must produce a different signature")
}
if signBody("other-secret", body) == got {
t.Error("different secret must produce a different signature")
}
}
func TestIntegrationSummaryNeverExposesCredentials(t *testing.T) {
ciphertext := []byte("nonce|aes-gcm-ciphertext|contains|api_key=super-secret")
row := db.CoreIntegration{
ID: uuid.New(),
ProjectID: uuid.New(),
Type: "rybbit",
Name: "prod",
BaseUrl: "https://analytics.example.com",
Config: []byte(`{"site_id":"s1"}`),
Credentials: ciphertext,
Status: "ok",
}
summary := toIntegrationSummary(row)
if !summary.HasCredentials {
t.Error("expected has_credentials=true")
}
raw, err := json.Marshal(summary)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, leak := range []string{"super-secret", "ciphertext", "api_key", "\"credentials\"", "aes-gcm"} {
if strings.Contains(string(raw), leak) {
t.Errorf("serialized summary leaks %q: %s", leak, raw)
}
}
}
func TestNormalizeWebhookURL(t *testing.T) {
ok := []string{
"https://hooks.example.com/x",
"http://localhost:9000/sink",
"http://127.0.0.1:9000/sink",
"http://primora-sink:9000/",
"http://192.168.1.40:8080/hook",
"http://nas.internal/events",
}
for _, u := range ok {
if _, err := normalizeWebhookURL(u); err != nil {
t.Errorf("expected %q accepted: %v", u, err)
}
}
bad := []string{
"",
"not-a-url",
"ftp://example.com",
"http://example.com/plain-remote",
"http://8.8.8.8/x",
"https://",
}
for _, u := range bad {
if _, err := normalizeWebhookURL(u); err == nil {
t.Errorf("expected %q rejected", u)
}
}
}
func TestValidateWebhookEvents(t *testing.T) {
got, err := validateWebhookEvents([]string{"issue.created", "deploy.marker", "issue.created", " "})
if err != nil {
t.Fatalf("validate: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected deduped 2 events, got %v", got)
}
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected")
}
}
@@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"path/filepath"
"strings"
@@ -22,6 +23,7 @@ import (
"github.com/tdvorak/primora/apps/backend/internal/dbx"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
@@ -33,6 +35,8 @@ type PlatformService struct {
dbx *dbx.Client
managedDBSeeds []ManagedDBSeed
hub *EventHub
enc *secrets.Encryptor
dispatcher *WebhookDispatcher
}
type BootstrapInput struct {
@@ -120,8 +124,8 @@ type CreateCollectionInput struct {
}
type UpdateCollectionInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Description *string `json:"description"`
Name *string `json:"name" validate:"omitempty,min=2"`
Description *string `json:"description"`
Schema map[string]any `json:"schema"`
}
@@ -210,8 +214,20 @@ type InvitationSummary struct {
Status string `json:"status"`
}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService {
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub()}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub(), enc: enc}
if enc != nil {
s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger)
s.dispatcher.Start(context.Background())
}
return s
}
// Close stops the webhook dispatcher. Idempotent.
func (s *PlatformService) Close() {
if s.dispatcher != nil {
s.dispatcher.Stop()
}
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
+274
View File
@@ -0,0 +1,274 @@
package services
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
"github.com/tdvorak/primora/apps/backend/internal/models"
)
// rybbitClient is a read-only client for a self-hosted Rybbit instance's
// documented stats API (/api/*). The Bearer key stays server-side.
type rybbitClient struct {
base string
apiKey string
http *http.Client
}
func newRybbitClient(base, apiKey string) *rybbitClient {
return &rybbitClient{base: base, apiKey: apiKey, http: &http.Client{Timeout: 8 * time.Second}}
}
// get fetches path and unmarshals the body. Non-2xx surfaces the status code.
func (c *rybbitClient) get(ctx context.Context, path string, query url.Values, out any) error {
u := c.base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("rybbit unreachable: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("rybbit returned status %d", resp.StatusCode)
}
if len(body) == 0 {
return nil
}
return json.Unmarshal(body, out)
}
type RybbitSite struct {
SiteID string `json:"site_id"`
Name string `json:"name"`
Domain string `json:"domain,omitempty"`
}
type RybbitOverview struct {
Sessions *float64 `json:"sessions,omitempty"`
Pageviews *float64 `json:"pageviews,omitempty"`
Users *float64 `json:"users,omitempty"`
PagesPerSession *float64 `json:"pages_per_session,omitempty"`
BounceRate *float64 `json:"bounce_rate,omitempty"`
SessionDuration *float64 `json:"session_duration,omitempty"`
}
type RybbitSeriesPoint struct {
Time string `json:"time"`
Sessions *float64 `json:"sessions,omitempty"`
Pageviews *float64 `json:"pageviews,omitempty"`
Users *float64 `json:"users,omitempty"`
BounceRate *float64 `json:"bounce_rate,omitempty"`
}
type RybbitMetricItem struct {
Value string `json:"value"`
Count float64 `json:"count"`
Percentage *float64 `json:"percentage,omitempty"`
}
type IntegrationAnalyticsResult struct {
IntegrationID uuid.UUID `json:"integration_id"`
Site *RybbitSite `json:"site,omitempty"`
Sites []RybbitSite `json:"sites,omitempty"`
Overview *RybbitOverview `json:"overview,omitempty"`
Series []RybbitSeriesPoint `json:"series,omitempty"`
TopPages []RybbitMetricItem `json:"top_pages,omitempty"`
TopReferrers []RybbitMetricItem `json:"top_referrers,omitempty"`
WindowDays int `json:"window_days"`
}
// rybbitOrg normalizes the few shapes GET /api/organizations returns across
// versions ([]org, {data:[...]}, {organizations:[...]}).
type rybbitOrg struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
func (c *rybbitClient) organizations(ctx context.Context) ([]rybbitOrg, error) {
var raw json.RawMessage
if err := c.get(ctx, "/api/organizations", nil, &raw); err != nil {
return nil, err
}
var list []rybbitOrg
if err := json.Unmarshal(raw, &list); err == nil {
return list, nil
}
for _, key := range []string{"data", "organizations"} {
var wrapped map[string]json.RawMessage
if err := json.Unmarshal(raw, &wrapped); err != nil {
break
}
if inner, ok := wrapped[key]; ok {
if err := json.Unmarshal(inner, &list); err == nil {
return list, nil
}
}
}
return nil, errors.New("unexpected /api/organizations shape")
}
func (c *rybbitClient) orgSites(ctx context.Context, orgID string) ([]RybbitSite, error) {
var resp struct {
Sites []struct {
SiteID json.Number `json:"siteId"`
Name string `json:"name"`
Domain string `json:"domain"`
} `json:"sites"`
}
if err := c.get(ctx, "/api/organizations/"+url.PathEscape(orgID)+"/sites", nil, &resp); err != nil {
return nil, err
}
out := make([]RybbitSite, 0, len(resp.Sites))
for _, site := range resp.Sites {
out = append(out, RybbitSite{SiteID: site.SiteID.String(), Name: site.Name, Domain: site.Domain})
}
return out, nil
}
func (c *rybbitClient) siteDetail(ctx context.Context, siteID string) (*RybbitSite, error) {
var resp struct {
SiteID json.Number `json:"siteId"`
Name string `json:"name"`
Domain string `json:"domain"`
}
if err := c.get(ctx, "/api/sites/"+url.PathEscape(siteID), nil, &resp); err != nil {
return nil, err
}
return &RybbitSite{SiteID: resp.SiteID.String(), Name: resp.Name, Domain: resp.Domain}, nil
}
func rybbitRangeQuery(days int) url.Values {
end := time.Now().UTC()
start := end.AddDate(0, 0, -days)
return url.Values{
"start_date": {start.Format("2006-01-02")},
"end_date": {end.Format("2006-01-02")},
"time_zone": {"UTC"},
}
}
// IntegrationAnalytics pulls normalized stats from a rybbit-type integration.
// Site resolution: ?site= override → configured site_id → first accessible site.
func (s *PlatformService) IntegrationAnalytics(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, siteOverride string, days int) (*IntegrationAnalyticsResult, error) {
row, err := s.integrationFor(ctx, actor, projectID, integrationID, false)
if err != nil {
return nil, err
}
if row.Type != "rybbit" {
return nil, fmt.Errorf("integration %s does not provide analytics", row.Type)
}
creds, err := s.decryptCredentials(row)
if err != nil {
return nil, err
}
if creds.APIKey == "" {
return nil, errors.New("integration has no credentials configured")
}
if days <= 0 || days > 365 {
days = 30
}
client := newRybbitClient(row.BaseUrl, creds.APIKey)
cfg := parseIntegrationConfig(row.Config)
result := &IntegrationAnalyticsResult{IntegrationID: row.ID, WindowDays: days}
siteID := siteOverride
if siteID == "" {
siteID = cfg.SiteID
}
// Site discovery only runs when no site is pinned — it needs an
// org-scope endpoint personal keys may not expose.
if siteID == "" {
orgs, err := client.organizations(ctx)
if err != nil {
return nil, fmt.Errorf("site discovery failed: %w — set site_id on the integration or pass ?site=", err)
}
for _, org := range orgs {
orgID := org.ID
if orgID == "" {
orgID = org.Slug
}
if orgID == "" {
continue
}
sites, err := client.orgSites(ctx, orgID)
if err != nil {
continue
}
result.Sites = append(result.Sites, sites...)
}
if len(result.Sites) == 0 {
return nil, errors.New("no rybbit sites found — set site_id on the integration")
}
siteID = result.Sites[0].SiteID
}
site, err := client.siteDetail(ctx, siteID)
if err == nil {
result.Site = site
} else {
result.Site = &RybbitSite{SiteID: siteID}
}
query := rybbitRangeQuery(days)
var overview struct {
Data RybbitOverview `json:"data"`
}
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/overview", query, &overview); err != nil {
return nil, err
}
result.Overview = &overview.Data
seriesQuery := rybbitRangeQuery(days)
seriesQuery.Set("bucket", "day")
var series struct {
Data []RybbitSeriesPoint `json:"data"`
}
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/overview/time-series", seriesQuery, &series); err == nil {
result.Series = series.Data
}
var metric struct {
Data struct {
Data []RybbitMetricItem `json:"data"`
} `json:"data"`
}
pagesQuery := rybbitRangeQuery(days)
pagesQuery.Set("parameter", "pathname")
pagesQuery.Set("limit", "10")
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/metric", pagesQuery, &metric); err == nil {
result.TopPages = metric.Data.Data
}
referrerQuery := rybbitRangeQuery(days)
referrerQuery.Set("parameter", "referrer")
referrerQuery.Set("limit", "10")
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/metric", referrerQuery, &metric); err == nil {
result.TopReferrers = metric.Data.Data
}
return result, nil
}
@@ -89,6 +89,7 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
projectID := *actor.ProjectID
var inserted []TelemetryEvent
var newIssues []TelemetryEvent
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
compCache := map[string]*uuid.UUID{}
for _, ev := range events {
@@ -136,6 +137,16 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
if len(payload) == 0 {
payload = json.RawMessage(`{}`)
}
newFingerprint := false
if ev.Type == "error" && fp != "" {
seen, err := q.HasFingerprintSeen(ctx, db.HasFingerprintSeenParams{
ProjectID: projectID,
Fingerprint: fp,
})
if err == nil && !seen {
newFingerprint = true
}
}
var cid pgtype.UUID
if compID != nil {
cid = pgtype.UUID{Bytes: *compID, Valid: true}
@@ -168,6 +179,9 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
out.ComponentName = ev.Component
}
inserted = append(inserted, out)
if newFingerprint {
newIssues = append(newIssues, out)
}
}
return nil
})
@@ -178,6 +192,18 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
for _, ev := range inserted {
s.hub.Broadcast(ev.ProjectID.String(), ev)
}
// A fresh error fingerprint is a new issue group — notify webhooks.
for _, issue := range newIssues {
s.dispatchEvent(ctx, projectID, WebhookEventIssueCreated, map[string]any{
"fingerprint": issue.Fingerprint,
"message": issue.Message,
"severity": issue.Severity,
"component_id": issue.ComponentID,
"component_name": issue.ComponentName,
"event_id": issue.ID,
"first_seen": issue.TS,
})
}
return len(inserted), nil
}
+664
View File
@@ -0,0 +1,664 @@
package services
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
const (
WebhookEventIssueCreated = "issue.created"
WebhookEventDeployMarker = "deploy.marker"
WebhookEventTest = "webhook.test"
)
var webhookEventTypes = map[string]bool{
WebhookEventIssueCreated: true,
WebhookEventDeployMarker: true,
WebhookEventTest: true,
}
const webhookMaxAttempts = 3
type CreateWebhookInput struct {
URL string `json:"url" validate:"required,url"`
Secret string `json:"secret"`
Events []string `json:"events"`
Enabled *bool `json:"enabled"`
}
type UpdateWebhookInput struct {
URL *string `json:"url" validate:"omitempty,url"`
Secret *string `json:"secret"`
Events []string `json:"events"`
Enabled *bool `json:"enabled"`
}
type WebhookSummary struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
URL string `json:"url"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
HasSecret bool `json:"has_secret"`
CreatedAt time.Time `json:"created_at"`
// Secret is populated only when Primora generated the secret — it is the
// one chance to see it. Not part of the stored shape.
Secret string `json:"secret,omitempty"`
}
type WebhookDeliverySummary struct {
ID uuid.UUID `json:"id"`
WebhookID uuid.UUID `json:"webhook_id"`
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
Attempts int32 `json:"attempts"`
LastStatusCode *int32 `json:"last_status_code"`
LastError string `json:"last_error"`
DeliveredAt *time.Time `json:"delivered_at"`
NextRetryAt *time.Time `json:"next_retry_at"`
CreatedAt time.Time `json:"created_at"`
}
type CreateDeployMarkerInput struct {
Version string `json:"version"`
Ref string `json:"ref"`
Environment string `json:"environment"`
Note string `json:"note"`
}
type DeployMarker struct {
Version string `json:"version,omitempty"`
Ref string `json:"ref,omitempty"`
Environment string `json:"environment,omitempty"`
Note string `json:"note,omitempty"`
RecordedAt time.Time `json:"recorded_at"`
}
func toWebhookSummary(row db.CoreWebhook) WebhookSummary {
events := row.Events
if events == nil {
events = []string{}
}
return WebhookSummary{
ID: row.ID,
ProjectID: row.ProjectID,
URL: row.Url,
Events: events,
Enabled: row.Enabled,
HasSecret: len(row.Secret) > 0,
CreatedAt: row.CreatedAt.Time,
}
}
func toDeliverySummary(row db.CoreWebhookDelivery) WebhookDeliverySummary {
out := WebhookDeliverySummary{
ID: row.ID,
WebhookID: row.WebhookID,
EventType: row.EventType,
Payload: row.Payload,
Status: row.Status,
Attempts: row.Attempts,
LastError: row.LastError,
CreatedAt: row.CreatedAt.Time,
}
if row.LastStatusCode != nil {
code := *row.LastStatusCode
out.LastStatusCode = &code
}
if row.DeliveredAt.Valid {
t := row.DeliveredAt.Time
out.DeliveredAt = &t
}
if row.NextRetryAt.Valid {
t := row.NextRetryAt.Time
out.NextRetryAt = &t
}
return out
}
// normalizeWebhookURL requires https for public targets. Plain http is allowed
// for private/self-hosted sinks: loopback, RFC1918/link-local IPs, .local /
// .internal / .lan names, host.docker.internal, and bare hostnames (Docker
// service names on the internal network).
func normalizeWebhookURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return "", errors.New("webhook url must be an https URL")
}
if u.Scheme == "https" {
return trimmed, nil
}
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
return trimmed, nil
}
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
}
func isPrivateWebhookHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if h == "localhost" || h == "host.docker.internal" ||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
return true
}
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
return true // bare hostname — docker service name, unqualified LAN host
}
ip := net.ParseIP(h)
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
}
func validateWebhookEvents(events []string) ([]string, error) {
out := make([]string, 0, len(events))
seen := map[string]bool{}
for _, e := range events {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !webhookEventTypes[e] {
return nil, fmt.Errorf("unknown event type %q", e)
}
if !seen[e] {
seen[e] = true
out = append(out, e)
}
}
return out, nil
}
func (s *PlatformService) webhookFor(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, write bool) (db.CoreWebhook, error) {
roles := []string{"admin", "developer", "viewer"}
if write {
roles = []string{"admin", "developer"}
}
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
return db.CoreWebhook{}, err
}
row, err := s.repo.Queries().GetWebhookByID(ctx, webhookID)
if err != nil {
return db.CoreWebhook{}, err
}
if row.ProjectID != projectID {
return db.CoreWebhook{}, fmt.Errorf("webhook access denied")
}
return row, nil
}
func (s *PlatformService) ListWebhooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]WebhookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListWebhooks(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]WebhookSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toWebhookSummary(row))
}
return out, nil
}
func (s *PlatformService) CreateWebhook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateWebhookInput, requestID string) (WebhookSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return WebhookSummary{}, err
}
hookURL, err := normalizeWebhookURL(input.URL)
if err != nil {
return WebhookSummary{}, err
}
events, err := validateWebhookEvents(input.Events)
if err != nil {
return WebhookSummary{}, err
}
secret := strings.TrimSpace(input.Secret)
generated := false
if secret == "" {
secret, err = randomToken(16)
if err != nil {
return WebhookSummary{}, err
}
generated = true
}
secretBytes, err := s.enc.Encrypt([]byte(secret))
if err != nil {
return WebhookSummary{}, err
}
enabled := true
if input.Enabled != nil {
enabled = *input.Enabled
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
row, err := s.repo.Queries().CreateWebhook(ctx, db.CreateWebhookParams{
ProjectID: projectID,
Url: hookURL,
Secret: secretBytes,
Events: events,
Enabled: enabled,
CreatedByUserID: createdBy,
})
if err != nil {
if strings.Contains(err.Error(), "webhooks_project_id_url_key") {
return WebhookSummary{}, fmt.Errorf("a webhook for %s already exists", hookURL)
}
return WebhookSummary{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.created", "webhook", row.ID.String(), map[string]any{
"url": row.Url, "events": events,
}))
}
summary := toWebhookSummary(row)
if generated {
summary.Secret = secret
}
return summary, nil
}
func (s *PlatformService) UpdateWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, input UpdateWebhookInput, requestID string) (WebhookSummary, error) {
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
if err != nil {
return WebhookSummary{}, err
}
hookURL := row.Url
if input.URL != nil {
if hookURL, err = normalizeWebhookURL(*input.URL); err != nil {
return WebhookSummary{}, err
}
}
events := row.Events
if input.Events != nil {
if events, err = validateWebhookEvents(input.Events); err != nil {
return WebhookSummary{}, err
}
}
enabled := row.Enabled
if input.Enabled != nil {
enabled = *input.Enabled
}
rotateSecret := input.Secret != nil
var secretBytes []byte
if rotateSecret {
secret := strings.TrimSpace(*input.Secret)
if secret == "" {
if secret, err = randomToken(16); err != nil {
return WebhookSummary{}, err
}
}
if secretBytes, err = s.enc.Encrypt([]byte(secret)); err != nil {
return WebhookSummary{}, err
}
}
updated, err := s.repo.Queries().UpdateWebhook(ctx, db.UpdateWebhookParams{
ID: webhookID,
ProjectID: projectID,
Url: hookURL,
Events: events,
Enabled: enabled,
Column6: rotateSecret,
Secret: secretBytes,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return WebhookSummary{}, err
}
if strings.Contains(err.Error(), "webhooks_project_id_url_key") {
return WebhookSummary{}, fmt.Errorf("a webhook for %s already exists", hookURL)
}
return WebhookSummary{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.updated", "webhook", webhookID.String(), map[string]any{
"url": updated.Url, "events": updated.Events, "enabled": updated.Enabled, "secret_rotated": rotateSecret,
}))
summary := toWebhookSummary(updated)
if rotateSecret && input.Secret != nil && strings.TrimSpace(*input.Secret) == "" {
// generated on rotation — return once
plain, decErr := s.enc.Decrypt(updated.Secret)
if decErr == nil {
summary.Secret = string(plain)
}
}
return summary, nil
}
func (s *PlatformService) DeleteWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, requestID string) error {
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
if err != nil {
return err
}
if _, err := s.repo.Queries().DeleteWebhook(ctx, db.DeleteWebhookParams{
ID: webhookID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.deleted", "webhook", webhookID.String(), map[string]any{
"url": row.Url,
}))
return nil
}
func (s *PlatformService) ListWebhookDeliveries(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, limit int32) ([]WebhookDeliverySummary, error) {
if _, err := s.webhookFor(ctx, actor, projectID, webhookID, false); err != nil {
return nil, err
}
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.repo.Queries().ListWebhookDeliveries(ctx, db.ListWebhookDeliveriesParams{
WebhookID: webhookID,
Limit: limit,
})
if err != nil {
return nil, err
}
out := make([]WebhookDeliverySummary, 0, len(rows))
for _, row := range rows {
out = append(out, toDeliverySummary(row))
}
return out, nil
}
// TestWebhook queues a webhook.test delivery to one endpoint.
func (s *PlatformService) TestWebhook(ctx context.Context, actor *models.Actor, projectID, webhookID uuid.UUID, requestID string) (WebhookDeliverySummary, error) {
row, err := s.webhookFor(ctx, actor, projectID, webhookID, true)
if err != nil {
return WebhookDeliverySummary{}, err
}
delivery, err := s.enqueueDelivery(ctx, row, WebhookEventTest, map[string]any{
"message": "primora webhook test",
})
if err != nil {
return WebhookDeliverySummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "webhook.tested", "webhook", webhookID.String(), map[string]any{
"url": row.Url,
}))
}
return toDeliverySummary(delivery), nil
}
// CreateDeployMarker records the marker in the audit log and fans out a
// deploy.marker event to matching webhooks.
func (s *PlatformService) CreateDeployMarker(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateDeployMarkerInput, requestID string) (DeployMarker, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return DeployMarker{}, err
}
marker := DeployMarker{
Version: strings.TrimSpace(input.Version),
Ref: strings.TrimSpace(input.Ref),
Environment: strings.TrimSpace(input.Environment),
Note: strings.TrimSpace(input.Note),
RecordedAt: time.Now().UTC(),
}
if marker.Version == "" && marker.Ref == "" {
return DeployMarker{}, errors.New("version or ref required")
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return DeployMarker{}, err
}
meta := map[string]any{"version": marker.Version, "ref": marker.Ref, "environment": marker.Environment, "note": marker.Note}
if _, err := s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "deploy.marker", "deploy_marker", marker.Ref+marker.Version, meta)); err != nil {
return DeployMarker{}, err
}
s.dispatchEvent(ctx, projectID, WebhookEventDeployMarker, map[string]any{
"version": marker.Version,
"ref": marker.Ref,
"environment": marker.Environment,
"note": marker.Note,
"recorded_at": marker.RecordedAt,
})
return marker, nil
}
// dispatchEvent fans an event out to matching enabled webhooks. Deliveries are
// persisted first, then queued in-process — the DB row is the source of truth.
func (s *PlatformService) dispatchEvent(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) {
hooks, err := s.repo.Queries().ListWebhooksForEvent(ctx, db.ListWebhooksForEventParams{
ProjectID: projectID,
Column2: eventType,
})
if err != nil {
return
}
for _, hook := range hooks {
_, _ = s.enqueueDelivery(ctx, hook, eventType, data)
}
}
func (s *PlatformService) enqueueDelivery(ctx context.Context, hook db.CoreWebhook, eventType string, data map[string]any) (db.CoreWebhookDelivery, error) {
payload, _ := json.Marshal(map[string]any{
"event": eventType,
"project_id": hook.ProjectID.String(),
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": data,
})
delivery, err := s.repo.Queries().InsertWebhookDelivery(ctx, db.InsertWebhookDeliveryParams{
WebhookID: hook.ID,
EventType: eventType,
Payload: payload,
})
if err != nil {
return db.CoreWebhookDelivery{}, err
}
if s.dispatcher != nil {
s.dispatcher.push(delivery.ID)
}
return delivery, nil
}
// ---------------------------------------------------------------------------
// dispatcher — in-process worker, buffered queue, backoff retry
// ---------------------------------------------------------------------------
type WebhookDispatcher struct {
repo queries
enc *secrets.Encryptor
logger *slog.Logger
http *http.Client
queue chan uuid.UUID
quit chan struct{}
wg sync.WaitGroup
timerMu sync.Mutex
timers map[uuid.UUID]*time.Timer
started sync.Once
}
// queries is the slice of db.Queries the dispatcher needs — kept narrow so
// tests can stub it without a database.
type queries interface {
GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (db.CoreWebhookDelivery, error)
GetWebhookByID(ctx context.Context, id uuid.UUID) (db.CoreWebhook, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]db.CoreWebhookDelivery, error)
MarkWebhookDelivery(ctx context.Context, params db.MarkWebhookDeliveryParams) error
}
func NewWebhookDispatcher(q queries, enc *secrets.Encryptor, logger *slog.Logger) *WebhookDispatcher {
if logger == nil {
logger = slog.Default()
}
return &WebhookDispatcher{
repo: q,
enc: enc,
logger: logger,
http: &http.Client{Timeout: 10 * time.Second},
queue: make(chan uuid.UUID, 1024),
quit: make(chan struct{}),
timers: map[uuid.UUID]*time.Timer{},
}
}
// Start launches the worker and re-queues deliveries left pending by a crash.
func (d *WebhookDispatcher) Start(ctx context.Context) {
d.started.Do(func() {
d.wg.Add(1)
go d.run()
if pending, err := d.repo.ListPendingWebhookDeliveries(ctx, 200); err == nil {
for _, row := range pending {
d.push(row.ID)
}
}
})
}
func (d *WebhookDispatcher) Stop() {
close(d.quit)
d.timerMu.Lock()
for _, t := range d.timers {
t.Stop()
}
d.timerMu.Unlock()
d.wg.Wait()
}
func (d *WebhookDispatcher) push(id uuid.UUID) {
select {
case d.queue <- id:
case <-d.quit:
default:
// queue full — drop; the row stays pending and is retried on restart
}
}
func (d *WebhookDispatcher) run() {
defer d.wg.Done()
for {
select {
case id := <-d.queue:
d.deliver(context.Background(), id)
case <-d.quit:
return
}
}
}
// signBody is the canonical HMAC-SHA256 signature: hex of the keyed digest of
// the exact bytes sent. Receivers verify against the raw body.
func signBody(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
func (d *WebhookDispatcher) deliver(ctx context.Context, deliveryID uuid.UUID) {
delivery, err := d.repo.GetWebhookDeliveryByID(ctx, deliveryID)
if err != nil || delivery.Status != "pending" {
return
}
hook, err := d.repo.GetWebhookByID(ctx, delivery.WebhookID)
if err != nil {
d.mark(delivery, "failed", nil, "webhook deleted", nil)
return
}
var statusCode *int32
var sendErr error
secret := ""
if len(hook.Secret) > 0 && d.enc != nil {
if plain, err := d.enc.Decrypt(hook.Secret); err == nil {
secret = string(plain)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.Url, bytes.NewReader(delivery.Payload))
if err == nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Primora-Event", delivery.EventType)
req.Header.Set("X-Primora-Delivery", delivery.ID.String())
req.Header.Set("X-Primora-Signature", "sha256="+signBody(secret, delivery.Payload))
resp, doErr := d.http.Do(req)
if doErr != nil {
sendErr = doErr
} else {
code := int32(resp.StatusCode)
statusCode = &code
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
now := time.Now()
d.mark(delivery, "delivered", statusCode, "", &now)
return
}
sendErr = fmt.Errorf("endpoint returned %d", resp.StatusCode)
}
} else {
sendErr = err
}
attempts := delivery.Attempts + 1
if attempts >= webhookMaxAttempts {
d.mark(delivery, "failed", statusCode, errString(sendErr), nil)
return
}
retryAt := time.Now().Add(time.Duration(attempts*attempts) * 15 * time.Second)
d.mark(delivery, "pending", statusCode, errString(sendErr), nil, retryAt)
d.timerMu.Lock()
d.timers[delivery.ID] = time.AfterFunc(time.Duration(attempts*attempts)*15*time.Second, func() {
d.timerMu.Lock()
delete(d.timers, delivery.ID)
d.timerMu.Unlock()
d.push(delivery.ID)
})
d.timerMu.Unlock()
}
func (d *WebhookDispatcher) mark(delivery db.CoreWebhookDelivery, status string, statusCode *int32, lastError string, deliveredAt *time.Time, nextRetry ...time.Time) {
params := db.MarkWebhookDeliveryParams{
ID: delivery.ID,
Status: status,
LastError: lastError,
}
params.LastStatusCode = statusCode
if deliveredAt != nil {
params.DeliveredAt = pgtype.Timestamptz{Time: *deliveredAt, Valid: true}
}
if len(nextRetry) > 0 {
params.NextRetryAt = pgtype.Timestamptz{Time: nextRetry[0], Valid: true}
} else {
params.NextRetryAt = pgtype.Timestamptz{}
}
if err := d.repo.MarkWebhookDelivery(context.Background(), params); err != nil {
d.logger.Warn("webhook delivery update failed", "delivery", delivery.ID, "error", err)
}
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}