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

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/auth",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,56 @@
-- +goose Up
CREATE TABLE core.integrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('rybbit')),
name TEXT NOT NULL,
base_url TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
credentials BYTEA NOT NULL DEFAULT ''::bytea,
status TEXT NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown', 'ok', 'error')),
last_health_at TIMESTAMPTZ,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE INDEX idx_integrations_project_id ON core.integrations(project_id);
CREATE TABLE core.webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret BYTEA NOT NULL DEFAULT ''::bytea,
events TEXT[] NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, url)
);
CREATE INDEX idx_webhooks_project_id ON core.webhooks(project_id);
CREATE TABLE core.webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES core.webhooks(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed')),
attempts INT NOT NULL DEFAULT 0,
last_status_code INT,
last_error TEXT NOT NULL DEFAULT '',
delivered_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_webhook_deliveries_webhook ON core.webhook_deliveries(webhook_id, created_at DESC);
CREATE INDEX idx_webhook_deliveries_retry ON core.webhook_deliveries(next_retry_at)
WHERE status = 'pending';
-- +goose Down
DROP TABLE IF EXISTS core.webhook_deliveries;
DROP TABLE IF EXISTS core.webhooks;
DROP TABLE IF EXISTS core.integrations;
+33
View File
@@ -0,0 +1,33 @@
-- name: ListIntegrations :many
SELECT * FROM core.integrations
WHERE project_id = $1
ORDER BY name ASC;
-- name: GetIntegrationByID :one
SELECT * FROM core.integrations
WHERE id = $1;
-- 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 *;
-- name: DeleteIntegration :one
DELETE FROM core.integrations
WHERE id = $1
AND project_id = $2
RETURNING *;
-- name: UpdateIntegrationHealth :exec
UPDATE core.integrations
SET status = $2,
last_health_at = NOW(),
updated_at = NOW()
WHERE id = $1;
+6
View File
@@ -17,6 +17,12 @@ ORDER BY name;
DELETE FROM core.components
WHERE id = $1 AND project_id = $2;
-- name: HasFingerprintSeen :one
SELECT EXISTS(
SELECT 1 FROM core.events
WHERE project_id = $1 AND fingerprint = $2 AND type = 'error'
) AS seen;
-- 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)
+78
View File
@@ -0,0 +1,78 @@
-- name: ListWebhooks :many
SELECT * FROM core.webhooks
WHERE project_id = $1
ORDER BY created_at ASC;
-- name: GetWebhookByID :one
SELECT * FROM core.webhooks
WHERE id = $1;
-- name: ListWebhooksForEvent :many
SELECT * FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events));
-- 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 *;
-- 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 *;
-- name: DeleteWebhook :one
DELETE FROM core.webhooks
WHERE id = $1
AND project_id = $2
RETURNING *;
-- name: InsertWebhookDelivery :one
INSERT INTO core.webhook_deliveries (
webhook_id,
event_type,
payload,
next_retry_at
) VALUES ($1, $2, $3, NOW())
RETURNING *;
-- name: GetWebhookDeliveryByID :one
SELECT * FROM core.webhook_deliveries
WHERE id = $1;
-- name: ListWebhookDeliveries :many
SELECT * FROM core.webhook_deliveries
WHERE webhook_id = $1
ORDER BY created_at DESC
LIMIT $2;
-- name: ListPendingWebhookDeliveries :many
SELECT * FROM core.webhook_deliveries
WHERE status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
LIMIT $1;
-- 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;
+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()
}
+568
View File
@@ -1286,6 +1286,277 @@ paths:
description: text/event-stream of TelemetryEvent messages
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/integrations:
get:
tags: [Integrations]
operationId: listIntegrations
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: External service integrations for the project
content:
application/json:
schema:
$ref: "#/components/schemas/IntegrationListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
post:
tags: [Integrations]
operationId: createIntegration
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateIntegrationRequest"
responses:
"201":
description: Integration created
content:
application/json:
schema:
$ref: "#/components/schemas/Integration"
"401":
$ref: "#/components/responses/ErrorResponse"
"409":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/integrations/{integrationID}:
delete:
tags: [Integrations]
operationId: deleteIntegration
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/IntegrationID"
responses:
"204":
description: Integration deleted
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/integrations/{integrationID}/test:
post:
tags: [Integrations]
operationId: testIntegration
summary: Health check — HTTP GET against the integration's base URL with stored credentials (~5s timeout). Updates status and last_health_at.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/IntegrationID"
responses:
"200":
description: Connectivity probe result
content:
application/json:
schema:
$ref: "#/components/schemas/IntegrationTestResult"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/integrations/{integrationID}/analytics:
get:
tags: [Integrations]
operationId: getIntegrationAnalytics
summary: Read-only analytics pulled from the integration (Rybbit). The API key never leaves the server.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/IntegrationID"
- name: site
in: query
required: false
schema:
type: string
description: Rybbit site id override; defaults to the integration's configured site, else first accessible site.
- name: days
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 365
default: 30
responses:
"200":
description: Normalized analytics payload
content:
application/json:
schema:
$ref: "#/components/schemas/IntegrationAnalytics"
"400":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
"502":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/webhooks:
get:
tags: [Webhooks]
operationId: listWebhooks
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Outbound webhooks for the project
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
post:
tags: [Webhooks]
operationId: createWebhook
summary: Deliveries are POSTed as JSON with X-Primora-Event and X-Primora-Signature (sha256=<HMAC-SHA256 hex of the raw body, keyed by the webhook secret>). If no secret is supplied one is generated and returned once.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWebhookRequest"
responses:
"201":
description: Webhook created
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookCreateResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"409":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/webhooks/{webhookID}:
patch:
tags: [Webhooks]
operationId: updateWebhook
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/WebhookID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateWebhookRequest"
responses:
"200":
description: Webhook updated
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookCreateResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Webhooks]
operationId: deleteWebhook
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/WebhookID"
responses:
"204":
description: Webhook deleted
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/webhooks/{webhookID}/deliveries:
get:
tags: [Webhooks]
operationId: listWebhookDeliveries
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/WebhookID"
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
"200":
description: Recent delivery attempts, newest first
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookDeliveryListResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/webhooks/{webhookID}/test:
post:
tags: [Webhooks]
operationId: testWebhook
summary: Queues a webhook.test ping delivery.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/WebhookID"
responses:
"202":
description: Test delivery queued
content:
application/json:
schema:
$ref: "#/components/schemas/WebhookDelivery"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/deploy-markers:
post:
tags: [Webhooks]
operationId: createDeployMarker
summary: Records a deployment marker in the audit log and fires the deploy.marker webhook event.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateDeployMarkerRequest"
responses:
"201":
description: Marker recorded
content:
application/json:
schema:
$ref: "#/components/schemas/DeployMarker"
"401":
$ref: "#/components/responses/ErrorResponse"
components:
securitySchemes:
bearerAuth:
@@ -1405,6 +1676,20 @@ components:
schema:
type: string
format: uuid
IntegrationID:
name: integrationID
in: path
required: true
schema:
type: string
format: uuid
WebhookID:
name: webhookID
in: path
required: true
schema:
type: string
format: uuid
DatabaseQuery:
name: database
in: query
@@ -2530,3 +2815,286 @@ components:
items:
$ref: "#/components/schemas/MetricPoint"
required: [items]
Integration:
type: object
properties:
id:
type: string
format: uuid
project_id:
type: string
format: uuid
type:
type: string
enum: [rybbit]
name:
type: string
base_url:
type: string
site_id:
type: string
description: Connector-specific target (Rybbit site id). Optional.
status:
type: string
enum: [unknown, ok, error]
last_health_at:
type: string
format: date-time
nullable: true
has_credentials:
type: boolean
created_at:
type: string
format: date-time
required: [id, project_id, type, name, base_url, status, has_credentials]
CreateIntegrationRequest:
type: object
properties:
name:
type: string
type:
type: string
enum: [rybbit]
base_url:
type: string
description: Root URL of the self-hosted instance, e.g. https://analytics.example.com
api_key:
type: string
description: Credential stored encrypted at rest; never returned by any endpoint.
site_id:
type: string
required: [name, type, base_url]
IntegrationListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/Integration"
required: [items]
IntegrationTestResult:
type: object
properties:
ok:
type: boolean
status_code:
type: integer
error:
type: string
required: [ok]
RybbitSite:
type: object
properties:
site_id:
type: string
name:
type: string
domain:
type: string
required: [site_id, name]
IntegrationAnalytics:
type: object
properties:
integration_id:
type: string
format: uuid
site:
$ref: "#/components/schemas/RybbitSite"
sites:
type: array
items:
$ref: "#/components/schemas/RybbitSite"
overview:
type: object
properties:
sessions:
type: number
pageviews:
type: number
users:
type: number
pages_per_session:
type: number
bounce_rate:
type: number
session_duration:
type: number
series:
type: array
items:
type: object
properties:
time:
type: string
sessions:
type: number
pageviews:
type: number
users:
type: number
bounce_rate:
type: number
top_pages:
type: array
items:
$ref: "#/components/schemas/RybbitMetricItem"
top_referrers:
type: array
items:
$ref: "#/components/schemas/RybbitMetricItem"
window_days:
type: integer
required: [integration_id, window_days]
RybbitMetricItem:
type: object
properties:
value:
type: string
count:
type: number
percentage:
type: number
required: [value, count]
Webhook:
type: object
properties:
id:
type: string
format: uuid
project_id:
type: string
format: uuid
url:
type: string
events:
type: array
items:
type: string
description: Event filter (issue.created, deploy.marker, webhook.test). Empty means all events.
enabled:
type: boolean
has_secret:
type: boolean
created_at:
type: string
format: date-time
required: [id, project_id, url, events, enabled, has_secret]
CreateWebhookRequest:
type: object
properties:
url:
type: string
format: uri
secret:
type: string
description: HMAC key for X-Primora-Signature. Generated when omitted.
events:
type: array
items:
type: string
enum: [issue.created, deploy.marker, webhook.test]
enabled:
type: boolean
required: [url]
UpdateWebhookRequest:
type: object
properties:
url:
type: string
format: uri
secret:
type: string
description: Replace the signing secret. Omit to keep the current one.
events:
type: array
items:
type: string
enum: [issue.created, deploy.marker, webhook.test]
enabled:
type: boolean
WebhookCreateResponse:
allOf:
- $ref: "#/components/schemas/Webhook"
- type: object
properties:
secret:
type: string
description: Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.
WebhookListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/Webhook"
required: [items]
WebhookDelivery:
type: object
properties:
id:
type: string
format: uuid
webhook_id:
type: string
format: uuid
event_type:
type: string
payload:
type: object
status:
type: string
enum: [pending, delivered, failed]
attempts:
type: integer
last_status_code:
type: integer
nullable: true
last_error:
type: string
delivered_at:
type: string
format: date-time
nullable: true
next_retry_at:
type: string
format: date-time
nullable: true
created_at:
type: string
format: date-time
required: [id, webhook_id, event_type, status, attempts]
WebhookDeliveryListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/WebhookDelivery"
required: [items]
CreateDeployMarkerRequest:
type: object
properties:
version:
type: string
ref:
type: string
description: Git SHA, tag or branch being deployed.
environment:
type: string
note:
type: string
DeployMarker:
type: object
properties:
version:
type: string
ref:
type: string
environment:
type: string
note:
type: string
recorded_at:
type: string
format: date-time
required: [recorded_at]
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/cli",
"version": "0.3.0",
"version": "0.4.0",
"description": "Primora command-line client",
"type": "module",
"bin": {
@@ -23,7 +23,7 @@
"picocolors": "^1.1.1"
},
"devDependencies": {
"@primora/api-client": "0.3.0",
"@primora/api-client": "0.4.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
+1 -1
View File
@@ -116,7 +116,7 @@ cli
.action(cmdAuditList);
cli.help();
cli.version("0.2.0");
cli.version("0.4.0");
// cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing —
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/frontend",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@@ -10,8 +10,8 @@
"test": "vitest run"
},
"dependencies": {
"@primora/api-client": "0.3.0",
"@primora/shared-types": "0.3.0",
"@primora/api-client": "0.4.0",
"@primora/shared-types": "0.4.0",
"better-auth": "^1.5.6",
"solid-js": "^1.9.12"
},
+9
View File
@@ -41,6 +41,7 @@ import {
CollectionsPage,
DatabasesPage,
TelemetryPage,
IntegrationsPage,
LoginPage,
MembersPage,
ProjectsPage,
@@ -1635,6 +1636,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "integrations"}>
<IntegrationsPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
+6 -2
View File
@@ -9,6 +9,7 @@ import {
IconCollections,
IconDatabases,
IconTelemetry,
IconIntegrations,
IconAudit,
IconAuth,
IconSettings,
@@ -28,6 +29,7 @@ export type ViewType =
| "collections"
| "databases"
| "telemetry"
| "integrations"
| "auth"
| "audit"
| "settings";
@@ -43,14 +45,15 @@ const projectNav: NavEntry[] = [
{ id: "dashboard", label: "Overview", icon: IconOverview },
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
];
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id));
const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects },
@@ -88,6 +91,7 @@ const viewTitle: Record<ViewType, string> = {
collections: "Collections",
databases: "Databases",
telemetry: "Telemetry",
integrations: "Integrations",
auth: "Authentication",
audit: "Audit log",
settings: "Settings",
+1
View File
@@ -128,6 +128,7 @@ export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 002
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
export const IconIntegrations = icon(() => (<><path d="M9 17H7A5 5 0 0 1 7 7h2" /><path d="M15 7h2a5 5 0 1 1 0 10h-2" /><line x1="8" x2="16" y1="12" y2="12" /></>));
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
+217
View File
@@ -4,9 +4,11 @@
*/
import {
Integration,
OrganizationInvitation,
OrganizationMember,
ProjectMember,
WebhookDelivery,
} from "@primora/api-client";
import type {
ApiKey,
@@ -15,7 +17,10 @@ import type {
BucketObject,
Collection,
DBConnection,
DeployMarker,
Document,
IntegrationAnalytics,
IntegrationTestResult,
MeResponse,
MetricPoint,
OrganizationSummary,
@@ -25,6 +30,8 @@ import type {
TelemetryEvent,
TelemetryIssue,
TelemetryStats,
Webhook,
WebhookCreateResponse,
} from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode";
@@ -421,6 +428,63 @@ const pushAudit = (action: string, resourceType: string, resourceId: string, met
/* demo service — same call shapes the app expects from the real API */
/* ------------------------------------------------------------------ */
const demoRybbitSites = [
{ site_id: "ryb-site-1", name: "Marketing site", domain: "acme.dev" },
{ site_id: "ryb-site-2", name: "Docs", domain: "docs.acme.dev" },
];
const demoIntegrations: Integration[] = [
{
id: "demo-int-1",
project_id: "demo-project-1",
type: Integration.type.RYBBIT,
name: "rybbit-prod",
base_url: "https://analytics.acme.dev",
site_id: "ryb-site-1",
status: Integration.status.OK,
last_health_at: new Date(Date.now() - 5 * 60_000).toISOString(),
has_credentials: true,
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
},
];
const demoWebhooks: Webhook[] = [
{
id: "demo-hook-1",
project_id: "demo-project-1",
url: "https://hooks.acme.dev/primora/issues",
events: ["issue.created"],
enabled: true,
has_secret: true,
created_at: new Date(Date.now() - 9 * 86_400_000).toISOString(),
},
];
const demoDeliveries: WebhookDelivery[] = [
{
id: "demo-del-1",
webhook_id: "demo-hook-1",
event_type: "issue.created",
payload: { event: "issue.created", data: { fingerprint: "9f2a1c", message: "TypeError: x is undefined" } },
status: WebhookDelivery.status.DELIVERED,
attempts: 1,
last_status_code: 200,
delivered_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
created_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
},
{
id: "demo-del-2",
webhook_id: "demo-hook-1",
event_type: "deploy.marker",
payload: { event: "deploy.marker", data: { version: "0.3.0", environment: "production" } },
status: WebhookDelivery.status.FAILED,
attempts: 3,
last_status_code: 503,
last_error: "endpoint returned 503",
created_at: new Date(Date.now() - 26 * 3_600_000).toISOString(),
},
];
class DemoService {
private delay(ms = 250) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1017,6 +1081,159 @@ class DemoService {
}),
};
}
/* ---------------------------------------------------------- */
/* integrations + webhooks */
/* ---------------------------------------------------------- */
async listIntegrations(): Promise<{ items: Integration[] }> {
await this.delay();
return { items: demoIntegrations };
}
async createIntegration(data: {
requestBody?: { name?: string; type?: string; base_url?: string; api_key?: string; site_id?: string };
}): Promise<Integration> {
await this.delay();
const item: Integration = {
id: `demo-int-${demoIntegrations.length + 1}`,
project_id: "demo-project-1",
type: Integration.type.RYBBIT,
name: data.requestBody?.name ?? "connector",
base_url: data.requestBody?.base_url ?? "https://analytics.example.com",
site_id: data.requestBody?.site_id || undefined,
status: Integration.status.UNKNOWN,
has_credentials: !!data.requestBody?.api_key,
created_at: new Date().toISOString(),
};
demoIntegrations.push(item);
return item;
}
async deleteIntegration(data: { integrationId?: string }) {
await this.delay();
const idx = demoIntegrations.findIndex((i) => i.id === data.integrationId);
if (idx >= 0) demoIntegrations.splice(idx, 1);
return {};
}
async testIntegration(data: { integrationId?: string }): Promise<IntegrationTestResult> {
await this.delay();
const item = demoIntegrations.find((i) => i.id === data.integrationId);
if (item) {
item.status = Integration.status.OK;
item.last_health_at = new Date().toISOString();
}
return { ok: true, status_code: 200 };
}
async getIntegrationAnalytics(data: { integrationId?: string; site?: string; days?: number }): Promise<IntegrationAnalytics> {
await this.delay();
const days = data.days ?? 30;
const site = demoRybbitSites.find((s) => s.site_id === data.site) ?? demoRybbitSites[0];
return {
integration_id: data.integrationId ?? "demo-int-1",
site,
sites: demoRybbitSites,
overview: {
sessions: 18_432,
pageviews: 61_908,
users: 12_077,
pages_per_session: 3.4,
bounce_rate: 41.2,
session_duration: 187,
},
series: Array.from({ length: days }, (_, i) => ({
time: new Date(Date.now() - (days - 1 - i) * 86_400_000).toISOString(),
sessions: 480 + Math.round(Math.sin(i / 3) * 120),
pageviews: 1600 + Math.round(Math.sin(i / 3) * 500),
users: 320 + Math.round(Math.sin(i / 4) * 90),
bounce_rate: 38 + Math.sin(i / 5) * 6,
})),
top_pages: [
{ value: "/", count: 14_203 },
{ value: "/pricing", count: 6_811 },
{ value: "/docs/getting-started", count: 5_902 },
{ value: "/blog/launch", count: 3_114 },
],
top_referrers: [
{ value: "google.com", count: 7_540 },
{ value: "news.ycombinator.com", count: 2_307 },
{ value: "github.com", count: 1_882 },
],
window_days: days,
};
}
async listWebhooks(): Promise<{ items: Webhook[] }> {
await this.delay();
return { items: demoWebhooks };
}
async createWebhook(data: {
requestBody?: { url?: string; secret?: string; events?: string[]; enabled?: boolean };
}): Promise<WebhookCreateResponse> {
await this.delay();
const generated = !data.requestBody?.secret;
const hook: Webhook = {
id: `demo-hook-${demoWebhooks.length + 1}`,
project_id: "demo-project-1",
url: data.requestBody?.url ?? "https://hooks.example.com/primora",
events: data.requestBody?.events ?? [],
enabled: data.requestBody?.enabled ?? true,
has_secret: true,
created_at: new Date().toISOString(),
};
demoWebhooks.push(hook);
return { ...hook, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
}
async updateWebhook(data: { webhookId?: string; requestBody?: Partial<Webhook> & { secret?: string } }): Promise<WebhookCreateResponse> {
await this.delay();
const hook = demoWebhooks.find((w) => w.id === data.webhookId);
if (hook && data.requestBody) {
if (data.requestBody.url !== undefined) hook.url = data.requestBody.url;
if (data.requestBody.events !== undefined) hook.events = data.requestBody.events;
if (data.requestBody.enabled !== undefined) hook.enabled = data.requestBody.enabled;
if (data.requestBody.secret !== undefined) hook.has_secret = true;
}
return { ...(hook as Webhook) };
}
async deleteWebhook(data: { webhookId?: string }) {
await this.delay();
const idx = demoWebhooks.findIndex((w) => w.id === data.webhookId);
if (idx >= 0) demoWebhooks.splice(idx, 1);
return {};
}
async listWebhookDeliveries(data: { webhookId?: string }): Promise<{ items: WebhookDelivery[] }> {
await this.delay();
return { items: demoDeliveries.filter((d) => d.webhook_id === data.webhookId) };
}
async testWebhook(data: { webhookId?: string }): Promise<WebhookDelivery> {
await this.delay();
const delivery: WebhookDelivery = {
id: `demo-del-${Date.now()}`,
webhook_id: data.webhookId ?? "demo-hook-1",
event_type: "webhook.test",
status: WebhookDelivery.status.DELIVERED,
attempts: 1,
last_status_code: 200,
delivered_at: new Date().toISOString(),
created_at: new Date().toISOString(),
};
demoDeliveries.unshift(delivery);
return delivery;
}
async createDeployMarker(data: {
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
}): Promise<DeployMarker> {
await this.delay();
return { ...(data.requestBody ?? {}), recorded_at: new Date().toISOString() };
}
}
export const demoService = new DemoService();
@@ -0,0 +1,764 @@
import { For, Show, createEffect, createSignal } from "solid-js";
import {
CreateIntegrationRequest,
IntegrationsService,
WebhooksService,
type Integration,
type IntegrationAnalytics,
type Webhook,
type WebhookDelivery,
} from "@primora/api-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal";
import { Input, Select } from "../components/Input";
import {
IconPlus,
IconRefresh,
IconTrash,
IconPlay,
IconChevronRight,
IconCheck,
IconCopy,
} from "../components/Icons";
interface IntegrationsPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type Tab = "integrations" | "webhooks";
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const intSvc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
const hookSvc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof WebhooksService) : WebhooksService);
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
const fmtNum = (n?: number) => (n == null ? "—" : n.toLocaleString());
const statusVariant = (s?: string) =>
s === "ok" || s === "delivered"
? "success"
: s === "error" || s === "failed"
? "error"
: s === "pending"
? "warning"
: "neutral";
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const;
export function IntegrationsPage(props: IntegrationsPageProps) {
const [tab, setTab] = createSignal<Tab>("integrations");
const [integrations, setIntegrations] = createSignal<Integration[]>([]);
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
const [deliveries, setDeliveries] = createSignal<Record<string, WebhookDelivery[]>>({});
const [analytics, setAnalytics] = createSignal<Record<string, IntegrationAnalytics>>({});
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
const [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const refresh = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const [int, hooks] = await Promise.all([
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
]);
setIntegrations(int.items ?? []);
setWebhooks(hooks.items ?? []);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
createEffect(() => {
if (props.projectID) void refresh();
});
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
setBusy(key);
setMessage("");
setError("");
try {
await task();
if (done) setMessage(done);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const createIntegration = (e: Event) => {
e.preventDefault();
const form = integrationForm();
void run("create-integration", async () => {
await intSvc(props.demoMode).createIntegration({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
type: CreateIntegrationRequest.type.RYBBIT,
base_url: form.base_url.trim(),
api_key: form.api_key.trim() || undefined,
site_id: form.site_id.trim() || undefined,
},
});
setShowCreateIntegration(false);
setIntegrationForm({ name: "", base_url: "", api_key: "", site_id: "" });
}, "Integration created");
};
const testIntegration = (id: string) =>
run(`test-${id}`, async () => {
const res = await intSvc(props.demoMode).testIntegration({ projectId: props.projectID!, integrationId: id });
setMessage(res.ok ? `Health check passed (${res.status_code ?? "—"})` : `Health check failed: ${res.error ?? "unknown"}`);
});
const deleteIntegration = (id: string) =>
run(
`del-int-${id}`,
() => intSvc(props.demoMode).deleteIntegration({ projectId: props.projectID!, integrationId: id }),
"Integration deleted",
);
const loadAnalytics = async (integration: Integration, site?: string) => {
if (!props.projectID) return;
setBusy(`analytics-${integration.id}`);
setError("");
try {
const res = await intSvc(props.demoMode).getIntegrationAnalytics({
projectId: props.projectID,
integrationId: integration.id,
site: site || analyticsSite()[integration.id] || integration.site_id || undefined,
days: 30,
});
setAnalytics((c) => ({ ...c, [integration.id]: res }));
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const toggleAnalytics = (integration: Integration) => {
if (expandedIntegration() === integration.id) {
setExpandedIntegration(undefined);
return;
}
setExpandedIntegration(integration.id);
void loadAnalytics(integration);
};
const createWebhook = (e: Event) => {
e.preventDefault();
const form = webhookForm();
void run("create-webhook", async () => {
const res = await hookSvc(props.demoMode).createWebhook({
projectId: props.projectID!,
requestBody: {
url: form.url.trim(),
secret: form.secret.trim() || undefined,
events: form.events as Array<"issue.created" | "deploy.marker" | "webhook.test">,
enabled: form.enabled,
},
});
if (res.secret) setCreatedSecret(res.secret);
setShowCreateWebhook(false);
setWebhookForm({ url: "", secret: "", events: [], enabled: true });
}, "Webhook created");
};
const toggleWebhook = (hook: Webhook) =>
run(
`toggle-${hook.id}`,
() =>
hookSvc(props.demoMode).updateWebhook({
projectId: props.projectID!,
webhookId: hook.id,
requestBody: { enabled: !hook.enabled },
}),
hook.enabled ? "Webhook disabled" : "Webhook enabled",
);
const deleteWebhook = (id: string) =>
run(
`del-hook-${id}`,
() => hookSvc(props.demoMode).deleteWebhook({ projectId: props.projectID!, webhookId: id }),
"Webhook deleted",
);
const testWebhook = (id: string) =>
run(
`test-hook-${id}`,
async () => {
await hookSvc(props.demoMode).testWebhook({ projectId: props.projectID!, webhookId: id });
await loadDeliveries(id);
},
"Test delivery queued",
);
const loadDeliveries = async (webhookId: string) => {
if (!props.projectID) return;
try {
const res = await hookSvc(props.demoMode).listWebhookDeliveries({ projectId: props.projectID, webhookId, limit: 25 });
setDeliveries((c) => ({ ...c, [webhookId]: res.items ?? [] }));
} catch (e) {
setError(err(e));
}
};
const toggleDeliveries = (hook: Webhook) => {
if (expandedWebhook() === hook.id) {
setExpandedWebhook(undefined);
return;
}
setExpandedWebhook(hook.id);
void loadDeliveries(hook.id);
};
const recordMarker = (e: Event) => {
e.preventDefault();
const form = markerForm();
void run(
"marker",
async () => {
await hookSvc(props.demoMode).createDeployMarker({
projectId: props.projectID!,
requestBody: {
version: form.version.trim() || undefined,
ref: form.ref.trim() || undefined,
environment: form.environment.trim() || undefined,
note: form.note.trim() || undefined,
},
});
setMarkerForm({ version: "", ref: "", environment: "", note: "" });
},
"Deploy marker recorded — deploy.marker fired",
);
};
const toggleEvent = (ev: string) =>
setWebhookForm((c) => ({
...c,
events: c.events.includes(ev) ? c.events.filter((x) => x !== ev) : [...c.events, ev],
}));
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Integrations</h1>
<p class="page-description">External connectors and outbound webhooks for this project.</p>
</div>
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
<IconRefresh class="w-4 h-4" />
Refresh
</button>
</div>
<Show when={message()}>
<div class="message message-success mb-4">{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error mb-4">{error()}</div>
</Show>
<Show when={createdSecret()}>
<div class="message message-warning mb-4">
<div class="flex-1 min-w-0">
<strong>Webhook secret — shown once.</strong>
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
</div>
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
<IconCopy class="w-4 h-4" />
</button>
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
<IconCheck class="w-4 h-4" />
</button>
</div>
</Show>
<div class="tabs mb-4">
<button class={`tab ${tab() === "integrations" ? "active" : ""}`} onClick={() => setTab("integrations")}>
Connectors
</button>
<button class={`tab ${tab() === "webhooks" ? "active" : ""}`} onClick={() => setTab("webhooks")}>
Webhooks
</button>
</div>
<Show when={tab() === "integrations"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Connectors</div>
<div class="card-header-description">
External services attached to this project. Credentials are encrypted at rest and never leave the server.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateIntegration(true)}>
<IconPlus class="w-4 h-4" />
Add connector
</button>
</Show>
</div>
<Show
when={integrations().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No connectors yet. Add a Rybbit instance to pull analytics into this project.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Base URL</th>
<th>Status</th>
<th class="hidden md:table-cell">Last check</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={integrations()}>
{(item) => (
<>
<tr>
<td class="font-medium">{item.name}</td>
<td>
<Badge variant="primary">{item.type}</Badge>
</td>
<td class="text-text-2" style="max-width:12rem;overflow:hidden;text-overflow:ellipsis">{item.base_url}</td>
<td>
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
</td>
<td class="hidden md:table-cell text-text-2">{fmtTime(item.last_health_at)}</td>
<td style="text-align:right;white-space:nowrap">
<Show when={item.type === "rybbit"}>
<button
class="btn btn-ghost btn-sm"
onClick={() => toggleAnalytics(item)}
disabled={busy() === `analytics-${item.id}`}
>
<IconChevronRight class={`w-4 h-4 ${expandedIntegration() === item.id ? "rotate-90" : ""}`} />
Analytics
</button>
</Show>
<button
class="btn btn-ghost btn-sm"
onClick={() => void testIntegration(item.id)}
disabled={busy() === `test-${item.id}`}
>
<IconPlay class="w-4 h-4" />
Test
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => void deleteIntegration(item.id)}
disabled={busy() === `del-int-${item.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
<Show when={expandedIntegration() === item.id}>
<tr>
<td colspan={6} style="padding:0">
<AnalyticsPanel
analytics={analytics()[item.id]}
loading={busy() === `analytics-${item.id}`}
site={analyticsSite()[item.id] ?? item.site_id ?? ""}
onSiteChange={(site) => {
setAnalyticsSite((c) => ({ ...c, [item.id]: site }));
void loadAnalytics(item, site);
}}
/>
</td>
</tr>
</Show>
</>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Show when={tab() === "webhooks"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Outbound webhooks</div>
<div class="card-header-description">
Primora signs each delivery with <code>X-Primora-Signature</code> (HMAC-SHA256 over the raw body).
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateWebhook(true)}>
<IconPlus class="w-4 h-4" />
Add webhook
</button>
</Show>
</div>
<Show
when={webhooks().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No webhooks yet. Add an HTTPS endpoint to receive issue.created and deploy.marker events.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Endpoint</th>
<th>Events</th>
<th>State</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={webhooks()}>
{(hook) => (
<>
<tr>
<td class="font-medium" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">{hook.url}</td>
<td>
<Show when={hook.events.length > 0} fallback={<span class="text-text-2">all</span>}>
<For each={hook.events}>{(ev) => <Badge variant="neutral" class="mr-1">{ev}</Badge>}</For>
</Show>
</td>
<td>
<Badge variant={hook.enabled ? "success" : "neutral"}>
{hook.enabled ? "enabled" : "disabled"}
</Badge>
</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => toggleDeliveries(hook)}>
<IconChevronRight class={`w-4 h-4 ${expandedWebhook() === hook.id ? "rotate-90" : ""}`} />
Deliveries
</button>
<button
class="btn btn-ghost btn-sm"
onClick={() => void testWebhook(hook.id)}
disabled={busy() === `test-hook-${hook.id}`}
>
<IconPlay class="w-4 h-4" />
Test
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => void toggleWebhook(hook)}
disabled={busy() === `toggle-${hook.id}`}
>
{hook.enabled ? "Disable" : "Enable"}
</button>
<button
class="btn btn-ghost btn-sm"
onClick={() => void deleteWebhook(hook.id)}
disabled={busy() === `del-hook-${hook.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
<Show when={expandedWebhook() === hook.id}>
<tr>
<td colspan={4} style="padding:0">
<DeliveriesPanel deliveries={deliveries()[hook.id]} />
</td>
</tr>
</Show>
</>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
<Show when={props.canManage}>
<div class="card mt-4">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Deploy marker</div>
<div class="card-header-description">
Record a deployment and fire <code>deploy.marker</code> to subscribed webhooks.
</div>
</div>
</div>
<form onSubmit={recordMarker} class="grid gap-3 sm:grid-cols-2 p-4">
<Input
label="Version"
placeholder="1.4.2"
value={markerForm().version}
onInput={(e) => setMarkerForm((c) => ({ ...c, version: e.currentTarget.value }))}
/>
<Input
label="Ref"
placeholder="git sha or tag"
value={markerForm().ref}
onInput={(e) => setMarkerForm((c) => ({ ...c, ref: e.currentTarget.value }))}
/>
<Input
label="Environment"
placeholder="production"
value={markerForm().environment}
onInput={(e) => setMarkerForm((c) => ({ ...c, environment: e.currentTarget.value }))}
/>
<Input
label="Note"
placeholder="optional"
value={markerForm().note}
onInput={(e) => setMarkerForm((c) => ({ ...c, note: e.currentTarget.value }))}
/>
<div class="sm:col-span-2">
<button
type="submit"
class="btn btn-secondary"
disabled={busy() === "marker" || (!markerForm().version.trim() && !markerForm().ref.trim())}
>
Record deploy marker
</button>
</div>
</form>
</div>
</Show>
</Show>
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
<form onSubmit={createIntegration} class="space-y-4">
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
<Input
label="Name"
placeholder="marketing-site"
required
value={integrationForm().name}
onInput={(e) => setIntegrationForm((c) => ({ ...c, name: e.currentTarget.value }))}
/>
<Input
label="Base URL"
placeholder="https://analytics.example.com"
required
value={integrationForm().base_url}
onInput={(e) => setIntegrationForm((c) => ({ ...c, base_url: e.currentTarget.value }))}
/>
<Input
label="API key"
type="password"
placeholder="Stored encrypted — never shown again"
value={integrationForm().api_key}
onInput={(e) => setIntegrationForm((c) => ({ ...c, api_key: e.currentTarget.value }))}
/>
<Input
label="Site ID"
placeholder="optional — defaults to the first site"
value={integrationForm().site_id}
onInput={(e) => setIntegrationForm((c) => ({ ...c, site_id: e.currentTarget.value }))}
/>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateIntegration(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-integration"}>
Create
</button>
</div>
</form>
</Modal>
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
<form onSubmit={createWebhook} class="space-y-4">
<Input
label="Endpoint URL"
placeholder="https://hooks.example.com/primora"
required
value={webhookForm().url}
onInput={(e) => setWebhookForm((c) => ({ ...c, url: e.currentTarget.value }))}
/>
<Input
label="Secret"
type="password"
placeholder="Leave empty to generate"
value={webhookForm().secret}
onInput={(e) => setWebhookForm((c) => ({ ...c, secret: e.currentTarget.value }))}
/>
<div>
<span class="label">Events</span>
<div class="flex gap-2 flex-wrap mt-1">
<For each={EVENT_TYPES}>
{(ev) => (
<button
type="button"
class={`btn btn-sm ${webhookForm().events.includes(ev) ? "btn-primary" : "btn-secondary"}`}
onClick={() => toggleEvent(ev)}
>
{ev}
</button>
)}
</For>
</div>
<p class="text-text-2 text-xs mt-1">No selection means the endpoint receives every event.</p>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateWebhook(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-webhook"}>
Create
</button>
</div>
</form>
</Modal>
</div>
);
}
function AnalyticsPanel(props: {
analytics?: IntegrationAnalytics;
loading: boolean;
site: string;
onSiteChange: (site: string) => void;
}) {
const stats = () => props.analytics?.overview;
const stat = (label: string, value?: number) => (
<div class="stat-card">
<span class="stat-label">{label}</span>
<span class="stat-value">{fmtNum(value)}</span>
</div>
);
return (
<div class="p-4" style="background: var(--surface-2)">
<Show when={props.loading}>
<p class="text-text-2 text-sm">Loading analytics…</p>
</Show>
<Show when={!props.loading && props.analytics}>
<Show when={(props.analytics?.sites?.length ?? 0) > 1}>
<div class="mb-3" style="max-width:20rem">
<Select label="Site" value={props.site} onChange={(e) => props.onSiteChange(e.currentTarget.value)}>
<For each={props.analytics?.sites ?? []}>
{(s) => (
<option value={s.site_id}>
{s.name}
{s.domain ? ` (${s.domain})` : ""}
</option>
)}
</For>
</Select>
</div>
</Show>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
{stat("Sessions", stats()?.sessions)}
{stat("Pageviews", stats()?.pageviews)}
{stat("Users", stats()?.users)}
{stat("Pages/session", stats()?.pages_per_session)}
{stat("Bounce %", stats()?.bounce_rate)}
{stat("Session (s)", stats()?.session_duration)}
</div>
<div class="grid gap-3 sm:grid-cols-2 mt-3">
<MetricTable title="Top pages" items={props.analytics?.top_pages} />
<MetricTable title="Top referrers" items={props.analytics?.top_referrers} />
</div>
</Show>
<Show when={!props.loading && !props.analytics}>
<p class="text-text-2 text-sm">No analytics loaded.</p>
</Show>
</div>
);
}
function MetricTable(props: { title: string; items?: { value: string; count: number; percentage?: number }[] }) {
return (
<div class="card card-flush">
<div class="card-header">
<div class="card-header-title">{props.title}</div>
</div>
<Show when={(props.items?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm p-3">No data in window.</p>}>
<table class="table">
<tbody>
<For each={props.items}>
{(item) => (
<tr>
<td style="word-break:break-all">{item.value}</td>
<td class="text-text-2" style="text-align:right;white-space:nowrap">
{item.count.toLocaleString()}
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
);
}
function DeliveriesPanel(props: { deliveries?: WebhookDelivery[] }) {
return (
<div class="p-4" style="background: var(--surface-2)">
<Show when={(props.deliveries?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm">No deliveries yet.</p>}>
<table class="table">
<thead>
<tr>
<th>Event</th>
<th>Status</th>
<th>Attempts</th>
<th>HTTP</th>
<th class="hidden md:table-cell">Error</th>
<th class="hidden md:table-cell">Time</th>
</tr>
</thead>
<tbody>
<For each={props.deliveries}>
{(d) => (
<tr>
<td>
<code>{d.event_type}</code>
</td>
<td>
<Badge variant={statusVariant(d.status)}>{d.status}</Badge>
</td>
<td>{d.attempts}</td>
<td>{d.last_status_code ?? "—"}</td>
<td class="hidden md:table-cell text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
{d.last_error || "—"}
</td>
<td class="hidden md:table-cell text-text-2">{fmtTime(d.delivered_at ?? d.created_at)}</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
);
}
+1
View File
@@ -6,6 +6,7 @@ export { StoragePage } from "./StoragePage";
export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@primora/mcp",
"version": "0.3.0",
"version": "0.4.0",
"publishConfig": {
"access": "public"
},
@@ -22,7 +22,7 @@
"zod": "^4.1.5"
},
"devDependencies": {
"@primora/api-client": "0.3.0",
"@primora/api-client": "0.4.0",
"@types/node": "^24.0.0",
"esbuild": "^0.27.4",
"typescript": "^5.9.2"
+280 -1
View File
@@ -4,12 +4,14 @@ import { z } from "zod";
import {
CreateBucketRequest,
IntegrationsService,
OpenAPI,
OrganizationsService,
PlatformService,
ProjectsService,
StorageService,
TelemetryService,
WebhooksService,
} from "@primora/api-client";
import { configureClient, loadConfig, type McpConfig } from "./config.js";
@@ -69,7 +71,7 @@ async function apiFetch(path: string): Promise<Response> {
return res;
}
const server = new McpServer({ name: "primora", version: "0.2.0" });
const server = new McpServer({ name: "primora", version: "0.4.0" });
server.registerTool(
"primora_whoami",
@@ -453,4 +455,281 @@ server.registerTool(
},
);
/* ---------------------------------------------------------- */
/* integrations */
/* ---------------------------------------------------------- */
const integrationRef = z.string().describe("Integration id (see primora_list_integrations)");
server.registerTool(
"primora_list_integrations",
{
description: "List external service connectors attached to a project",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await IntegrationsService.listIntegrations({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_integration",
{
description:
"Attach an external service. Credentials are stored encrypted and never returned.",
inputSchema: {
name: z.string(),
baseUrl: z.string().describe("Root URL of the self-hosted instance"),
apiKey: z.string().optional().describe("Stored encrypted; never returned by any endpoint"),
siteId: z.string().optional().describe("Connector-specific target (Rybbit site id)"),
projectId: z.string().optional(),
},
},
async ({ name, baseUrl, apiKey, siteId, projectId: p }) => {
try {
return ok(
await IntegrationsService.createIntegration({
projectId: projectId(p),
requestBody: {
name,
type: "rybbit",
base_url: baseUrl,
api_key: apiKey,
site_id: siteId,
},
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_integration",
{
description: "Remove a connector from the project",
inputSchema: { integrationId: integrationRef, projectId: z.string().optional() },
},
async ({ integrationId, projectId: p }) => {
try {
await IntegrationsService.deleteIntegration({ projectId: projectId(p), integrationId });
return ok({ deleted: integrationId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_test_integration",
{
description: "Run the connector health check against its base URL and update its status",
inputSchema: { integrationId: integrationRef, projectId: z.string().optional() },
},
async ({ integrationId, projectId: p }) => {
try {
return ok(await IntegrationsService.testIntegration({ projectId: projectId(p), integrationId }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_integration_analytics",
{
description:
"Normalized analytics from a Rybbit connector — overview, series, top pages and referrers",
inputSchema: {
integrationId: integrationRef,
site: z.string().optional().describe("Rybbit site id override"),
days: z.number().int().optional().describe("Lookback window (default 30)"),
projectId: z.string().optional(),
},
},
async ({ integrationId, site, days, projectId: p }) => {
try {
return ok(
await IntegrationsService.getIntegrationAnalytics({
projectId: projectId(p),
integrationId,
site,
days,
}),
);
} catch (e) {
return fail(e);
}
},
);
/* ---------------------------------------------------------- */
/* webhooks */
/* ---------------------------------------------------------- */
const webhookRef = z.string().describe("Webhook id (see primora_list_webhooks)");
const webhookEvents = z
.array(z.enum(["issue.created", "deploy.marker", "webhook.test"]))
.describe("Event filter — empty means every event");
server.registerTool(
"primora_list_webhooks",
{
description: "List outbound webhooks for a project",
inputSchema: { projectId: z.string().optional() },
},
async ({ projectId: p }) => {
try {
return ok(await WebhooksService.listWebhooks({ projectId: projectId(p) }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_webhook",
{
description:
"Create an outbound webhook. Omit secret to generate one — it is returned once.",
inputSchema: {
url: z.string().describe("HTTPS endpoint (http allowed only for loopback sinks)"),
secret: z.string().optional(),
events: webhookEvents.optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ url, secret, events, enabled, projectId: p }) => {
try {
return ok(
await WebhooksService.createWebhook({
projectId: projectId(p),
requestBody: { url, secret, events, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_update_webhook",
{
description:
"Update a webhook — url, event filter, enabled flag, or rotate the secret (empty string regenerates it)",
inputSchema: {
webhookId: webhookRef,
url: z.string().optional(),
secret: z.string().optional(),
events: webhookEvents.optional(),
enabled: z.boolean().optional(),
projectId: z.string().optional(),
},
},
async ({ webhookId, url, secret, events, enabled, projectId: p }) => {
try {
return ok(
await WebhooksService.updateWebhook({
projectId: projectId(p),
webhookId,
requestBody: { url, secret, events, enabled },
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_delete_webhook",
{
description: "Delete a webhook and stop future deliveries",
inputSchema: { webhookId: webhookRef, projectId: z.string().optional() },
},
async ({ webhookId, projectId: p }) => {
try {
await WebhooksService.deleteWebhook({ projectId: projectId(p), webhookId });
return ok({ deleted: webhookId });
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_list_webhook_deliveries",
{
description: "Recent delivery attempts for a webhook — status, attempts, HTTP code, errors",
inputSchema: {
webhookId: webhookRef,
limit: z.number().int().optional(),
projectId: z.string().optional(),
},
},
async ({ webhookId, limit, projectId: p }) => {
try {
return ok(
await WebhooksService.listWebhookDeliveries({
projectId: projectId(p),
webhookId,
limit,
}),
);
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_test_webhook",
{
description: "Queue a webhook.test delivery to verify the endpoint and signature",
inputSchema: { webhookId: webhookRef, projectId: z.string().optional() },
},
async ({ webhookId, projectId: p }) => {
try {
return ok(await WebhooksService.testWebhook({ projectId: projectId(p), webhookId }));
} catch (e) {
return fail(e);
}
},
);
server.registerTool(
"primora_create_deploy_marker",
{
description:
"Record a deployment (version/ref/environment) and fire the deploy.marker webhook event",
inputSchema: {
version: z.string().optional(),
ref: z.string().optional().describe("Git sha or tag"),
environment: z.string().optional(),
note: z.string().optional(),
projectId: z.string().optional(),
},
},
async ({ version, ref, environment, note, projectId: p }) => {
try {
if (!version && !ref) throw new Error("pass version or ref");
return ok(
await WebhooksService.createDeployMarker({
projectId: projectId(p),
requestBody: { version, ref, environment, note },
}),
);
} catch (e) {
return fail(e);
}
},
);
await server.connect(new StdioServerTransport());