diff --git a/.env.example b/.env.example index c48ebf3..ca3aada 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ JWT_AUDIENCE=primora-api JWT_SECRET=change-me-super-long-jwt-secret JWT_TTL_SECONDS=900 +# AES-256 key (32 bytes, hex or base64) encrypting integration credentials and +# webhook secrets at rest. Required in production; dev falls back to this value. +PRIMORA_ENCRYPTION_KEY=3031323334353637383961626364656630313233343536373839616263646566 + AUTH_PORT=3001 AUTH_BASE_URL=http://localhost/auth AUTH_INTERNAL_BASE_URL=http://auth:3001 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d00bcc..130df1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac ### Added +- **Connector framework** — `core.integrations` (migration 00005) stores per-project external services with type, base URL, `has_credentials` flag, health status/`last_health_at`. Credentials are AES-256-GCM encrypted at rest (`PRIMORA_ENCRYPTION_KEY`, hex/base64, 32 bytes; dev fallback outside production) and are never serialized to the client. Endpoints under `/projects/:id/integrations`: list, create, delete, `POST …/test` (5s health check against the base URL with stored credentials). +- **Rybbit connector** — `GET …/integrations/:id/analytics` proxies a self-hosted Rybbit instance server-side (`/api/organizations` site discovery, `/api/sites/:site/overview`, time series, top pages/referrers) and returns a normalized shape. The API key never leaves the server; read-only, no tracking proxy. Site selection via `?site=` or the integration's configured `site_id`. +- **Outbound webhooks** — `core.webhooks` + `core.webhook_deliveries`. Endpoints: list/create/update/delete under `/projects/:id/webhooks`, `GET …/deliveries`, `POST …/test`. Events: `issue.created` (new error fingerprint on ingest), `deploy.marker` (`POST …/deploy-markers`, records version/ref/environment/note in the audit log), `webhook.test`. HTTPS required for public targets; plain HTTP allowed for private/self-hosted sinks. Deliveries are signed `X-Primora-Signature: sha256=` and retried by an in-process dispatcher (buffered queue, 3 attempts, quadratic backoff, 10s timeout, crash recovery on boot). Generated secrets are returned exactly once. +- **Integrations page** — SolidJS view (Connectors + Webhooks tabs): connector table with status/test/inline Rybbit analytics panel, webhook table with per-endpoint delivery log, secret-shown-once banner, deploy-marker form. Demo-mode stubs included. +- **New `primora_*` MCP tools** — `primora_list_integrations`, `primora_create_integration`, `primora_delete_integration`, `primora_test_integration`, `primora_integration_analytics`, `primora_list_webhooks`, `primora_create_webhook`, `primora_update_webhook`, `primora_delete_webhook`, `primora_list_webhook_deliveries`, `primora_test_webhook`, `primora_create_deploy_marker`. - **Telemetry (IMS merge)** — `POST /api/v1/ingest` accepts a single event or a ≤500-event batch, authenticated by existing project `prm_` API keys via `X-Primora-Key` (or `X-API-Key`); `OPTIONS` preflight + permissive CORS for browser SDKs. Components auto-register on first use (`frontend`, `backend`, `database`, `android`, `desktop`, `web`, `other`). Event types: `error`, `metric`, `log`, `heartbeat`, `event`; missing fingerprints are derived from component + first message line. - **Issue grouping** — `GET /projects/:id/issues` groups errors by fingerprint (count, severity, first/last seen, latest component). - **Telemetry read APIs** — `GET …/events` (type/component/fingerprint filters, `before` cursor), `GET …/components`, `DELETE …/components/:id`, `GET …/telemetry/stats` (bucketed series + component health + totals + metric names over 1h/24h/7d/30d), `GET …/telemetry/metrics/series` (avg/p50/p95/max per bucket). diff --git a/ROADMAP.md b/ROADMAP.md index 8d707ad..1f5472a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -83,15 +83,21 @@ IMS (`~/Desktop/PROG+HTML/IMS`) is discontinued and folded in — same stack `primora_telemetry_stats`, `primora_metric_series` in `apps/mcp`. - Migration note in IMS repo README → archive, point at Primora. -## Phase 4 — Integrations +## Phase 4 — Integrations ✅ -- **Connector framework** — per-project external services: type, base URL, - credentials (encrypted at rest), health check. `core.integrations` table. -- **Rybbit connector** — user supplies their self-hosted Rybbit URL + API key; - Primora pulls site stats into a project **Analytics** page and dashboard - widgets. Read-only, no tracking proxy. -- **Webhooks** — outbound events (issue created, deploy marker) to arbitrary - URLs, so Primora can push into other self-hosted tools. +- ~~**Connector framework**~~ — `core.integrations` (migration 00005): type, + base URL, AES-256-GCM-encrypted credentials (`PRIMORA_ENCRYPTION_KEY`), + health check (`POST …/test`). `has_credentials` only — secrets never + serialized. +- ~~**Rybbit connector**~~ — self-hosted URL + API key; + `GET …/integrations/:id/analytics` normalizes overview/series/top + pages/referrers server-side. Integrations page carries the per-connector + analytics panel. Read-only, no tracking proxy. +- ~~**Webhooks**~~ — `core.webhooks` + `core.webhook_deliveries`; events + `issue.created` (new fingerprint on ingest), `deploy.marker` + (`POST …/deploy-markers`), `webhook.test`. HMAC-SHA256 signature header + (`X-Primora-Signature`), in-process dispatcher with backoff retries, HTTPS + for public targets (HTTP allowed for private/self-hosted sinks). ## Phase 5 — Distribution diff --git a/apps/auth/package.json b/apps/auth/package.json index ec9547c..2e60007 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -1,6 +1,6 @@ { "name": "@primora/auth", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/apps/backend/db/migrations/00005_integrations.sql b/apps/backend/db/migrations/00005_integrations.sql new file mode 100644 index 0000000..3672ab0 --- /dev/null +++ b/apps/backend/db/migrations/00005_integrations.sql @@ -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; diff --git a/apps/backend/db/queries/integrations.sql b/apps/backend/db/queries/integrations.sql new file mode 100644 index 0000000..cacbfd9 --- /dev/null +++ b/apps/backend/db/queries/integrations.sql @@ -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; diff --git a/apps/backend/db/queries/telemetry.sql b/apps/backend/db/queries/telemetry.sql index 2e1ec5b..4a41ec4 100644 --- a/apps/backend/db/queries/telemetry.sql +++ b/apps/backend/db/queries/telemetry.sql @@ -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) diff --git a/apps/backend/db/queries/webhooks.sql b/apps/backend/db/queries/webhooks.sql new file mode 100644 index 0000000..daa8a7c --- /dev/null +++ b/apps/backend/db/queries/webhooks.sql @@ -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; diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 9532a8a..8a77deb 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -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() } diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 3436b59..972f26c 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -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, ", ")) } diff --git a/apps/backend/internal/database/db/integrations.sql.go b/apps/backend/internal/database/db/integrations.sql.go new file mode 100644 index 0000000..c4db661 --- /dev/null +++ b/apps/backend/internal/database/db/integrations.sql.go @@ -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 +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 6b422ea..7bad57a 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -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"` +} diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 095e5e1..b908794 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -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) } diff --git a/apps/backend/internal/database/db/telemetry.sql.go b/apps/backend/internal/database/db/telemetry.sql.go index c0883b8..c9175e3 100644 --- a/apps/backend/internal/database/db/telemetry.sql.go +++ b/apps/backend/internal/database/db/telemetry.sql.go @@ -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) diff --git a/apps/backend/internal/database/db/webhooks.sql.go b/apps/backend/internal/database/db/webhooks.sql.go new file mode 100644 index 0000000..bf1eaa4 --- /dev/null +++ b/apps/backend/internal/database/db/webhooks.sql.go @@ -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 +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 727a7fc..f1338af 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -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) diff --git a/apps/backend/internal/handlers/integrations.go b/apps/backend/internal/handlers/integrations.go new file mode 100644 index 0000000..22d422d --- /dev/null +++ b/apps/backend/internal/handlers/integrations.go @@ -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) +} diff --git a/apps/backend/internal/handlers/webhooks.go b/apps/backend/internal/handlers/webhooks.go new file mode 100644 index 0000000..8c58e71 --- /dev/null +++ b/apps/backend/internal/handlers/webhooks.go @@ -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) +} diff --git a/apps/backend/internal/secrets/secrets.go b/apps/backend/internal/secrets/secrets.go new file mode 100644 index 0000000..0fcd1ba --- /dev/null +++ b/apps/backend/internal/secrets/secrets.go @@ -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 +} diff --git a/apps/backend/internal/secrets/secrets_test.go b/apps/backend/internal/secrets/secrets_test.go new file mode 100644 index 0000000..e80b5e3 --- /dev/null +++ b/apps/backend/internal/secrets/secrets_test.go @@ -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") + } +} diff --git a/apps/backend/internal/services/integrations.go b/apps/backend/internal/services/integrations.go new file mode 100644 index 0000000..66ae292 --- /dev/null +++ b/apps/backend/internal/services/integrations.go @@ -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 +} diff --git a/apps/backend/internal/services/integrations_test.go b/apps/backend/internal/services/integrations_test.go new file mode 100644 index 0000000..e46276a --- /dev/null +++ b/apps/backend/internal/services/integrations_test.go @@ -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") + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 16195a1..6453667 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -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) { diff --git a/apps/backend/internal/services/rybbit.go b/apps/backend/internal/services/rybbit.go new file mode 100644 index 0000000..bf74182 --- /dev/null +++ b/apps/backend/internal/services/rybbit.go @@ -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 +} diff --git a/apps/backend/internal/services/telemetry.go b/apps/backend/internal/services/telemetry.go index 273749a..f4271cb 100644 --- a/apps/backend/internal/services/telemetry.go +++ b/apps/backend/internal/services/telemetry.go @@ -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 } diff --git a/apps/backend/internal/services/webhooks.go b/apps/backend/internal/services/webhooks.go new file mode 100644 index 0000000..475ea55 --- /dev/null +++ b/apps/backend/internal/services/webhooks.go @@ -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() +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index c3f506c..d1fee40 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -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=). 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] diff --git a/apps/cli/package.json b/apps/cli/package.json index 619ef19..f4462db 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 61630fe..6195599 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -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 — diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2ae20bc..d4b8d55 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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" }, diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index e9d0b63..96f0681 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -41,6 +41,7 @@ import { CollectionsPage, DatabasesPage, TelemetryPage, + IntegrationsPage, LoginPage, MembersPage, ProjectsPage, @@ -1635,6 +1636,14 @@ export default function App() { /> + + + + 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 = { collections: "Collections", databases: "Databases", telemetry: "Telemetry", + integrations: "Integrations", auth: "Authentication", audit: "Audit log", settings: "Settings", diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 45b71fd..85ae471 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -128,6 +128,7 @@ export const IconMoon = icon(() => ()); export const IconZap = icon(() => ()); export const IconTelemetry = icon(() => ()); +export const IconIntegrations = icon(() => (<>)); export const IconClock = icon(() => (<>)); export const IconLink = icon(() => (<>)); export const IconOrganization = icon(() => (<>)); diff --git a/apps/frontend/src/lib/demo-mode.ts b/apps/frontend/src/lib/demo-mode.ts index b28f658..f59f5c9 100644 --- a/apps/frontend/src/lib/demo-mode.ts +++ b/apps/frontend/src/lib/demo-mode.ts @@ -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 { + 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 { + 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 { + 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 { + 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 & { secret?: string } }): Promise { + 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 { + 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 { + await this.delay(); + return { ...(data.requestBody ?? {}), recorded_at: new Date().toISOString() }; + } } export const demoService = new DemoService(); diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx new file mode 100644 index 0000000..9422a72 --- /dev/null +++ b/apps/frontend/src/pages/IntegrationsPage.tsx @@ -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("integrations"); + const [integrations, setIntegrations] = createSignal([]); + const [webhooks, setWebhooks] = createSignal([]); + const [deliveries, setDeliveries] = createSignal>({}); + const [analytics, setAnalytics] = createSignal>({}); + const [analyticsSite, setAnalyticsSite] = createSignal>({}); + const [expandedIntegration, setExpandedIntegration] = createSignal(); + const [expandedWebhook, setExpandedWebhook] = createSignal(); + 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(); + 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, 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 ( +
+ + + +
{message()}
+
+ +
{error()}
+
+ +
+
+ Webhook secret — shown once. + {createdSecret()} +
+ + +
+
+ +
+ + +
+ + +
+
+
+
Connectors
+
+ External services attached to this project. Credentials are encrypted at rest and never leave the server. +
+
+ + + +
+ 0} + fallback={ +

+ No connectors yet. Add a Rybbit instance to pull analytics into this project. +

+ } + > +
+ + + + + + + + + + + + + + {(item) => ( + <> + + + + + + + + + + + + + + + )} + + +
NameTypeBase URLStatusActions
{item.name} + {item.type} + {item.base_url} + {item.status} + + + + + + + + +
+ { + setAnalyticsSite((c) => ({ ...c, [item.id]: site })); + void loadAnalytics(item, site); + }} + /> +
+
+
+
+
+ + +
+
+
+
Outbound webhooks
+
+ Primora signs each delivery with X-Primora-Signature (HMAC-SHA256 over the raw body). +
+
+ + + +
+ 0} + fallback={ +

+ No webhooks yet. Add an HTTPS endpoint to receive issue.created and deploy.marker events. +

+ } + > +
+ + + + + + + + + + + + {(hook) => ( + <> + + + + + + + + + + + + + )} + + +
EndpointEventsStateActions
{hook.url} + 0} fallback={all}> + {(ev) => {ev}} + + + + {hook.enabled ? "enabled" : "disabled"} + + + + + + + + +
+ +
+
+
+
+ + +
+
+
+
Deploy marker
+
+ Record a deployment and fire deploy.marker to subscribed webhooks. +
+
+
+
+ setMarkerForm((c) => ({ ...c, version: e.currentTarget.value }))} + /> + setMarkerForm((c) => ({ ...c, ref: e.currentTarget.value }))} + /> + setMarkerForm((c) => ({ ...c, environment: e.currentTarget.value }))} + /> + setMarkerForm((c) => ({ ...c, note: e.currentTarget.value }))} + /> +
+ +
+
+
+
+
+ + setShowCreateIntegration(false)} title="Add connector" size="md"> +
+ setIntegrationForm((c) => ({ ...c, name: e.currentTarget.value }))} + /> + setIntegrationForm((c) => ({ ...c, base_url: e.currentTarget.value }))} + /> + setIntegrationForm((c) => ({ ...c, api_key: e.currentTarget.value }))} + /> + setIntegrationForm((c) => ({ ...c, site_id: e.currentTarget.value }))} + /> +
+ + +
+
+
+ + setShowCreateWebhook(false)} title="Add webhook" size="md"> +
+ setWebhookForm((c) => ({ ...c, url: e.currentTarget.value }))} + /> + setWebhookForm((c) => ({ ...c, secret: e.currentTarget.value }))} + /> +
+ Events +
+ + {(ev) => ( + + )} + +
+

No selection means the endpoint receives every event.

+
+
+ + +
+
+
+
+ ); +} + +function AnalyticsPanel(props: { + analytics?: IntegrationAnalytics; + loading: boolean; + site: string; + onSiteChange: (site: string) => void; +}) { + const stats = () => props.analytics?.overview; + const stat = (label: string, value?: number) => ( +
+ {label} + {fmtNum(value)} +
+ ); + return ( +
+ +

Loading analytics…

+
+ + 1}> +
+ +
+
+
+ {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)} +
+
+ + +
+
+ +

No analytics loaded.

+
+
+ ); +} + +function MetricTable(props: { title: string; items?: { value: string; count: number; percentage?: number }[] }) { + return ( +
+
+
{props.title}
+
+ 0} fallback={

No data in window.

}> + + + + {(item) => ( + + + + + )} + + +
{item.value} + {item.count.toLocaleString()} +
+
+
+ ); +} + +function DeliveriesPanel(props: { deliveries?: WebhookDelivery[] }) { + return ( +
+ 0} fallback={

No deliveries yet.

}> + + + + + + + + + + + + + + {(d) => ( + + + + + + + + + )} + + +
EventStatusAttemptsHTTP
+ {d.event_type} + + {d.status} + {d.attempts}{d.last_status_code ?? "—"}
+
+
+ ); +} diff --git a/apps/frontend/src/pages/index.ts b/apps/frontend/src/pages/index.ts index 915a51d..039c9dd 100644 --- a/apps/frontend/src/pages/index.ts +++ b/apps/frontend/src/pages/index.ts @@ -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"; diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 4588373..40f071a 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -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" diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 6e7c565..2662876 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -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 { 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()); diff --git a/package-lock.json b/package-lock.json index 6d9e218..aa89534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "primora", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "primora", - "version": "0.3.0", + "version": "0.4.0", "workspaces": [ "apps/*", "packages/*" @@ -14,7 +14,7 @@ }, "apps/auth": { "name": "@primora/auth", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@hono/node-server": "^1.19.4", "@hono/zod-validator": "^0.7.2", @@ -38,7 +38,7 @@ }, "apps/cli": { "name": "@primora/cli", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@clack/prompts": "^1.8.1", "cac": "^7.0.0", @@ -48,7 +48,7 @@ "primora": "dist/cli.js" }, "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" @@ -67,10 +67,10 @@ }, "apps/frontend": { "name": "@primora/frontend", - "version": "0.3.0", + "version": "0.4.0", "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" }, @@ -89,9 +89,13 @@ "vitest": "^3.2.4" } }, + "apps/frontend/node_modules/@primora/shared-types": { + "resolved": "packages/shared-types", + "link": true + }, "apps/mcp": { "name": "@primora/mcp", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^4.1.5" @@ -100,7 +104,7 @@ "primora-mcp": "dist/server.js" }, "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" @@ -1365,10 +1369,6 @@ "resolved": "apps/mcp", "link": true }, - "node_modules/@primora/shared-types": { - "resolved": "packages/shared-types", - "link": true - }, "node_modules/@redis/bloom": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz", @@ -6787,7 +6787,7 @@ }, "packages/api-client": { "name": "@primora/api-client", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "cross-fetch": "^4.1.0" }, @@ -6797,11 +6797,11 @@ }, "packages/client": { "name": "@primora/client", - "version": "0.3.0" + "version": "0.4.0" }, "packages/shared-types": { "name": "@primora/shared-types", - "version": "0.3.0" + "version": "0.4.0" } } } diff --git a/package.json b/package.json index 3657b2f..33f284d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "primora", - "version": "0.3.0", + "version": "0.4.0", "private": true, "packageManager": "npm@9.2.0", "workspaces": [ diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 153a6bb..c3af81f 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@primora/api-client", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 6a7c75b..07fc527 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -32,10 +32,13 @@ export { CreateBucketRequest } from './models/CreateBucketRequest'; export type { CreateCollectionRequest } from './models/CreateCollectionRequest'; export type { CreatedApiKey } from './models/CreatedApiKey'; export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest'; +export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest'; export type { CreateDocumentRequest } from './models/CreateDocumentRequest'; +export { CreateIntegrationRequest } from './models/CreateIntegrationRequest'; export { CreateInvitationRequest } from './models/CreateInvitationRequest'; export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest'; export type { CreateProjectRequest } from './models/CreateProjectRequest'; +export type { CreateWebhookRequest } from './models/CreateWebhookRequest'; export type { DatabaseQuery } from './models/DatabaseQuery'; export type { DBConnection } from './models/DBConnection'; export type { DBConnectionListResponse } from './models/DBConnectionListResponse'; @@ -53,6 +56,7 @@ export type { DBRedisResponse } from './models/DBRedisResponse'; export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse'; export type { DBTableListResponse } from './models/DBTableListResponse'; export type { DBXStatus } from './models/DBXStatus'; +export type { DeployMarker } from './models/DeployMarker'; export type { Document } from './models/Document'; export type { DocumentID } from './models/DocumentID'; export type { DocumentListResponse } from './models/DocumentListResponse'; @@ -62,6 +66,11 @@ export type { HealthStatus } from './models/HealthStatus'; export { IngestEvent } from './models/IngestEvent'; export type { IngestRequest } from './models/IngestRequest'; export type { IngestResponse } from './models/IngestResponse'; +export { Integration } from './models/Integration'; +export type { IntegrationAnalytics } from './models/IntegrationAnalytics'; +export type { IntegrationID } from './models/IntegrationID'; +export type { IntegrationListResponse } from './models/IntegrationListResponse'; +export type { IntegrationTestResult } from './models/IntegrationTestResult'; export type { Invitation } from './models/Invitation'; export type { InvitationID } from './models/InvitationID'; export type { MeResponse } from './models/MeResponse'; @@ -82,6 +91,8 @@ export { ProjectMember } from './models/ProjectMember'; export type { ProjectOverview } from './models/ProjectOverview'; export type { ProjectSummary } from './models/ProjectSummary'; export type { ReadinessStatus } from './models/ReadinessStatus'; +export type { RybbitMetricItem } from './models/RybbitMetricItem'; +export type { RybbitSite } from './models/RybbitSite'; export type { SchemaQuery } from './models/SchemaQuery'; export type { TableQuery } from './models/TableQuery'; export type { TelemetryComponent } from './models/TelemetryComponent'; @@ -99,8 +110,15 @@ export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganization export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest'; export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest'; export type { UpdateProjectRequest } from './models/UpdateProjectRequest'; +export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest'; export type { UserID } from './models/UserID'; export type { UserSummary } from './models/UserSummary'; +export type { Webhook } from './models/Webhook'; +export type { WebhookCreateResponse } from './models/WebhookCreateResponse'; +export { WebhookDelivery } from './models/WebhookDelivery'; +export type { WebhookDeliveryListResponse } from './models/WebhookDeliveryListResponse'; +export type { WebhookID } from './models/WebhookID'; +export type { WebhookListResponse } from './models/WebhookListResponse'; export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest'; export { $ApiKey } from './schemas/$ApiKey'; @@ -127,10 +145,13 @@ export { $CreateBucketRequest } from './schemas/$CreateBucketRequest'; export { $CreateCollectionRequest } from './schemas/$CreateCollectionRequest'; export { $CreatedApiKey } from './schemas/$CreatedApiKey'; export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest'; +export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest'; export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest'; +export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest'; export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest'; export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest'; export { $CreateProjectRequest } from './schemas/$CreateProjectRequest'; +export { $CreateWebhookRequest } from './schemas/$CreateWebhookRequest'; export { $DatabaseQuery } from './schemas/$DatabaseQuery'; export { $DBConnection } from './schemas/$DBConnection'; export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse'; @@ -148,6 +169,7 @@ export { $DBRedisResponse } from './schemas/$DBRedisResponse'; export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse'; export { $DBTableListResponse } from './schemas/$DBTableListResponse'; export { $DBXStatus } from './schemas/$DBXStatus'; +export { $DeployMarker } from './schemas/$DeployMarker'; export { $Document } from './schemas/$Document'; export { $DocumentID } from './schemas/$DocumentID'; export { $DocumentListResponse } from './schemas/$DocumentListResponse'; @@ -157,6 +179,11 @@ export { $HealthStatus } from './schemas/$HealthStatus'; export { $IngestEvent } from './schemas/$IngestEvent'; export { $IngestRequest } from './schemas/$IngestRequest'; export { $IngestResponse } from './schemas/$IngestResponse'; +export { $Integration } from './schemas/$Integration'; +export { $IntegrationAnalytics } from './schemas/$IntegrationAnalytics'; +export { $IntegrationID } from './schemas/$IntegrationID'; +export { $IntegrationListResponse } from './schemas/$IntegrationListResponse'; +export { $IntegrationTestResult } from './schemas/$IntegrationTestResult'; export { $Invitation } from './schemas/$Invitation'; export { $InvitationID } from './schemas/$InvitationID'; export { $MeResponse } from './schemas/$MeResponse'; @@ -177,6 +204,8 @@ export { $ProjectMember } from './schemas/$ProjectMember'; export { $ProjectOverview } from './schemas/$ProjectOverview'; export { $ProjectSummary } from './schemas/$ProjectSummary'; export { $ReadinessStatus } from './schemas/$ReadinessStatus'; +export { $RybbitMetricItem } from './schemas/$RybbitMetricItem'; +export { $RybbitSite } from './schemas/$RybbitSite'; export { $SchemaQuery } from './schemas/$SchemaQuery'; export { $TableQuery } from './schemas/$TableQuery'; export { $TelemetryComponent } from './schemas/$TelemetryComponent'; @@ -194,14 +223,23 @@ export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizat export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest'; export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest'; export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest'; +export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest'; export { $UserID } from './schemas/$UserID'; export { $UserSummary } from './schemas/$UserSummary'; +export { $Webhook } from './schemas/$Webhook'; +export { $WebhookCreateResponse } from './schemas/$WebhookCreateResponse'; +export { $WebhookDelivery } from './schemas/$WebhookDelivery'; +export { $WebhookDeliveryListResponse } from './schemas/$WebhookDeliveryListResponse'; +export { $WebhookID } from './schemas/$WebhookID'; +export { $WebhookListResponse } from './schemas/$WebhookListResponse'; export { CollectionsService } from './services/CollectionsService'; export { DatabasesService } from './services/DatabasesService'; export { HealthService } from './services/HealthService'; +export { IntegrationsService } from './services/IntegrationsService'; export { OrganizationsService } from './services/OrganizationsService'; export { PlatformService } from './services/PlatformService'; export { ProjectsService } from './services/ProjectsService'; export { StorageService } from './services/StorageService'; export { TelemetryService } from './services/TelemetryService'; +export { WebhooksService } from './services/WebhooksService'; diff --git a/packages/api-client/src/generated/models/CreateDeployMarkerRequest.ts b/packages/api-client/src/generated/models/CreateDeployMarkerRequest.ts new file mode 100644 index 0000000..06a3e16 --- /dev/null +++ b/packages/api-client/src/generated/models/CreateDeployMarkerRequest.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreateDeployMarkerRequest = { + version?: string; + /** + * Git SHA, tag or branch being deployed. + */ + ref?: string; + environment?: string; + note?: string; +}; + diff --git a/packages/api-client/src/generated/models/CreateIntegrationRequest.ts b/packages/api-client/src/generated/models/CreateIntegrationRequest.ts new file mode 100644 index 0000000..2b2c3fd --- /dev/null +++ b/packages/api-client/src/generated/models/CreateIntegrationRequest.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreateIntegrationRequest = { + name: string; + type: CreateIntegrationRequest.type; + /** + * Root URL of the self-hosted instance, e.g. https://analytics.example.com + */ + base_url: string; + /** + * Credential stored encrypted at rest; never returned by any endpoint. + */ + api_key?: string; + site_id?: string; +}; +export namespace CreateIntegrationRequest { + export enum type { + RYBBIT = 'rybbit', + } +} + diff --git a/packages/api-client/src/generated/models/CreateWebhookRequest.ts b/packages/api-client/src/generated/models/CreateWebhookRequest.ts new file mode 100644 index 0000000..b309262 --- /dev/null +++ b/packages/api-client/src/generated/models/CreateWebhookRequest.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreateWebhookRequest = { + url: string; + /** + * HMAC key for X-Primora-Signature. Generated when omitted. + */ + secret?: string; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test'>; + enabled?: boolean; +}; + diff --git a/packages/api-client/src/generated/models/DeployMarker.ts b/packages/api-client/src/generated/models/DeployMarker.ts new file mode 100644 index 0000000..c460521 --- /dev/null +++ b/packages/api-client/src/generated/models/DeployMarker.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeployMarker = { + version?: string; + ref?: string; + environment?: string; + note?: string; + recorded_at: string; +}; + diff --git a/packages/api-client/src/generated/models/Integration.ts b/packages/api-client/src/generated/models/Integration.ts new file mode 100644 index 0000000..c0c992b --- /dev/null +++ b/packages/api-client/src/generated/models/Integration.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Integration = { + id: string; + project_id: string; + type: Integration.type; + name: string; + base_url: string; + /** + * Connector-specific target (Rybbit site id). Optional. + */ + site_id?: string; + status: Integration.status; + last_health_at?: string | null; + has_credentials: boolean; + created_at?: string; +}; +export namespace Integration { + export enum type { + RYBBIT = 'rybbit', + } + export enum status { + UNKNOWN = 'unknown', + OK = 'ok', + ERROR = 'error', + } +} + diff --git a/packages/api-client/src/generated/models/IntegrationAnalytics.ts b/packages/api-client/src/generated/models/IntegrationAnalytics.ts new file mode 100644 index 0000000..b0fab9d --- /dev/null +++ b/packages/api-client/src/generated/models/IntegrationAnalytics.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { RybbitMetricItem } from './RybbitMetricItem'; +import type { RybbitSite } from './RybbitSite'; +export type IntegrationAnalytics = { + integration_id: string; + site?: RybbitSite; + sites?: Array; + overview?: { + sessions?: number; + pageviews?: number; + users?: number; + pages_per_session?: number; + bounce_rate?: number; + session_duration?: number; + }; + series?: Array<{ + time?: string; + sessions?: number; + pageviews?: number; + users?: number; + bounce_rate?: number; + }>; + top_pages?: Array; + top_referrers?: Array; + window_days: number; +}; + diff --git a/packages/api-client/src/generated/models/IntegrationID.ts b/packages/api-client/src/generated/models/IntegrationID.ts new file mode 100644 index 0000000..770806a --- /dev/null +++ b/packages/api-client/src/generated/models/IntegrationID.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type IntegrationID = string; diff --git a/packages/api-client/src/generated/models/IntegrationListResponse.ts b/packages/api-client/src/generated/models/IntegrationListResponse.ts new file mode 100644 index 0000000..17ffaf3 --- /dev/null +++ b/packages/api-client/src/generated/models/IntegrationListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Integration } from './Integration'; +export type IntegrationListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/models/IntegrationTestResult.ts b/packages/api-client/src/generated/models/IntegrationTestResult.ts new file mode 100644 index 0000000..68664f4 --- /dev/null +++ b/packages/api-client/src/generated/models/IntegrationTestResult.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type IntegrationTestResult = { + ok: boolean; + status_code?: number; + error?: string; +}; + diff --git a/packages/api-client/src/generated/models/RybbitMetricItem.ts b/packages/api-client/src/generated/models/RybbitMetricItem.ts new file mode 100644 index 0000000..2e8623b --- /dev/null +++ b/packages/api-client/src/generated/models/RybbitMetricItem.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RybbitMetricItem = { + value: string; + count: number; + percentage?: number; +}; + diff --git a/packages/api-client/src/generated/models/RybbitSite.ts b/packages/api-client/src/generated/models/RybbitSite.ts new file mode 100644 index 0000000..bf22148 --- /dev/null +++ b/packages/api-client/src/generated/models/RybbitSite.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RybbitSite = { + site_id: string; + name: string; + domain?: string; +}; + diff --git a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts new file mode 100644 index 0000000..30d1a9a --- /dev/null +++ b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type UpdateWebhookRequest = { + url?: string; + /** + * Replace the signing secret. Omit to keep the current one. + */ + secret?: string; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test'>; + enabled?: boolean; +}; + diff --git a/packages/api-client/src/generated/models/Webhook.ts b/packages/api-client/src/generated/models/Webhook.ts new file mode 100644 index 0000000..dcfba30 --- /dev/null +++ b/packages/api-client/src/generated/models/Webhook.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type Webhook = { + id: string; + project_id: string; + url: string; + /** + * Event filter (issue.created, deploy.marker, webhook.test). Empty means all events. + */ + events: Array; + enabled: boolean; + has_secret: boolean; + created_at?: string; +}; + diff --git a/packages/api-client/src/generated/models/WebhookCreateResponse.ts b/packages/api-client/src/generated/models/WebhookCreateResponse.ts new file mode 100644 index 0000000..6d65d84 --- /dev/null +++ b/packages/api-client/src/generated/models/WebhookCreateResponse.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Webhook } from './Webhook'; +export type WebhookCreateResponse = (Webhook & { + /** + * Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered. + */ + secret?: string; +}); + diff --git a/packages/api-client/src/generated/models/WebhookDelivery.ts b/packages/api-client/src/generated/models/WebhookDelivery.ts new file mode 100644 index 0000000..71a72cc --- /dev/null +++ b/packages/api-client/src/generated/models/WebhookDelivery.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WebhookDelivery = { + id: string; + webhook_id: string; + event_type: string; + payload?: Record; + status: WebhookDelivery.status; + attempts: number; + last_status_code?: number | null; + last_error?: string; + delivered_at?: string | null; + next_retry_at?: string | null; + created_at?: string; +}; +export namespace WebhookDelivery { + export enum status { + PENDING = 'pending', + DELIVERED = 'delivered', + FAILED = 'failed', + } +} + diff --git a/packages/api-client/src/generated/models/WebhookDeliveryListResponse.ts b/packages/api-client/src/generated/models/WebhookDeliveryListResponse.ts new file mode 100644 index 0000000..a500931 --- /dev/null +++ b/packages/api-client/src/generated/models/WebhookDeliveryListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { WebhookDelivery } from './WebhookDelivery'; +export type WebhookDeliveryListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/models/WebhookID.ts b/packages/api-client/src/generated/models/WebhookID.ts new file mode 100644 index 0000000..a237235 --- /dev/null +++ b/packages/api-client/src/generated/models/WebhookID.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type WebhookID = string; diff --git a/packages/api-client/src/generated/models/WebhookListResponse.ts b/packages/api-client/src/generated/models/WebhookListResponse.ts new file mode 100644 index 0000000..9d81c1b --- /dev/null +++ b/packages/api-client/src/generated/models/WebhookListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { Webhook } from './Webhook'; +export type WebhookListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/schemas/$CreateDeployMarkerRequest.ts b/packages/api-client/src/generated/schemas/$CreateDeployMarkerRequest.ts new file mode 100644 index 0000000..3f65fba --- /dev/null +++ b/packages/api-client/src/generated/schemas/$CreateDeployMarkerRequest.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $CreateDeployMarkerRequest = { + properties: { + version: { + type: 'string', + }, + ref: { + type: 'string', + description: `Git SHA, tag or branch being deployed.`, + }, + environment: { + type: 'string', + }, + note: { + type: 'string', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$CreateIntegrationRequest.ts b/packages/api-client/src/generated/schemas/$CreateIntegrationRequest.ts new file mode 100644 index 0000000..69502a7 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$CreateIntegrationRequest.ts @@ -0,0 +1,28 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $CreateIntegrationRequest = { + properties: { + name: { + type: 'string', + isRequired: true, + }, + type: { + type: 'Enum', + isRequired: true, + }, + base_url: { + type: 'string', + description: `Root URL of the self-hosted instance, e.g. https://analytics.example.com`, + isRequired: true, + }, + api_key: { + type: 'string', + description: `Credential stored encrypted at rest; never returned by any endpoint.`, + }, + site_id: { + type: 'string', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$CreateWebhookRequest.ts b/packages/api-client/src/generated/schemas/$CreateWebhookRequest.ts new file mode 100644 index 0000000..54fce57 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$CreateWebhookRequest.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $CreateWebhookRequest = { + properties: { + url: { + type: 'string', + isRequired: true, + format: 'uri', + }, + secret: { + type: 'string', + description: `HMAC key for X-Primora-Signature. Generated when omitted.`, + }, + events: { + type: 'array', + contains: { + type: 'Enum', + }, + }, + enabled: { + type: 'boolean', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$DeployMarker.ts b/packages/api-client/src/generated/schemas/$DeployMarker.ts new file mode 100644 index 0000000..36ea2bb --- /dev/null +++ b/packages/api-client/src/generated/schemas/$DeployMarker.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $DeployMarker = { + properties: { + version: { + type: 'string', + }, + ref: { + type: 'string', + }, + environment: { + type: 'string', + }, + note: { + type: 'string', + }, + recorded_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$Integration.ts b/packages/api-client/src/generated/schemas/$Integration.ts new file mode 100644 index 0000000..f140e53 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$Integration.ts @@ -0,0 +1,51 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $Integration = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + project_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + type: { + type: 'Enum', + isRequired: true, + }, + name: { + type: 'string', + isRequired: true, + }, + base_url: { + type: 'string', + isRequired: true, + }, + site_id: { + type: 'string', + description: `Connector-specific target (Rybbit site id). Optional.`, + }, + status: { + type: 'Enum', + isRequired: true, + }, + last_health_at: { + type: 'string', + isNullable: true, + format: 'date-time', + }, + has_credentials: { + type: 'boolean', + isRequired: true, + }, + created_at: { + type: 'string', + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$IntegrationAnalytics.ts b/packages/api-client/src/generated/schemas/$IntegrationAnalytics.ts new file mode 100644 index 0000000..ec65974 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$IntegrationAnalytics.ts @@ -0,0 +1,82 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $IntegrationAnalytics = { + properties: { + integration_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + site: { + type: 'RybbitSite', + }, + sites: { + type: 'array', + contains: { + type: 'RybbitSite', + }, + }, + overview: { + 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', + contains: { + properties: { + time: { + type: 'string', + }, + sessions: { + type: 'number', + }, + pageviews: { + type: 'number', + }, + users: { + type: 'number', + }, + bounce_rate: { + type: 'number', + }, + }, + }, + }, + top_pages: { + type: 'array', + contains: { + type: 'RybbitMetricItem', + }, + }, + top_referrers: { + type: 'array', + contains: { + type: 'RybbitMetricItem', + }, + }, + window_days: { + type: 'number', + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$IntegrationID.ts b/packages/api-client/src/generated/schemas/$IntegrationID.ts new file mode 100644 index 0000000..72a0afd --- /dev/null +++ b/packages/api-client/src/generated/schemas/$IntegrationID.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $IntegrationID = { + type: 'string', + format: 'uuid', +} as const; diff --git a/packages/api-client/src/generated/schemas/$IntegrationListResponse.ts b/packages/api-client/src/generated/schemas/$IntegrationListResponse.ts new file mode 100644 index 0000000..4c32850 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$IntegrationListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $IntegrationListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'Integration', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$IntegrationTestResult.ts b/packages/api-client/src/generated/schemas/$IntegrationTestResult.ts new file mode 100644 index 0000000..dccccc1 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$IntegrationTestResult.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $IntegrationTestResult = { + properties: { + ok: { + type: 'boolean', + isRequired: true, + }, + status_code: { + type: 'number', + }, + error: { + type: 'string', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$RybbitMetricItem.ts b/packages/api-client/src/generated/schemas/$RybbitMetricItem.ts new file mode 100644 index 0000000..62e41a8 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$RybbitMetricItem.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $RybbitMetricItem = { + properties: { + value: { + type: 'string', + isRequired: true, + }, + count: { + type: 'number', + isRequired: true, + }, + percentage: { + type: 'number', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$RybbitSite.ts b/packages/api-client/src/generated/schemas/$RybbitSite.ts new file mode 100644 index 0000000..589aaa1 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$RybbitSite.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $RybbitSite = { + properties: { + site_id: { + type: 'string', + isRequired: true, + }, + name: { + type: 'string', + isRequired: true, + }, + domain: { + type: 'string', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$UpdateWebhookRequest.ts b/packages/api-client/src/generated/schemas/$UpdateWebhookRequest.ts new file mode 100644 index 0000000..3b5981f --- /dev/null +++ b/packages/api-client/src/generated/schemas/$UpdateWebhookRequest.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $UpdateWebhookRequest = { + properties: { + url: { + type: 'string', + format: 'uri', + }, + secret: { + type: 'string', + description: `Replace the signing secret. Omit to keep the current one.`, + }, + events: { + type: 'array', + contains: { + type: 'Enum', + }, + }, + enabled: { + type: 'boolean', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$Webhook.ts b/packages/api-client/src/generated/schemas/$Webhook.ts new file mode 100644 index 0000000..737c642 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$Webhook.ts @@ -0,0 +1,41 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $Webhook = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + project_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + url: { + type: 'string', + isRequired: true, + }, + events: { + type: 'array', + contains: { + type: 'string', + }, + isRequired: true, + }, + enabled: { + type: 'boolean', + isRequired: true, + }, + has_secret: { + type: 'boolean', + isRequired: true, + }, + created_at: { + type: 'string', + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$WebhookCreateResponse.ts b/packages/api-client/src/generated/schemas/$WebhookCreateResponse.ts new file mode 100644 index 0000000..f58bd09 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$WebhookCreateResponse.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WebhookCreateResponse = { + type: 'all-of', + contains: [{ + type: 'Webhook', + }, { + properties: { + secret: { + type: 'string', + description: `Plaintext secret — present only when Primora generated it. Store it; it cannot be recovered.`, + }, + }, + }], +} as const; diff --git a/packages/api-client/src/generated/schemas/$WebhookDelivery.ts b/packages/api-client/src/generated/schemas/$WebhookDelivery.ts new file mode 100644 index 0000000..161c2d7 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$WebhookDelivery.ts @@ -0,0 +1,58 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WebhookDelivery = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + webhook_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + event_type: { + type: 'string', + isRequired: true, + }, + payload: { + type: 'dictionary', + contains: { + properties: { + }, + }, + }, + status: { + type: 'Enum', + isRequired: true, + }, + attempts: { + type: 'number', + isRequired: true, + }, + last_status_code: { + type: 'number', + isNullable: true, + }, + last_error: { + type: 'string', + }, + delivered_at: { + type: 'string', + isNullable: true, + format: 'date-time', + }, + next_retry_at: { + type: 'string', + isNullable: true, + format: 'date-time', + }, + created_at: { + type: 'string', + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$WebhookDeliveryListResponse.ts b/packages/api-client/src/generated/schemas/$WebhookDeliveryListResponse.ts new file mode 100644 index 0000000..777809f --- /dev/null +++ b/packages/api-client/src/generated/schemas/$WebhookDeliveryListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WebhookDeliveryListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'WebhookDelivery', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$WebhookID.ts b/packages/api-client/src/generated/schemas/$WebhookID.ts new file mode 100644 index 0000000..fe5d4af --- /dev/null +++ b/packages/api-client/src/generated/schemas/$WebhookID.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WebhookID = { + type: 'string', + format: 'uuid', +} as const; diff --git a/packages/api-client/src/generated/schemas/$WebhookListResponse.ts b/packages/api-client/src/generated/schemas/$WebhookListResponse.ts new file mode 100644 index 0000000..f1806be --- /dev/null +++ b/packages/api-client/src/generated/schemas/$WebhookListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $WebhookListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'Webhook', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/IntegrationsService.ts b/packages/api-client/src/generated/services/IntegrationsService.ts new file mode 100644 index 0000000..aefa6c3 --- /dev/null +++ b/packages/api-client/src/generated/services/IntegrationsService.ts @@ -0,0 +1,143 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CreateIntegrationRequest } from '../models/CreateIntegrationRequest'; +import type { Integration } from '../models/Integration'; +import type { IntegrationAnalytics } from '../models/IntegrationAnalytics'; +import type { IntegrationListResponse } from '../models/IntegrationListResponse'; +import type { IntegrationTestResult } from '../models/IntegrationTestResult'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class IntegrationsService { + /** + * @returns IntegrationListResponse External service integrations for the project + * @throws ApiError + */ + public static listIntegrations({ + projectId, + }: { + projectId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/integrations', + path: { + 'projectID': projectId, + }, + errors: { + 401: `Error response`, + }, + }); + } + /** + * @returns Integration Integration created + * @throws ApiError + */ + public static createIntegration({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: CreateIntegrationRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/integrations', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Error response`, + 409: `Error response`, + }, + }); + } + /** + * @returns void + * @throws ApiError + */ + public static deleteIntegration({ + projectId, + integrationId, + }: { + projectId: string, + integrationId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/projects/{projectID}/integrations/{integrationID}', + path: { + 'projectID': projectId, + 'integrationID': integrationId, + }, + errors: { + 404: `Error response`, + }, + }); + } + /** + * Health check — HTTP GET against the integration's base URL with stored credentials (~5s timeout). Updates status and last_health_at. + * @returns IntegrationTestResult Connectivity probe result + * @throws ApiError + */ + public static testIntegration({ + projectId, + integrationId, + }: { + projectId: string, + integrationId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/integrations/{integrationID}/test', + path: { + 'projectID': projectId, + 'integrationID': integrationId, + }, + errors: { + 404: `Error response`, + }, + }); + } + /** + * Read-only analytics pulled from the integration (Rybbit). The API key never leaves the server. + * @returns IntegrationAnalytics Normalized analytics payload + * @throws ApiError + */ + public static getIntegrationAnalytics({ + projectId, + integrationId, + site, + days = 30, + }: { + projectId: string, + integrationId: string, + /** + * Rybbit site id override; defaults to the integration's configured site, else first accessible site. + */ + site?: string, + days?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/integrations/{integrationID}/analytics', + path: { + 'projectID': projectId, + 'integrationID': integrationId, + }, + query: { + 'site': site, + 'days': days, + }, + errors: { + 400: `Error response`, + 404: `Error response`, + 502: `Error response`, + }, + }); + } +} diff --git a/packages/api-client/src/generated/services/WebhooksService.ts b/packages/api-client/src/generated/services/WebhooksService.ts new file mode 100644 index 0000000..48df783 --- /dev/null +++ b/packages/api-client/src/generated/services/WebhooksService.ts @@ -0,0 +1,190 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CreateDeployMarkerRequest } from '../models/CreateDeployMarkerRequest'; +import type { CreateWebhookRequest } from '../models/CreateWebhookRequest'; +import type { DeployMarker } from '../models/DeployMarker'; +import type { UpdateWebhookRequest } from '../models/UpdateWebhookRequest'; +import type { WebhookCreateResponse } from '../models/WebhookCreateResponse'; +import type { WebhookDelivery } from '../models/WebhookDelivery'; +import type { WebhookDeliveryListResponse } from '../models/WebhookDeliveryListResponse'; +import type { WebhookListResponse } from '../models/WebhookListResponse'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class WebhooksService { + /** + * @returns WebhookListResponse Outbound webhooks for the project + * @throws ApiError + */ + public static listWebhooks({ + projectId, + }: { + projectId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/webhooks', + path: { + 'projectID': projectId, + }, + errors: { + 401: `Error response`, + }, + }); + } + /** + * Deliveries are POSTed as JSON with X-Primora-Event and X-Primora-Signature (sha256=). If no secret is supplied one is generated and returned once. + * @returns WebhookCreateResponse Webhook created + * @throws ApiError + */ + public static createWebhook({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: CreateWebhookRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/webhooks', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Error response`, + 409: `Error response`, + }, + }); + } + /** + * @returns WebhookCreateResponse Webhook updated + * @throws ApiError + */ + public static updateWebhook({ + projectId, + webhookId, + requestBody, + }: { + projectId: string, + webhookId: string, + requestBody: UpdateWebhookRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/projects/{projectID}/webhooks/{webhookID}', + path: { + 'projectID': projectId, + 'webhookID': webhookId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 404: `Error response`, + }, + }); + } + /** + * @returns void + * @throws ApiError + */ + public static deleteWebhook({ + projectId, + webhookId, + }: { + projectId: string, + webhookId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/projects/{projectID}/webhooks/{webhookID}', + path: { + 'projectID': projectId, + 'webhookID': webhookId, + }, + errors: { + 404: `Error response`, + }, + }); + } + /** + * @returns WebhookDeliveryListResponse Recent delivery attempts, newest first + * @throws ApiError + */ + public static listWebhookDeliveries({ + projectId, + webhookId, + limit = 50, + }: { + projectId: string, + webhookId: string, + limit?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/webhooks/{webhookID}/deliveries', + path: { + 'projectID': projectId, + 'webhookID': webhookId, + }, + query: { + 'limit': limit, + }, + errors: { + 404: `Error response`, + }, + }); + } + /** + * Queues a webhook.test ping delivery. + * @returns WebhookDelivery Test delivery queued + * @throws ApiError + */ + public static testWebhook({ + projectId, + webhookId, + }: { + projectId: string, + webhookId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/webhooks/{webhookID}/test', + path: { + 'projectID': projectId, + 'webhookID': webhookId, + }, + errors: { + 404: `Error response`, + }, + }); + } + /** + * Records a deployment marker in the audit log and fires the deploy.marker webhook event. + * @returns DeployMarker Marker recorded + * @throws ApiError + */ + public static createDeployMarker({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: CreateDeployMarkerRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/deploy-markers', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 401: `Error response`, + }, + }); + } +} diff --git a/packages/client/package.json b/packages/client/package.json index b6b98af..d701160 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@primora/client", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index 09267bf..09cfb77 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -1,6 +1,6 @@ { "name": "@primora/shared-types", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "main": "./src/index.ts",