mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: phase 4 integrations — connectors, rybbit analytics, webhooks
- core.integrations (migration 00005) with AES-256-GCM-encrypted credentials (PRIMORA_ENCRYPTION_KEY, hex/base64, dev fallback); has_credentials serialization only — secrets never leave the server - /projects/:id/integrations list/create/delete/test; health check hits the stored base URL with a 5s timeout and records status - Rybbit connector: GET …/integrations/:id/analytics proxies /api/organizations + /api/sites/* server-side into a normalized shape - core.webhooks + core.webhook_deliveries; CRUD, deliveries, test, and POST …/deploy-markers. Events: issue.created (new fingerprint on ingest), deploy.marker, webhook.test. X-Primora-Signature HMAC-SHA256 over the raw body; in-process dispatcher, 3 attempts, quadratic backoff, 10s timeout, pending-scan on boot - HTTPS enforced for public targets; HTTP allowed for loopback/private/ self-hosted sinks - Integrations page (connectors + webhooks tabs, inline analytics, delivery log, secret-shown-once), demo-mode stubs - 12 new primora_* MCP tools - workspace 0.4.0 Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
8cfddf9184
commit
e25eb92339
82 files changed
+5693
-44
No files matched your search
@@ -18,6 +18,10 @@ JWT_AUDIENCE=primora-api
|
|||||||
JWT_SECRET=change-me-super-long-jwt-secret
|
JWT_SECRET=change-me-super-long-jwt-secret
|
||||||
JWT_TTL_SECONDS=900
|
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_PORT=3001
|
||||||
AUTH_BASE_URL=http://localhost/auth
|
AUTH_BASE_URL=http://localhost/auth
|
||||||
AUTH_INTERNAL_BASE_URL=http://auth:3001
|
AUTH_INTERNAL_BASE_URL=http://auth:3001
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
|
|
||||||
### Added
|
### 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=<hmac-sha256(body)>` 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.
|
- **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).
|
- **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).
|
- **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).
|
||||||
|
|||||||
+14
-8
@@ -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`.
|
`primora_telemetry_stats`, `primora_metric_series` in `apps/mcp`.
|
||||||
- Migration note in IMS repo README → archive, point at Primora.
|
- 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,
|
- ~~**Connector framework**~~ — `core.integrations` (migration 00005): type,
|
||||||
credentials (encrypted at rest), health check. `core.integrations` table.
|
base URL, AES-256-GCM-encrypted credentials (`PRIMORA_ENCRYPTION_KEY`),
|
||||||
- **Rybbit connector** — user supplies their self-hosted Rybbit URL + API key;
|
health check (`POST …/test`). `has_credentials` only — secrets never
|
||||||
Primora pulls site stats into a project **Analytics** page and dashboard
|
serialized.
|
||||||
widgets. Read-only, no tracking proxy.
|
- ~~**Rybbit connector**~~ — self-hosted URL + API key;
|
||||||
- **Webhooks** — outbound events (issue created, deploy marker) to arbitrary
|
`GET …/integrations/:id/analytics` normalizes overview/series/top
|
||||||
URLs, so Primora can push into other self-hosted tools.
|
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
|
## Phase 5 — Distribution
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/auth",
|
"name": "@primora/auth",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE core.integrations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('rybbit')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
credentials BYTEA NOT NULL DEFAULT ''::bytea,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown', 'ok', 'error')),
|
||||||
|
last_health_at TIMESTAMPTZ,
|
||||||
|
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_integrations_project_id ON core.integrations(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE core.webhooks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret BYTEA NOT NULL DEFAULT ''::bytea,
|
||||||
|
events TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_by_user_id UUID REFERENCES core.users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (project_id, url)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_webhooks_project_id ON core.webhooks(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE core.webhook_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
webhook_id UUID NOT NULL REFERENCES core.webhooks(id) ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'failed')),
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
last_status_code INT,
|
||||||
|
last_error TEXT NOT NULL DEFAULT '',
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_webhook_deliveries_webhook ON core.webhook_deliveries(webhook_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_webhook_deliveries_retry ON core.webhook_deliveries(next_retry_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE IF EXISTS core.webhook_deliveries;
|
||||||
|
DROP TABLE IF EXISTS core.webhooks;
|
||||||
|
DROP TABLE IF EXISTS core.integrations;
|
||||||
@@ -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;
|
||||||
@@ -17,6 +17,12 @@ ORDER BY name;
|
|||||||
DELETE FROM core.components
|
DELETE FROM core.components
|
||||||
WHERE id = $1 AND project_id = $2;
|
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
|
-- name: InsertEvent :one
|
||||||
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
"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/services"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,7 @@ type App struct {
|
|||||||
DB *pgxpool.Pool
|
DB *pgxpool.Pool
|
||||||
Redis *redis.Client
|
Redis *redis.Client
|
||||||
DBX *dbx.Client
|
DBX *dbx.Client
|
||||||
|
Platform *services.PlatformService
|
||||||
}
|
}
|
||||||
|
|
||||||
func Bootstrap(ctx context.Context) (*App, error) {
|
func Bootstrap(ctx context.Context) (*App, error) {
|
||||||
@@ -82,11 +84,18 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
}
|
}
|
||||||
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
dbxClient := dbx.NewClient(logger, cfg.DBXDataDir)
|
||||||
repo := repositories.NewCoreRepository(dbPool)
|
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" {
|
if cfg.Env == "production" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
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 := gin.New()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
router.Use(middleware.RequestID())
|
router.Use(middleware.RequestID())
|
||||||
@@ -155,6 +164,7 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
DB: dbPool,
|
DB: dbPool,
|
||||||
Redis: redisClient,
|
Redis: redisClient,
|
||||||
DBX: dbxClient,
|
DBX: dbxClient,
|
||||||
|
Platform: platform,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +206,9 @@ func firstPartyDBSeeds(cfg config.Config) []services.ManagedDBSeed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
|
if a.Platform != nil {
|
||||||
|
a.Platform.Close()
|
||||||
|
}
|
||||||
if a.DBX != nil {
|
if a.DBX != nil {
|
||||||
a.DBX.Close()
|
a.DBX.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,15 @@ type Config struct {
|
|||||||
SMTPUser string
|
SMTPUser string
|
||||||
SMTPPassword string
|
SMTPPassword string
|
||||||
SMTPSecure bool
|
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) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
Env: getenv("NODE_ENV", "development"),
|
Env: getenv("NODE_ENV", "development"),
|
||||||
@@ -55,6 +62,7 @@ func Load() (Config, error) {
|
|||||||
SMTPHost: getenv("SMTP_HOST", "localhost"),
|
SMTPHost: getenv("SMTP_HOST", "localhost"),
|
||||||
SMTPUser: os.Getenv("SMTP_USER"),
|
SMTPUser: os.Getenv("SMTP_USER"),
|
||||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"),
|
||||||
}
|
}
|
||||||
|
|
||||||
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025"))
|
||||||
@@ -103,6 +111,13 @@ func Load() (Config, error) {
|
|||||||
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
|
if cfg.ResendAPIKey == "" && cfg.SMTPHost == "" {
|
||||||
missing = append(missing, "RESEND_API_KEY or SMTP_HOST")
|
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 {
|
if len(missing) > 0 {
|
||||||
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
|
return Config{}, errors.New("missing required environment values: " + strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: integrations.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createIntegration = `-- name: CreateIntegration :one
|
||||||
|
INSERT INTO core.integrations (
|
||||||
|
project_id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
base_url,
|
||||||
|
config,
|
||||||
|
credentials,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateIntegrationParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseUrl string `json:"base_url"`
|
||||||
|
Config []byte `json:"config"`
|
||||||
|
Credentials []byte `json:"credentials"`
|
||||||
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createIntegration,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Type,
|
||||||
|
arg.Name,
|
||||||
|
arg.BaseUrl,
|
||||||
|
arg.Config,
|
||||||
|
arg.Credentials,
|
||||||
|
arg.CreatedByUserID,
|
||||||
|
)
|
||||||
|
var i CoreIntegration
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Config,
|
||||||
|
&i.Credentials,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastHealthAt,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteIntegration = `-- name: DeleteIntegration :one
|
||||||
|
DELETE FROM core.integrations
|
||||||
|
WHERE id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
RETURNING id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteIntegrationParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) {
|
||||||
|
row := q.db.QueryRow(ctx, deleteIntegration, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreIntegration
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Config,
|
||||||
|
&i.Credentials,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastHealthAt,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIntegrationByID = `-- name: GetIntegrationByID :one
|
||||||
|
SELECT id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at FROM core.integrations
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getIntegrationByID, id)
|
||||||
|
var i CoreIntegration
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Config,
|
||||||
|
&i.Credentials,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastHealthAt,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listIntegrations = `-- name: ListIntegrations :many
|
||||||
|
SELECT id, project_id, type, name, base_url, config, credentials, status, last_health_at, created_by_user_id, created_at, updated_at FROM core.integrations
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listIntegrations, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreIntegration
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreIntegration
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Type,
|
||||||
|
&i.Name,
|
||||||
|
&i.BaseUrl,
|
||||||
|
&i.Config,
|
||||||
|
&i.Credentials,
|
||||||
|
&i.Status,
|
||||||
|
&i.LastHealthAt,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateIntegrationHealth = `-- name: UpdateIntegrationHealth :exec
|
||||||
|
UPDATE core.integrations
|
||||||
|
SET status = $2,
|
||||||
|
last_health_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateIntegrationHealthParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateIntegrationHealth, arg.ID, arg.Status)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -242,6 +242,21 @@ type CoreEvent struct {
|
|||||||
Ts pgtype.Timestamptz `json:"ts"`
|
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 {
|
type CoreOrganization struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
@@ -298,3 +313,29 @@ type CoreUser struct {
|
|||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoreWebhook struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoreWebhookDelivery struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
WebhookID uuid.UUID `json:"webhook_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Attempts int32 `json:"attempts"`
|
||||||
|
LastStatusCode *int32 `json:"last_status_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
DeliveredAt pgtype.Timestamptz `json:"delivered_at"`
|
||||||
|
NextRetryAt pgtype.Timestamptz `json:"next_retry_at"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -28,18 +28,22 @@ type Querier interface {
|
|||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, 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)
|
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||||
CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, 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)
|
DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error)
|
||||||
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error)
|
||||||
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error
|
||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) 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)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, 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)
|
EventTotals(ctx context.Context, arg EventTotalsParams) (EventTotalsRow, error)
|
||||||
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
EventTypeSeries(ctx context.Context, arg EventTypeSeriesParams) ([]EventTypeSeriesRow, error)
|
||||||
GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, 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)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, 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)
|
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, 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)
|
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||||
GetUserByID(ctx context.Context, id uuid.UUID) (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)
|
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)
|
ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error)
|
||||||
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error)
|
||||||
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error)
|
||||||
@@ -72,12 +81,18 @@ type Querier interface {
|
|||||||
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, 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)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, 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)
|
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
||||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, 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)
|
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)
|
MetricNames(ctx context.Context, projectID uuid.UUID) ([]string, error)
|
||||||
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error)
|
||||||
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error)
|
||||||
@@ -89,10 +104,12 @@ type Querier interface {
|
|||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error)
|
||||||
|
UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error
|
||||||
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error)
|
||||||
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error)
|
||||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
|
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (CoreProject, error)
|
||||||
UpdateProjectMemberRole(ctx context.Context, arg UpdateProjectMemberRoleParams) (CoreProjectMember, 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)
|
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,25 @@ func (q *Queries) GetComponentByName(ctx context.Context, arg GetComponentByName
|
|||||||
return i, err
|
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
|
const insertEvent = `-- name: InsertEvent :one
|
||||||
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
INSERT INTO core.events (project_id, component_id, type, severity, message, payload, fingerprint, ts)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.29.0
|
||||||
|
// source: webhooks.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const createWebhook = `-- name: CreateWebhook :one
|
||||||
|
INSERT INTO core.webhooks (
|
||||||
|
project_id,
|
||||||
|
url,
|
||||||
|
secret,
|
||||||
|
events,
|
||||||
|
enabled,
|
||||||
|
created_by_user_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateWebhookParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedByUserID pgtype.UUID `json:"created_by_user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, createWebhook,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Url,
|
||||||
|
arg.Secret,
|
||||||
|
arg.Events,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.CreatedByUserID,
|
||||||
|
)
|
||||||
|
var i CoreWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteWebhook = `-- name: DeleteWebhook :one
|
||||||
|
DELETE FROM core.webhooks
|
||||||
|
WHERE id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteWebhookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, deleteWebhook, arg.ID, arg.ProjectID)
|
||||||
|
var i CoreWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWebhookByID = `-- name: GetWebhookByID :one
|
||||||
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetWebhookByID(ctx context.Context, id uuid.UUID) (CoreWebhook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getWebhookByID, id)
|
||||||
|
var i CoreWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWebhookDeliveryByID = `-- name: GetWebhookDeliveryByID :one
|
||||||
|
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetWebhookDeliveryByID(ctx context.Context, id uuid.UUID) (CoreWebhookDelivery, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getWebhookDeliveryByID, id)
|
||||||
|
var i CoreWebhookDelivery
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookID,
|
||||||
|
&i.EventType,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastStatusCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.DeliveredAt,
|
||||||
|
&i.NextRetryAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertWebhookDelivery = `-- name: InsertWebhookDelivery :one
|
||||||
|
INSERT INTO core.webhook_deliveries (
|
||||||
|
webhook_id,
|
||||||
|
event_type,
|
||||||
|
payload,
|
||||||
|
next_retry_at
|
||||||
|
) VALUES ($1, $2, $3, NOW())
|
||||||
|
RETURNING id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertWebhookDeliveryParams struct {
|
||||||
|
WebhookID uuid.UUID `json:"webhook_id"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Payload []byte `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertWebhookDelivery, arg.WebhookID, arg.EventType, arg.Payload)
|
||||||
|
var i CoreWebhookDelivery
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookID,
|
||||||
|
&i.EventType,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastStatusCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.DeliveredAt,
|
||||||
|
&i.NextRetryAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPendingWebhookDeliveries = `-- name: ListPendingWebhookDeliveries :many
|
||||||
|
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listPendingWebhookDeliveries, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreWebhookDelivery
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreWebhookDelivery
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookID,
|
||||||
|
&i.EventType,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastStatusCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.DeliveredAt,
|
||||||
|
&i.NextRetryAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWebhookDeliveries = `-- name: ListWebhookDeliveries :many
|
||||||
|
SELECT id, webhook_id, event_type, payload, status, attempts, last_status_code, last_error, delivered_at, next_retry_at, created_at FROM core.webhook_deliveries
|
||||||
|
WHERE webhook_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListWebhookDeliveriesParams struct {
|
||||||
|
WebhookID uuid.UUID `json:"webhook_id"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhookDeliveries(ctx context.Context, arg ListWebhookDeliveriesParams) ([]CoreWebhookDelivery, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWebhookDeliveries, arg.WebhookID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreWebhookDelivery
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreWebhookDelivery
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.WebhookID,
|
||||||
|
&i.EventType,
|
||||||
|
&i.Payload,
|
||||||
|
&i.Status,
|
||||||
|
&i.Attempts,
|
||||||
|
&i.LastStatusCode,
|
||||||
|
&i.LastError,
|
||||||
|
&i.DeliveredAt,
|
||||||
|
&i.NextRetryAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWebhooks = `-- name: ListWebhooks :many
|
||||||
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhooks(ctx context.Context, projectID uuid.UUID) ([]CoreWebhook, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWebhooks, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreWebhook
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreWebhook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
|
||||||
|
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND enabled
|
||||||
|
AND (cardinality(events) = 0 OR $2::text = ANY(events))
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListWebhooksForEventParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Column2 string `json:"column_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListWebhooksForEvent(ctx context.Context, arg ListWebhooksForEventParams) ([]CoreWebhook, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWebhooksForEvent, arg.ProjectID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CoreWebhook
|
||||||
|
for rows.Next() {
|
||||||
|
var i CoreWebhook
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const markWebhookDelivery = `-- name: MarkWebhookDelivery :exec
|
||||||
|
UPDATE core.webhook_deliveries
|
||||||
|
SET status = $2,
|
||||||
|
attempts = attempts + 1,
|
||||||
|
last_status_code = $3,
|
||||||
|
last_error = $4,
|
||||||
|
delivered_at = $5,
|
||||||
|
next_retry_at = $6
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type MarkWebhookDeliveryParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastStatusCode *int32 `json:"last_status_code"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
DeliveredAt pgtype.Timestamptz `json:"delivered_at"`
|
||||||
|
NextRetryAt pgtype.Timestamptz `json:"next_retry_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) MarkWebhookDelivery(ctx context.Context, arg MarkWebhookDeliveryParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, markWebhookDelivery,
|
||||||
|
arg.ID,
|
||||||
|
arg.Status,
|
||||||
|
arg.LastStatusCode,
|
||||||
|
arg.LastError,
|
||||||
|
arg.DeliveredAt,
|
||||||
|
arg.NextRetryAt,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWebhook = `-- name: UpdateWebhook :one
|
||||||
|
UPDATE core.webhooks
|
||||||
|
SET url = $3,
|
||||||
|
events = $4,
|
||||||
|
enabled = $5,
|
||||||
|
secret = CASE WHEN $6::boolean THEN $7 ELSE secret END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND project_id = $2
|
||||||
|
RETURNING id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWebhookParams struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Column6 bool `json:"column_6"`
|
||||||
|
Secret []byte `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateWebhook,
|
||||||
|
arg.ID,
|
||||||
|
arg.ProjectID,
|
||||||
|
arg.Url,
|
||||||
|
arg.Events,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.Column6,
|
||||||
|
arg.Secret,
|
||||||
|
)
|
||||||
|
var i CoreWebhook
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.Url,
|
||||||
|
&i.Secret,
|
||||||
|
&i.Events,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.CreatedByUserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -91,6 +91,18 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.GET("/projects/:projectID/db-connections/:connectionID/foreign-keys", h.dbxForeignKeys)
|
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/query", h.dbxQuery)
|
||||||
api.POST("/projects/:projectID/db-connections/:connectionID/redis", h.dbxRedis)
|
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.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||||
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
api.POST("/buckets/:bucketID/object-copies", h.copyObject)
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actorProjectIntegration resolves the actor, :projectID and :integrationID.
|
||||||
|
func (h *HTTPHandler) actorProjectIntegration(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return nil, uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
integrationID, ok := parseUUIDParam(c, "integrationID")
|
||||||
|
if !ok {
|
||||||
|
return nil, uuid.Nil, uuid.Nil, false
|
||||||
|
}
|
||||||
|
return actor, projectID, integrationID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) integrationError(c *gin.Context, err error) {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
apperrors.Abort(c, http.StatusNotFound, "not_found", "integration not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "access denied") || strings.Contains(msg, "insufficient") || strings.Contains(msg, "authentication required") || strings.Contains(msg, "actor required") {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "rybbit") || strings.Contains(msg, "unreachable") {
|
||||||
|
apperrors.Abort(c, http.StatusBadGateway, "integration_error", msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) listIntegrations(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.ListIntegrations(c.Request.Context(), actor, projectID)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) createIntegration(c *gin.Context) {
|
||||||
|
actor, projectID, ok := h.actorAndProject(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body services.CreateIntegrationInput
|
||||||
|
if !h.bindAndValidate(c, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.CreateIntegration(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
apperrors.Abort(c, http.StatusConflict, "name_conflict", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) deleteIntegration(c *gin.Context) {
|
||||||
|
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Platform.DeleteIntegration(c.Request.Context(), actor, projectID, integrationID, middleware.RequestIDFromContext(c)); err != nil {
|
||||||
|
h.integrationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) testIntegration(c *gin.Context) {
|
||||||
|
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.Platform.TestIntegration(c.Request.Context(), actor, projectID, integrationID, middleware.RequestIDFromContext(c))
|
||||||
|
if err != nil {
|
||||||
|
h.integrationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) integrationAnalytics(c *gin.Context) {
|
||||||
|
actor, projectID, integrationID, ok := h.actorProjectIntegration(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||||
|
result, err := h.Platform.IntegrationAnalytics(c.Request.Context(), actor, projectID, integrationID,
|
||||||
|
strings.TrimSpace(c.Query("site")), days)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "site") || strings.Contains(err.Error(), "does not provide") || strings.Contains(err.Error(), "no credentials") {
|
||||||
|
apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.integrationError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Package secrets encrypts credential payloads at rest with AES-256-GCM.
|
||||||
|
// Ciphertext layout: nonce || ciphertext.
|
||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Encryptor struct {
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEncryptor accepts a 32-byte key as hex or base64 (std or raw encoding).
|
||||||
|
func NewEncryptor(raw string) (*Encryptor, error) {
|
||||||
|
key, err := decodeKey(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init gcm: %w", err)
|
||||||
|
}
|
||||||
|
return &Encryptor{aead: aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKey(raw string) ([]byte, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, errors.New("encryption key is empty")
|
||||||
|
}
|
||||||
|
for _, decode := range []func(string) ([]byte, error){
|
||||||
|
hex.DecodeString,
|
||||||
|
base64.StdEncoding.DecodeString,
|
||||||
|
base64.RawStdEncoding.DecodeString,
|
||||||
|
base64.URLEncoding.DecodeString,
|
||||||
|
base64.RawURLEncoding.DecodeString,
|
||||||
|
} {
|
||||||
|
key, err := decode(trimmed)
|
||||||
|
if err == nil && len(key) == 32 {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("encryption key must decode to 32 bytes (hex or base64)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
nonce := make([]byte, e.aead.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("nonce: %w", err)
|
||||||
|
}
|
||||||
|
return e.aead.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
if len(ciphertext) < e.aead.NonceSize() {
|
||||||
|
return nil, errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, body := ciphertext[:e.aead.NonceSize()], ciphertext[e.aead.NonceSize():]
|
||||||
|
plain, err := e.aead.Open(nil, nonce, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decrypt: %w", err)
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package secrets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testKeyHex = "3031323334353637383961626364656630313233343536373839616263646566"
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
enc, err := NewEncryptor(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptor: %v", err)
|
||||||
|
}
|
||||||
|
plain := []byte(`{"api_key":"ryb-secret-token"}`)
|
||||||
|
ciphertext, err := enc.Encrypt(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
if string(ciphertext) == string(plain) {
|
||||||
|
t.Fatal("ciphertext contains plaintext")
|
||||||
|
}
|
||||||
|
got, err := enc.Decrypt(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(plain) {
|
||||||
|
t.Fatalf("round trip mismatch: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyDecoding(t *testing.T) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
for i := range raw {
|
||||||
|
raw[i] = byte(i)
|
||||||
|
}
|
||||||
|
for name, key := range map[string]string{
|
||||||
|
"hex": hex.EncodeToString(raw),
|
||||||
|
"base64": base64.StdEncoding.EncodeToString(raw),
|
||||||
|
"base64-raw": base64.RawStdEncoding.EncodeToString(raw),
|
||||||
|
"base64-url": base64.URLEncoding.EncodeToString(raw),
|
||||||
|
"whitespace": " " + hex.EncodeToString(raw) + "\n",
|
||||||
|
} {
|
||||||
|
if _, err := NewEncryptor(key); err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsBadKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{
|
||||||
|
"",
|
||||||
|
"too-short-hex",
|
||||||
|
hex.EncodeToString([]byte("sixteen-byte-key")),
|
||||||
|
"!!!not-encoded!!!",
|
||||||
|
} {
|
||||||
|
if _, err := NewEncryptor(key); err == nil {
|
||||||
|
t.Errorf("expected error for key %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptRejectsGarbage(t *testing.T) {
|
||||||
|
enc, err := NewEncryptor(testKeyHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptor: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := enc.Decrypt([]byte("short")); err == nil {
|
||||||
|
t.Error("expected error for short ciphertext")
|
||||||
|
}
|
||||||
|
if _, err := enc.Decrypt(make([]byte, 64)); err == nil {
|
||||||
|
t.Error("expected error for unauthenticated ciphertext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossKeyFails(t *testing.T) {
|
||||||
|
a, _ := NewEncryptor(testKeyHex)
|
||||||
|
other := hex.EncodeToString([]byte("0123456789abcdef0123456789abcde0"))
|
||||||
|
b, _ := NewEncryptor(other)
|
||||||
|
ciphertext, _ := a.Encrypt([]byte("secret"))
|
||||||
|
if _, err := b.Decrypt(ciphertext); err == nil {
|
||||||
|
t.Error("expected decryption to fail with a different key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntegrationCredentials is the secret payload stored AES-256-GCM encrypted in
|
||||||
|
// core.integrations.credentials. It is never serialized to the client — see
|
||||||
|
// IntegrationSummary.
|
||||||
|
type IntegrationCredentials struct {
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntegrationConfig holds non-secret connector settings (core.integrations.config).
|
||||||
|
type IntegrationConfig struct {
|
||||||
|
SiteID string `json:"site_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateIntegrationInput struct {
|
||||||
|
Name string `json:"name" validate:"required,min=1"`
|
||||||
|
Type string `json:"type" validate:"required,oneof=rybbit"`
|
||||||
|
BaseURL string `json:"base_url" validate:"required,url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntegrationSummary is the client-facing shape — credentials reduced to a flag.
|
||||||
|
type IntegrationSummary struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastHealthAt *time.Time `json:"last_health_at"`
|
||||||
|
HasCredentials bool `json:"has_credentials"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var integrationTypes = map[string]bool{"rybbit": true}
|
||||||
|
|
||||||
|
func parseIntegrationConfig(raw []byte) IntegrationConfig {
|
||||||
|
var cfg IntegrationConfig
|
||||||
|
_ = json.Unmarshal(raw, &cfg)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIntegrationSummary(row db.CoreIntegration) IntegrationSummary {
|
||||||
|
cfg := parseIntegrationConfig(row.Config)
|
||||||
|
out := IntegrationSummary{
|
||||||
|
ID: row.ID,
|
||||||
|
ProjectID: row.ProjectID,
|
||||||
|
Type: row.Type,
|
||||||
|
Name: row.Name,
|
||||||
|
BaseURL: row.BaseUrl,
|
||||||
|
SiteID: cfg.SiteID,
|
||||||
|
Status: row.Status,
|
||||||
|
HasCredentials: len(row.Credentials) > 0,
|
||||||
|
CreatedAt: row.CreatedAt.Time,
|
||||||
|
}
|
||||||
|
if row.LastHealthAt.Valid {
|
||||||
|
t := row.LastHealthAt.Time
|
||||||
|
out.LastHealthAt = &t
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimRight(raw, "/"))
|
||||||
|
u, err := url.Parse(trimmed)
|
||||||
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||||
|
return "", errors.New("base_url must be an http(s) URL")
|
||||||
|
}
|
||||||
|
return trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) decryptCredentials(row db.CoreIntegration) (IntegrationCredentials, error) {
|
||||||
|
var creds IntegrationCredentials
|
||||||
|
if len(row.Credentials) == 0 {
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
if s.enc == nil {
|
||||||
|
return creds, errors.New("encryption not configured")
|
||||||
|
}
|
||||||
|
plain, err := s.enc.Decrypt(row.Credentials)
|
||||||
|
if err != nil {
|
||||||
|
return creds, fmt.Errorf("credentials unreadable: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(plain, &creds); err != nil {
|
||||||
|
return creds, fmt.Errorf("credentials malformed: %w", err)
|
||||||
|
}
|
||||||
|
return creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) integrationFor(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, write bool) (db.CoreIntegration, error) {
|
||||||
|
roles := []string{"admin", "developer", "viewer"}
|
||||||
|
if write {
|
||||||
|
roles = []string{"admin", "developer"}
|
||||||
|
}
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, roles...); err != nil {
|
||||||
|
return db.CoreIntegration{}, err
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().GetIntegrationByID(ctx, integrationID)
|
||||||
|
if err != nil {
|
||||||
|
return db.CoreIntegration{}, err
|
||||||
|
}
|
||||||
|
if row.ProjectID != projectID {
|
||||||
|
return db.CoreIntegration{}, fmt.Errorf("integration access denied")
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) ListIntegrations(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]IntegrationSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.repo.Queries().ListIntegrations(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]IntegrationSummary, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, toIntegrationSummary(row))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) CreateIntegration(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateIntegrationInput, requestID string) (IntegrationSummary, error) {
|
||||||
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||||
|
return IntegrationSummary{}, err
|
||||||
|
}
|
||||||
|
if !integrationTypes[input.Type] {
|
||||||
|
return IntegrationSummary{}, fmt.Errorf("unsupported integration type %q", input.Type)
|
||||||
|
}
|
||||||
|
name := normalizeSlug(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return IntegrationSummary{}, errors.New("name must contain letters or digits")
|
||||||
|
}
|
||||||
|
baseURL, err := normalizeBaseURL(input.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return IntegrationSummary{}, err
|
||||||
|
}
|
||||||
|
var credBytes []byte
|
||||||
|
if strings.TrimSpace(input.APIKey) != "" {
|
||||||
|
raw, _ := json.Marshal(IntegrationCredentials{APIKey: strings.TrimSpace(input.APIKey)})
|
||||||
|
credBytes, err = s.enc.Encrypt(raw)
|
||||||
|
if err != nil {
|
||||||
|
return IntegrationSummary{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfgBytes, _ := json.Marshal(IntegrationConfig{SiteID: strings.TrimSpace(input.SiteID)})
|
||||||
|
var createdBy pgtype.UUID
|
||||||
|
if actor.UserID != nil {
|
||||||
|
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
||||||
|
}
|
||||||
|
row, err := s.repo.Queries().CreateIntegration(ctx, db.CreateIntegrationParams{
|
||||||
|
ProjectID: projectID,
|
||||||
|
Type: input.Type,
|
||||||
|
Name: name,
|
||||||
|
BaseUrl: baseURL,
|
||||||
|
Config: cfgBytes,
|
||||||
|
Credentials: credBytes,
|
||||||
|
CreatedByUserID: createdBy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "integrations_project_id_name_key") {
|
||||||
|
return IntegrationSummary{}, fmt.Errorf("an integration named %s already exists", name)
|
||||||
|
}
|
||||||
|
return IntegrationSummary{}, err
|
||||||
|
}
|
||||||
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.created", "integration", row.ID.String(), map[string]any{
|
||||||
|
"name": row.Name, "type": row.Type, "base_url": row.BaseUrl,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return toIntegrationSummary(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) DeleteIntegration(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, requestID string) error {
|
||||||
|
row, err := s.integrationFor(ctx, actor, projectID, integrationID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.repo.Queries().DeleteIntegration(ctx, db.DeleteIntegrationParams{
|
||||||
|
ID: integrationID,
|
||||||
|
ProjectID: projectID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.deleted", "integration", integrationID.String(), map[string]any{
|
||||||
|
"name": row.Name, "type": row.Type,
|
||||||
|
}))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// integrationProbePath is the lightest authenticated endpoint per type; it
|
||||||
|
// verifies reachability and credentials in one request.
|
||||||
|
func integrationProbePath(integrationType string) string {
|
||||||
|
if integrationType == "rybbit" {
|
||||||
|
return "/api/organizations"
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PlatformService) TestIntegration(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, requestID string) (map[string]any, error) {
|
||||||
|
row, err := s.integrationFor(ctx, actor, projectID, integrationID, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creds, err := s.decryptCredentials(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "error"
|
||||||
|
result := map[string]any{"ok": false}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, row.BaseUrl+integrationProbePath(row.Type), nil)
|
||||||
|
if err == nil {
|
||||||
|
if creds.APIKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+creds.APIKey)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, reqErr := client.Do(req)
|
||||||
|
if reqErr != nil {
|
||||||
|
result["error"] = reqErr.Error()
|
||||||
|
} else {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
result["status_code"] = resp.StatusCode
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||||
|
result["ok"] = true
|
||||||
|
status = "ok"
|
||||||
|
} else {
|
||||||
|
result["error"] = fmt.Sprintf("unexpected status %d", resp.StatusCode)
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
result["error"] = "credentials rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["error"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.repo.Queries().UpdateIntegrationHealth(ctx, db.UpdateIntegrationHealthParams{
|
||||||
|
ID: row.ID,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||||
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "integration.tested", "integration", integrationID.String(), map[string]any{
|
||||||
|
"name": row.Name, "status": status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSignBodyMatchesReceiverVerification(t *testing.T) {
|
||||||
|
secret := "whsec_test_123"
|
||||||
|
body := []byte(`{"event":"issue.created","data":{"fingerprint":"abc"}}`)
|
||||||
|
|
||||||
|
got := signBody(secret, body)
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
want := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("signature mismatch: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
if len(got) != 64 {
|
||||||
|
t.Fatalf("expected 64-char hex digest, got %d", len(got))
|
||||||
|
}
|
||||||
|
if signBody(secret, []byte(`{"event":"issue.created"}`)) == got {
|
||||||
|
t.Error("different body must produce a different signature")
|
||||||
|
}
|
||||||
|
if signBody("other-secret", body) == got {
|
||||||
|
t.Error("different secret must produce a different signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationSummaryNeverExposesCredentials(t *testing.T) {
|
||||||
|
ciphertext := []byte("nonce|aes-gcm-ciphertext|contains|api_key=super-secret")
|
||||||
|
row := db.CoreIntegration{
|
||||||
|
ID: uuid.New(),
|
||||||
|
ProjectID: uuid.New(),
|
||||||
|
Type: "rybbit",
|
||||||
|
Name: "prod",
|
||||||
|
BaseUrl: "https://analytics.example.com",
|
||||||
|
Config: []byte(`{"site_id":"s1"}`),
|
||||||
|
Credentials: ciphertext,
|
||||||
|
Status: "ok",
|
||||||
|
}
|
||||||
|
summary := toIntegrationSummary(row)
|
||||||
|
|
||||||
|
if !summary.HasCredentials {
|
||||||
|
t.Error("expected has_credentials=true")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"super-secret", "ciphertext", "api_key", "\"credentials\"", "aes-gcm"} {
|
||||||
|
if strings.Contains(string(raw), leak) {
|
||||||
|
t.Errorf("serialized summary leaks %q: %s", leak, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeWebhookURL(t *testing.T) {
|
||||||
|
ok := []string{
|
||||||
|
"https://hooks.example.com/x",
|
||||||
|
"http://localhost:9000/sink",
|
||||||
|
"http://127.0.0.1:9000/sink",
|
||||||
|
"http://primora-sink:9000/",
|
||||||
|
"http://192.168.1.40:8080/hook",
|
||||||
|
"http://nas.internal/events",
|
||||||
|
}
|
||||||
|
for _, u := range ok {
|
||||||
|
if _, err := normalizeWebhookURL(u); err != nil {
|
||||||
|
t.Errorf("expected %q accepted: %v", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
"",
|
||||||
|
"not-a-url",
|
||||||
|
"ftp://example.com",
|
||||||
|
"http://example.com/plain-remote",
|
||||||
|
"http://8.8.8.8/x",
|
||||||
|
"https://",
|
||||||
|
}
|
||||||
|
for _, u := range bad {
|
||||||
|
if _, err := normalizeWebhookURL(u); err == nil {
|
||||||
|
t.Errorf("expected %q rejected", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateWebhookEvents(t *testing.T) {
|
||||||
|
got, err := validateWebhookEvents([]string{"issue.created", "deploy.marker", "issue.created", " "})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected deduped 2 events, got %v", got)
|
||||||
|
}
|
||||||
|
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
|
||||||
|
t.Error("expected unknown event rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"github.com/tdvorak/primora/apps/backend/internal/dbx"
|
"github.com/tdvorak/primora/apps/backend/internal/dbx"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||||
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +35,8 @@ type PlatformService struct {
|
|||||||
dbx *dbx.Client
|
dbx *dbx.Client
|
||||||
managedDBSeeds []ManagedDBSeed
|
managedDBSeeds []ManagedDBSeed
|
||||||
hub *EventHub
|
hub *EventHub
|
||||||
|
enc *secrets.Encryptor
|
||||||
|
dispatcher *WebhookDispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
@@ -210,8 +214,20 @@ type InvitationSummary struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed) *PlatformService {
|
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, managedDBSeeds []ManagedDBSeed, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService {
|
||||||
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, managedDBSeeds: managedDBSeeds, hub: NewEventHub()}
|
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) {
|
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rybbitClient is a read-only client for a self-hosted Rybbit instance's
|
||||||
|
// documented stats API (/api/*). The Bearer key stays server-side.
|
||||||
|
type rybbitClient struct {
|
||||||
|
base string
|
||||||
|
apiKey string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRybbitClient(base, apiKey string) *rybbitClient {
|
||||||
|
return &rybbitClient{base: base, apiKey: apiKey, http: &http.Client{Timeout: 8 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get fetches path and unmarshals the body. Non-2xx surfaces the status code.
|
||||||
|
func (c *rybbitClient) get(ctx context.Context, path string, query url.Values, out any) error {
|
||||||
|
u := c.base + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
u += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.apiKey != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rybbit unreachable: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("rybbit returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RybbitSite struct {
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RybbitOverview struct {
|
||||||
|
Sessions *float64 `json:"sessions,omitempty"`
|
||||||
|
Pageviews *float64 `json:"pageviews,omitempty"`
|
||||||
|
Users *float64 `json:"users,omitempty"`
|
||||||
|
PagesPerSession *float64 `json:"pages_per_session,omitempty"`
|
||||||
|
BounceRate *float64 `json:"bounce_rate,omitempty"`
|
||||||
|
SessionDuration *float64 `json:"session_duration,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RybbitSeriesPoint struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Sessions *float64 `json:"sessions,omitempty"`
|
||||||
|
Pageviews *float64 `json:"pageviews,omitempty"`
|
||||||
|
Users *float64 `json:"users,omitempty"`
|
||||||
|
BounceRate *float64 `json:"bounce_rate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RybbitMetricItem struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Count float64 `json:"count"`
|
||||||
|
Percentage *float64 `json:"percentage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntegrationAnalyticsResult struct {
|
||||||
|
IntegrationID uuid.UUID `json:"integration_id"`
|
||||||
|
Site *RybbitSite `json:"site,omitempty"`
|
||||||
|
Sites []RybbitSite `json:"sites,omitempty"`
|
||||||
|
Overview *RybbitOverview `json:"overview,omitempty"`
|
||||||
|
Series []RybbitSeriesPoint `json:"series,omitempty"`
|
||||||
|
TopPages []RybbitMetricItem `json:"top_pages,omitempty"`
|
||||||
|
TopReferrers []RybbitMetricItem `json:"top_referrers,omitempty"`
|
||||||
|
WindowDays int `json:"window_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rybbitOrg normalizes the few shapes GET /api/organizations returns across
|
||||||
|
// versions ([]org, {data:[...]}, {organizations:[...]}).
|
||||||
|
type rybbitOrg struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rybbitClient) organizations(ctx context.Context) ([]rybbitOrg, error) {
|
||||||
|
var raw json.RawMessage
|
||||||
|
if err := c.get(ctx, "/api/organizations", nil, &raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var list []rybbitOrg
|
||||||
|
if err := json.Unmarshal(raw, &list); err == nil {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
for _, key := range []string{"data", "organizations"} {
|
||||||
|
var wrapped map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &wrapped); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inner, ok := wrapped[key]; ok {
|
||||||
|
if err := json.Unmarshal(inner, &list); err == nil {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("unexpected /api/organizations shape")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rybbitClient) orgSites(ctx context.Context, orgID string) ([]RybbitSite, error) {
|
||||||
|
var resp struct {
|
||||||
|
Sites []struct {
|
||||||
|
SiteID json.Number `json:"siteId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
} `json:"sites"`
|
||||||
|
}
|
||||||
|
if err := c.get(ctx, "/api/organizations/"+url.PathEscape(orgID)+"/sites", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]RybbitSite, 0, len(resp.Sites))
|
||||||
|
for _, site := range resp.Sites {
|
||||||
|
out = append(out, RybbitSite{SiteID: site.SiteID.String(), Name: site.Name, Domain: site.Domain})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rybbitClient) siteDetail(ctx context.Context, siteID string) (*RybbitSite, error) {
|
||||||
|
var resp struct {
|
||||||
|
SiteID json.Number `json:"siteId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
if err := c.get(ctx, "/api/sites/"+url.PathEscape(siteID), nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &RybbitSite{SiteID: resp.SiteID.String(), Name: resp.Name, Domain: resp.Domain}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rybbitRangeQuery(days int) url.Values {
|
||||||
|
end := time.Now().UTC()
|
||||||
|
start := end.AddDate(0, 0, -days)
|
||||||
|
return url.Values{
|
||||||
|
"start_date": {start.Format("2006-01-02")},
|
||||||
|
"end_date": {end.Format("2006-01-02")},
|
||||||
|
"time_zone": {"UTC"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntegrationAnalytics pulls normalized stats from a rybbit-type integration.
|
||||||
|
// Site resolution: ?site= override → configured site_id → first accessible site.
|
||||||
|
func (s *PlatformService) IntegrationAnalytics(ctx context.Context, actor *models.Actor, projectID, integrationID uuid.UUID, siteOverride string, days int) (*IntegrationAnalyticsResult, error) {
|
||||||
|
row, err := s.integrationFor(ctx, actor, projectID, integrationID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.Type != "rybbit" {
|
||||||
|
return nil, fmt.Errorf("integration %s does not provide analytics", row.Type)
|
||||||
|
}
|
||||||
|
creds, err := s.decryptCredentials(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if creds.APIKey == "" {
|
||||||
|
return nil, errors.New("integration has no credentials configured")
|
||||||
|
}
|
||||||
|
if days <= 0 || days > 365 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newRybbitClient(row.BaseUrl, creds.APIKey)
|
||||||
|
cfg := parseIntegrationConfig(row.Config)
|
||||||
|
result := &IntegrationAnalyticsResult{IntegrationID: row.ID, WindowDays: days}
|
||||||
|
|
||||||
|
siteID := siteOverride
|
||||||
|
if siteID == "" {
|
||||||
|
siteID = cfg.SiteID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site discovery only runs when no site is pinned — it needs an
|
||||||
|
// org-scope endpoint personal keys may not expose.
|
||||||
|
if siteID == "" {
|
||||||
|
orgs, err := client.organizations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("site discovery failed: %w — set site_id on the integration or pass ?site=", err)
|
||||||
|
}
|
||||||
|
for _, org := range orgs {
|
||||||
|
orgID := org.ID
|
||||||
|
if orgID == "" {
|
||||||
|
orgID = org.Slug
|
||||||
|
}
|
||||||
|
if orgID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sites, err := client.orgSites(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Sites = append(result.Sites, sites...)
|
||||||
|
}
|
||||||
|
if len(result.Sites) == 0 {
|
||||||
|
return nil, errors.New("no rybbit sites found — set site_id on the integration")
|
||||||
|
}
|
||||||
|
siteID = result.Sites[0].SiteID
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := client.siteDetail(ctx, siteID)
|
||||||
|
if err == nil {
|
||||||
|
result.Site = site
|
||||||
|
} else {
|
||||||
|
result.Site = &RybbitSite{SiteID: siteID}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := rybbitRangeQuery(days)
|
||||||
|
|
||||||
|
var overview struct {
|
||||||
|
Data RybbitOverview `json:"data"`
|
||||||
|
}
|
||||||
|
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/overview", query, &overview); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Overview = &overview.Data
|
||||||
|
|
||||||
|
seriesQuery := rybbitRangeQuery(days)
|
||||||
|
seriesQuery.Set("bucket", "day")
|
||||||
|
var series struct {
|
||||||
|
Data []RybbitSeriesPoint `json:"data"`
|
||||||
|
}
|
||||||
|
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/overview/time-series", seriesQuery, &series); err == nil {
|
||||||
|
result.Series = series.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
var metric struct {
|
||||||
|
Data struct {
|
||||||
|
Data []RybbitMetricItem `json:"data"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
pagesQuery := rybbitRangeQuery(days)
|
||||||
|
pagesQuery.Set("parameter", "pathname")
|
||||||
|
pagesQuery.Set("limit", "10")
|
||||||
|
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/metric", pagesQuery, &metric); err == nil {
|
||||||
|
result.TopPages = metric.Data.Data
|
||||||
|
}
|
||||||
|
referrerQuery := rybbitRangeQuery(days)
|
||||||
|
referrerQuery.Set("parameter", "referrer")
|
||||||
|
referrerQuery.Set("limit", "10")
|
||||||
|
if err := client.get(ctx, "/api/sites/"+url.PathEscape(siteID)+"/metric", referrerQuery, &metric); err == nil {
|
||||||
|
result.TopReferrers = metric.Data.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
|
|||||||
projectID := *actor.ProjectID
|
projectID := *actor.ProjectID
|
||||||
|
|
||||||
var inserted []TelemetryEvent
|
var inserted []TelemetryEvent
|
||||||
|
var newIssues []TelemetryEvent
|
||||||
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
||||||
compCache := map[string]*uuid.UUID{}
|
compCache := map[string]*uuid.UUID{}
|
||||||
for _, ev := range events {
|
for _, ev := range events {
|
||||||
@@ -136,6 +137,16 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
|
|||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
payload = json.RawMessage(`{}`)
|
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
|
var cid pgtype.UUID
|
||||||
if compID != nil {
|
if compID != nil {
|
||||||
cid = pgtype.UUID{Bytes: *compID, Valid: true}
|
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
|
out.ComponentName = ev.Component
|
||||||
}
|
}
|
||||||
inserted = append(inserted, out)
|
inserted = append(inserted, out)
|
||||||
|
if newFingerprint {
|
||||||
|
newIssues = append(newIssues, out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -178,6 +192,18 @@ func (s *PlatformService) IngestEvents(ctx context.Context, actor *models.Actor,
|
|||||||
for _, ev := range inserted {
|
for _, ev := range inserted {
|
||||||
s.hub.Broadcast(ev.ProjectID.String(), ev)
|
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
|
return len(inserted), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1286,6 +1286,277 @@ paths:
|
|||||||
description: text/event-stream of TelemetryEvent messages
|
description: text/event-stream of TelemetryEvent messages
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/integrations:
|
||||||
|
get:
|
||||||
|
tags: [Integrations]
|
||||||
|
operationId: listIntegrations
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: External service integrations for the project
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/IntegrationListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Integrations]
|
||||||
|
operationId: createIntegration
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateIntegrationRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Integration created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Integration"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/integrations/{integrationID}:
|
||||||
|
delete:
|
||||||
|
tags: [Integrations]
|
||||||
|
operationId: deleteIntegration
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/IntegrationID"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Integration deleted
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/integrations/{integrationID}/test:
|
||||||
|
post:
|
||||||
|
tags: [Integrations]
|
||||||
|
operationId: testIntegration
|
||||||
|
summary: Health check — HTTP GET against the integration's base URL with stored credentials (~5s timeout). Updates status and last_health_at.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/IntegrationID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Connectivity probe result
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/IntegrationTestResult"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/integrations/{integrationID}/analytics:
|
||||||
|
get:
|
||||||
|
tags: [Integrations]
|
||||||
|
operationId: getIntegrationAnalytics
|
||||||
|
summary: Read-only analytics pulled from the integration (Rybbit). The API key never leaves the server.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/IntegrationID"
|
||||||
|
- name: site
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Rybbit site id override; defaults to the integration's configured site, else first accessible site.
|
||||||
|
- name: days
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 365
|
||||||
|
default: 30
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Normalized analytics payload
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/IntegrationAnalytics"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"502":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/webhooks:
|
||||||
|
get:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: listWebhooks
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Outbound webhooks for the project
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/WebhookListResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
post:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: createWebhook
|
||||||
|
summary: Deliveries are POSTed as JSON with X-Primora-Event and X-Primora-Signature (sha256=<HMAC-SHA256 hex of the raw body, keyed by the webhook secret>). If no secret is supplied one is generated and returned once.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateWebhookRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Webhook created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/WebhookCreateResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/webhooks/{webhookID}:
|
||||||
|
patch:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: updateWebhook
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/WebhookID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UpdateWebhookRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Webhook updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/WebhookCreateResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
delete:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: deleteWebhook
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/WebhookID"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Webhook deleted
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/webhooks/{webhookID}/deliveries:
|
||||||
|
get:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: listWebhookDeliveries
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/WebhookID"
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
default: 50
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Recent delivery attempts, newest first
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/WebhookDeliveryListResponse"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/webhooks/{webhookID}/test:
|
||||||
|
post:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: testWebhook
|
||||||
|
summary: Queues a webhook.test ping delivery.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
- $ref: "#/components/parameters/WebhookID"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Test delivery queued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/WebhookDelivery"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
|
/projects/{projectID}/deploy-markers:
|
||||||
|
post:
|
||||||
|
tags: [Webhooks]
|
||||||
|
operationId: createDeployMarker
|
||||||
|
summary: Records a deployment marker in the audit log and fires the deploy.marker webhook event.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
- apiKeyAuth: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/ProjectID"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CreateDeployMarkerRequest"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Marker recorded
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DeployMarker"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -1405,6 +1676,20 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
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:
|
DatabaseQuery:
|
||||||
name: database
|
name: database
|
||||||
in: query
|
in: query
|
||||||
@@ -2530,3 +2815,286 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/MetricPoint"
|
$ref: "#/components/schemas/MetricPoint"
|
||||||
required: [items]
|
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]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/cli",
|
"name": "@primora/cli",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"description": "Primora command-line client",
|
"description": "Primora command-line client",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ cli
|
|||||||
.action(cmdAuditList);
|
.action(cmdAuditList);
|
||||||
|
|
||||||
cli.help();
|
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
|
// cac only matches the first positional token, so "orgs list" cannot be a
|
||||||
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/frontend",
|
"name": "@primora/frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@primora/shared-types": "0.3.0",
|
"@primora/shared-types": "0.4.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"solid-js": "^1.9.12"
|
"solid-js": "^1.9.12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
CollectionsPage,
|
CollectionsPage,
|
||||||
DatabasesPage,
|
DatabasesPage,
|
||||||
TelemetryPage,
|
TelemetryPage,
|
||||||
|
IntegrationsPage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
MembersPage,
|
MembersPage,
|
||||||
ProjectsPage,
|
ProjectsPage,
|
||||||
@@ -1635,6 +1636,14 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeView() === "integrations"}>
|
||||||
|
<IntegrationsPage
|
||||||
|
projectID={activeProject()?.id}
|
||||||
|
canManage={canUpdateProject()}
|
||||||
|
demoMode={isDemo}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={activeView() === "storage"}>
|
<Show when={activeView() === "storage"}>
|
||||||
<StoragePage
|
<StoragePage
|
||||||
buckets={buckets()}
|
buckets={buckets()}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IconCollections,
|
IconCollections,
|
||||||
IconDatabases,
|
IconDatabases,
|
||||||
IconTelemetry,
|
IconTelemetry,
|
||||||
|
IconIntegrations,
|
||||||
IconAudit,
|
IconAudit,
|
||||||
IconAuth,
|
IconAuth,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -28,6 +29,7 @@ export type ViewType =
|
|||||||
| "collections"
|
| "collections"
|
||||||
| "databases"
|
| "databases"
|
||||||
| "telemetry"
|
| "telemetry"
|
||||||
|
| "integrations"
|
||||||
| "auth"
|
| "auth"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "settings";
|
| "settings";
|
||||||
@@ -43,14 +45,15 @@ const projectNav: NavEntry[] = [
|
|||||||
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
{ id: "dashboard", label: "Overview", icon: IconOverview },
|
||||||
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
|
||||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||||
|
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||||
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
{ id: "audit", label: "Audit log", shortLabel: "Audit", icon: IconAudit },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Instagram-style bottom bar caps at five slots — Collections and Telemetry stay reachable via Menu.
|
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
|
||||||
const bottomNav: NavEntry[] = projectNav.filter((e) => e.id !== "collections" && e.id !== "telemetry");
|
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id));
|
||||||
|
|
||||||
const workspaceNav: NavEntry[] = [
|
const workspaceNav: NavEntry[] = [
|
||||||
{ id: "projects", label: "Projects", icon: IconProjects },
|
{ id: "projects", label: "Projects", icon: IconProjects },
|
||||||
@@ -88,6 +91,7 @@ const viewTitle: Record<ViewType, string> = {
|
|||||||
collections: "Collections",
|
collections: "Collections",
|
||||||
databases: "Databases",
|
databases: "Databases",
|
||||||
telemetry: "Telemetry",
|
telemetry: "Telemetry",
|
||||||
|
integrations: "Integrations",
|
||||||
auth: "Authentication",
|
auth: "Authentication",
|
||||||
audit: "Audit log",
|
audit: "Audit log",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const IconMoon = icon(() => (<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 002
|
|||||||
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
|
export const IconLayers = icon(() => (<><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5" /><path d="M2 12l10 5 10-5" /></>));
|
||||||
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
|
export const IconZap = icon(() => (<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />));
|
||||||
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
|
export const IconTelemetry = icon(() => (<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />));
|
||||||
|
export const IconIntegrations = icon(() => (<><path d="M9 17H7A5 5 0 0 1 7 7h2" /><path d="M15 7h2a5 5 0 1 1 0 10h-2" /><line x1="8" x2="16" y1="12" y2="12" /></>));
|
||||||
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
|
export const IconClock = icon(() => (<><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></>));
|
||||||
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
|
export const IconLink = icon(() => (<><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></>));
|
||||||
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
|
export const IconOrganization = icon(() => (<><rect x="4" y="2" width="16" height="20" rx="2" /><path d="M9 22v-4h6v4" /><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M8 10h.01M16 10h.01M8 14h.01M16 14h.01M12 14h.01" /></>));
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Integration,
|
||||||
OrganizationInvitation,
|
OrganizationInvitation,
|
||||||
OrganizationMember,
|
OrganizationMember,
|
||||||
ProjectMember,
|
ProjectMember,
|
||||||
|
WebhookDelivery,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
import type {
|
import type {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -15,7 +17,10 @@ import type {
|
|||||||
BucketObject,
|
BucketObject,
|
||||||
Collection,
|
Collection,
|
||||||
DBConnection,
|
DBConnection,
|
||||||
|
DeployMarker,
|
||||||
Document,
|
Document,
|
||||||
|
IntegrationAnalytics,
|
||||||
|
IntegrationTestResult,
|
||||||
MeResponse,
|
MeResponse,
|
||||||
MetricPoint,
|
MetricPoint,
|
||||||
OrganizationSummary,
|
OrganizationSummary,
|
||||||
@@ -25,6 +30,8 @@ import type {
|
|||||||
TelemetryEvent,
|
TelemetryEvent,
|
||||||
TelemetryIssue,
|
TelemetryIssue,
|
||||||
TelemetryStats,
|
TelemetryStats,
|
||||||
|
Webhook,
|
||||||
|
WebhookCreateResponse,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
|
|
||||||
const STORAGE_KEY = "primora_demo_mode";
|
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 */
|
/* 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 {
|
class DemoService {
|
||||||
private delay(ms = 250) {
|
private delay(ms = 250) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -1017,6 +1081,159 @@ class DemoService {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
/* integrations + webhooks */
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
async listIntegrations(): Promise<{ items: Integration[] }> {
|
||||||
|
await this.delay();
|
||||||
|
return { items: demoIntegrations };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createIntegration(data: {
|
||||||
|
requestBody?: { name?: string; type?: string; base_url?: string; api_key?: string; site_id?: string };
|
||||||
|
}): Promise<Integration> {
|
||||||
|
await this.delay();
|
||||||
|
const item: Integration = {
|
||||||
|
id: `demo-int-${demoIntegrations.length + 1}`,
|
||||||
|
project_id: "demo-project-1",
|
||||||
|
type: Integration.type.RYBBIT,
|
||||||
|
name: data.requestBody?.name ?? "connector",
|
||||||
|
base_url: data.requestBody?.base_url ?? "https://analytics.example.com",
|
||||||
|
site_id: data.requestBody?.site_id || undefined,
|
||||||
|
status: Integration.status.UNKNOWN,
|
||||||
|
has_credentials: !!data.requestBody?.api_key,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
demoIntegrations.push(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteIntegration(data: { integrationId?: string }) {
|
||||||
|
await this.delay();
|
||||||
|
const idx = demoIntegrations.findIndex((i) => i.id === data.integrationId);
|
||||||
|
if (idx >= 0) demoIntegrations.splice(idx, 1);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async testIntegration(data: { integrationId?: string }): Promise<IntegrationTestResult> {
|
||||||
|
await this.delay();
|
||||||
|
const item = demoIntegrations.find((i) => i.id === data.integrationId);
|
||||||
|
if (item) {
|
||||||
|
item.status = Integration.status.OK;
|
||||||
|
item.last_health_at = new Date().toISOString();
|
||||||
|
}
|
||||||
|
return { ok: true, status_code: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getIntegrationAnalytics(data: { integrationId?: string; site?: string; days?: number }): Promise<IntegrationAnalytics> {
|
||||||
|
await this.delay();
|
||||||
|
const days = data.days ?? 30;
|
||||||
|
const site = demoRybbitSites.find((s) => s.site_id === data.site) ?? demoRybbitSites[0];
|
||||||
|
return {
|
||||||
|
integration_id: data.integrationId ?? "demo-int-1",
|
||||||
|
site,
|
||||||
|
sites: demoRybbitSites,
|
||||||
|
overview: {
|
||||||
|
sessions: 18_432,
|
||||||
|
pageviews: 61_908,
|
||||||
|
users: 12_077,
|
||||||
|
pages_per_session: 3.4,
|
||||||
|
bounce_rate: 41.2,
|
||||||
|
session_duration: 187,
|
||||||
|
},
|
||||||
|
series: Array.from({ length: days }, (_, i) => ({
|
||||||
|
time: new Date(Date.now() - (days - 1 - i) * 86_400_000).toISOString(),
|
||||||
|
sessions: 480 + Math.round(Math.sin(i / 3) * 120),
|
||||||
|
pageviews: 1600 + Math.round(Math.sin(i / 3) * 500),
|
||||||
|
users: 320 + Math.round(Math.sin(i / 4) * 90),
|
||||||
|
bounce_rate: 38 + Math.sin(i / 5) * 6,
|
||||||
|
})),
|
||||||
|
top_pages: [
|
||||||
|
{ value: "/", count: 14_203 },
|
||||||
|
{ value: "/pricing", count: 6_811 },
|
||||||
|
{ value: "/docs/getting-started", count: 5_902 },
|
||||||
|
{ value: "/blog/launch", count: 3_114 },
|
||||||
|
],
|
||||||
|
top_referrers: [
|
||||||
|
{ value: "google.com", count: 7_540 },
|
||||||
|
{ value: "news.ycombinator.com", count: 2_307 },
|
||||||
|
{ value: "github.com", count: 1_882 },
|
||||||
|
],
|
||||||
|
window_days: days,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWebhooks(): Promise<{ items: Webhook[] }> {
|
||||||
|
await this.delay();
|
||||||
|
return { items: demoWebhooks };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWebhook(data: {
|
||||||
|
requestBody?: { url?: string; secret?: string; events?: string[]; enabled?: boolean };
|
||||||
|
}): Promise<WebhookCreateResponse> {
|
||||||
|
await this.delay();
|
||||||
|
const generated = !data.requestBody?.secret;
|
||||||
|
const hook: Webhook = {
|
||||||
|
id: `demo-hook-${demoWebhooks.length + 1}`,
|
||||||
|
project_id: "demo-project-1",
|
||||||
|
url: data.requestBody?.url ?? "https://hooks.example.com/primora",
|
||||||
|
events: data.requestBody?.events ?? [],
|
||||||
|
enabled: data.requestBody?.enabled ?? true,
|
||||||
|
has_secret: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
demoWebhooks.push(hook);
|
||||||
|
return { ...hook, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWebhook(data: { webhookId?: string; requestBody?: Partial<Webhook> & { secret?: string } }): Promise<WebhookCreateResponse> {
|
||||||
|
await this.delay();
|
||||||
|
const hook = demoWebhooks.find((w) => w.id === data.webhookId);
|
||||||
|
if (hook && data.requestBody) {
|
||||||
|
if (data.requestBody.url !== undefined) hook.url = data.requestBody.url;
|
||||||
|
if (data.requestBody.events !== undefined) hook.events = data.requestBody.events;
|
||||||
|
if (data.requestBody.enabled !== undefined) hook.enabled = data.requestBody.enabled;
|
||||||
|
if (data.requestBody.secret !== undefined) hook.has_secret = true;
|
||||||
|
}
|
||||||
|
return { ...(hook as Webhook) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteWebhook(data: { webhookId?: string }) {
|
||||||
|
await this.delay();
|
||||||
|
const idx = demoWebhooks.findIndex((w) => w.id === data.webhookId);
|
||||||
|
if (idx >= 0) demoWebhooks.splice(idx, 1);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listWebhookDeliveries(data: { webhookId?: string }): Promise<{ items: WebhookDelivery[] }> {
|
||||||
|
await this.delay();
|
||||||
|
return { items: demoDeliveries.filter((d) => d.webhook_id === data.webhookId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async testWebhook(data: { webhookId?: string }): Promise<WebhookDelivery> {
|
||||||
|
await this.delay();
|
||||||
|
const delivery: WebhookDelivery = {
|
||||||
|
id: `demo-del-${Date.now()}`,
|
||||||
|
webhook_id: data.webhookId ?? "demo-hook-1",
|
||||||
|
event_type: "webhook.test",
|
||||||
|
status: WebhookDelivery.status.DELIVERED,
|
||||||
|
attempts: 1,
|
||||||
|
last_status_code: 200,
|
||||||
|
delivered_at: new Date().toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
demoDeliveries.unshift(delivery);
|
||||||
|
return delivery;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDeployMarker(data: {
|
||||||
|
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
|
||||||
|
}): Promise<DeployMarker> {
|
||||||
|
await this.delay();
|
||||||
|
return { ...(data.requestBody ?? {}), recorded_at: new Date().toISOString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const demoService = new DemoService();
|
export const demoService = new DemoService();
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||||
|
import {
|
||||||
|
CreateIntegrationRequest,
|
||||||
|
IntegrationsService,
|
||||||
|
WebhooksService,
|
||||||
|
type Integration,
|
||||||
|
type IntegrationAnalytics,
|
||||||
|
type Webhook,
|
||||||
|
type WebhookDelivery,
|
||||||
|
} from "@primora/api-client";
|
||||||
|
import { demoService } from "../lib/demo-mode";
|
||||||
|
import { Badge } from "../components/Badge";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import { Input, Select } from "../components/Input";
|
||||||
|
import {
|
||||||
|
IconPlus,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrash,
|
||||||
|
IconPlay,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCheck,
|
||||||
|
IconCopy,
|
||||||
|
} from "../components/Icons";
|
||||||
|
|
||||||
|
interface IntegrationsPageProps {
|
||||||
|
projectID?: string;
|
||||||
|
canManage: boolean;
|
||||||
|
demoMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "integrations" | "webhooks";
|
||||||
|
|
||||||
|
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||||
|
const intSvc = (demo: boolean) =>
|
||||||
|
(demo ? (demoService as unknown as typeof IntegrationsService) : IntegrationsService);
|
||||||
|
const hookSvc = (demo: boolean) =>
|
||||||
|
(demo ? (demoService as unknown as typeof WebhooksService) : WebhooksService);
|
||||||
|
|
||||||
|
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||||
|
const fmtNum = (n?: number) => (n == null ? "—" : n.toLocaleString());
|
||||||
|
|
||||||
|
const statusVariant = (s?: string) =>
|
||||||
|
s === "ok" || s === "delivered"
|
||||||
|
? "success"
|
||||||
|
: s === "error" || s === "failed"
|
||||||
|
? "error"
|
||||||
|
: s === "pending"
|
||||||
|
? "warning"
|
||||||
|
: "neutral";
|
||||||
|
|
||||||
|
const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const;
|
||||||
|
|
||||||
|
export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||||
|
const [tab, setTab] = createSignal<Tab>("integrations");
|
||||||
|
const [integrations, setIntegrations] = createSignal<Integration[]>([]);
|
||||||
|
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
||||||
|
const [deliveries, setDeliveries] = createSignal<Record<string, WebhookDelivery[]>>({});
|
||||||
|
const [analytics, setAnalytics] = createSignal<Record<string, IntegrationAnalytics>>({});
|
||||||
|
const [analyticsSite, setAnalyticsSite] = createSignal<Record<string, string>>({});
|
||||||
|
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||||
|
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||||
|
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||||
|
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||||
|
const [integrationForm, setIntegrationForm] = createSignal({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||||
|
const [webhookForm, setWebhookForm] = createSignal({ url: "", secret: "", events: [] as string[], enabled: true });
|
||||||
|
const [markerForm, setMarkerForm] = createSignal({ version: "", ref: "", environment: "", note: "" });
|
||||||
|
const [createdSecret, setCreatedSecret] = createSignal("");
|
||||||
|
const [loading, setLoading] = createSignal(false);
|
||||||
|
const [busy, setBusy] = createSignal<string>();
|
||||||
|
const [message, setMessage] = createSignal("");
|
||||||
|
const [error, setError] = createSignal("");
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [int, hooks] = await Promise.all([
|
||||||
|
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||||
|
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||||
|
]);
|
||||||
|
setIntegrations(int.items ?? []);
|
||||||
|
setWebhooks(hooks.items ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.projectID) void refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
|
||||||
|
setBusy(key);
|
||||||
|
setMessage("");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
if (done) setMessage(done);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIntegration = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = integrationForm();
|
||||||
|
void run("create-integration", async () => {
|
||||||
|
await intSvc(props.demoMode).createIntegration({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
requestBody: {
|
||||||
|
name: form.name.trim(),
|
||||||
|
type: CreateIntegrationRequest.type.RYBBIT,
|
||||||
|
base_url: form.base_url.trim(),
|
||||||
|
api_key: form.api_key.trim() || undefined,
|
||||||
|
site_id: form.site_id.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setShowCreateIntegration(false);
|
||||||
|
setIntegrationForm({ name: "", base_url: "", api_key: "", site_id: "" });
|
||||||
|
}, "Integration created");
|
||||||
|
};
|
||||||
|
|
||||||
|
const testIntegration = (id: string) =>
|
||||||
|
run(`test-${id}`, async () => {
|
||||||
|
const res = await intSvc(props.demoMode).testIntegration({ projectId: props.projectID!, integrationId: id });
|
||||||
|
setMessage(res.ok ? `Health check passed (${res.status_code ?? "—"})` : `Health check failed: ${res.error ?? "unknown"}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteIntegration = (id: string) =>
|
||||||
|
run(
|
||||||
|
`del-int-${id}`,
|
||||||
|
() => intSvc(props.demoMode).deleteIntegration({ projectId: props.projectID!, integrationId: id }),
|
||||||
|
"Integration deleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadAnalytics = async (integration: Integration, site?: string) => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
setBusy(`analytics-${integration.id}`);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await intSvc(props.demoMode).getIntegrationAnalytics({
|
||||||
|
projectId: props.projectID,
|
||||||
|
integrationId: integration.id,
|
||||||
|
site: site || analyticsSite()[integration.id] || integration.site_id || undefined,
|
||||||
|
days: 30,
|
||||||
|
});
|
||||||
|
setAnalytics((c) => ({ ...c, [integration.id]: res }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAnalytics = (integration: Integration) => {
|
||||||
|
if (expandedIntegration() === integration.id) {
|
||||||
|
setExpandedIntegration(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedIntegration(integration.id);
|
||||||
|
void loadAnalytics(integration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWebhook = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = webhookForm();
|
||||||
|
void run("create-webhook", async () => {
|
||||||
|
const res = await hookSvc(props.demoMode).createWebhook({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
requestBody: {
|
||||||
|
url: form.url.trim(),
|
||||||
|
secret: form.secret.trim() || undefined,
|
||||||
|
events: form.events as Array<"issue.created" | "deploy.marker" | "webhook.test">,
|
||||||
|
enabled: form.enabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.secret) setCreatedSecret(res.secret);
|
||||||
|
setShowCreateWebhook(false);
|
||||||
|
setWebhookForm({ url: "", secret: "", events: [], enabled: true });
|
||||||
|
}, "Webhook created");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWebhook = (hook: Webhook) =>
|
||||||
|
run(
|
||||||
|
`toggle-${hook.id}`,
|
||||||
|
() =>
|
||||||
|
hookSvc(props.demoMode).updateWebhook({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
webhookId: hook.id,
|
||||||
|
requestBody: { enabled: !hook.enabled },
|
||||||
|
}),
|
||||||
|
hook.enabled ? "Webhook disabled" : "Webhook enabled",
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteWebhook = (id: string) =>
|
||||||
|
run(
|
||||||
|
`del-hook-${id}`,
|
||||||
|
() => hookSvc(props.demoMode).deleteWebhook({ projectId: props.projectID!, webhookId: id }),
|
||||||
|
"Webhook deleted",
|
||||||
|
);
|
||||||
|
|
||||||
|
const testWebhook = (id: string) =>
|
||||||
|
run(
|
||||||
|
`test-hook-${id}`,
|
||||||
|
async () => {
|
||||||
|
await hookSvc(props.demoMode).testWebhook({ projectId: props.projectID!, webhookId: id });
|
||||||
|
await loadDeliveries(id);
|
||||||
|
},
|
||||||
|
"Test delivery queued",
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadDeliveries = async (webhookId: string) => {
|
||||||
|
if (!props.projectID) return;
|
||||||
|
try {
|
||||||
|
const res = await hookSvc(props.demoMode).listWebhookDeliveries({ projectId: props.projectID, webhookId, limit: 25 });
|
||||||
|
setDeliveries((c) => ({ ...c, [webhookId]: res.items ?? [] }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(err(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDeliveries = (hook: Webhook) => {
|
||||||
|
if (expandedWebhook() === hook.id) {
|
||||||
|
setExpandedWebhook(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedWebhook(hook.id);
|
||||||
|
void loadDeliveries(hook.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordMarker = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = markerForm();
|
||||||
|
void run(
|
||||||
|
"marker",
|
||||||
|
async () => {
|
||||||
|
await hookSvc(props.demoMode).createDeployMarker({
|
||||||
|
projectId: props.projectID!,
|
||||||
|
requestBody: {
|
||||||
|
version: form.version.trim() || undefined,
|
||||||
|
ref: form.ref.trim() || undefined,
|
||||||
|
environment: form.environment.trim() || undefined,
|
||||||
|
note: form.note.trim() || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setMarkerForm({ version: "", ref: "", environment: "", note: "" });
|
||||||
|
},
|
||||||
|
"Deploy marker recorded — deploy.marker fired",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvent = (ev: string) =>
|
||||||
|
setWebhookForm((c) => ({
|
||||||
|
...c,
|
||||||
|
events: c.events.includes(ev) ? c.events.filter((x) => x !== ev) : [...c.events, ev],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Integrations</h1>
|
||||||
|
<p class="page-description">External connectors and outbound webhooks for this project.</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
|
||||||
|
<IconRefresh class="w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={message()}>
|
||||||
|
<div class="message message-success mb-4">{message()}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={error()}>
|
||||||
|
<div class="message message-error mb-4">{error()}</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={createdSecret()}>
|
||||||
|
<div class="message message-warning mb-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<strong>Webhook secret — shown once.</strong>
|
||||||
|
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
|
||||||
|
<IconCopy class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
|
||||||
|
<IconCheck class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="tabs mb-4">
|
||||||
|
<button class={`tab ${tab() === "integrations" ? "active" : ""}`} onClick={() => setTab("integrations")}>
|
||||||
|
Connectors
|
||||||
|
</button>
|
||||||
|
<button class={`tab ${tab() === "webhooks" ? "active" : ""}`} onClick={() => setTab("webhooks")}>
|
||||||
|
Webhooks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={tab() === "integrations"}>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Connectors</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
External services attached to this project. Credentials are encrypted at rest and never leave the server.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateIntegration(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" />
|
||||||
|
Add connector
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={integrations().length > 0}
|
||||||
|
fallback={
|
||||||
|
<p class="text-text-2 text-sm p-4">
|
||||||
|
No connectors yet. Add a Rybbit instance to pull analytics into this project.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Base URL</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="hidden md:table-cell">Last check</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={integrations()}>
|
||||||
|
{(item) => (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{item.name}</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant="primary">{item.type}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="text-text-2" style="max-width:12rem;overflow:hidden;text-overflow:ellipsis">{item.base_url}</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={statusVariant(item.status)}>{item.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell text-text-2">{fmtTime(item.last_health_at)}</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<Show when={item.type === "rybbit"}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => toggleAnalytics(item)}
|
||||||
|
disabled={busy() === `analytics-${item.id}`}
|
||||||
|
>
|
||||||
|
<IconChevronRight class={`w-4 h-4 ${expandedIntegration() === item.id ? "rotate-90" : ""}`} />
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void testIntegration(item.id)}
|
||||||
|
disabled={busy() === `test-${item.id}`}
|
||||||
|
>
|
||||||
|
<IconPlay class="w-4 h-4" />
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void deleteIntegration(item.id)}
|
||||||
|
disabled={busy() === `del-int-${item.id}`}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<Show when={expandedIntegration() === item.id}>
|
||||||
|
<tr>
|
||||||
|
<td colspan={6} style="padding:0">
|
||||||
|
<AnalyticsPanel
|
||||||
|
analytics={analytics()[item.id]}
|
||||||
|
loading={busy() === `analytics-${item.id}`}
|
||||||
|
site={analyticsSite()[item.id] ?? item.site_id ?? ""}
|
||||||
|
onSiteChange={(site) => {
|
||||||
|
setAnalyticsSite((c) => ({ ...c, [item.id]: site }));
|
||||||
|
void loadAnalytics(item, site);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={tab() === "webhooks"}>
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Outbound webhooks</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
Primora signs each delivery with <code>X-Primora-Signature</code> (HMAC-SHA256 over the raw body).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateWebhook(true)}>
|
||||||
|
<IconPlus class="w-4 h-4" />
|
||||||
|
Add webhook
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={webhooks().length > 0}
|
||||||
|
fallback={
|
||||||
|
<p class="text-text-2 text-sm p-4">
|
||||||
|
No webhooks yet. Add an HTTPS endpoint to receive issue.created and deploy.marker events.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>Events</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={webhooks()}>
|
||||||
|
{(hook) => (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">{hook.url}</td>
|
||||||
|
<td>
|
||||||
|
<Show when={hook.events.length > 0} fallback={<span class="text-text-2">all</span>}>
|
||||||
|
<For each={hook.events}>{(ev) => <Badge variant="neutral" class="mr-1">{ev}</Badge>}</For>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={hook.enabled ? "success" : "neutral"}>
|
||||||
|
{hook.enabled ? "enabled" : "disabled"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;white-space:nowrap">
|
||||||
|
<button class="btn btn-ghost btn-sm" onClick={() => toggleDeliveries(hook)}>
|
||||||
|
<IconChevronRight class={`w-4 h-4 ${expandedWebhook() === hook.id ? "rotate-90" : ""}`} />
|
||||||
|
Deliveries
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void testWebhook(hook.id)}
|
||||||
|
disabled={busy() === `test-hook-${hook.id}`}
|
||||||
|
>
|
||||||
|
<IconPlay class="w-4 h-4" />
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void toggleWebhook(hook)}
|
||||||
|
disabled={busy() === `toggle-${hook.id}`}
|
||||||
|
>
|
||||||
|
{hook.enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => void deleteWebhook(hook.id)}
|
||||||
|
disabled={busy() === `del-hook-${hook.id}`}
|
||||||
|
>
|
||||||
|
<IconTrash class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<Show when={expandedWebhook() === hook.id}>
|
||||||
|
<tr>
|
||||||
|
<td colspan={4} style="padding:0">
|
||||||
|
<DeliveriesPanel deliveries={deliveries()[hook.id]} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.canManage}>
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card-header-title">Deploy marker</div>
|
||||||
|
<div class="card-header-description">
|
||||||
|
Record a deployment and fire <code>deploy.marker</code> to subscribed webhooks.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={recordMarker} class="grid gap-3 sm:grid-cols-2 p-4">
|
||||||
|
<Input
|
||||||
|
label="Version"
|
||||||
|
placeholder="1.4.2"
|
||||||
|
value={markerForm().version}
|
||||||
|
onInput={(e) => setMarkerForm((c) => ({ ...c, version: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Ref"
|
||||||
|
placeholder="git sha or tag"
|
||||||
|
value={markerForm().ref}
|
||||||
|
onInput={(e) => setMarkerForm((c) => ({ ...c, ref: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Environment"
|
||||||
|
placeholder="production"
|
||||||
|
value={markerForm().environment}
|
||||||
|
onInput={(e) => setMarkerForm((c) => ({ ...c, environment: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Note"
|
||||||
|
placeholder="optional"
|
||||||
|
value={markerForm().note}
|
||||||
|
onInput={(e) => setMarkerForm((c) => ({ ...c, note: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled={busy() === "marker" || (!markerForm().version.trim() && !markerForm().ref.trim())}
|
||||||
|
>
|
||||||
|
Record deploy marker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Modal open={showCreateIntegration()} onClose={() => setShowCreateIntegration(false)} title="Add connector" size="md">
|
||||||
|
<form onSubmit={createIntegration} class="space-y-4">
|
||||||
|
<Select label="Type" value="rybbit" disabled options={[{ value: "rybbit", label: "Rybbit — web analytics" }]} />
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
placeholder="marketing-site"
|
||||||
|
required
|
||||||
|
value={integrationForm().name}
|
||||||
|
onInput={(e) => setIntegrationForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Base URL"
|
||||||
|
placeholder="https://analytics.example.com"
|
||||||
|
required
|
||||||
|
value={integrationForm().base_url}
|
||||||
|
onInput={(e) => setIntegrationForm((c) => ({ ...c, base_url: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="API key"
|
||||||
|
type="password"
|
||||||
|
placeholder="Stored encrypted — never shown again"
|
||||||
|
value={integrationForm().api_key}
|
||||||
|
onInput={(e) => setIntegrationForm((c) => ({ ...c, api_key: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Site ID"
|
||||||
|
placeholder="optional — defaults to the first site"
|
||||||
|
value={integrationForm().site_id}
|
||||||
|
onInput={(e) => setIntegrationForm((c) => ({ ...c, site_id: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateIntegration(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create-integration"}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={showCreateWebhook()} onClose={() => setShowCreateWebhook(false)} title="Add webhook" size="md">
|
||||||
|
<form onSubmit={createWebhook} class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Endpoint URL"
|
||||||
|
placeholder="https://hooks.example.com/primora"
|
||||||
|
required
|
||||||
|
value={webhookForm().url}
|
||||||
|
onInput={(e) => setWebhookForm((c) => ({ ...c, url: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Secret"
|
||||||
|
type="password"
|
||||||
|
placeholder="Leave empty to generate"
|
||||||
|
value={webhookForm().secret}
|
||||||
|
onInput={(e) => setWebhookForm((c) => ({ ...c, secret: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="label">Events</span>
|
||||||
|
<div class="flex gap-2 flex-wrap mt-1">
|
||||||
|
<For each={EVENT_TYPES}>
|
||||||
|
{(ev) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`btn btn-sm ${webhookForm().events.includes(ev) ? "btn-primary" : "btn-secondary"}`}
|
||||||
|
onClick={() => toggleEvent(ev)}
|
||||||
|
>
|
||||||
|
{ev}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<p class="text-text-2 text-xs mt-1">No selection means the endpoint receives every event.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateWebhook(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={busy() === "create-webhook"}>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsPanel(props: {
|
||||||
|
analytics?: IntegrationAnalytics;
|
||||||
|
loading: boolean;
|
||||||
|
site: string;
|
||||||
|
onSiteChange: (site: string) => void;
|
||||||
|
}) {
|
||||||
|
const stats = () => props.analytics?.overview;
|
||||||
|
const stat = (label: string, value?: number) => (
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-label">{label}</span>
|
||||||
|
<span class="stat-value">{fmtNum(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div class="p-4" style="background: var(--surface-2)">
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<p class="text-text-2 text-sm">Loading analytics…</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.loading && props.analytics}>
|
||||||
|
<Show when={(props.analytics?.sites?.length ?? 0) > 1}>
|
||||||
|
<div class="mb-3" style="max-width:20rem">
|
||||||
|
<Select label="Site" value={props.site} onChange={(e) => props.onSiteChange(e.currentTarget.value)}>
|
||||||
|
<For each={props.analytics?.sites ?? []}>
|
||||||
|
{(s) => (
|
||||||
|
<option value={s.site_id}>
|
||||||
|
{s.name}
|
||||||
|
{s.domain ? ` (${s.domain})` : ""}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
||||||
|
{stat("Sessions", stats()?.sessions)}
|
||||||
|
{stat("Pageviews", stats()?.pageviews)}
|
||||||
|
{stat("Users", stats()?.users)}
|
||||||
|
{stat("Pages/session", stats()?.pages_per_session)}
|
||||||
|
{stat("Bounce %", stats()?.bounce_rate)}
|
||||||
|
{stat("Session (s)", stats()?.session_duration)}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 mt-3">
|
||||||
|
<MetricTable title="Top pages" items={props.analytics?.top_pages} />
|
||||||
|
<MetricTable title="Top referrers" items={props.analytics?.top_referrers} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.loading && !props.analytics}>
|
||||||
|
<p class="text-text-2 text-sm">No analytics loaded.</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricTable(props: { title: string; items?: { value: string; count: number; percentage?: number }[] }) {
|
||||||
|
return (
|
||||||
|
<div class="card card-flush">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-title">{props.title}</div>
|
||||||
|
</div>
|
||||||
|
<Show when={(props.items?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm p-3">No data in window.</p>}>
|
||||||
|
<table class="table">
|
||||||
|
<tbody>
|
||||||
|
<For each={props.items}>
|
||||||
|
{(item) => (
|
||||||
|
<tr>
|
||||||
|
<td style="word-break:break-all">{item.value}</td>
|
||||||
|
<td class="text-text-2" style="text-align:right;white-space:nowrap">
|
||||||
|
{item.count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveriesPanel(props: { deliveries?: WebhookDelivery[] }) {
|
||||||
|
return (
|
||||||
|
<div class="p-4" style="background: var(--surface-2)">
|
||||||
|
<Show when={(props.deliveries?.length ?? 0) > 0} fallback={<p class="text-text-2 text-sm">No deliveries yet.</p>}>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Attempts</th>
|
||||||
|
<th>HTTP</th>
|
||||||
|
<th class="hidden md:table-cell">Error</th>
|
||||||
|
<th class="hidden md:table-cell">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={props.deliveries}>
|
||||||
|
{(d) => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>{d.event_type}</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge variant={statusVariant(d.status)}>{d.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td>{d.attempts}</td>
|
||||||
|
<td>{d.last_status_code ?? "—"}</td>
|
||||||
|
<td class="hidden md:table-cell text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
|
||||||
|
{d.last_error || "—"}
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell text-text-2">{fmtTime(d.delivered_at ?? d.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export { StoragePage } from "./StoragePage";
|
|||||||
export { CollectionsPage } from "./CollectionsPage";
|
export { CollectionsPage } from "./CollectionsPage";
|
||||||
export { DatabasesPage } from "./DatabasesPage";
|
export { DatabasesPage } from "./DatabasesPage";
|
||||||
export { TelemetryPage } from "./TelemetryPage";
|
export { TelemetryPage } from "./TelemetryPage";
|
||||||
|
export { IntegrationsPage } from "./IntegrationsPage";
|
||||||
export { SettingsPage } from "./SettingsPage";
|
export { SettingsPage } from "./SettingsPage";
|
||||||
export { AuditPage } from "./AuditPage";
|
export { AuditPage } from "./AuditPage";
|
||||||
export { AuthPage, type AuthUser } from "./AuthPage";
|
export { AuthPage, type AuthUser } from "./AuthPage";
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/mcp",
|
"name": "@primora/mcp",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
|
|||||||
+280
-1
@@ -4,12 +4,14 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreateBucketRequest,
|
CreateBucketRequest,
|
||||||
|
IntegrationsService,
|
||||||
OpenAPI,
|
OpenAPI,
|
||||||
OrganizationsService,
|
OrganizationsService,
|
||||||
PlatformService,
|
PlatformService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
StorageService,
|
StorageService,
|
||||||
TelemetryService,
|
TelemetryService,
|
||||||
|
WebhooksService,
|
||||||
} from "@primora/api-client";
|
} from "@primora/api-client";
|
||||||
|
|
||||||
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
import { configureClient, loadConfig, type McpConfig } from "./config.js";
|
||||||
@@ -69,7 +71,7 @@ async function apiFetch(path: string): Promise<Response> {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = new McpServer({ name: "primora", version: "0.2.0" });
|
const server = new McpServer({ name: "primora", version: "0.4.0" });
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"primora_whoami",
|
"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());
|
await server.connect(new StdioServerTransport());
|
||||||
Generated
+17
-17
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "primora",
|
"name": "primora",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "primora",
|
"name": "primora",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"apps/auth": {
|
"apps/auth": {
|
||||||
"name": "@primora/auth",
|
"name": "@primora/auth",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.19.4",
|
"@hono/node-server": "^1.19.4",
|
||||||
"@hono/zod-validator": "^0.7.2",
|
"@hono/zod-validator": "^0.7.2",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"apps/cli": {
|
"apps/cli": {
|
||||||
"name": "@primora/cli",
|
"name": "@primora/cli",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.8.1",
|
"@clack/prompts": "^1.8.1",
|
||||||
"cac": "^7.0.0",
|
"cac": "^7.0.0",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"primora": "dist/cli.js"
|
"primora": "dist/cli.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
@@ -67,10 +67,10 @@
|
|||||||
},
|
},
|
||||||
"apps/frontend": {
|
"apps/frontend": {
|
||||||
"name": "@primora/frontend",
|
"name": "@primora/frontend",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@primora/shared-types": "0.3.0",
|
"@primora/shared-types": "0.4.0",
|
||||||
"better-auth": "^1.5.6",
|
"better-auth": "^1.5.6",
|
||||||
"solid-js": "^1.9.12"
|
"solid-js": "^1.9.12"
|
||||||
},
|
},
|
||||||
@@ -89,9 +89,13 @@
|
|||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/frontend/node_modules/@primora/shared-types": {
|
||||||
|
"resolved": "packages/shared-types",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"apps/mcp": {
|
"apps/mcp": {
|
||||||
"name": "@primora/mcp",
|
"name": "@primora/mcp",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
@@ -100,7 +104,7 @@
|
|||||||
"primora-mcp": "dist/server.js"
|
"primora-mcp": "dist/server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@primora/api-client": "0.3.0",
|
"@primora/api-client": "0.4.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"esbuild": "^0.27.4",
|
"esbuild": "^0.27.4",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
@@ -1365,10 +1369,6 @@
|
|||||||
"resolved": "apps/mcp",
|
"resolved": "apps/mcp",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@primora/shared-types": {
|
|
||||||
"resolved": "packages/shared-types",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@redis/bloom": {
|
"node_modules/@redis/bloom": {
|
||||||
"version": "5.11.0",
|
"version": "5.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.11.0.tgz",
|
||||||
@@ -6787,7 +6787,7 @@
|
|||||||
},
|
},
|
||||||
"packages/api-client": {
|
"packages/api-client": {
|
||||||
"name": "@primora/api-client",
|
"name": "@primora/api-client",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-fetch": "^4.1.0"
|
"cross-fetch": "^4.1.0"
|
||||||
},
|
},
|
||||||
@@ -6797,11 +6797,11 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@primora/client",
|
"name": "@primora/client",
|
||||||
"version": "0.3.0"
|
"version": "0.4.0"
|
||||||
},
|
},
|
||||||
"packages/shared-types": {
|
"packages/shared-types": {
|
||||||
"name": "@primora/shared-types",
|
"name": "@primora/shared-types",
|
||||||
"version": "0.3.0"
|
"version": "0.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "primora",
|
"name": "primora",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/api-client",
|
"name": "@primora/api-client",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -32,10 +32,13 @@ export { CreateBucketRequest } from './models/CreateBucketRequest';
|
|||||||
export type { CreateCollectionRequest } from './models/CreateCollectionRequest';
|
export type { CreateCollectionRequest } from './models/CreateCollectionRequest';
|
||||||
export type { CreatedApiKey } from './models/CreatedApiKey';
|
export type { CreatedApiKey } from './models/CreatedApiKey';
|
||||||
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
||||||
|
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
||||||
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
||||||
|
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
||||||
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
||||||
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
|
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
|
||||||
export type { CreateProjectRequest } from './models/CreateProjectRequest';
|
export type { CreateProjectRequest } from './models/CreateProjectRequest';
|
||||||
|
export type { CreateWebhookRequest } from './models/CreateWebhookRequest';
|
||||||
export type { DatabaseQuery } from './models/DatabaseQuery';
|
export type { DatabaseQuery } from './models/DatabaseQuery';
|
||||||
export type { DBConnection } from './models/DBConnection';
|
export type { DBConnection } from './models/DBConnection';
|
||||||
export type { DBConnectionListResponse } from './models/DBConnectionListResponse';
|
export type { DBConnectionListResponse } from './models/DBConnectionListResponse';
|
||||||
@@ -53,6 +56,7 @@ export type { DBRedisResponse } from './models/DBRedisResponse';
|
|||||||
export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse';
|
export type { DBSchemaContextResponse } from './models/DBSchemaContextResponse';
|
||||||
export type { DBTableListResponse } from './models/DBTableListResponse';
|
export type { DBTableListResponse } from './models/DBTableListResponse';
|
||||||
export type { DBXStatus } from './models/DBXStatus';
|
export type { DBXStatus } from './models/DBXStatus';
|
||||||
|
export type { DeployMarker } from './models/DeployMarker';
|
||||||
export type { Document } from './models/Document';
|
export type { Document } from './models/Document';
|
||||||
export type { DocumentID } from './models/DocumentID';
|
export type { DocumentID } from './models/DocumentID';
|
||||||
export type { DocumentListResponse } from './models/DocumentListResponse';
|
export type { DocumentListResponse } from './models/DocumentListResponse';
|
||||||
@@ -62,6 +66,11 @@ export type { HealthStatus } from './models/HealthStatus';
|
|||||||
export { IngestEvent } from './models/IngestEvent';
|
export { IngestEvent } from './models/IngestEvent';
|
||||||
export type { IngestRequest } from './models/IngestRequest';
|
export type { IngestRequest } from './models/IngestRequest';
|
||||||
export type { IngestResponse } from './models/IngestResponse';
|
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 { Invitation } from './models/Invitation';
|
||||||
export type { InvitationID } from './models/InvitationID';
|
export type { InvitationID } from './models/InvitationID';
|
||||||
export type { MeResponse } from './models/MeResponse';
|
export type { MeResponse } from './models/MeResponse';
|
||||||
@@ -82,6 +91,8 @@ export { ProjectMember } from './models/ProjectMember';
|
|||||||
export type { ProjectOverview } from './models/ProjectOverview';
|
export type { ProjectOverview } from './models/ProjectOverview';
|
||||||
export type { ProjectSummary } from './models/ProjectSummary';
|
export type { ProjectSummary } from './models/ProjectSummary';
|
||||||
export type { ReadinessStatus } from './models/ReadinessStatus';
|
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 { SchemaQuery } from './models/SchemaQuery';
|
||||||
export type { TableQuery } from './models/TableQuery';
|
export type { TableQuery } from './models/TableQuery';
|
||||||
export type { TelemetryComponent } from './models/TelemetryComponent';
|
export type { TelemetryComponent } from './models/TelemetryComponent';
|
||||||
@@ -99,8 +110,15 @@ export { UpdateOrganizationMemberRoleRequest } from './models/UpdateOrganization
|
|||||||
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
export type { UpdateOrganizationRequest } from './models/UpdateOrganizationRequest';
|
||||||
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
|
export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRoleRequest';
|
||||||
export type { UpdateProjectRequest } from './models/UpdateProjectRequest';
|
export type { UpdateProjectRequest } from './models/UpdateProjectRequest';
|
||||||
|
export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest';
|
||||||
export type { UserID } from './models/UserID';
|
export type { UserID } from './models/UserID';
|
||||||
export type { UserSummary } from './models/UserSummary';
|
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 { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest';
|
||||||
export { $ApiKey } from './schemas/$ApiKey';
|
export { $ApiKey } from './schemas/$ApiKey';
|
||||||
@@ -127,10 +145,13 @@ export { $CreateBucketRequest } from './schemas/$CreateBucketRequest';
|
|||||||
export { $CreateCollectionRequest } from './schemas/$CreateCollectionRequest';
|
export { $CreateCollectionRequest } from './schemas/$CreateCollectionRequest';
|
||||||
export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
||||||
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
||||||
|
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
||||||
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
||||||
|
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
||||||
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
||||||
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
|
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
|
||||||
export { $CreateProjectRequest } from './schemas/$CreateProjectRequest';
|
export { $CreateProjectRequest } from './schemas/$CreateProjectRequest';
|
||||||
|
export { $CreateWebhookRequest } from './schemas/$CreateWebhookRequest';
|
||||||
export { $DatabaseQuery } from './schemas/$DatabaseQuery';
|
export { $DatabaseQuery } from './schemas/$DatabaseQuery';
|
||||||
export { $DBConnection } from './schemas/$DBConnection';
|
export { $DBConnection } from './schemas/$DBConnection';
|
||||||
export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse';
|
export { $DBConnectionListResponse } from './schemas/$DBConnectionListResponse';
|
||||||
@@ -148,6 +169,7 @@ export { $DBRedisResponse } from './schemas/$DBRedisResponse';
|
|||||||
export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse';
|
export { $DBSchemaContextResponse } from './schemas/$DBSchemaContextResponse';
|
||||||
export { $DBTableListResponse } from './schemas/$DBTableListResponse';
|
export { $DBTableListResponse } from './schemas/$DBTableListResponse';
|
||||||
export { $DBXStatus } from './schemas/$DBXStatus';
|
export { $DBXStatus } from './schemas/$DBXStatus';
|
||||||
|
export { $DeployMarker } from './schemas/$DeployMarker';
|
||||||
export { $Document } from './schemas/$Document';
|
export { $Document } from './schemas/$Document';
|
||||||
export { $DocumentID } from './schemas/$DocumentID';
|
export { $DocumentID } from './schemas/$DocumentID';
|
||||||
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
||||||
@@ -157,6 +179,11 @@ export { $HealthStatus } from './schemas/$HealthStatus';
|
|||||||
export { $IngestEvent } from './schemas/$IngestEvent';
|
export { $IngestEvent } from './schemas/$IngestEvent';
|
||||||
export { $IngestRequest } from './schemas/$IngestRequest';
|
export { $IngestRequest } from './schemas/$IngestRequest';
|
||||||
export { $IngestResponse } from './schemas/$IngestResponse';
|
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 { $Invitation } from './schemas/$Invitation';
|
||||||
export { $InvitationID } from './schemas/$InvitationID';
|
export { $InvitationID } from './schemas/$InvitationID';
|
||||||
export { $MeResponse } from './schemas/$MeResponse';
|
export { $MeResponse } from './schemas/$MeResponse';
|
||||||
@@ -177,6 +204,8 @@ export { $ProjectMember } from './schemas/$ProjectMember';
|
|||||||
export { $ProjectOverview } from './schemas/$ProjectOverview';
|
export { $ProjectOverview } from './schemas/$ProjectOverview';
|
||||||
export { $ProjectSummary } from './schemas/$ProjectSummary';
|
export { $ProjectSummary } from './schemas/$ProjectSummary';
|
||||||
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
||||||
|
export { $RybbitMetricItem } from './schemas/$RybbitMetricItem';
|
||||||
|
export { $RybbitSite } from './schemas/$RybbitSite';
|
||||||
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
||||||
export { $TableQuery } from './schemas/$TableQuery';
|
export { $TableQuery } from './schemas/$TableQuery';
|
||||||
export { $TelemetryComponent } from './schemas/$TelemetryComponent';
|
export { $TelemetryComponent } from './schemas/$TelemetryComponent';
|
||||||
@@ -194,14 +223,23 @@ export { $UpdateOrganizationMemberRoleRequest } from './schemas/$UpdateOrganizat
|
|||||||
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
export { $UpdateOrganizationRequest } from './schemas/$UpdateOrganizationRequest';
|
||||||
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
|
export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberRoleRequest';
|
||||||
export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest';
|
export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest';
|
||||||
|
export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest';
|
||||||
export { $UserID } from './schemas/$UserID';
|
export { $UserID } from './schemas/$UserID';
|
||||||
export { $UserSummary } from './schemas/$UserSummary';
|
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 { CollectionsService } from './services/CollectionsService';
|
||||||
export { DatabasesService } from './services/DatabasesService';
|
export { DatabasesService } from './services/DatabasesService';
|
||||||
export { HealthService } from './services/HealthService';
|
export { HealthService } from './services/HealthService';
|
||||||
|
export { IntegrationsService } from './services/IntegrationsService';
|
||||||
export { OrganizationsService } from './services/OrganizationsService';
|
export { OrganizationsService } from './services/OrganizationsService';
|
||||||
export { PlatformService } from './services/PlatformService';
|
export { PlatformService } from './services/PlatformService';
|
||||||
export { ProjectsService } from './services/ProjectsService';
|
export { ProjectsService } from './services/ProjectsService';
|
||||||
export { StorageService } from './services/StorageService';
|
export { StorageService } from './services/StorageService';
|
||||||
export { TelemetryService } from './services/TelemetryService';
|
export { TelemetryService } from './services/TelemetryService';
|
||||||
|
export { WebhooksService } from './services/WebhooksService';
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<RybbitSite>;
|
||||||
|
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<RybbitMetricItem>;
|
||||||
|
top_referrers?: Array<RybbitMetricItem>;
|
||||||
|
window_days: number;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type IntegrationID = string;
|
||||||
@@ -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<Integration>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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<string>;
|
||||||
|
enabled: boolean;
|
||||||
|
has_secret: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<string, any>;
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<WebhookDelivery>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* generated using openapi-typescript-codegen -- do not edit */
|
||||||
|
/* istanbul ignore file */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export type WebhookID = string;
|
||||||
@@ -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<Webhook>;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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<IntegrationListResponse> {
|
||||||
|
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<Integration> {
|
||||||
|
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<void> {
|
||||||
|
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<IntegrationTestResult> {
|
||||||
|
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<IntegrationAnalytics> {
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WebhookListResponse> {
|
||||||
|
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=<HMAC-SHA256 hex of the raw body, keyed by the webhook secret>). 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<WebhookCreateResponse> {
|
||||||
|
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<WebhookCreateResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<WebhookDeliveryListResponse> {
|
||||||
|
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<WebhookDelivery> {
|
||||||
|
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<DeployMarker> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/projects/{projectID}/deploy-markers',
|
||||||
|
path: {
|
||||||
|
'projectID': projectId,
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
401: `Error response`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/client",
|
"name": "@primora/client",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@primora/shared-types",
|
"name": "@primora/shared-types",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
Reference in new issue
Block a user