From 2f4de543a9636fefe5eb72ff499ac23a3b307d0d Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:09:19 +0200 Subject: [PATCH 01/18] feat: telemetry alert rules with webhook delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.alert_rules (migration 00012) — heartbeat_silence and error_spike rules evaluated by a 60s backend ticker. Transition-based firing: alert.fired on breach, alert.resolved on recovery, both first-class webhook event types riding the signed dispatcher + realtime stream. Dashboard manages rules on the Integrations page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ROADMAP.md | 15 +- .../db/migrations/00012_alert_rules.sql | 16 + apps/backend/db/queries/alert_rules.sql | 49 +++ .../internal/database/db/alert_rules.sql.go | 290 ++++++++++++++ apps/backend/internal/database/db/models.go | 12 + apps/backend/internal/database/db/querier.go | 11 + apps/backend/internal/handlers/alerts.go | 97 +++++ apps/backend/internal/handlers/http.go | 5 + apps/backend/internal/services/alerting.go | 368 ++++++++++++++++++ .../internal/services/alerting_test.go | 162 ++++++++ .../internal/services/platform_service.go | 6 + apps/backend/internal/services/webhooks.go | 4 + apps/backend/openapi/openapi.yaml | 174 ++++++++- apps/frontend/src/pages/IntegrationsPage.tsx | 173 +++++++- packages/api-client/src/generated/index.ts | 9 + .../src/generated/models/AlertRule.ts | 26 ++ .../src/generated/models/AlertRuleConfig.ts | 23 ++ .../generated/models/AlertRuleListResponse.ts | 9 + .../generated/models/CreateWebhookRequest.ts | 2 +- .../generated/models/UpdateWebhookRequest.ts | 2 +- .../models/UpsertAlertRuleRequest.ts | 21 + .../src/generated/models/Webhook.ts | 2 +- .../src/generated/schemas/$AlertRule.ts | 51 +++ .../src/generated/schemas/$AlertRuleConfig.ts | 25 ++ .../schemas/$AlertRuleListResponse.ts | 15 + .../schemas/$UpsertAlertRuleRequest.ts | 23 ++ .../src/generated/services/AlertsService.ts | 112 ++++++ 28 files changed, 1693 insertions(+), 10 deletions(-) create mode 100644 apps/backend/db/migrations/00012_alert_rules.sql create mode 100644 apps/backend/db/queries/alert_rules.sql create mode 100644 apps/backend/internal/database/db/alert_rules.sql.go create mode 100644 apps/backend/internal/handlers/alerts.go create mode 100644 apps/backend/internal/services/alerting.go create mode 100644 apps/backend/internal/services/alerting_test.go create mode 100644 packages/api-client/src/generated/models/AlertRule.ts create mode 100644 packages/api-client/src/generated/models/AlertRuleConfig.ts create mode 100644 packages/api-client/src/generated/models/AlertRuleListResponse.ts create mode 100644 packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$AlertRule.ts create mode 100644 packages/api-client/src/generated/schemas/$AlertRuleConfig.ts create mode 100644 packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts create mode 100644 packages/api-client/src/generated/services/AlertsService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9328929..91ad15b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values. - **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext. - **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`. +- **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index 42c309c..1ff7f87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -307,6 +307,19 @@ Channel subscriptions over the Phase 7 SSE stream, in `@primora/client`. - Presence and client-broadcast deliberately deferred — protocol question, no consumer yet. +## Notifications — alert rules (unreleased) + +Telemetry-driven alerts delivered through the existing webhook dispatcher. + +- `core.alert_rules` (migration 00012) — `heartbeat_silence` and + `error_spike` kinds, per-component or project-wide scope, evaluated by a + 60 s backend ticker. +- Transition-based firing: `alert.fired` once on breach, `alert.resolved` + on recovery — no notification storms. Both are webhook event types and + realtime events, so any existing subscriber receives them signed. +- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to + webhooks and shows live `firing` state. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. @@ -317,8 +330,6 @@ Ordered loosely by leverage. None committed; each gets scoped when picked. - **Phase 10 — Storage backends** — S3-compatible object storage behind the existing bucket abstraction (deferred from Phase 6), plus presigned-URL uploads for large files. -- **Notifications** — alert rules on telemetry (error-rate thresholds, - heartbeat silence) fanning out through the webhook dispatcher. - **Inbound webhooks** — project-scoped ingest endpoints that turn external events into domain events/jobs. - **HA mode** — leader-elected scheduler + distributed locks so two diff --git a/apps/backend/db/migrations/00012_alert_rules.sql b/apps/backend/db/migrations/00012_alert_rules.sql new file mode 100644 index 0000000..e4dc5b1 --- /dev/null +++ b/apps/backend/db/migrations/00012_alert_rules.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE core.alert_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('heartbeat_silence','error_spike')), + config JSONB NOT NULL DEFAULT '{}'::jsonb, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + state JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +-- +goose Down +DROP TABLE IF EXISTS core.alert_rules; diff --git a/apps/backend/db/queries/alert_rules.sql b/apps/backend/db/queries/alert_rules.sql new file mode 100644 index 0000000..3e46d70 --- /dev/null +++ b/apps/backend/db/queries/alert_rules.sql @@ -0,0 +1,49 @@ +-- name: CreateAlertRule :one +INSERT INTO core.alert_rules (project_id, name, kind, config) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: ListAlertRules :many +SELECT * FROM core.alert_rules +WHERE project_id = $1 +ORDER BY name; + +-- name: ListEnabledAlertRules :many +SELECT * FROM core.alert_rules +WHERE enabled; + +-- name: GetAlertRule :one +SELECT * FROM core.alert_rules +WHERE id = $1 AND project_id = $2; + +-- name: UpdateAlertRule :one +UPDATE core.alert_rules +SET name = $3, config = $4, enabled = $5, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING *; + +-- name: SetAlertRuleState :exec +UPDATE core.alert_rules +SET state = $3, updated_at = NOW() +WHERE id = $1 AND project_id = $2; + +-- name: DeleteAlertRule :exec +DELETE FROM core.alert_rules +WHERE id = $1 AND project_id = $2; + +-- Per-component most recent heartbeat — heartbeat_silence detection. +-- name: LastHeartbeats :many +SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat +FROM core.components co +JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat' +WHERE co.project_id = $1 +GROUP BY co.name; + +-- Per-component error count inside a sliding window — error_spike detection. +-- name: RecentErrorCounts :many +SELECT co.name AS component_name, count(*) AS errors +FROM core.events e +JOIN core.components co ON co.id = e.component_id +WHERE e.project_id = $1 AND e.type = 'error' + AND e.ts > now() - make_interval(secs => $2::bigint) +GROUP BY co.name; diff --git a/apps/backend/internal/database/db/alert_rules.sql.go b/apps/backend/internal/database/db/alert_rules.sql.go new file mode 100644 index 0000000..5b1d4ca --- /dev/null +++ b/apps/backend/internal/database/db/alert_rules.sql.go @@ -0,0 +1,290 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: alert_rules.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createAlertRule = `-- name: CreateAlertRule :one +INSERT INTO core.alert_rules (project_id, name, kind, config) +VALUES ($1, $2, $3, $4) +RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at +` + +type CreateAlertRuleParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config []byte `json:"config"` +} + +func (q *Queries) CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, createAlertRule, + arg.ProjectID, + arg.Name, + arg.Kind, + arg.Config, + ) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAlertRule = `-- name: DeleteAlertRule :exec +DELETE FROM core.alert_rules +WHERE id = $1 AND project_id = $2 +` + +type DeleteAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error { + _, err := q.db.Exec(ctx, deleteAlertRule, arg.ID, arg.ProjectID) + return err +} + +const getAlertRule = `-- name: GetAlertRule :one +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE id = $1 AND project_id = $2 +` + +type GetAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, getAlertRule, arg.ID, arg.ProjectID) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const lastHeartbeats = `-- name: LastHeartbeats :many +SELECT co.name AS component_name, max(e.ts)::timestamptz AS last_beat +FROM core.components co +JOIN core.events e ON e.component_id = co.id AND e.type = 'heartbeat' +WHERE co.project_id = $1 +GROUP BY co.name +` + +type LastHeartbeatsRow struct { + ComponentName string `json:"component_name"` + LastBeat pgtype.Timestamptz `json:"last_beat"` +} + +// Per-component most recent heartbeat — heartbeat_silence detection. +func (q *Queries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) { + rows, err := q.db.Query(ctx, lastHeartbeats, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LastHeartbeatsRow + for rows.Next() { + var i LastHeartbeatsRow + if err := rows.Scan(&i.ComponentName, &i.LastBeat); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlertRules = `-- name: ListAlertRules :many +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE project_id = $1 +ORDER BY name +` + +func (q *Queries) ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) { + rows, err := q.db.Query(ctx, listAlertRules, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreAlertRule + for rows.Next() { + var i CoreAlertRule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &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 listEnabledAlertRules = `-- name: ListEnabledAlertRules :many +SELECT id, project_id, name, kind, config, enabled, state, created_at, updated_at FROM core.alert_rules +WHERE enabled +` + +func (q *Queries) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) { + rows, err := q.db.Query(ctx, listEnabledAlertRules) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreAlertRule + for rows.Next() { + var i CoreAlertRule + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &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 recentErrorCounts = `-- name: RecentErrorCounts :many +SELECT co.name AS component_name, count(*) AS errors +FROM core.events e +JOIN core.components co ON co.id = e.component_id +WHERE e.project_id = $1 AND e.type = 'error' + AND e.ts > now() - make_interval(secs => $2::bigint) +GROUP BY co.name +` + +type RecentErrorCountsParams struct { + ProjectID uuid.UUID `json:"project_id"` + Column2 int64 `json:"column_2"` +} + +type RecentErrorCountsRow struct { + ComponentName string `json:"component_name"` + Errors int64 `json:"errors"` +} + +// Per-component error count inside a sliding window — error_spike detection. +func (q *Queries) RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) { + rows, err := q.db.Query(ctx, recentErrorCounts, arg.ProjectID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecentErrorCountsRow + for rows.Next() { + var i RecentErrorCountsRow + if err := rows.Scan(&i.ComponentName, &i.Errors); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setAlertRuleState = `-- name: SetAlertRuleState :exec +UPDATE core.alert_rules +SET state = $3, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +` + +type SetAlertRuleStateParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + State []byte `json:"state"` +} + +func (q *Queries) SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error { + _, err := q.db.Exec(ctx, setAlertRuleState, arg.ID, arg.ProjectID, arg.State) + return err +} + +const updateAlertRule = `-- name: UpdateAlertRule :one +UPDATE core.alert_rules +SET name = $3, config = $4, enabled = $5, updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING id, project_id, name, kind, config, enabled, state, created_at, updated_at +` + +type UpdateAlertRuleParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Config []byte `json:"config"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) { + row := q.db.QueryRow(ctx, updateAlertRule, + arg.ID, + arg.ProjectID, + arg.Name, + arg.Config, + arg.Enabled, + ) + var i CoreAlertRule + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Kind, + &i.Config, + &i.Enabled, + &i.State, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 9f97578..ac84b38 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -140,6 +140,18 @@ func (ns NullCoreProjectRole) Value() (driver.Value, error) { return string(ns.CoreProjectRole), nil } +type CoreAlertRule struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config []byte `json:"config"` + Enabled bool `json:"enabled"` + State []byte `json:"state"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreApiKey struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 902086a..8f67465 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -23,6 +23,7 @@ type Querier interface { CountOrganizations(ctx context.Context) (int64, error) CountProjectAdmins(ctx context.Context, projectID uuid.UUID) (int64, error) CreateAPIKey(ctx context.Context, arg CreateAPIKeyParams) (CoreApiKey, error) + CreateAlertRule(ctx context.Context, arg CreateAlertRuleParams) (CoreAlertRule, error) CreateAuditLog(ctx context.Context, arg CreateAuditLogParams) (CoreAuditLog, error) CreateBucket(ctx context.Context, arg CreateBucketParams) (CoreBucket, error) CreateBucketObject(ctx context.Context, arg CreateBucketObjectParams) (CoreBucketObject, error) @@ -35,6 +36,7 @@ type Querier interface { CreateProject(ctx context.Context, arg CreateProjectParams) (CoreProject, error) CreateScheduledJob(ctx context.Context, arg CreateScheduledJobParams) (CoreScheduledJob, error) CreateWebhook(ctx context.Context, arg CreateWebhookParams) (CoreWebhook, error) + DeleteAlertRule(ctx context.Context, arg DeleteAlertRuleParams) error DeleteBucketByID(ctx context.Context, id uuid.UUID) (CoreBucket, error) DeleteBucketObjectByKey(ctx context.Context, arg DeleteBucketObjectByKeyParams) (CoreBucketObject, error) DeleteCollection(ctx context.Context, arg DeleteCollectionParams) error @@ -54,6 +56,7 @@ type Querier interface { FinishScheduledJobRun(ctx context.Context, arg FinishScheduledJobRunParams) (CoreScheduledJobRun, error) GetAPIKeyByIDForProject(ctx context.Context, arg GetAPIKeyByIDForProjectParams) (CoreApiKey, error) GetAPIKeyByPrefix(ctx context.Context, prefix string) (GetAPIKeyByPrefixRow, error) + GetAlertRule(ctx context.Context, arg GetAlertRuleParams) (CoreAlertRule, error) GetBucketByID(ctx context.Context, id uuid.UUID) (GetBucketByIDRow, error) GetBucketObjectByKey(ctx context.Context, arg GetBucketObjectByKeyParams) (CoreBucketObject, error) GetCollectionByID(ctx context.Context, id uuid.UUID) (CoreCollection, error) @@ -80,7 +83,10 @@ type Querier interface { InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) + // Per-component most recent heartbeat — heartbeat_silence detection. + LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]LastHeartbeatsRow, error) ListAPIKeysForProject(ctx context.Context, projectID uuid.UUID) ([]CoreApiKey, error) + ListAlertRules(ctx context.Context, projectID uuid.UUID) ([]CoreAlertRule, error) ListAuditLogsForProject(ctx context.Context, arg ListAuditLogsForProjectParams) ([]CoreAuditLog, error) ListBucketObjects(ctx context.Context, arg ListBucketObjectsParams) ([]CoreBucketObject, error) ListBucketsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]uuid.UUID, error) @@ -90,6 +96,7 @@ type Querier interface { ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) + ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) @@ -114,10 +121,13 @@ type Querier interface { MetricSeries(ctx context.Context, arg MetricSeriesParams) ([]MetricSeriesRow, error) MoveBucketObject(ctx context.Context, arg MoveBucketObjectParams) (CoreBucketObject, error) PruneScheduledJobRuns(ctx context.Context, arg PruneScheduledJobRunsParams) error + // Per-component error count inside a sliding window — error_spike detection. + RecentErrorCounts(ctx context.Context, arg RecentErrorCountsParams) ([]RecentErrorCountsRow, error) RemoveOrganizationMember(ctx context.Context, arg RemoveOrganizationMemberParams) (CoreOrganizationMember, error) RemoveProjectMember(ctx context.Context, arg RemoveProjectMemberParams) (CoreProjectMember, error) RemoveProjectMembershipsForOrganizationUser(ctx context.Context, arg RemoveProjectMembershipsForOrganizationUserParams) error RevokeAPIKey(ctx context.Context, arg RevokeAPIKeyParams) (CoreApiKey, error) + SetAlertRuleState(ctx context.Context, arg SetAlertRuleStateParams) error SweepExpiredAuditLogs(ctx context.Context) (int64, error) // Per-project retention sweeps. A retention of 0 days disables the sweep for // that stream. Pending webhook deliveries are never swept — they may still be @@ -128,6 +138,7 @@ type Querier interface { SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) TouchAPIKey(ctx context.Context, id uuid.UUID) error + UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) diff --git a/apps/backend/internal/handlers/alerts.go b/apps/backend/internal/handlers/alerts.go new file mode 100644 index 0000000..d0ee024 --- /dev/null +++ b/apps/backend/internal/handlers/alerts.go @@ -0,0 +1,97 @@ +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" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) alertError(c *gin.Context, err error) { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "alert rule not found") + return + } + msg := err.Error() + if strings.Contains(msg, "invalid") || strings.Contains(msg, "must be") || strings.Contains(msg, "already exists") || strings.Contains(msg, "too long") { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg) + return + } + h.handleError(c, err) +} + +func (h *HTTPHandler) listAlertRules(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListAlertRules(c.Request.Context(), actor, projectID) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) createAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.UpsertAlertRuleInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.CreateAlertRule(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusCreated, result) +} + +func (h *HTTPHandler) updateAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + ruleID, err := uuid.Parse(c.Param("ruleID")) + if err != nil { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id") + return + } + var body services.UpsertAlertRuleInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.UpdateAlertRule(c.Request.Context(), actor, projectID, ruleID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.alertError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) deleteAlertRule(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + ruleID, err := uuid.Parse(c.Param("ruleID")) + if err != nil { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid alert rule id") + return + } + if err := h.Platform.DeleteAlertRule(c.Request.Context(), actor, projectID, ruleID, middleware.RequestIDFromContext(c)); err != nil { + h.alertError(c, err) + return + } + c.Status(http.StatusNoContent) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 672aa00..4deea1a 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -121,6 +121,11 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) + + api.GET("/projects/:projectID/alerts", h.listAlertRules) + api.POST("/projects/:projectID/alerts", h.createAlertRule) + api.PUT("/projects/:projectID/alerts/:ruleID", h.updateAlertRule) + api.DELETE("/projects/:projectID/alerts/:ruleID", h.deleteAlertRule) api.GET("/projects/:projectID/realtime/stream", h.realtimeStream) api.GET("/buckets/:bucketID/objects", h.listObjects) api.POST("/buckets/:bucketID/objects", h.uploadObject) diff --git a/apps/backend/internal/services/alerting.go b/apps/backend/internal/services/alerting.go new file mode 100644 index 0000000..587a3d4 --- /dev/null +++ b/apps/backend/internal/services/alerting.go @@ -0,0 +1,368 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/google/uuid" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/models" +) + +// Alert rules detect conditions in telemetry (component heartbeat silence, +// error-rate spikes) and fire `alert.fired` / `alert.resolved` domain events. +// Delivery reuses the webhook dispatcher + realtime stream — the evaluator is +// the only new machinery. Rules fire on state transitions only, so a flaky +// component produces one fired + one resolved, not a storm. + +const ( + AlertEventFired = "alert.fired" + AlertEventResolved = "alert.resolved" + + AlertKindHeartbeatSilence = "heartbeat_silence" + AlertKindErrorSpike = "error_spike" +) + +// AlertRuleConfig is the JSONB config per rule kind. +// +// heartbeat_silence: component beats less often than ThresholdMinutes. +// error_spike: component logs more than ThresholdCount errors in +// WindowMinutes. +// Component empty = evaluate every component in the project. +type AlertRuleConfig struct { + Component string `json:"component,omitempty"` + ThresholdMinutes int `json:"threshold_minutes,omitempty"` + ThresholdCount int64 `json:"threshold_count,omitempty"` + WindowMinutes int `json:"window_minutes,omitempty"` +} + +type AlertRuleSummary struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Kind string `json:"kind"` + Config AlertRuleConfig `json:"config"` + Enabled bool `json:"enabled"` + Firing []string `json:"firing"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertAlertRuleInput struct { + Name string `json:"name" validate:"required"` + Kind string `json:"kind" validate:"required"` + Config AlertRuleConfig `json:"config"` + Enabled *bool `json:"enabled"` +} + +func (s *PlatformService) validateAlertRule(input UpsertAlertRuleInput) (AlertRuleConfig, error) { + if !projectSecretNameRe.MatchString(input.Name) { + return AlertRuleConfig{}, fmt.Errorf("invalid alert rule name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name) + } + cfg := input.Config + switch input.Kind { + case AlertKindHeartbeatSilence: + if cfg.ThresholdMinutes < 1 || cfg.ThresholdMinutes > 10080 { + return cfg, errors.New("threshold_minutes must be 1–10080") + } + case AlertKindErrorSpike: + if cfg.ThresholdCount < 1 { + return cfg, errors.New("threshold_count must be ≥ 1") + } + if cfg.WindowMinutes == 0 { + cfg.WindowMinutes = 15 + } + if cfg.WindowMinutes < 1 || cfg.WindowMinutes > 1440 { + return cfg, errors.New("window_minutes must be 1–1440") + } + default: + return cfg, fmt.Errorf("invalid kind %q — %s or %s", input.Kind, AlertKindHeartbeatSilence, AlertKindErrorSpike) + } + if cfg.Component != "" && len(cfg.Component) > 128 { + return cfg, errors.New("component name too long") + } + return cfg, nil +} + +func alertRuleSummary(row db.CoreAlertRule) AlertRuleSummary { + var cfg AlertRuleConfig + _ = json.Unmarshal(row.Config, &cfg) + firing := parseFiring(row.State) + return AlertRuleSummary{ + ID: row.ID.String(), + ProjectID: row.ProjectID.String(), + Name: row.Name, + Kind: row.Kind, + Config: cfg, + Enabled: row.Enabled, + Firing: firing.list(), + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (s *PlatformService) ListAlertRules(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListAlertRules(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]AlertRuleSummary, len(rows)) + for i, r := range rows { + out[i] = alertRuleSummary(r) + } + return out, nil +} + +func (s *PlatformService) CreateAlertRule(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return AlertRuleSummary{}, err + } + cfg, err := s.validateAlertRule(input) + if err != nil { + return AlertRuleSummary{}, err + } + rawCfg, _ := json.Marshal(cfg) + row, err := s.repo.Queries().CreateAlertRule(ctx, db.CreateAlertRuleParams{ + ProjectID: projectID, + Name: input.Name, + Kind: input.Kind, + Config: rawCfg, + }) + if err != nil { + if strings.Contains(err.Error(), "alert_rules_project_id_name_key") { + return AlertRuleSummary{}, fmt.Errorf("an alert rule named %s already exists", input.Name) + } + return AlertRuleSummary{}, err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.created", row.ID, input.Name) + return alertRuleSummary(row), nil +} + +func (s *PlatformService) UpdateAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, input UpsertAlertRuleInput, requestID string) (AlertRuleSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return AlertRuleSummary{}, err + } + if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return AlertRuleSummary{}, err // pgx.ErrNoRows → 404 at the handler + } + cfg, err := s.validateAlertRule(input) + if err != nil { + return AlertRuleSummary{}, err + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + rawCfg, _ := json.Marshal(cfg) + row, err := s.repo.Queries().UpdateAlertRule(ctx, db.UpdateAlertRuleParams{ + ID: ruleID, ProjectID: projectID, Name: input.Name, Config: rawCfg, Enabled: enabled, + }) + if err != nil { + return AlertRuleSummary{}, err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.updated", row.ID, input.Name) + return alertRuleSummary(row), nil +} + +func (s *PlatformService) DeleteAlertRule(ctx context.Context, actor *models.Actor, projectID, ruleID uuid.UUID, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + if _, err := s.repo.Queries().GetAlertRule(ctx, db.GetAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return err + } + if err := s.repo.Queries().DeleteAlertRule(ctx, db.DeleteAlertRuleParams{ID: ruleID, ProjectID: projectID}); err != nil { + return err + } + s.auditAlert(ctx, actor, projectID, requestID, "alert.deleted", ruleID, "") + return nil +} + +func (s *PlatformService) auditAlert(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID, action string, ruleID uuid.UUID, name string) { + project, err := s.repo.Queries().GetProjectByID(ctx, projectID) + if err != nil { + return + } + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, action, "alert_rule", ruleID.String(), map[string]any{"name": name})) +} + +// --------------------------------------------------------------------------- +// evaluator — one ticker per backend, transition-based firing +// --------------------------------------------------------------------------- + +type firingSet map[string]bool + +func parseFiring(raw []byte) firingSet { + var state struct { + Firing map[string]bool `json:"firing"` + } + if err := json.Unmarshal(raw, &state); err != nil || state.Firing == nil { + return firingSet{} + } + return state.Firing +} + +func (f firingSet) list() []string { + out := make([]string, 0, len(f)) + for name, on := range f { + if on { + out = append(out, name) + } + } + return out +} + +// alertQueries is the slice of db.Queries the evaluator needs — narrow so +// tests can stub it without a database. +type alertQueries interface { + ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) + LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) + RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) + SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error +} + +type alertEvaluator struct { + q alertQueries + publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) + logger *slog.Logger + tick time.Duration + quit chan struct{} + wg sync.WaitGroup + started sync.Once +} + +func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger) *alertEvaluator { + return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{})} +} + +func (e *alertEvaluator) Start(ctx context.Context) { + e.started.Do(func() { + e.wg.Add(1) + go func() { + defer e.wg.Done() + t := time.NewTicker(e.tick) + defer t.Stop() + for { + select { + case <-t.C: + e.scan(ctx) + case <-e.quit: + return + case <-ctx.Done(): + return + } + } + }() + }) +} + +func (e *alertEvaluator) Stop() { + close(e.quit) + e.wg.Wait() +} + +func (e *alertEvaluator) scan(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + rules, err := e.q.ListEnabledAlertRules(ctx) + if err != nil { + e.logger.Warn("alert scan: list rules", "error", err) + return + } + for _, rule := range rules { + if err := e.evaluate(ctx, rule); err != nil { + e.logger.Warn("alert scan: evaluate", "rule", rule.Name, "error", err) + } + } +} + +func (e *alertEvaluator) evaluate(ctx context.Context, rule db.CoreAlertRule) error { + var cfg AlertRuleConfig + if err := json.Unmarshal(rule.Config, &cfg); err != nil { + return nil // bad config can't be fixed by re-scanning; skip quietly + } + now := time.Now() + var breached map[string]float64 // component → measured value + var metric, unit string + + switch rule.Kind { + case AlertKindHeartbeatSilence: + rows, err := e.q.LastHeartbeats(ctx, rule.ProjectID) + if err != nil { + return err + } + breached = map[string]float64{} + for _, r := range rows { + silent := now.Sub(r.LastBeat.Time).Minutes() + if silent >= float64(cfg.ThresholdMinutes) { + breached[r.ComponentName] = silent + } + } + metric, unit = "silence", "minutes" + + case AlertKindErrorSpike: + rows, err := e.q.RecentErrorCounts(ctx, db.RecentErrorCountsParams{ + ProjectID: rule.ProjectID, + Column2: int64(cfg.WindowMinutes) * 60, + }) + if err != nil { + return err + } + breached = map[string]float64{} + for _, r := range rows { + if r.Errors >= cfg.ThresholdCount { + breached[r.ComponentName] = float64(r.Errors) + } + } + metric, unit = "errors", "count" + + default: + return nil + } + + firing := parseFiring(rule.State) + changed := false + // newly breached components → fired + for comp, value := range breached { + if cfg.Component != "" && comp != cfg.Component { + continue + } + if firing[comp] { + continue + } + firing[comp] = true + changed = true + e.publish(ctx, rule.ProjectID, AlertEventFired, map[string]any{ + "rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind, + "component": comp, "metric": metric, "value": value, "unit": unit, + }) + } + // recovered components → resolved + for comp, on := range firing { + _, stillBreached := breached[comp] + if !on || stillBreached { + continue + } + firing[comp] = false + changed = true + e.publish(ctx, rule.ProjectID, AlertEventResolved, map[string]any{ + "rule_id": rule.ID.String(), "rule": rule.Name, "kind": rule.Kind, + "component": comp, + }) + } + if !changed { + return nil + } + raw, _ := json.Marshal(map[string]any{"firing": firing}) + return e.q.SetAlertRuleState(ctx, db.SetAlertRuleStateParams{ID: rule.ID, ProjectID: rule.ProjectID, State: raw}) +} diff --git a/apps/backend/internal/services/alerting_test.go b/apps/backend/internal/services/alerting_test.go new file mode 100644 index 0000000..6ab4e18 --- /dev/null +++ b/apps/backend/internal/services/alerting_test.go @@ -0,0 +1,162 @@ +package services + +import ( + "context" + "encoding/json" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" +) + +type stubAlertQueries struct { + rules []db.CoreAlertRule + beats []db.LastHeartbeatsRow + errors []db.RecentErrorCountsRow + stateSaved map[uuid.UUID][]byte +} + +func (s *stubAlertQueries) ListEnabledAlertRules(ctx context.Context) ([]db.CoreAlertRule, error) { + return s.rules, nil +} +func (s *stubAlertQueries) LastHeartbeats(ctx context.Context, projectID uuid.UUID) ([]db.LastHeartbeatsRow, error) { + return s.beats, nil +} +func (s *stubAlertQueries) RecentErrorCounts(ctx context.Context, arg db.RecentErrorCountsParams) ([]db.RecentErrorCountsRow, error) { + return s.errors, nil +} +func (s *stubAlertQueries) SetAlertRuleState(ctx context.Context, arg db.SetAlertRuleStateParams) error { + if s.stateSaved == nil { + s.stateSaved = map[uuid.UUID][]byte{} + } + s.stateSaved[arg.ID] = arg.State + // persist back so subsequent evaluations see the transition + for i, r := range s.rules { + if r.ID == arg.ID { + s.rules[i].State = arg.State + } + } + return nil +} + +func rule(t *testing.T, kind string, cfg AlertRuleConfig) db.CoreAlertRule { + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + return db.CoreAlertRule{ID: uuid.New(), ProjectID: uuid.New(), Name: "r1", Kind: kind, Config: raw, State: []byte(`{"firing":{}}`)} +} + +func collect() (func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), *[]string) { + events := &[]string{} + return func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) { + *events = append(*events, eventType+":"+data["component"].(string)) + }, events +} + +func TestAlertSilenceFiresOnceThenResolves(t *testing.T) { + r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{ThresholdMinutes: 5}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + beats: []db.LastHeartbeatsRow{{ + ComponentName: "web", + LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-10 * time.Minute), Valid: true}, + }}, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:web" { + t.Fatalf("want one alert.fired:web, got %v", *events) + } + + // second evaluation — still silent, must not refire + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 { + t.Fatalf("refired: %v", *events) + } + + // heartbeat resumes → resolved + stub.beats[0].LastBeat = pgtype.Timestamptz{Time: time.Now(), Valid: true} + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 2 || (*events)[1] != "alert.resolved:web" { + t.Fatalf("want alert.resolved:web, got %v", *events) + } +} + +func TestAlertErrorSpikeThreshold(t *testing.T) { + r := rule(t, AlertKindErrorSpike, AlertRuleConfig{ThresholdCount: 3, WindowMinutes: 15}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + errors: []db.RecentErrorCountsRow{{ComponentName: "api", Errors: 2}}, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 0 { + t.Fatalf("under threshold fired: %v", *events) + } + + stub.errors[0].Errors = 5 + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:api" { + t.Fatalf("want alert.fired:api, got %v", *events) + } +} + +func TestAlertComponentFilter(t *testing.T) { + r := rule(t, AlertKindHeartbeatSilence, AlertRuleConfig{Component: "web", ThresholdMinutes: 5}) + stub := &stubAlertQueries{ + rules: []db.CoreAlertRule{r}, + beats: []db.LastHeartbeatsRow{ + {ComponentName: "web", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}, + {ComponentName: "api", LastBeat: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}}, + }, + } + publish, events := collect() + e := &alertEvaluator{q: stub, publish: publish, logger: slog.Default()} + + if err := e.evaluate(context.Background(), stub.rules[0]); err != nil { + t.Fatal(err) + } + if len(*events) != 1 || (*events)[0] != "alert.fired:web" { + t.Fatalf("filtered rule must fire only for web, got %v", *events) + } +} + +func TestValidateAlertRule(t *testing.T) { + s := &PlatformService{} + if _, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}); err != nil { + t.Fatalf("valid silence rule rejected: %v", err) + } + cfg, err := s.validateAlertRule(UpsertAlertRuleInput{Name: "ok", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 3}}) + if err != nil || cfg.WindowMinutes != 15 { + t.Fatalf("window default: cfg=%+v err=%v", cfg, err) + } + for _, bad := range []UpsertAlertRuleInput{ + {Name: "bad name!", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 5}}, + {Name: "x", Kind: "bogus", Config: AlertRuleConfig{}}, + {Name: "x", Kind: AlertKindHeartbeatSilence, Config: AlertRuleConfig{ThresholdMinutes: 0}}, + {Name: "x", Kind: AlertKindErrorSpike, Config: AlertRuleConfig{ThresholdCount: 0}}, + } { + if _, err := s.validateAlertRule(bad); err == nil { + t.Fatalf("accepted invalid rule %+v", bad) + } + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index b1273bd..6b3c6d5 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -38,6 +38,7 @@ type PlatformService struct { enc *secrets.Encryptor dispatcher *WebhookDispatcher scheduler *JobScheduler + alerts *alertEvaluator } type BootstrapInput struct { @@ -230,6 +231,8 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS s.dispatcher.Start(context.Background()) s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) s.scheduler.Start(context.Background()) + s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger) + s.alerts.Start(context.Background()) } return s } @@ -242,6 +245,9 @@ func (s *PlatformService) Close() { if s.scheduler != nil { s.scheduler.Stop() } + if s.alerts != nil { + s.alerts.Stop() + } } // publishEvent fans a domain event out to realtime SSE subscribers and to diff --git a/apps/backend/internal/services/webhooks.go b/apps/backend/internal/services/webhooks.go index f9f6925..a2944b5 100644 --- a/apps/backend/internal/services/webhooks.go +++ b/apps/backend/internal/services/webhooks.go @@ -31,6 +31,8 @@ const ( WebhookEventIssueCreated = "issue.created" WebhookEventDeployMarker = "deploy.marker" WebhookEventTest = "webhook.test" + WebhookEventAlertFired = "alert.fired" + WebhookEventAlertResolved = "alert.resolved" WebhookEventDocumentCreated = "document.created" WebhookEventDocumentUpdated = "document.updated" WebhookEventDocumentDeleted = "document.deleted" @@ -43,6 +45,8 @@ var webhookEventTypes = map[string]bool{ WebhookEventIssueCreated: true, WebhookEventDeployMarker: true, WebhookEventTest: true, + WebhookEventAlertFired: true, + WebhookEventAlertResolved: true, WebhookEventJobRun: true, WebhookEventDocumentCreated: true, WebhookEventDocumentUpdated: true, diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index d7b0629..f8445ee 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -1941,6 +1941,106 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts: + get: + tags: [Alerts] + operationId: listAlertRules + summary: Lists telemetry alert rules for the project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Alert rules + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRuleListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Alerts] + operationId: createAlertRule + summary: Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "201": + description: Alert rule created + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/alerts/{ruleID}: + put: + tags: [Alerts] + operationId: updateAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertAlertRuleRequest" + responses: + "200": + description: Alert rule updated + content: + application/json: + schema: + $ref: "#/components/schemas/AlertRule" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Alerts] + operationId: deleteAlertRule + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: ruleID + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Alert rule deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -3512,7 +3612,7 @@ components: type: array items: type: string - description: Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events. + description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. enabled: type: boolean has_secret: @@ -3534,7 +3634,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean required: [url] @@ -3551,7 +3651,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean WebhookCreateResponse: @@ -3821,3 +3921,71 @@ components: value: type: string required: [name, value] + AlertRuleConfig: + type: object + properties: + component: + type: string + description: Restrict the rule to one component name; empty = all components. + threshold_minutes: + type: integer + description: heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080). + threshold_count: + type: integer + format: int64 + description: error_spike — fire when a component logs at least this many errors inside the window. + window_minutes: + type: integer + description: error_spike — sliding window size (default 15, 1–1440). + AlertRule: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + firing: + type: array + items: + type: string + description: Component names currently in breached state. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, project_id, name, kind, config, enabled, firing, created_at, updated_at] + AlertRuleListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/AlertRule" + required: [items] + UpsertAlertRuleRequest: + type: object + properties: + name: + type: string + description: Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*). + kind: + type: string + enum: [heartbeat_silence, error_spike] + config: + $ref: "#/components/schemas/AlertRuleConfig" + enabled: + type: boolean + required: [name, kind] diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx index 9422a72..359c4b3 100644 --- a/apps/frontend/src/pages/IntegrationsPage.tsx +++ b/apps/frontend/src/pages/IntegrationsPage.tsx @@ -1,8 +1,11 @@ import { For, Show, createEffect, createSignal } from "solid-js"; import { + AlertsService, CreateIntegrationRequest, IntegrationsService, + UpsertAlertRuleRequest, WebhooksService, + type AlertRule, type Integration, type IntegrationAnalytics, type Webhook, @@ -48,7 +51,7 @@ const statusVariant = (s?: string) => ? "warning" : "neutral"; -const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test"] as const; +const EVENT_TYPES = ["issue.created", "deploy.marker", "webhook.test", "alert.fired", "alert.resolved"] as const; export function IntegrationsPage(props: IntegrationsPageProps) { const [tab, setTab] = createSignal("integrations"); @@ -59,8 +62,17 @@ export function IntegrationsPage(props: IntegrationsPageProps) { const [analyticsSite, setAnalyticsSite] = createSignal>({}); const [expandedIntegration, setExpandedIntegration] = createSignal(); const [expandedWebhook, setExpandedWebhook] = createSignal(); + const [alertRules, setAlertRules] = createSignal([]); const [showCreateIntegration, setShowCreateIntegration] = createSignal(false); const [showCreateWebhook, setShowCreateWebhook] = createSignal(false); + const [showCreateAlert, setShowCreateAlert] = createSignal(false); + const [alertForm, setAlertForm] = createSignal({ + name: "", + kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind, + component: "", + threshold: "5", + window: "15", + }); 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: "" }); @@ -75,12 +87,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) { setLoading(true); setError(""); try { - const [int, hooks] = await Promise.all([ + const [int, hooks, rules] = await Promise.all([ intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }), hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }), + props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }), ]); setIntegrations(int.items ?? []); setWebhooks(hooks.items ?? []); + setAlertRules(rules.items ?? []); } catch (e) { setError(err(e)); } finally { @@ -205,6 +219,31 @@ export function IntegrationsPage(props: IntegrationsPageProps) { "Webhook deleted", ); + const createAlertRule = (e: Event) => { + e.preventDefault(); + const form = alertForm(); + const threshold = parseInt(form.threshold, 10) || 0; + const config = + form.kind === UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE + ? { component: form.component.trim() || undefined, threshold_minutes: threshold } + : { component: form.component.trim() || undefined, threshold_count: threshold, window_minutes: parseInt(form.window, 10) || 15 }; + void run("create-alert", async () => { + await AlertsService.createAlertRule({ + projectId: props.projectID!, + requestBody: { name: form.name.trim(), kind: form.kind, config }, + }); + setShowCreateAlert(false); + setAlertForm({ name: "", kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE, component: "", threshold: "5", window: "15" }); + }, "Alert rule created"); + }; + + const deleteAlertRule = (id: string) => + run( + `del-alert-${id}`, + () => AlertsService.deleteAlertRule({ projectId: props.projectID!, ruleId: id }), + "Alert rule deleted", + ); + const testWebhook = (id: string) => run( `test-hook-${id}`, @@ -506,6 +545,84 @@ export function IntegrationsPage(props: IntegrationsPageProps) { +
+
+
+
Alert rules
+
+ Telemetry conditions that fire alert.fired / alert.resolved events — + subscribe a webhook above to receive them. +
+
+ + + +
+ 0} + fallback={ +

+ No alert rules yet. Watch for heartbeat silence or error spikes across your components. +

+ } + > +
+ + + + + + + + + + + + + {(rule) => ( + + + + + + + + )} + + +
NameConditionScopeStateActions
{rule.name} + {rule.kind === "heartbeat_silence" + ? `no heartbeat for ${rule.config.threshold_minutes ?? "?"}m` + : `≥${rule.config.threshold_count ?? "?"} errors in ${rule.config.window_minutes ?? 15}m`} + + all components}> + {rule.config.component} + + + 0} + fallback={{rule.enabled ? "watching" : "disabled"}} + > + firing: {rule.firing.join(", ")} + + + + + +
+
+
+
+
@@ -639,6 +756,58 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
+ + setShowCreateAlert(false)} title="Add alert rule" size="md"> +
+ setAlertForm((c) => ({ ...c, name: e.currentTarget.value }))} + /> + setAlertForm((c) => ({ ...c, component: e.currentTarget.value }))} + /> +
+ setAlertForm((c) => ({ ...c, threshold: e.currentTarget.value }))} + /> + + setAlertForm((c) => ({ ...c, window: e.currentTarget.value }))} + /> + +
+
+ + +
+
+
); } diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 18299f0..5003850 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -8,6 +8,9 @@ export { OpenAPI } from './core/OpenAPI'; export type { OpenAPIConfig } from './core/OpenAPI'; export type { AcceptInvitationRequest } from './models/AcceptInvitationRequest'; +export { AlertRule } from './models/AlertRule'; +export type { AlertRuleConfig } from './models/AlertRuleConfig'; +export type { AlertRuleListResponse } from './models/AlertRuleListResponse'; export type { ApiKey } from './models/ApiKey'; export type { ApiKeyID } from './models/ApiKeyID'; export type { AuditAction } from './models/AuditAction'; @@ -130,6 +133,7 @@ export { UpdateProjectMemberRoleRequest } from './models/UpdateProjectMemberRole export type { UpdateProjectRequest } from './models/UpdateProjectRequest'; export type { UpdateScheduledJobRequest } from './models/UpdateScheduledJobRequest'; export type { UpdateWebhookRequest } from './models/UpdateWebhookRequest'; +export { UpsertAlertRuleRequest } from './models/UpsertAlertRuleRequest'; export type { UserID } from './models/UserID'; export type { UserSummary } from './models/UserSummary'; export type { Webhook } from './models/Webhook'; @@ -140,6 +144,9 @@ export type { WebhookID } from './models/WebhookID'; export type { WebhookListResponse } from './models/WebhookListResponse'; export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest'; +export { $AlertRule } from './schemas/$AlertRule'; +export { $AlertRuleConfig } from './schemas/$AlertRuleConfig'; +export { $AlertRuleListResponse } from './schemas/$AlertRuleListResponse'; export { $ApiKey } from './schemas/$ApiKey'; export { $ApiKeyID } from './schemas/$ApiKeyID'; export { $AuditAction } from './schemas/$AuditAction'; @@ -262,6 +269,7 @@ export { $UpdateProjectMemberRoleRequest } from './schemas/$UpdateProjectMemberR export { $UpdateProjectRequest } from './schemas/$UpdateProjectRequest'; export { $UpdateScheduledJobRequest } from './schemas/$UpdateScheduledJobRequest'; export { $UpdateWebhookRequest } from './schemas/$UpdateWebhookRequest'; +export { $UpsertAlertRuleRequest } from './schemas/$UpsertAlertRuleRequest'; export { $UserID } from './schemas/$UserID'; export { $UserSummary } from './schemas/$UserSummary'; export { $Webhook } from './schemas/$Webhook'; @@ -271,6 +279,7 @@ export { $WebhookDeliveryListResponse } from './schemas/$WebhookDeliveryListResp export { $WebhookID } from './schemas/$WebhookID'; export { $WebhookListResponse } from './schemas/$WebhookListResponse'; +export { AlertsService } from './services/AlertsService'; export { AutomationService } from './services/AutomationService'; export { CollectionsService } from './services/CollectionsService'; export { DatabasesService } from './services/DatabasesService'; diff --git a/packages/api-client/src/generated/models/AlertRule.ts b/packages/api-client/src/generated/models/AlertRule.ts new file mode 100644 index 0000000..eae3976 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRule.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRuleConfig } from './AlertRuleConfig'; +export type AlertRule = { + id: string; + project_id: string; + name: string; + kind: AlertRule.kind; + config: AlertRuleConfig; + enabled: boolean; + /** + * Component names currently in breached state. + */ + firing: Array; + created_at: string; + updated_at: string; +}; +export namespace AlertRule { + export enum kind { + HEARTBEAT_SILENCE = 'heartbeat_silence', + ERROR_SPIKE = 'error_spike', + } +} + diff --git a/packages/api-client/src/generated/models/AlertRuleConfig.ts b/packages/api-client/src/generated/models/AlertRuleConfig.ts new file mode 100644 index 0000000..01732b4 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRuleConfig.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type AlertRuleConfig = { + /** + * Restrict the rule to one component name; empty = all components. + */ + component?: string; + /** + * heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080). + */ + threshold_minutes?: number; + /** + * error_spike — fire when a component logs at least this many errors inside the window. + */ + threshold_count?: number; + /** + * error_spike — sliding window size (default 15, 1–1440). + */ + window_minutes?: number; +}; + diff --git a/packages/api-client/src/generated/models/AlertRuleListResponse.ts b/packages/api-client/src/generated/models/AlertRuleListResponse.ts new file mode 100644 index 0000000..80b4263 --- /dev/null +++ b/packages/api-client/src/generated/models/AlertRuleListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRule } from './AlertRule'; +export type AlertRuleListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/models/CreateWebhookRequest.ts b/packages/api-client/src/generated/models/CreateWebhookRequest.ts index f6e3229..56f934a 100644 --- a/packages/api-client/src/generated/models/CreateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/CreateWebhookRequest.ts @@ -8,7 +8,7 @@ export type CreateWebhookRequest = { * HMAC key for X-Primora-Signature. Generated when omitted. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts index 1951e41..a642879 100644 --- a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts @@ -8,7 +8,7 @@ export type UpdateWebhookRequest = { * Replace the signing secret. Omit to keep the current one. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts b/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts new file mode 100644 index 0000000..0dbcec9 --- /dev/null +++ b/packages/api-client/src/generated/models/UpsertAlertRuleRequest.ts @@ -0,0 +1,21 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRuleConfig } from './AlertRuleConfig'; +export type UpsertAlertRuleRequest = { + /** + * Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*). + */ + name: string; + kind: UpsertAlertRuleRequest.kind; + config?: AlertRuleConfig; + enabled?: boolean; +}; +export namespace UpsertAlertRuleRequest { + export enum kind { + HEARTBEAT_SILENCE = 'heartbeat_silence', + ERROR_SPIKE = 'error_spike', + } +} + diff --git a/packages/api-client/src/generated/models/Webhook.ts b/packages/api-client/src/generated/models/Webhook.ts index 34c5496..cbf89a2 100644 --- a/packages/api-client/src/generated/models/Webhook.ts +++ b/packages/api-client/src/generated/models/Webhook.ts @@ -7,7 +7,7 @@ export type Webhook = { project_id: string; url: string; /** - * Event filter (issue.created, deploy.marker, webhook.test, job.run, document.*, object.*). Empty means all events. + * Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. */ events: Array; enabled: boolean; diff --git a/packages/api-client/src/generated/schemas/$AlertRule.ts b/packages/api-client/src/generated/schemas/$AlertRule.ts new file mode 100644 index 0000000..af2aacf --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRule.ts @@ -0,0 +1,51 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRule = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + project_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + name: { + type: 'string', + isRequired: true, + }, + kind: { + type: 'Enum', + isRequired: true, + }, + config: { + type: 'AlertRuleConfig', + isRequired: true, + }, + enabled: { + type: 'boolean', + isRequired: true, + }, + firing: { + type: 'array', + contains: { + type: 'string', + }, + isRequired: true, + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + updated_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts b/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts new file mode 100644 index 0000000..4f559c4 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRuleConfig.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRuleConfig = { + properties: { + component: { + type: 'string', + description: `Restrict the rule to one component name; empty = all components.`, + }, + threshold_minutes: { + type: 'number', + description: `heartbeat_silence — fire when a component has not heartbeated for this many minutes (1–10080).`, + }, + threshold_count: { + type: 'number', + description: `error_spike — fire when a component logs at least this many errors inside the window.`, + format: 'int64', + }, + window_minutes: { + type: 'number', + description: `error_spike — sliding window size (default 15, 1–1440).`, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts b/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts new file mode 100644 index 0000000..0f184de --- /dev/null +++ b/packages/api-client/src/generated/schemas/$AlertRuleListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $AlertRuleListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'AlertRule', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts b/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts new file mode 100644 index 0000000..b73574f --- /dev/null +++ b/packages/api-client/src/generated/schemas/$UpsertAlertRuleRequest.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $UpsertAlertRuleRequest = { + properties: { + name: { + type: 'string', + description: `Env-var-safe name ([A-Za-z_][A-Za-z0-9_]*).`, + isRequired: true, + }, + kind: { + type: 'Enum', + isRequired: true, + }, + config: { + type: 'AlertRuleConfig', + }, + enabled: { + type: 'boolean', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/AlertsService.ts b/packages/api-client/src/generated/services/AlertsService.ts new file mode 100644 index 0000000..ab39f8e --- /dev/null +++ b/packages/api-client/src/generated/services/AlertsService.ts @@ -0,0 +1,112 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { AlertRule } from '../models/AlertRule'; +import type { AlertRuleListResponse } from '../models/AlertRuleListResponse'; +import type { UpsertAlertRuleRequest } from '../models/UpsertAlertRuleRequest'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class AlertsService { + /** + * Lists telemetry alert rules for the project. + * @returns AlertRuleListResponse Alert rules + * @throws ApiError + */ + public static listAlertRules({ + projectId, + }: { + projectId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/alerts', + path: { + 'projectID': projectId, + }, + errors: { + 401: `Error response`, + }, + }); + } + /** + * Creates an alert rule. Rules fire `alert.fired`/`alert.resolved` domain events on state transitions — subscribe a webhook to those event types to receive them. + * @returns AlertRule Alert rule created + * @throws ApiError + */ + public static createAlertRule({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: UpsertAlertRuleRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/alerts', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + }, + }); + } + /** + * @returns AlertRule Alert rule updated + * @throws ApiError + */ + public static updateAlertRule({ + projectId, + ruleId, + requestBody, + }: { + projectId: string, + ruleId: string, + requestBody: UpsertAlertRuleRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/projects/{projectID}/alerts/{ruleID}', + path: { + 'projectID': projectId, + 'ruleID': ruleId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + 404: `Error response`, + }, + }); + } + /** + * @returns void + * @throws ApiError + */ + public static deleteAlertRule({ + projectId, + ruleId, + }: { + projectId: string, + ruleId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/projects/{projectID}/alerts/{ruleID}', + path: { + 'projectID': projectId, + 'ruleID': ruleId, + }, + errors: { + 401: `Error response`, + 404: `Error response`, + }, + }); + } +} From 45a16219094ba3453a68f7a37761683859506c45 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:15:12 +0200 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20inbound=20webhooks=20=E2=80=94=20?= =?UTF-8?q?public=20ingest=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.inbound_hooks (migration 00013): POST /api/v1/hooks/ accepts external events. event mode republishes as inbound.received through the signed dispatcher + realtime; job mode triggers a scheduled-job run with the received body as payload. Optional X-Primora-Signature HMAC verify. Dashboard card on the Integrations page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ROADMAP.md | 13 +- .../db/migrations/00013_inbound_hooks.sql | 29 ++ apps/backend/db/queries/inbound_hooks.sql | 26 ++ .../internal/database/db/inbound_hooks.sql.go | 173 ++++++++++++ apps/backend/internal/database/db/models.go | 14 + apps/backend/internal/database/db/querier.go | 6 + apps/backend/internal/handlers/http.go | 4 + apps/backend/internal/handlers/inbound.go | 97 +++++++ apps/backend/internal/services/inbound.go | 249 ++++++++++++++++++ apps/backend/internal/services/webhooks.go | 1 + apps/backend/openapi/openapi.yaml | 176 ++++++++++++- apps/frontend/src/pages/IntegrationsPage.tsx | 168 +++++++++++- packages/api-client/src/generated/index.ts | 9 + .../models/CreateInboundHookRequest.ts | 24 ++ .../generated/models/CreateWebhookRequest.ts | 2 +- .../src/generated/models/InboundHook.ts | 27 ++ .../models/InboundHookListResponse.ts | 9 + .../models/InboundReceiveResponse.ts | 10 + .../generated/models/UpdateWebhookRequest.ts | 2 +- .../src/generated/models/Webhook.ts | 2 +- .../schemas/$CreateInboundHookRequest.ts | 26 ++ .../src/generated/schemas/$InboundHook.ts | 56 ++++ .../schemas/$InboundHookListResponse.ts | 15 ++ .../schemas/$InboundReceiveResponse.ts | 18 ++ .../src/generated/services/InboundService.ts | 116 ++++++++ 26 files changed, 1264 insertions(+), 9 deletions(-) create mode 100644 apps/backend/db/migrations/00013_inbound_hooks.sql create mode 100644 apps/backend/db/queries/inbound_hooks.sql create mode 100644 apps/backend/internal/database/db/inbound_hooks.sql.go create mode 100644 apps/backend/internal/handlers/inbound.go create mode 100644 apps/backend/internal/services/inbound.go create mode 100644 packages/api-client/src/generated/models/CreateInboundHookRequest.ts create mode 100644 packages/api-client/src/generated/models/InboundHook.ts create mode 100644 packages/api-client/src/generated/models/InboundHookListResponse.ts create mode 100644 packages/api-client/src/generated/models/InboundReceiveResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$CreateInboundHookRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$InboundHook.ts create mode 100644 packages/api-client/src/generated/schemas/$InboundHookListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$InboundReceiveResponse.ts create mode 100644 packages/api-client/src/generated/services/InboundService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ad15b..ce8a983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext. - **Realtime channel subscriptions** (`@primora/client`) — `createRealtimeClient({ endpoint, key, projectId })` opens the project SSE stream (`/realtime/stream`) via fetch + ReadableStream (EventSource can't send `X-API-Key`). `channel("documents").on("created", cb)` subscribes by event namespace (`document`/`object`/`issue`/`deploy`, `"*"` for all); auto-reconnects with capped exponential backoff; `onStateChange` reports `connecting|open|reconnecting|closed`. - **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components. +- **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index 1ff7f87..e970fe4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,6 +320,17 @@ Telemetry-driven alerts delivered through the existing webhook dispatcher. - CRUD via `/projects/:id/alerts`; Integrations page manages rules next to webhooks and shows live `firing` state. +## Inbound webhooks (unreleased) + +Project-scoped public ingest for external systems. + +- `POST /api/v1/hooks/` — token is the credential; optional + `X-Primora-Signature` HMAC-SHA256 verification when a secret is set. +- `event` mode republishes the body as `inbound.received` (webhooks + + realtime fan out for free); `job` mode enqueues a scheduled-job run with + the received body as payload (`triggered_by: "hook"`). +- 256 KiB body cap; `last_received_at` tracked; managed from Integrations. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. @@ -330,8 +341,6 @@ Ordered loosely by leverage. None committed; each gets scoped when picked. - **Phase 10 — Storage backends** — S3-compatible object storage behind the existing bucket abstraction (deferred from Phase 6), plus presigned-URL uploads for large files. -- **Inbound webhooks** — project-scoped ingest endpoints that turn - external events into domain events/jobs. - **HA mode** — leader-elected scheduler + distributed locks so two backend replicas don't double-fire jobs; Postgres advisory locks are enough at this scale. diff --git a/apps/backend/db/migrations/00013_inbound_hooks.sql b/apps/backend/db/migrations/00013_inbound_hooks.sql new file mode 100644 index 0000000..b248972 --- /dev/null +++ b/apps/backend/db/migrations/00013_inbound_hooks.sql @@ -0,0 +1,29 @@ +-- +goose Up +CREATE TABLE core.inbound_hooks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')), + job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE, + secret BYTEA NOT NULL DEFAULT ''::bytea, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_received_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +ALTER TABLE core.scheduled_job_runs + DROP CONSTRAINT scheduled_job_runs_triggered_by_check; +ALTER TABLE core.scheduled_job_runs + ADD CONSTRAINT scheduled_job_runs_triggered_by_check + CHECK (triggered_by IN ('schedule','manual','hook')); + +-- +goose Down +ALTER TABLE core.scheduled_job_runs + DROP CONSTRAINT scheduled_job_runs_triggered_by_check; +ALTER TABLE core.scheduled_job_runs + ADD CONSTRAINT scheduled_job_runs_triggered_by_check + CHECK (triggered_by IN ('schedule','manual')); +DROP TABLE IF EXISTS core.inbound_hooks; diff --git a/apps/backend/db/queries/inbound_hooks.sql b/apps/backend/db/queries/inbound_hooks.sql new file mode 100644 index 0000000..1a55588 --- /dev/null +++ b/apps/backend/db/queries/inbound_hooks.sql @@ -0,0 +1,26 @@ +-- name: CreateInboundHook :one +INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, secret, enabled) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: ListInboundHooks :many +SELECT * FROM core.inbound_hooks +WHERE project_id = $1 +ORDER BY name; + +-- name: GetInboundHookByToken :one +SELECT * FROM core.inbound_hooks +WHERE token = $1; + +-- name: GetInboundHook :one +SELECT * FROM core.inbound_hooks +WHERE id = $1 AND project_id = $2; + +-- name: TouchInboundHook :exec +UPDATE core.inbound_hooks +SET last_received_at = NOW() +WHERE id = $1; + +-- name: DeleteInboundHook :exec +DELETE FROM core.inbound_hooks +WHERE id = $1 AND project_id = $2; diff --git a/apps/backend/internal/database/db/inbound_hooks.sql.go b/apps/backend/internal/database/db/inbound_hooks.sql.go new file mode 100644 index 0000000..ef736df --- /dev/null +++ b/apps/backend/internal/database/db/inbound_hooks.sql.go @@ -0,0 +1,173 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: inbound_hooks.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createInboundHook = `-- name: CreateInboundHook :one +INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, secret, enabled) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at +` + +type CreateInboundHookParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Token string `json:"token"` + Mode string `json:"mode"` + JobID pgtype.UUID `json:"job_id"` + Secret []byte `json:"secret"` + Enabled bool `json:"enabled"` +} + +func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) { + row := q.db.QueryRow(ctx, createInboundHook, + arg.ProjectID, + arg.Name, + arg.Token, + arg.Mode, + arg.JobID, + arg.Secret, + arg.Enabled, + ) + var i CoreInboundHook + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Token, + &i.Mode, + &i.JobID, + &i.Secret, + &i.Enabled, + &i.LastReceivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteInboundHook = `-- name: DeleteInboundHook :exec +DELETE FROM core.inbound_hooks +WHERE id = $1 AND project_id = $2 +` + +type DeleteInboundHookParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error { + _, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID) + return err +} + +const getInboundHook = `-- name: GetInboundHook :one +SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks +WHERE id = $1 AND project_id = $2 +` + +type GetInboundHookParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) { + row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID) + var i CoreInboundHook + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Token, + &i.Mode, + &i.JobID, + &i.Secret, + &i.Enabled, + &i.LastReceivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getInboundHookByToken = `-- name: GetInboundHookByToken :one +SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks +WHERE token = $1 +` + +func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) { + row := q.db.QueryRow(ctx, getInboundHookByToken, token) + var i CoreInboundHook + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Token, + &i.Mode, + &i.JobID, + &i.Secret, + &i.Enabled, + &i.LastReceivedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listInboundHooks = `-- name: ListInboundHooks :many +SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks +WHERE project_id = $1 +ORDER BY name +` + +func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) { + rows, err := q.db.Query(ctx, listInboundHooks, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreInboundHook + for rows.Next() { + var i CoreInboundHook + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Token, + &i.Mode, + &i.JobID, + &i.Secret, + &i.Enabled, + &i.LastReceivedAt, + &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 touchInboundHook = `-- name: TouchInboundHook :exec +UPDATE core.inbound_hooks +SET last_received_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, touchInboundHook, id) + return err +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index ac84b38..c1d6e94 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -255,6 +255,20 @@ type CoreEvent struct { Ts pgtype.Timestamptz `json:"ts"` } +type CoreInboundHook struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Token string `json:"token"` + Mode string `json:"mode"` + JobID pgtype.UUID `json:"job_id"` + Secret []byte `json:"secret"` + Enabled bool `json:"enabled"` + LastReceivedAt pgtype.Timestamptz `json:"last_received_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreIntegration struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 8f67465..53e32dc 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -30,6 +30,7 @@ type Querier interface { CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) + CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error) @@ -43,6 +44,7 @@ type Querier interface { DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error + DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) @@ -65,6 +67,8 @@ type Querier interface { GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) + GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) + GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error) @@ -99,6 +103,7 @@ type Querier interface { ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) + ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) @@ -138,6 +143,7 @@ type Querier interface { SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error) SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error) TouchAPIKey(ctx context.Context, id uuid.UUID) error + TouchInboundHook(ctx context.Context, id uuid.UUID) error UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error) UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 4deea1a..a3d0002 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -79,6 +79,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument) api.OPTIONS("/ingest", h.ingestOptions) api.POST("/ingest", h.ingest) + api.POST("/hooks/:token", h.receiveInboundHook) + api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks) + api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook) + api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook) api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/components", h.listComponents) diff --git a/apps/backend/internal/handlers/inbound.go b/apps/backend/internal/handlers/inbound.go new file mode 100644 index 0000000..acc9a87 --- /dev/null +++ b/apps/backend/internal/handlers/inbound.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "errors" + "io" + "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" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB + +func (h *HTTPHandler) inboundError(c *gin.Context, err error) { + if errors.Is(err, pgx.ErrNoRows) { + apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found") + return + } + msg := err.Error() + switch { + case strings.Contains(msg, "invalid signature"): + apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg) + case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"): + apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg) + default: + h.handleError(c, err) + } +} + +func (h *HTTPHandler) listInboundHooks(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID) + if err != nil { + h.inboundError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) createInboundHook(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.CreateInboundHookInput + if !h.bindAndValidate(c, &body) { + return + } + result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.inboundError(c, err) + return + } + c.JSON(http.StatusCreated, result) +} + +func (h *HTTPHandler) deleteInboundHook(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + hookID, err := uuid.Parse(c.Param("hookID")) + if err != nil { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id") + return + } + if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil { + h.inboundError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// receiveInboundHook is the public ingest endpoint — the URL token is the +// credential, so no actor is required. +func (h *HTTPHandler) receiveInboundHook(c *gin.Context) { + body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody)) + if err != nil { + apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB") + return + } + result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body) + if err != nil { + h.inboundError(c, err) + return + } + c.JSON(http.StatusAccepted, result) +} diff --git a/apps/backend/internal/services/inbound.go b/apps/backend/internal/services/inbound.go new file mode 100644 index 0000000..a7921a5 --- /dev/null +++ b/apps/backend/internal/services/inbound.go @@ -0,0 +1,249 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "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" +) + +// Inbound hooks are project-scoped public ingest endpoints: +// POST /api/v1/hooks/. A hook either republishes the body as an +// `inbound.received` domain event (webhooks + realtime fan out for free) or +// triggers a scheduled-job run with the body as the payload. When a secret is +// set, callers must sign the raw body with X-Primora-Signature — the same +// HMAC-SHA256 scheme outbound deliveries use. + +const ( + WebhookEventInboundReceived = "inbound.received" + + InboundModeEvent = "event" + InboundModeJob = "job" +) + +type InboundHookSummary struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Name string `json:"name"` + Token string `json:"token"` + URL string `json:"url"` + Mode string `json:"mode"` + JobID string `json:"job_id,omitempty"` + HasSecret bool `json:"has_secret"` + Enabled bool `json:"enabled"` + LastReceivedAt *time.Time `json:"last_received_at"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateInboundHookInput struct { + Name string `json:"name" validate:"required"` + Mode string `json:"mode"` + JobID string `json:"job_id"` + Secret string `json:"secret"` + Enabled *bool `json:"enabled"` +} + +type InboundReceiveResult struct { + Received bool `json:"received"` + Event string `json:"event,omitempty"` + RunID string `json:"run_id,omitempty"` +} + +func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary { + out := InboundHookSummary{ + ID: row.ID.String(), + ProjectID: row.ProjectID.String(), + Name: row.Name, + Token: row.Token, + URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token, + Mode: row.Mode, + HasSecret: len(row.Secret) > 0, + Enabled: row.Enabled, + CreatedAt: row.CreatedAt.Time, + } + if row.JobID.Valid { + out.JobID = uuid.UUID(row.JobID.Bytes).String() + } + if row.LastReceivedAt.Valid { + t := row.LastReceivedAt.Time + out.LastReceivedAt = &t + } + return out +} + +func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]InboundHookSummary, len(rows)) + for i, r := range rows { + out[i] = inboundHookSummary(r, s.publicURL) + } + return out, nil +} + +func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return InboundHookSummary{}, err + } + if !projectSecretNameRe.MatchString(input.Name) { + return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name) + } + mode := input.Mode + if mode == "" { + mode = InboundModeEvent + } + var jobID pgtype.UUID + if mode == InboundModeJob { + parsed, err := uuid.Parse(input.JobID) + if err != nil { + return InboundHookSummary{}, errors.New("job mode requires a valid job_id") + } + job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed) + if err != nil || job.ProjectID != projectID { + return InboundHookSummary{}, errors.New("job not found in this project") + } + jobID = pgtype.UUID{Bytes: parsed, Valid: true} + } else if mode != InboundModeEvent { + return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event or job", input.Mode) + } + secretBytes := []byte{} + if input.Secret != "" { + if s.enc == nil { + return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets") + } + var err error + if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil { + return InboundHookSummary{}, err + } + } + token := "prm_hook_" + randHex(16) + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{ + ProjectID: projectID, + Name: input.Name, + Token: token, + Mode: mode, + JobID: jobID, + Secret: secretBytes, + Enabled: enabled, + }) + if err != nil { + if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") { + return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name) + } + return InboundHookSummary{}, err + } + project, err := s.repo.Queries().GetProjectByID(ctx, projectID) + if err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{ + "name": row.Name, "mode": row.Mode, + })) + } + return inboundHookSummary(row, s.publicURL), nil +} + +func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil { + return err + } + if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil { + return err + } + project, err := s.repo.Queries().GetProjectByID(ctx, projectID) + if err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil)) + } + return nil +} + +// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the +// token is the credential. Signature check applies when a secret is set. +func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) { + hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token) + if err != nil { + return InboundReceiveResult{}, err // pgx.ErrNoRows → 404 + } + if !hook.Enabled { + return InboundReceiveResult{}, errors.New("hook is disabled") + } + if len(hook.Secret) > 0 { + if s.enc == nil { + return InboundReceiveResult{}, errors.New("encryption is not configured") + } + plain, err := s.enc.Decrypt(hook.Secret) + if err != nil { + return InboundReceiveResult{}, err + } + want := "sha256=" + signBody(string(plain), body) + if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 { + return InboundReceiveResult{}, errors.New("invalid signature") + } + } + var payload map[string]any + if len(body) > 0 { + if err := json.Unmarshal(body, &payload); err != nil { + return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err) + } + } + _ = s.repo.Queries().TouchInboundHook(ctx, hook.ID) + + switch hook.Mode { + case InboundModeJob: + if s.scheduler == nil { + return InboundReceiveResult{}, errors.New("scheduler unavailable") + } + if !hook.JobID.Valid { + return InboundReceiveResult{}, errors.New("hook has no job configured") + } + job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes) + if err != nil { + return InboundReceiveResult{}, err + } + if payload != nil { + raw, _ := json.Marshal(payload) + job.Payload = raw // received body replaces the configured payload + } + run, err := s.scheduler.enqueue(ctx, job, "hook") + if err != nil { + return InboundReceiveResult{}, err + } + return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil + default: + s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{ + "hook_id": hook.ID.String(), + "hook_name": hook.Name, + "payload": payload, + }) + return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil + } +} + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return hex.EncodeToString(b) +} diff --git a/apps/backend/internal/services/webhooks.go b/apps/backend/internal/services/webhooks.go index a2944b5..92e13b9 100644 --- a/apps/backend/internal/services/webhooks.go +++ b/apps/backend/internal/services/webhooks.go @@ -47,6 +47,7 @@ var webhookEventTypes = map[string]bool{ WebhookEventTest: true, WebhookEventAlertFired: true, WebhookEventAlertResolved: true, + WebhookEventInboundReceived: true, WebhookEventJobRun: true, WebhookEventDocumentCreated: true, WebhookEventDocumentUpdated: true, diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index f8445ee..74948bf 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -2041,6 +2041,107 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/inbound-hooks: + get: + tags: [Inbound] + operationId: listInboundHooks + summary: Lists inbound webhook endpoints for the project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Inbound hooks + content: + application/json: + schema: + $ref: "#/components/schemas/InboundHookListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Inbound] + operationId: createInboundHook + summary: Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body). + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateInboundHookRequest" + responses: + "201": + description: Inbound hook created + content: + application/json: + schema: + $ref: "#/components/schemas/InboundHook" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/inbound-hooks/{hookID}: + delete: + tags: [Inbound] + operationId: deleteInboundHook + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: hookID + in: path + required: true + schema: + type: string + format: uuid + responses: + "204": + description: Inbound hook deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /hooks/{token}: + post: + tags: [Inbound] + operationId: receiveInboundHook + summary: Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret. + security: [] + parameters: + - name: token + in: path + required: true + schema: + type: string + - name: X-Primora-Signature + in: header + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "202": + description: Accepted + content: + application/json: + schema: + $ref: "#/components/schemas/InboundReceiveResponse" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -3612,7 +3713,7 @@ components: type: array items: type: string - description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. + description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events. enabled: type: boolean has_secret: @@ -3634,7 +3735,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean required: [url] @@ -3651,7 +3752,7 @@ components: type: array items: type: string - enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] + enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted] enabled: type: boolean WebhookCreateResponse: @@ -3989,3 +4090,72 @@ components: enabled: type: boolean required: [name, kind] + InboundHook: + type: object + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + name: + type: string + token: + type: string + url: + type: string + description: Full ingest URL — POST here from the external system. + mode: + type: string + enum: [event, job] + job_id: + type: string + has_secret: + type: boolean + enabled: + type: boolean + last_received_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + required: [id, project_id, name, token, url, mode, has_secret, enabled, created_at] + InboundHookListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/InboundHook" + required: [items] + CreateInboundHookRequest: + type: object + properties: + name: + type: string + mode: + type: string + enum: [event, job] + default: event + job_id: + type: string + description: Required when mode=job — scheduled job to trigger. + secret: + type: string + description: Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature. + enabled: + type: boolean + required: [name] + InboundReceiveResponse: + type: object + properties: + received: + type: boolean + event: + type: string + run_id: + type: string + required: [received] diff --git a/apps/frontend/src/pages/IntegrationsPage.tsx b/apps/frontend/src/pages/IntegrationsPage.tsx index 359c4b3..68062e1 100644 --- a/apps/frontend/src/pages/IntegrationsPage.tsx +++ b/apps/frontend/src/pages/IntegrationsPage.tsx @@ -1,11 +1,16 @@ import { For, Show, createEffect, createSignal } from "solid-js"; import { AlertsService, + AutomationService, + CreateInboundHookRequest, CreateIntegrationRequest, + InboundService, IntegrationsService, UpsertAlertRuleRequest, WebhooksService, type AlertRule, + type InboundHook, + type ScheduledJob, type Integration, type IntegrationAnalytics, type Webhook, @@ -63,9 +68,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) { const [expandedIntegration, setExpandedIntegration] = createSignal(); const [expandedWebhook, setExpandedWebhook] = createSignal(); const [alertRules, setAlertRules] = createSignal([]); + const [inboundHooks, setInboundHooks] = createSignal([]); + const [scheduledJobs, setScheduledJobs] = createSignal([]); const [showCreateIntegration, setShowCreateIntegration] = createSignal(false); const [showCreateWebhook, setShowCreateWebhook] = createSignal(false); const [showCreateAlert, setShowCreateAlert] = createSignal(false); + const [showCreateInbound, setShowCreateInbound] = createSignal(false); + const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", secret: "" }); + const [copiedUrl, setCopiedUrl] = createSignal(""); const [alertForm, setAlertForm] = createSignal({ name: "", kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind, @@ -87,14 +97,18 @@ export function IntegrationsPage(props: IntegrationsPageProps) { setLoading(true); setError(""); try { - const [int, hooks, rules] = await Promise.all([ + const [int, hooks, rules, inbound, jobs] = await Promise.all([ intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }), hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }), props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }), + props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }), + props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }), ]); setIntegrations(int.items ?? []); setWebhooks(hooks.items ?? []); setAlertRules(rules.items ?? []); + setInboundHooks(inbound.items ?? []); + setScheduledJobs(jobs.items ?? []); } catch (e) { setError(err(e)); } finally { @@ -244,6 +258,38 @@ export function IntegrationsPage(props: IntegrationsPageProps) { "Alert rule deleted", ); + const createInboundHook = (e: Event) => { + e.preventDefault(); + const form = inboundForm(); + void run("create-inbound", async () => { + await InboundService.createInboundHook({ + projectId: props.projectID!, + requestBody: { + name: form.name.trim(), + mode: form.mode, + job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined, + secret: form.secret.trim() || undefined, + }, + }); + setShowCreateInbound(false); + setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", secret: "" }); + }, "Inbound hook created"); + }; + + const deleteInboundHook = (id: string) => + run( + `del-inbound-${id}`, + () => InboundService.deleteInboundHook({ projectId: props.projectID!, hookId: id }), + "Inbound hook deleted", + ); + + const copyHookUrl = (hook: InboundHook) => { + void navigator.clipboard?.writeText(hook.url).then(() => { + setCopiedUrl(hook.id); + setTimeout(() => setCopiedUrl(""), 1500); + }); + }; + const testWebhook = (id: string) => run( `test-hook-${id}`, @@ -623,6 +669,78 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
+
+
+
+
Inbound hooks
+
+ Public ingest endpoints — POST from external systems to republish as inbound.received + events or trigger a scheduled job. +
+
+ + + +
+ 0} + fallback={

No inbound hooks yet.

} + > +
+ + + + + + + + + + + + + {(hook) => ( + + + + + + + + )} + + +
NameModeSecurityLast receivedActions
{hook.name} + {hook.mode === "job" ? "job trigger" : "event"} + + {" "}disabled + + + token only}> + signed + + {fmtTime(hook.last_received_at)} + + + + +
+
+
+
+
@@ -808,6 +926,54 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
+ + setShowCreateInbound(false)} title="Add inbound hook" size="md"> +
+ setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))} + /> + setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))} + options={[ + { value: "", label: "Select a job…" }, + ...scheduledJobs().map((j) => ({ value: j.id, label: j.name })), + ]} + /> +

The received body becomes the job's payload for that run.

+ + setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))} + /> +
+ + +
+
+
); } diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 5003850..459f4b7 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -37,6 +37,7 @@ export type { CreatedApiKey } from './models/CreatedApiKey'; export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest'; export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest'; export type { CreateDocumentRequest } from './models/CreateDocumentRequest'; +export { CreateInboundHookRequest } from './models/CreateInboundHookRequest'; export { CreateIntegrationRequest } from './models/CreateIntegrationRequest'; export { CreateInvitationRequest } from './models/CreateInvitationRequest'; export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest'; @@ -69,6 +70,9 @@ export type { DocumentListResponse } from './models/DocumentListResponse'; export type { ErrorEnvelope } from './models/ErrorEnvelope'; export type { EventSeriesBucket } from './models/EventSeriesBucket'; export type { HealthStatus } from './models/HealthStatus'; +export { InboundHook } from './models/InboundHook'; +export type { InboundHookListResponse } from './models/InboundHookListResponse'; +export type { InboundReceiveResponse } from './models/InboundReceiveResponse'; export { IngestEvent } from './models/IngestEvent'; export type { IngestRequest } from './models/IngestRequest'; export type { IngestResponse } from './models/IngestResponse'; @@ -173,6 +177,7 @@ export { $CreatedApiKey } from './schemas/$CreatedApiKey'; export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest'; export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest'; export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest'; +export { $CreateInboundHookRequest } from './schemas/$CreateInboundHookRequest'; export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest'; export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest'; export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest'; @@ -205,6 +210,9 @@ export { $DocumentListResponse } from './schemas/$DocumentListResponse'; export { $ErrorEnvelope } from './schemas/$ErrorEnvelope'; export { $EventSeriesBucket } from './schemas/$EventSeriesBucket'; export { $HealthStatus } from './schemas/$HealthStatus'; +export { $InboundHook } from './schemas/$InboundHook'; +export { $InboundHookListResponse } from './schemas/$InboundHookListResponse'; +export { $InboundReceiveResponse } from './schemas/$InboundReceiveResponse'; export { $IngestEvent } from './schemas/$IngestEvent'; export { $IngestRequest } from './schemas/$IngestRequest'; export { $IngestResponse } from './schemas/$IngestResponse'; @@ -284,6 +292,7 @@ export { AutomationService } from './services/AutomationService'; export { CollectionsService } from './services/CollectionsService'; export { DatabasesService } from './services/DatabasesService'; export { HealthService } from './services/HealthService'; +export { InboundService } from './services/InboundService'; export { InstanceService } from './services/InstanceService'; export { IntegrationsService } from './services/IntegrationsService'; export { OrganizationsService } from './services/OrganizationsService'; diff --git a/packages/api-client/src/generated/models/CreateInboundHookRequest.ts b/packages/api-client/src/generated/models/CreateInboundHookRequest.ts new file mode 100644 index 0000000..eb71e13 --- /dev/null +++ b/packages/api-client/src/generated/models/CreateInboundHookRequest.ts @@ -0,0 +1,24 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type CreateInboundHookRequest = { + name: string; + mode?: CreateInboundHookRequest.mode; + /** + * Required when mode=job — scheduled job to trigger. + */ + job_id?: string; + /** + * Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature. + */ + secret?: string; + enabled?: boolean; +}; +export namespace CreateInboundHookRequest { + export enum mode { + EVENT = 'event', + JOB = 'job', + } +} + diff --git a/packages/api-client/src/generated/models/CreateWebhookRequest.ts b/packages/api-client/src/generated/models/CreateWebhookRequest.ts index 56f934a..881cdfd 100644 --- a/packages/api-client/src/generated/models/CreateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/CreateWebhookRequest.ts @@ -8,7 +8,7 @@ export type CreateWebhookRequest = { * HMAC key for X-Primora-Signature. Generated when omitted. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'inbound.received' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/InboundHook.ts b/packages/api-client/src/generated/models/InboundHook.ts new file mode 100644 index 0000000..c6e3af7 --- /dev/null +++ b/packages/api-client/src/generated/models/InboundHook.ts @@ -0,0 +1,27 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type InboundHook = { + id: string; + project_id: string; + name: string; + token: string; + /** + * Full ingest URL — POST here from the external system. + */ + url: string; + mode: InboundHook.mode; + job_id?: string; + has_secret: boolean; + enabled: boolean; + last_received_at?: string | null; + created_at: string; +}; +export namespace InboundHook { + export enum mode { + EVENT = 'event', + JOB = 'job', + } +} + diff --git a/packages/api-client/src/generated/models/InboundHookListResponse.ts b/packages/api-client/src/generated/models/InboundHookListResponse.ts new file mode 100644 index 0000000..f43b03c --- /dev/null +++ b/packages/api-client/src/generated/models/InboundHookListResponse.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { InboundHook } from './InboundHook'; +export type InboundHookListResponse = { + items: Array; +}; + diff --git a/packages/api-client/src/generated/models/InboundReceiveResponse.ts b/packages/api-client/src/generated/models/InboundReceiveResponse.ts new file mode 100644 index 0000000..72d4c00 --- /dev/null +++ b/packages/api-client/src/generated/models/InboundReceiveResponse.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type InboundReceiveResponse = { + received: boolean; + event?: string; + run_id?: string; +}; + diff --git a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts index a642879..d67e215 100644 --- a/packages/api-client/src/generated/models/UpdateWebhookRequest.ts +++ b/packages/api-client/src/generated/models/UpdateWebhookRequest.ts @@ -8,7 +8,7 @@ export type UpdateWebhookRequest = { * Replace the signing secret. Omit to keep the current one. */ secret?: string; - events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; + events?: Array<'issue.created' | 'deploy.marker' | 'webhook.test' | 'job.run' | 'alert.fired' | 'alert.resolved' | 'inbound.received' | 'document.created' | 'document.updated' | 'document.deleted' | 'object.created' | 'object.updated' | 'object.deleted'>; enabled?: boolean; }; diff --git a/packages/api-client/src/generated/models/Webhook.ts b/packages/api-client/src/generated/models/Webhook.ts index cbf89a2..b4b0029 100644 --- a/packages/api-client/src/generated/models/Webhook.ts +++ b/packages/api-client/src/generated/models/Webhook.ts @@ -7,7 +7,7 @@ export type Webhook = { project_id: string; url: string; /** - * Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events. + * Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events. */ events: Array; enabled: boolean; diff --git a/packages/api-client/src/generated/schemas/$CreateInboundHookRequest.ts b/packages/api-client/src/generated/schemas/$CreateInboundHookRequest.ts new file mode 100644 index 0000000..7d9ee5f --- /dev/null +++ b/packages/api-client/src/generated/schemas/$CreateInboundHookRequest.ts @@ -0,0 +1,26 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $CreateInboundHookRequest = { + properties: { + name: { + type: 'string', + isRequired: true, + }, + mode: { + type: 'Enum', + }, + job_id: { + type: 'string', + description: `Required when mode=job — scheduled job to trigger.`, + }, + secret: { + type: 'string', + description: `Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature.`, + }, + enabled: { + type: 'boolean', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$InboundHook.ts b/packages/api-client/src/generated/schemas/$InboundHook.ts new file mode 100644 index 0000000..e2fa595 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InboundHook.ts @@ -0,0 +1,56 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $InboundHook = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + project_id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + name: { + type: 'string', + isRequired: true, + }, + token: { + type: 'string', + isRequired: true, + }, + url: { + type: 'string', + description: `Full ingest URL — POST here from the external system.`, + isRequired: true, + }, + mode: { + type: 'Enum', + isRequired: true, + }, + job_id: { + type: 'string', + }, + has_secret: { + type: 'boolean', + isRequired: true, + }, + enabled: { + type: 'boolean', + isRequired: true, + }, + last_received_at: { + type: 'string', + isNullable: true, + format: 'date-time', + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$InboundHookListResponse.ts b/packages/api-client/src/generated/schemas/$InboundHookListResponse.ts new file mode 100644 index 0000000..312b3ce --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InboundHookListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $InboundHookListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'InboundHook', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$InboundReceiveResponse.ts b/packages/api-client/src/generated/schemas/$InboundReceiveResponse.ts new file mode 100644 index 0000000..c74dd37 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$InboundReceiveResponse.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $InboundReceiveResponse = { + properties: { + received: { + type: 'boolean', + isRequired: true, + }, + event: { + type: 'string', + }, + run_id: { + type: 'string', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/InboundService.ts b/packages/api-client/src/generated/services/InboundService.ts new file mode 100644 index 0000000..3b8e95c --- /dev/null +++ b/packages/api-client/src/generated/services/InboundService.ts @@ -0,0 +1,116 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CreateInboundHookRequest } from '../models/CreateInboundHookRequest'; +import type { InboundHook } from '../models/InboundHook'; +import type { InboundHookListResponse } from '../models/InboundHookListResponse'; +import type { InboundReceiveResponse } from '../models/InboundReceiveResponse'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class InboundService { + /** + * Lists inbound webhook endpoints for the project. + * @returns InboundHookListResponse Inbound hooks + * @throws ApiError + */ + public static listInboundHooks({ + projectId, + }: { + projectId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/inbound-hooks', + path: { + 'projectID': projectId, + }, + errors: { + 401: `Error response`, + }, + }); + } + /** + * Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body). + * @returns InboundHook Inbound hook created + * @throws ApiError + */ + public static createInboundHook({ + projectId, + requestBody, + }: { + projectId: string, + requestBody: CreateInboundHookRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/projects/{projectID}/inbound-hooks', + path: { + 'projectID': projectId, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + }, + }); + } + /** + * @returns void + * @throws ApiError + */ + public static deleteInboundHook({ + projectId, + hookId, + }: { + projectId: string, + hookId: string, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/projects/{projectID}/inbound-hooks/{hookID}', + path: { + 'projectID': projectId, + 'hookID': hookId, + }, + errors: { + 401: `Error response`, + 404: `Error response`, + }, + }); + } + /** + * Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret. + * @returns InboundReceiveResponse Accepted + * @throws ApiError + */ + public static receiveInboundHook({ + token, + xPrimoraSignature, + requestBody, + }: { + token: string, + xPrimoraSignature?: string, + requestBody?: Record, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/hooks/{token}', + path: { + 'token': token, + }, + headers: { + 'X-Primora-Signature': xPrimoraSignature, + }, + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Error response`, + 401: `Error response`, + 404: `Error response`, + }, + }); + } +} From f747b33b464dda10d3e1c658a61882bbfd660cbc Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:27:06 +0200 Subject: [PATCH 03/18] feat: HA scheduler leadership + templated email log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA scheduler: the job scheduler and alert evaluator hold session-scoped PostgreSQL advisory locks on pinned pool connections — replicas scan for due work exactly once, failover verified live with two replicas. Manual and hook-triggered runs stay available on any replica. Email surface: mailer renders registered templates (invitation); every send is logged to core.email_log with status and error text. New GET /projects/:id/emails endpoint; Members page shows an Email log card. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 + ROADMAP.md | 22 +++++ .../backend/db/migrations/00014_email_log.sql | 16 +++ apps/backend/db/queries/email_log.sql | 10 ++ .../internal/database/db/email_log.sql.go | 91 +++++++++++++++++ apps/backend/internal/database/db/models.go | 11 +++ apps/backend/internal/database/db/querier.go | 2 + apps/backend/internal/handlers/http.go | 1 + apps/backend/internal/handlers/inbound.go | 20 ++++ apps/backend/internal/services/alerting.go | 11 ++- apps/backend/internal/services/jobs.go | 12 ++- apps/backend/internal/services/leader.go | 98 +++++++++++++++++++ apps/backend/internal/services/mailer.go | 30 +++++- apps/backend/internal/services/mailer_test.go | 37 +++++++ .../internal/services/platform_service.go | 78 ++++++++++++++- apps/backend/openapi/openapi.yaml | 54 ++++++++++ apps/frontend/src/App.tsx | 2 + apps/frontend/src/pages/MembersPage.tsx | 66 ++++++++++++- packages/api-client/src/generated/index.ts | 5 + .../src/generated/models/EmailLogEntry.ts | 20 ++++ .../generated/models/EmailLogListResponse.ts | 9 ++ .../src/generated/schemas/$EmailLogEntry.ts | 37 +++++++ .../schemas/$EmailLogListResponse.ts | 15 +++ .../src/generated/services/EmailService.ts | 36 +++++++ 24 files changed, 669 insertions(+), 16 deletions(-) create mode 100644 apps/backend/db/migrations/00014_email_log.sql create mode 100644 apps/backend/db/queries/email_log.sql create mode 100644 apps/backend/internal/database/db/email_log.sql.go create mode 100644 apps/backend/internal/services/leader.go create mode 100644 apps/backend/internal/services/mailer_test.go create mode 100644 packages/api-client/src/generated/models/EmailLogEntry.ts create mode 100644 packages/api-client/src/generated/models/EmailLogListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$EmailLogEntry.ts create mode 100644 packages/api-client/src/generated/schemas/$EmailLogListResponse.ts create mode 100644 packages/api-client/src/generated/services/EmailService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8a983..cf2cc01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **Telemetry alert rules** (`core.alert_rules`, migration 00012) — `heartbeat_silence` (component quiet for N minutes) and `error_spike` (≥N errors in a sliding window) rules per project, evaluated every 60 s. Rules fire on state transitions only: `alert.fired` once when breached, `alert.resolved` on recovery — both are first-class webhook event types and ride the existing signed dispatcher + realtime stream. API: `GET/POST /projects/:id/alerts`, `PUT/DELETE …/alerts/:ruleID`; dashboard manages them from Integrations → Webhooks; `firing` shows the breached components. - **Inbound webhooks** (`core.inbound_hooks`, migration 00013) — public ingest endpoints `POST /api/v1/hooks/` (the token is the credential). `event` mode republishes the body as `inbound.received` domain events through the signed dispatcher + realtime; `job` mode triggers a scheduled-job run with the received body as its payload (`triggered_by: "hook"`). Optional `secret` requires `X-Primora-Signature` HMAC-SHA256 — the same scheme outbound deliveries use. Bodies capped at 256 KiB. Managed from Integrations → Webhooks. - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. +- **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test. +- **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index e970fe4..f402f2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -331,6 +331,28 @@ Project-scoped public ingest for external systems. the received body as payload (`triggered_by: "hook"`). - 256 KiB body cap; `last_received_at` tracked; managed from Integrations. +## HA scheduler (unreleased) + +Multi-replica safety for the job scheduler and alert evaluator. + +- PostgreSQL advisory locks (session-scoped, separate keys per component) + on a dedicated pinned pool connection; only the leader scans for due work. +- Manual runs and hook-triggered runs work on any replica — the lock gates + only the scheduled scan, so leadership loss never blocks on-demand work. +- Released on shutdown; failover verified with a live two-replica test — + the survivor acquired leadership and fired the due job. + +## Email surface (unreleased) + +Reusable templates + an operational send log. + +- `core.email_log` (migration 00014) — every send recorded: template, + recipient, subject, `sent`/`failed` status, error text. +- `Mailer` stays transport-only (Resend when configured, SMTP otherwise); + templates render subject + plaintext body. First template: `invitation`. +- `GET /projects/:id/emails` lists recent sends; Members page shows an + "Email log" card under pending invitations. + ## Future phases — candidates Ordered loosely by leverage. None committed; each gets scoped when picked. diff --git a/apps/backend/db/migrations/00014_email_log.sql b/apps/backend/db/migrations/00014_email_log.sql new file mode 100644 index 0000000..fcf22ad --- /dev/null +++ b/apps/backend/db/migrations/00014_email_log.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE core.email_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID REFERENCES core.projects(id) ON DELETE CASCADE, + template TEXT NOT NULL, + to_email TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('sent','failed')), + error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_email_log_project ON core.email_log(project_id, created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS core.email_log; diff --git a/apps/backend/db/queries/email_log.sql b/apps/backend/db/queries/email_log.sql new file mode 100644 index 0000000..d1c2d50 --- /dev/null +++ b/apps/backend/db/queries/email_log.sql @@ -0,0 +1,10 @@ +-- name: InsertEmailLog :one +INSERT INTO core.email_log (project_id, template, to_email, subject, status, error) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: ListEmailLog :many +SELECT * FROM core.email_log +WHERE project_id = $1 +ORDER BY created_at DESC +LIMIT $2; diff --git a/apps/backend/internal/database/db/email_log.sql.go b/apps/backend/internal/database/db/email_log.sql.go new file mode 100644 index 0000000..6ec05dd --- /dev/null +++ b/apps/backend/internal/database/db/email_log.sql.go @@ -0,0 +1,91 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: email_log.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertEmailLog = `-- name: InsertEmailLog :one +INSERT INTO core.email_log (project_id, template, to_email, subject, status, error) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, project_id, template, to_email, subject, status, error, created_at +` + +type InsertEmailLogParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error"` +} + +func (q *Queries) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) { + row := q.db.QueryRow(ctx, insertEmailLog, + arg.ProjectID, + arg.Template, + arg.ToEmail, + arg.Subject, + arg.Status, + arg.Error, + ) + var i CoreEmailLog + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Template, + &i.ToEmail, + &i.Subject, + &i.Status, + &i.Error, + &i.CreatedAt, + ) + return i, err +} + +const listEmailLog = `-- name: ListEmailLog :many +SELECT id, project_id, template, to_email, subject, status, error, created_at FROM core.email_log +WHERE project_id = $1 +ORDER BY created_at DESC +LIMIT $2 +` + +type ListEmailLogParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) { + rows, err := q.db.Query(ctx, listEmailLog, arg.ProjectID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreEmailLog + for rows.Next() { + var i CoreEmailLog + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Template, + &i.ToEmail, + &i.Subject, + &i.Status, + &i.Error, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index c1d6e94..23f06dd 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -243,6 +243,17 @@ type CoreDocument struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type CoreEmailLog struct { + ID uuid.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type CoreEvent struct { ID int64 `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index 53e32dc..e99b449 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -84,6 +84,7 @@ type Querier interface { 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) + InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) @@ -100,6 +101,7 @@ type Querier interface { ListDBConnections(ctx context.Context, projectID uuid.UUID) ([]CoreDbConnection, error) ListDocuments(ctx context.Context, arg ListDocumentsParams) ([]CoreDocument, error) ListDueScheduledJobs(ctx context.Context, nextRunAt pgtype.Timestamptz) ([]CoreScheduledJob, error) + ListEmailLog(ctx context.Context, arg ListEmailLogParams) ([]CoreEmailLog, error) ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index a3d0002..c6e486f 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -83,6 +83,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks) api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook) api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook) + api.GET("/projects/:projectID/emails", h.listEmailLog) api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/components", h.listComponents) diff --git a/apps/backend/internal/handlers/inbound.go b/apps/backend/internal/handlers/inbound.go index acc9a87..bafa9d5 100644 --- a/apps/backend/internal/handlers/inbound.go +++ b/apps/backend/internal/handlers/inbound.go @@ -4,6 +4,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -80,6 +81,25 @@ func (h *HTTPHandler) deleteInboundHook(c *gin.Context) { c.Status(http.StatusNoContent) } +func (h *HTTPHandler) listEmailLog(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var limit int64 + if raw := c.Query("limit"); raw != "" { + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + limit = n + } + } + result, err := h.Platform.ListEmailLog(c.Request.Context(), actor, projectID, limit) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + // receiveInboundHook is the public ingest endpoint — the URL token is the // credential, so no actor is required. func (h *HTTPHandler) receiveInboundHook(c *gin.Context) { diff --git a/apps/backend/internal/services/alerting.go b/apps/backend/internal/services/alerting.go index 587a3d4..01a2327 100644 --- a/apps/backend/internal/services/alerting.go +++ b/apps/backend/internal/services/alerting.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" db "github.com/tdvorak/primora/apps/backend/internal/database/db" "github.com/tdvorak/primora/apps/backend/internal/models" @@ -239,10 +240,11 @@ type alertEvaluator struct { quit chan struct{} wg sync.WaitGroup started sync.Once + leader *leaderState } -func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger) *alertEvaluator { - return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{})} +func newAlertEvaluator(q alertQueries, publish func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), logger *slog.Logger, pool *pgxpool.Pool) *alertEvaluator { + return &alertEvaluator{q: q, publish: publish, logger: logger, tick: time.Minute, quit: make(chan struct{}), leader: newLeaderState(pool, leaderKeyAlerts)} } func (e *alertEvaluator) Start(ctx context.Context) { @@ -255,8 +257,11 @@ func (e *alertEvaluator) Start(ctx context.Context) { for { select { case <-t.C: - e.scan(ctx) + if e.leader.hold(ctx) { + e.scan(ctx) + } case <-e.quit: + e.leader.stop(context.Background()) return case <-ctx.Done(): return diff --git a/apps/backend/internal/services/jobs.go b/apps/backend/internal/services/jobs.go index 8421fd9..0a55ae8 100644 --- a/apps/backend/internal/services/jobs.go +++ b/apps/backend/internal/services/jobs.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" "github.com/robfig/cron/v3" db "github.com/tdvorak/primora/apps/backend/internal/database/db" @@ -406,6 +407,7 @@ type JobScheduler struct { quit chan struct{} wg sync.WaitGroup started sync.Once + leader *leaderState // onEvent fans a finished run out to webhooks and the realtime stream. onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any) } @@ -415,7 +417,7 @@ type jobExec struct { run db.CoreScheduledJobRun } -func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any)) *JobScheduler { +func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, onEvent func(ctx context.Context, projectID uuid.UUID, eventType string, data map[string]any), pool *pgxpool.Pool) *JobScheduler { if logger == nil { logger = slog.Default() } @@ -428,6 +430,7 @@ func NewJobScheduler(q jobQueries, enc *secrets.Encryptor, logger *slog.Logger, queue: make(chan jobExec, 256), quit: make(chan struct{}), onEvent: onEvent, + leader: newLeaderState(pool, leaderKeyScheduler), } } @@ -452,8 +455,13 @@ func (j *JobScheduler) run(ctx context.Context) { case exec := <-j.queue: j.execute(exec.job, exec.run) case <-ticker.C: - j.scan() + // Only the leader fires scheduled runs — manual/hook enqueues are + // already scoped to whichever replica received the request. + if j.leader.hold(ctx) { + j.scan() + } case <-j.quit: + j.leader.stop(context.Background()) return case <-ctx.Done(): return diff --git a/apps/backend/internal/services/leader.go b/apps/backend/internal/services/leader.go new file mode 100644 index 0000000..1e268b9 --- /dev/null +++ b/apps/backend/internal/services/leader.go @@ -0,0 +1,98 @@ +package services + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Leader election via Postgres advisory locks. The lock is session-scoped, so +// the lease pins its pool connection for its entire lifetime — a handoff back +// to the pool would silently drop it. A single-replica deployment simply +// always holds the lock; there is no feature flag. +// +// Distinct keys per component so the job scheduler and the alert evaluator +// elect independently. +const ( + leaderKeyScheduler int64 = 728201 + leaderKeyAlerts int64 = 728202 +) + +type leaderLease struct { + conn *pgxpool.Conn + key int64 +} + +// acquireLeadership returns a lease when this process wins the lock, nil when +// another replica holds it, or an error when the check itself failed. +func acquireLeadership(ctx context.Context, pool *pgxpool.Pool, key int64) (*leaderLease, error) { + conn, err := pool.Acquire(ctx) + if err != nil { + return nil, err + } + var got bool + if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&got); err != nil { + conn.Release() + return nil, err + } + if !got { + conn.Release() + return nil, nil + } + return &leaderLease{conn: conn, key: key}, nil +} + +// alive pings the pinned connection — a dead conn means Postgres dropped the +// session and released the lock, so leadership is lost even though the lease +// object still exists. +func (l *leaderLease) alive(ctx context.Context) bool { + var one int + return l.conn.QueryRow(ctx, "SELECT 1").Scan(&one) == nil +} + +func (l *leaderLease) release(ctx context.Context) { + _, _ = l.conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", l.key) + l.conn.Release() +} + +// leaderState tracks a lease inside a ticker loop — acquire when absent, +// verify when held, drop and re-elect when the connection dies. +type leaderState struct { + pool *pgxpool.Pool + key int64 + lease *leaderLease +} + +func newLeaderState(pool *pgxpool.Pool, key int64) *leaderState { + return &leaderState{pool: pool, key: key} +} + +// hold reports whether this process currently leads. A nil pool means no +// election configured (tests, embedded use) — always lead. +func (l *leaderState) hold(ctx context.Context) bool { + if l == nil || l.pool == nil { + return true + } + if l.lease != nil { + if l.lease.alive(ctx) { + return true + } + l.lease.release(ctx) + l.lease = nil + } + lease, err := acquireLeadership(ctx, l.pool, l.key) + if err != nil { + return false + } + l.lease = lease + return lease != nil +} + +func (l *leaderState) stop(ctx context.Context) { + if l != nil && l.lease != nil { + l.lease.release(ctx) + l.lease = nil + } +} + + diff --git a/apps/backend/internal/services/mailer.go b/apps/backend/internal/services/mailer.go index 49d940b..7652258 100644 --- a/apps/backend/internal/services/mailer.go +++ b/apps/backend/internal/services/mailer.go @@ -11,6 +11,9 @@ import ( "github.com/tdvorak/primora/apps/backend/internal/config" ) +// Mailer is transport-only: Resend when configured, plain SMTP otherwise. +// Templates render (subject, body) from data; sends are logged to +// core.email_log by the service layer so operators can see what went out. type Mailer struct { cfg config.Config resend *resend.Client @@ -24,9 +27,30 @@ func NewMailer(cfg config.Config) *Mailer { return &Mailer{cfg: cfg, resend: resendClient} } -func (m *Mailer) SendInvitation(ctx context.Context, toEmail, organizationName, inviteURL string) error { - subject := fmt.Sprintf("You were invited to %s on Primora", organizationName) - text := "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n" + inviteURL + "\n" +// mailTemplate renders a subject + plaintext body from template data. +type mailTemplate func(data map[string]string) (subject string, body string) + +var mailTemplates = map[string]mailTemplate{ + "invitation": func(d map[string]string) (string, string) { + subject := fmt.Sprintf("You were invited to %s on Primora", d["organization"]) + body := fmt.Sprintf( + "You have been invited to Primora.\n\nOpen this link to accept the invitation:\n%s\n\nThis link expires in 72 hours.\n", + d["invite_url"], + ) + return subject, body + }, +} + +func renderMailTemplate(template string, data map[string]string) (string, string, error) { + tpl, ok := mailTemplates[template] + if !ok { + return "", "", fmt.Errorf("unknown email template %q", template) + } + subject, body := tpl(data) + return subject, body, nil +} + +func (m *Mailer) Send(ctx context.Context, toEmail, subject, text string) error { if m.resend != nil { _, err := m.resend.Emails.SendWithContext(ctx, &resend.SendEmailRequest{ From: m.cfg.MailFrom, diff --git a/apps/backend/internal/services/mailer_test.go b/apps/backend/internal/services/mailer_test.go new file mode 100644 index 0000000..c1912ef --- /dev/null +++ b/apps/backend/internal/services/mailer_test.go @@ -0,0 +1,37 @@ +package services + +import ( + "strings" + "testing" +) + +func TestRenderMailTemplateInvitation(t *testing.T) { + subject, body, err := renderMailTemplate("invitation", map[string]string{ + "organization": "Acme Corp", + "invite_url": "https://app.primora.dev/accept?token=abc", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(subject, "Acme Corp") { + t.Fatalf("subject missing organization: %q", subject) + } + if !strings.Contains(body, "https://app.primora.dev/accept?token=abc") { + t.Fatalf("body missing invite url: %q", body) + } +} + +func TestRenderMailTemplateUnknown(t *testing.T) { + if _, _, err := renderMailTemplate("does-not-exist", nil); err == nil { + t.Fatal("expected error for unknown template") + } +} + +func TestExtractEmail(t *testing.T) { + if got := extractEmail("Primora "); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail = %q", got) + } + if got := extractEmail("no-reply@primora.dev"); got != "no-reply@primora.dev" { + t.Fatalf("extractEmail bare = %q", got) + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 6b3c6d5..4996b9b 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -229,14 +229,78 @@ func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalS if enc != nil { s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher.Start(context.Background()) - s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent) + s.scheduler = NewJobScheduler(repo.Queries(), enc, logger, s.publishEvent, repo.Pool()) s.scheduler.Start(context.Background()) - s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger) + s.alerts = newAlertEvaluator(repo.Queries(), s.publishEvent, logger, repo.Pool()) s.alerts.Start(context.Background()) } return s } +// sendTemplatedEmail renders a registered template, sends it, and writes an +// email_log row with the outcome — failed sends are still logged so the +// dashboard shows them. +func (s *PlatformService) sendTemplatedEmail(ctx context.Context, projectID *uuid.UUID, template, to string, data map[string]string) error { + subject, body, err := renderMailTemplate(template, data) + status, errText := "sent", "" + if err == nil { + if sendErr := s.mailer.Send(ctx, to, subject, body); sendErr != nil { + status, errText, err = "failed", sendErr.Error(), sendErr + } + } else { + status, errText = "failed", err.Error() + } + var pid pgtype.UUID + if projectID != nil { + pid = pgtype.UUID{Bytes: *projectID, Valid: true} + } + _, _ = s.repo.Queries().InsertEmailLog(ctx, db.InsertEmailLogParams{ + ProjectID: pid, + Template: template, + ToEmail: to, + Subject: subject, + Status: status, + Error: errText, + }) + return err +} + +type EmailLogSummary struct { + ID string `json:"id"` + Template string `json:"template"` + ToEmail string `json:"to_email"` + Subject string `json:"subject"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *PlatformService) ListEmailLog(ctx context.Context, actor *models.Actor, projectID uuid.UUID, limit int64) ([]EmailLogSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.repo.Queries().ListEmailLog(ctx, db.ListEmailLogParams{ProjectID: pgtype.UUID{Bytes: projectID, Valid: true}, Limit: int32(limit)}) + if err != nil { + return nil, err + } + out := make([]EmailLogSummary, len(rows)) + for i, r := range rows { + out[i] = EmailLogSummary{ + ID: r.ID.String(), + Template: r.Template, + ToEmail: r.ToEmail, + Subject: r.Subject, + Status: r.Status, + Error: r.Error, + CreatedAt: r.CreatedAt.Time, + } + } + return out, nil +} + // Close stops the webhook dispatcher and job scheduler. Idempotent. func (s *PlatformService) Close() { if s.dispatcher != nil { @@ -913,7 +977,15 @@ func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Ac if err != nil { inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token } - if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil { + var inviteProject *uuid.UUID + if projectUUID.Valid { + id := uuid.UUID(projectUUID.Bytes) + inviteProject = &id + } + if err := s.sendTemplatedEmail(ctx, inviteProject, "invitation", invitation.Email, map[string]string{ + "organization": "Primora", + "invite_url": inviteURL, + }); err != nil { return nil, err } return map[string]any{ diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index 74948bf..feb7283 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -2142,6 +2142,31 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/emails: + get: + tags: [Email] + operationId: listEmailLog + summary: Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 200 + responses: + "200": + description: Email log entries + content: + application/json: + schema: + $ref: "#/components/schemas/EmailLogListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -4159,3 +4184,32 @@ components: run_id: type: string required: [received] + EmailLogEntry: + type: object + properties: + id: + type: string + format: uuid + template: + type: string + to_email: + type: string + subject: + type: string + status: + type: string + enum: [sent, failed] + error: + type: string + created_at: + type: string + format: date-time + required: [id, template, to_email, subject, status, created_at] + EmailLogListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/EmailLogEntry" + required: [items] diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index b1d65ae..1829d5c 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1722,6 +1722,8 @@ export default function App() { ("org"); const [inviteOpen, setInviteOpen] = createSignal(false); const [query, setQuery] = createSignal(""); + const [emails, setEmails] = createSignal([]); + + createEffect(() => { + const pid = props.projectID; + if (!pid || props.demoMode) return; + void EmailService.listEmailLog({ projectId: pid, limit: 20 }) + .then((res) => setEmails(res.items ?? [])) + .catch(() => {}); + }); const filteredOrgMembers = createMemo(() => { const q = query().toLowerCase(); @@ -368,6 +381,49 @@ export function MembersPage(props: MembersPageProps) { + 0}> +
+
+ Email log + + Transactional email sent for this project — every send is recorded, including failures. + +
+
+ + + + + + + + + + + + + {(mail) => ( + + + + + + + + )} + + +
ToSubjectTemplateStatusSent
{mail.to_email} + {mail.subject} + + {mail.template} + + {mail.status} + {formatDate(mail.created_at)}
+
+
+
+ {/* Invite modal */} ; +}; + diff --git a/packages/api-client/src/generated/schemas/$EmailLogEntry.ts b/packages/api-client/src/generated/schemas/$EmailLogEntry.ts new file mode 100644 index 0000000..900a9f2 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$EmailLogEntry.ts @@ -0,0 +1,37 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $EmailLogEntry = { + properties: { + id: { + type: 'string', + isRequired: true, + format: 'uuid', + }, + template: { + type: 'string', + isRequired: true, + }, + to_email: { + type: 'string', + isRequired: true, + }, + subject: { + type: 'string', + isRequired: true, + }, + status: { + type: 'Enum', + isRequired: true, + }, + error: { + type: 'string', + }, + created_at: { + type: 'string', + isRequired: true, + format: 'date-time', + }, + }, +} as const; diff --git a/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts b/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts new file mode 100644 index 0000000..82b0f24 --- /dev/null +++ b/packages/api-client/src/generated/schemas/$EmailLogListResponse.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export const $EmailLogListResponse = { + properties: { + items: { + type: 'array', + contains: { + type: 'EmailLogEntry', + }, + isRequired: true, + }, + }, +} as const; diff --git a/packages/api-client/src/generated/services/EmailService.ts b/packages/api-client/src/generated/services/EmailService.ts new file mode 100644 index 0000000..116e585 --- /dev/null +++ b/packages/api-client/src/generated/services/EmailService.ts @@ -0,0 +1,36 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { EmailLogListResponse } from '../models/EmailLogListResponse'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class EmailService { + /** + * Recent transactional emails sent for this project (invitations etc.) — every send is logged with its outcome. + * @returns EmailLogListResponse Email log entries + * @throws ApiError + */ + public static listEmailLog({ + projectId, + limit = 50, + }: { + projectId: string, + limit?: number, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/projects/{projectID}/emails', + path: { + 'projectID': projectId, + }, + query: { + 'limit': limit, + }, + errors: { + 401: `Error response`, + }, + }); + } +} From 3aca5bd1e05a9e017f1b20ab872d53cd26e5ac33 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:38:50 +0200 Subject: [PATCH 04/18] feat: S3-compatible object storage backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKEND_STORAGE_DRIVER=s3 swaps LocalStore for a stdlib-only SigV4 implementation — PutObject/GetObject/DeleteObject/CopyObject/ListObjectsV2 against any S3-compatible API (AWS, MinIO, Garage, R2). Storage becomes a Store interface; storage_path records s3://bucket/key. Content-Length is resolved by seeking or temp-file spooling — chunked uploads are rejected by real S3 servers. Bucket auto-created on startup. Verified live against zenko/cloudserver: bucket, put, get, copy, delete. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 11 + CHANGELOG.md | 1 + DEPLOYMENT_GUIDE.md | 6 +- ROADMAP.md | 16 +- apps/backend/internal/app/app.go | 27 +- apps/backend/internal/config/config.go | 42 +- .../internal/services/platform_service.go | 4 +- apps/backend/internal/storage/local.go | 2 +- apps/backend/internal/storage/s3.go | 486 ++++++++++++++++++ apps/backend/internal/storage/s3_test.go | 216 ++++++++ apps/backend/internal/storage/store.go | 16 + 11 files changed, 810 insertions(+), 17 deletions(-) create mode 100644 apps/backend/internal/storage/s3.go create mode 100644 apps/backend/internal/storage/s3_test.go create mode 100644 apps/backend/internal/storage/store.go diff --git a/.env.example b/.env.example index 9ac12dd..e514954 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,17 @@ BACKEND_PUBLIC_URL=http://localhost/api/v1 BACKEND_INTERNAL_URL=http://backend:8080 BACKEND_STORAGE_ROOT=/data/storage +# Object storage driver: "local" (default, uses BACKEND_STORAGE_ROOT) or "s3". +# For s3, point S3_ENDPOINT at any S3-compatible API (AWS, MinIO, Garage, R2). +BACKEND_STORAGE_DRIVER=local +#S3_ENDPOINT=http://minio:9000 +#S3_REGION=us-east-1 +#S3_BUCKET=primora-objects +#S3_ACCESS_KEY_ID= +#S3_SECRET_ACCESS_KEY= +#S3_PREFIX= +#S3_PATH_STYLE=true + # Mail defaults — each is overridable in-app (Settings → Instance). In-app # values win over these env vars; deleting the in-app value reverts here. RESEND_API_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2cc01..46184bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **Document filtering and ordering** — `GET /collections/:id/documents` accepts `filter` (comma-separated `field.op.value`; ops `eq neq gt gte lt lte like in is`; dotted paths index into document data, `id`/`created_at`/`updated_at` filterable) and `order` (`field.asc|field.desc` list). Whitelisted parsing + parameterized values; plain lists unchanged. CLI: `primora documents list --collection --filter … --order …`. - **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test. - **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations. +- **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete). ## [0.7.1] - 2026-09-23 diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 5dc1e93..6132c43 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -41,7 +41,11 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d | `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes | | `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes | -| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container | yes | +| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes | +| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no | +| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3..amazonaws.com`), region, bucket | with `s3` | +| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` | +| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no | | `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production | diff --git a/ROADMAP.md b/ROADMAP.md index f402f2d..1f1a698 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -360,14 +360,14 @@ Ordered loosely by leverage. None committed; each gets scoped when picked. - **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. The job runner already supplies the scheduling half. -- **Phase 10 — Storage backends** — S3-compatible object storage behind - the existing bucket abstraction (deferred from Phase 6), plus - presigned-URL uploads for large files. -- **HA mode** — leader-elected scheduler + distributed locks so two - backend replicas don't double-fire jobs; Postgres advisory locks are - enough at this scale. -- **Email surface** — transactional templates + send log in the - dashboard, backed by the auth service's existing SMTP/Resend wiring. +- **Phase 10 — Storage backends** — ✅ S3-compatible object storage shipped: + `BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified + against a real S3 server. Presigned-URL uploads for large files remain a + candidate. +- **HA mode** — ✅ shipped: PostgreSQL advisory-lock leadership for the + scheduler and alert evaluator; failover verified with two replicas. +- **Email surface** — ✅ shipped: templated sends + `core.email_log` + + dashboard card; more templates land as new flows need them. --- diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index f118103..9680c83 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -81,9 +81,30 @@ func Bootstrap(ctx context.Context) (*App, error) { } } - store, err := storage.NewLocalStore(cfg.StorageRoot) - if err != nil { - return nil, err + var store storage.Store + if cfg.StorageDriver == "s3" { + s3Store, err := storage.NewS3Store(storage.S3Config{ + Endpoint: cfg.S3Endpoint, + Region: cfg.S3Region, + Bucket: cfg.S3Bucket, + AccessKeyID: cfg.S3AccessKeyID, + SecretAccessKey: cfg.S3SecretAccessKey, + Prefix: cfg.S3Prefix, + PathStyle: cfg.S3PathStyle, + }) + if err != nil { + return nil, fmt.Errorf("s3 storage: %w", err) + } + if err := s3Store.EnsureBucket(context.Background()); err != nil { + return nil, err + } + store = s3Store + } else { + localStore, err := storage.NewLocalStore(cfg.StorageRoot) + if err != nil { + return nil, err + } + store = localStore } dbxClient := dbx.NewClient(logger, cfg.DBXDataDir) repo := repositories.NewCoreRepository(dbPool) diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 972f26c..f699364 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -35,6 +35,16 @@ type Config struct { SMTPSecure bool EncryptionKey string + + // Object storage: "local" (BACKEND_STORAGE_ROOT) or "s3". + StorageDriver string + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKeyID string + S3SecretAccessKey string + S3Prefix string + S3PathStyle bool } // devEncryptionKey is the built-in credential key for local development only. @@ -63,6 +73,13 @@ func Load() (Config, error) { SMTPUser: os.Getenv("SMTP_USER"), SMTPPassword: os.Getenv("SMTP_PASSWORD"), EncryptionKey: os.Getenv("PRIMORA_ENCRYPTION_KEY"), + StorageDriver: getenv("BACKEND_STORAGE_DRIVER", "local"), + S3Endpoint: os.Getenv("S3_ENDPOINT"), + S3Region: getenv("S3_REGION", "us-east-1"), + S3Bucket: os.Getenv("S3_BUCKET"), + S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), + S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), + S3Prefix: os.Getenv("S3_PREFIX"), } smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025")) @@ -95,6 +112,12 @@ func Load() (Config, error) { } cfg.SMTPSecure = smtpSecure + s3PathStyle, err := strconv.ParseBool(getenv("S3_PATH_STYLE", "true")) + if err != nil { + return Config{}, fmt.Errorf("parse S3_PATH_STYLE: %w", err) + } + cfg.S3PathStyle = s3PathStyle + var missing []string if cfg.DatabaseURL == "" { missing = append(missing, "DATABASE_URL") @@ -102,8 +125,23 @@ func Load() (Config, error) { if cfg.JWTSecret == "" { missing = append(missing, "JWT_SECRET") } - if cfg.StorageRoot == "" { - missing = append(missing, "BACKEND_STORAGE_ROOT") + switch cfg.StorageDriver { + case "local": + if cfg.StorageRoot == "" { + missing = append(missing, "BACKEND_STORAGE_ROOT") + } + case "s3": + if cfg.S3Bucket == "" { + missing = append(missing, "S3_BUCKET") + } + if cfg.S3AccessKeyID == "" { + missing = append(missing, "S3_ACCESS_KEY_ID") + } + if cfg.S3SecretAccessKey == "" { + missing = append(missing, "S3_SECRET_ACCESS_KEY") + } + default: + return Config{}, fmt.Errorf("unknown BACKEND_STORAGE_DRIVER %q (want local or s3)", cfg.StorageDriver) } if cfg.AuthInternalBaseURL == "" { missing = append(missing, "AUTH_INTERNAL_BASE_URL") diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index 4996b9b..e693c58 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -29,7 +29,7 @@ import ( type PlatformService struct { repo *repositories.CoreRepository - store *storage.LocalStore + store storage.Store mailer *Mailer publicURL string dbx *dbx.Client @@ -224,7 +224,7 @@ type InvitationSummary struct { Status string `json:"status"` } -func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { +func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc} if enc != nil { s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) diff --git a/apps/backend/internal/storage/local.go b/apps/backend/internal/storage/local.go index 249cfe1..7a4a9d9 100644 --- a/apps/backend/internal/storage/local.go +++ b/apps/backend/internal/storage/local.go @@ -65,7 +65,7 @@ func (s *LocalStore) Put(ctx context.Context, bucketID, objectKey string, reader }, nil } -func (s *LocalStore) Open(bucketID, objectKey string) (*os.File, string, error) { +func (s *LocalStore) Open(bucketID, objectKey string) (io.ReadCloser, string, error) { cleanKey, err := sanitizeObjectKey(objectKey) if err != nil { return nil, "", err diff --git a/apps/backend/internal/storage/s3.go b/apps/backend/internal/storage/s3.go new file mode 100644 index 0000000..7081b17 --- /dev/null +++ b/apps/backend/internal/storage/s3.go @@ -0,0 +1,486 @@ +package storage + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "sort" + "strings" + "time" +) + +// S3Config configures an S3-compatible object store (AWS S3, MinIO, +// Cloudflare R2, Garage, …). Endpoint may be empty for AWS, in which case +// the standard regional endpoint is derived from Region + Bucket. +type S3Config struct { + Endpoint string // e.g. http://localhost:9000 or https://s3.eu-central-1.amazonaws.com + Region string + Bucket string + AccessKeyID string + SecretAccessKey string + Prefix string // optional key prefix, e.g. "primora/" + PathStyle bool // bucket/key path style (MinIO, Garage) vs virtual-hosted + HTTPClient *http.Client +} + +// S3Store implements Store against an S3-compatible API using stdlib-only +// SigV4 signing — no AWS SDK dependency. Payloads are sent with +// x-amz-content-sha256=UNSIGNED-PAYLOAD so bodies stream without buffering; +// the SHA-256 for PutResult is computed on a hashing tee instead. +// jarvis: ceiling UNSIGNED-PAYLOAD needs TLS on AWS proper; over plain +// http:// it relies on the server accepting it (MinIO/Garage/LocalStack do). +// Upgrade to aws-chunked streaming signatures if a backend rejects it. +type S3Store struct { + cfg S3Config + client *http.Client + now func() time.Time // injectable for tests +} + +func NewS3Store(cfg S3Config) (*S3Store, error) { + if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { + return nil, fmt.Errorf("s3 storage requires S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY") + } + if cfg.Region == "" { + cfg.Region = "us-east-1" + } + if cfg.Endpoint == "" { + cfg.Endpoint = fmt.Sprintf("https://s3.%s.amazonaws.com", cfg.Region) + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 5 * time.Minute} + } + return &S3Store{cfg: cfg, client: client, now: time.Now}, nil +} + +func escapeKey(key string) string { + parts := strings.Split(key, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +func (s *S3Store) Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) { + key, err := s.objectKey(bucketID, objectKey) + if err != nil { + return PutResult{}, err + } + + // S3 requires a Content-Length — chunked transfer encoding is rejected. + // Seekable readers (uploaded *os.File) give the length for free; + // anything else is spooled to a temp file, same cost the local driver + // pays on every write anyway. + body, length, cleanup, err := bodyWithLength(reader) + if err != nil { + return PutResult{}, err + } + defer cleanup() + + hasher := sha256.New() + counter := &countingReader{r: io.TeeReader(body, hasher)} + + req, err := s.signedRequest(ctx, http.MethodPut, key, counter) + if err != nil { + return PutResult{}, err + } + req.ContentLength = length + resp, err := s.client.Do(req) + if err != nil { + return PutResult{}, fmt.Errorf("s3 put: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return PutResult{}, fmt.Errorf("s3 put: %s: %s", resp.Status, readErrBody(resp)) + } + return PutResult{ + Path: fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), + SizeBytes: counter.n, + SHA256Digest: hex.EncodeToString(hasher.Sum(nil)), + }, nil +} + +// bodyWithLength returns a reader plus its total length. The cleanup func +// removes any temp file it created. +func bodyWithLength(r io.Reader) (io.Reader, int64, func(), error) { + nop := func() {} + if seeker, ok := r.(io.Seeker); ok { + size, err := seeker.Seek(0, io.SeekEnd) + if err != nil { + return nil, 0, nil, fmt.Errorf("seek object body: %w", err) + } + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return nil, 0, nil, fmt.Errorf("rewind object body: %w", err) + } + return r, size, nop, nil + } + tmp, err := os.CreateTemp("", "primora-s3-put-*") + if err != nil { + return nil, 0, nil, fmt.Errorf("spool object body: %w", err) + } + size, err := io.Copy(tmp, r) + if err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return nil, 0, nil, fmt.Errorf("spool object body: %w", err) + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return nil, 0, nil, err + } + cleanup := func() { + tmp.Close() + os.Remove(tmp.Name()) + } + return tmp, size, cleanup, nil +} + +type countingReader struct { + r io.Reader + n int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += int64(n) + return n, err +} + +func (s *S3Store) Open(bucketID, objectKey string) (io.ReadCloser, string, error) { + key, err := s.objectKey(bucketID, objectKey) + if err != nil { + return nil, "", err + } + req, err := s.signedRequest(context.Background(), http.MethodGet, key, nil) + if err != nil { + return nil, "", err + } + resp, err := s.client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("s3 get: %w", err) + } + if resp.StatusCode/100 != 2 { + defer resp.Body.Close() + return nil, "", fmt.Errorf("s3 get: %s: %s", resp.Status, readErrBody(resp)) + } + return resp.Body, fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, key), nil +} + +func (s *S3Store) Delete(bucketID, objectKey string) error { + key, err := s.objectKey(bucketID, objectKey) + if err != nil { + return err + } + req, err := s.signedRequest(context.Background(), http.MethodDelete, key, nil) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("s3 delete: %w", err) + } + defer resp.Body.Close() + // S3 delete is idempotent: 204 on success, 404 is fine too. + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("s3 delete: %s: %s", resp.Status, readErrBody(resp)) + } + return nil +} + +func (s *S3Store) MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) { + src, err := s.objectKey(sourceBucketID, fromKey) + if err != nil { + return "", err + } + dst, err := s.objectKey(destinationBucketID, toKey) + if err != nil { + return "", err + } + // x-amz-copy-source must be set before signing — strict S3 + // implementations require all x-amz-* headers to be signed. + key := dst + u := strings.TrimSuffix(s.cfg.Endpoint, "/") + "/" + s.cfg.Bucket + "/" + escapeKey(key) + if !s.cfg.PathStyle { + parsed, perr := url.Parse(strings.TrimSuffix(s.cfg.Endpoint, "/")) + if perr != nil { + return "", perr + } + parsed.Host = s.cfg.Bucket + "." + parsed.Host + parsed.Path = "/" + escapeKey(key) + u = parsed.String() + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, u, nil) + if err != nil { + return "", err + } + req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + req.Header.Set("x-amz-copy-source", "/"+s.cfg.Bucket+"/"+escapeKey(src)) + s.sign(req) + resp, err := s.client.Do(req) + if err != nil { + return "", fmt.Errorf("s3 copy: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", fmt.Errorf("s3 copy: %s: %s", resp.Status, readErrBody(resp)) + } + if err := s.Delete(sourceBucketID, fromKey); err != nil { + return "", fmt.Errorf("s3 move: delete source: %w", err) + } + return fmt.Sprintf("s3://%s/%s", s.cfg.Bucket, dst), nil +} + +// DeleteBucket lists every object under the bucket prefix and deletes them +// one by one — DeleteObjects batching is an optimization not worth the code +// at this scale. +func (s *S3Store) DeleteBucket(bucketID string) error { + prefix := path.Join(s.cfg.Prefix, bucketID) + "/" + continuation := "" + for { + keys, next, err := s.listPage(prefix, continuation) + if err != nil { + return err + } + for _, k := range keys { + req, err := s.signedRequest(context.Background(), http.MethodDelete, k, nil) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("s3 delete %s: %w", k, err) + } + resp.Body.Close() + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("s3 delete %s: %s", k, resp.Status) + } + } + if next == "" { + return nil + } + continuation = next + } +} + +type listResult struct { + Contents []struct{ Key string `xml:"Key"` } `xml:"Contents"` + NextContinuationToken string `xml:"NextContinuationToken"` + IsTruncated bool `xml:"IsTruncated"` +} + +func (s *S3Store) listPage(prefix, continuation string) ([]string, string, error) { + query := url.Values{ + "list-type": {"2"}, + "prefix": {prefix}, + "max-keys": {"1000"}, + } + if continuation != "" { + query.Set("continuation-token", continuation) + } + req, err := s.signedRequestWithQuery(context.Background(), http.MethodGet, "", query) + if err != nil { + return nil, "", err + } + resp, err := s.client.Do(req) + if err != nil { + return nil, "", fmt.Errorf("s3 list: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return nil, "", fmt.Errorf("s3 list: %s: %s", resp.Status, readErrBody(resp)) + } + var out listResult + if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, "", fmt.Errorf("s3 list decode: %w", err) + } + keys := make([]string, len(out.Contents)) + for i, c := range out.Contents { + keys[i] = c.Key + } + next := out.NextContinuationToken + if next == "" && out.IsTruncated && len(keys) > 0 { + next = keys[len(keys)-1] // S3 uses last key as marker when no token + } + return keys, next, nil +} + +// --- SigV4 signing ------------------------------------------------------- + +func (s *S3Store) signedRequest(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) { + return s.signed(ctx, method, key, nil, body) +} + +func (s *S3Store) signedRequestWithQuery(ctx context.Context, method, key string, query url.Values) (*http.Request, error) { + return s.signed(ctx, method, key, query, nil) +} + +func (s *S3Store) signed(ctx context.Context, method, key string, query url.Values, body io.Reader) (*http.Request, error) { + var reqURL string + endpoint := strings.TrimSuffix(s.cfg.Endpoint, "/") + if s.cfg.PathStyle { + reqURL = endpoint + "/" + s.cfg.Bucket + if key != "" { + reqURL += "/" + escapeKey(key) + } + } else { + u, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("s3 endpoint: %w", err) + } + u.Host = s.cfg.Bucket + "." + u.Host + u.Path = "/" + escapeKey(key) + reqURL = u.String() + } + if len(query) > 0 { + reqURL += "?" + query.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) + if err != nil { + return nil, err + } + req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + s.sign(req) + return req, nil +} + +// sign applies AWS Signature Version 4 to req in place. +func (s *S3Store) sign(req *http.Request) { + now := s.now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + req.Header.Set("x-amz-date", amzDate) + + // Canonical request: method, path, sorted query, headers, signed list, + // payload hash. Signed headers are host + every x-amz-* header present — + // required for x-amz-copy-source on CopyObject and future metadata + // headers. + signedHeaders := []string{"host"} + for h := range req.Header { + lh := strings.ToLower(h) + if strings.HasPrefix(lh, "x-amz-") { + signedHeaders = append(signedHeaders, lh) + } + } + sort.Strings(signedHeaders) + var canonicalHeaders strings.Builder + for _, h := range signedHeaders { + v := req.Host + if h != "host" { + v = strings.TrimSpace(req.Header.Get(h)) + } + canonicalHeaders.WriteString(h + ":" + v + "\n") + } + canonicalURI := req.URL.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI, + canonicalQuery(req.URL.Query()), + canonicalHeaders.String(), + strings.Join(signedHeaders, ";"), + "UNSIGNED-PAYLOAD", + }, "\n") + + scope := fmt.Sprintf("%s/%s/s3/aws4_request", dateStamp, s.cfg.Region) + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", + amzDate, + scope, + hexSHA256(canonicalRequest), + }, "\n") + + kDate := hmacSHA256([]byte("AWS4"+s.cfg.SecretAccessKey), dateStamp) + kRegion := hmacSHA256(kDate, s.cfg.Region) + kService := hmacSHA256(kRegion, "s3") + kSigning := hmacSHA256(kService, "aws4_request") + signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) + + req.Header.Set("Authorization", fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + s.cfg.AccessKeyID, scope, strings.Join(signedHeaders, ";"), signature)) +} + +// canonicalQuery re-encodes the query per SigV4: sorted keys, each name and +// value URI-encoded per RFC 3986 (url.Values.Encode's "+" for space is not +// acceptable here). +func canonicalQuery(q url.Values) string { + type pair struct{ k, v string } + pairs := make([]pair, 0, len(q)) + for k, vs := range q { + for _, v := range vs { + pairs = append(pairs, pair{sigV4Escape(k), sigV4Escape(v)}) + } + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].k == pairs[j].k { + return pairs[i].v < pairs[j].v + } + return pairs[i].k < pairs[j].k + }) + var b strings.Builder + for i, p := range pairs { + if i > 0 { + b.WriteByte('&') + } + b.WriteString(p.k + "=" + p.v) + } + return b.String() +} + +func sigV4Escape(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + +func hexSHA256(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func hmacSHA256(key []byte, data string) []byte { + m := hmac.New(sha256.New, key) + m.Write([]byte(data)) + return m.Sum(nil) +} + +// EnsureBucket creates the configured bucket if it does not exist. Called +// once at startup when the s3 driver is selected; a pre-existing bucket is +// a no-op. +func (s *S3Store) EnsureBucket(ctx context.Context) error { + req, err := s.signedRequest(ctx, http.MethodPut, "", nil) + if err != nil { + return err + } + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("s3 create bucket: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusConflict { + return fmt.Errorf("s3 create bucket: %s: %s", resp.Status, readErrBody(resp)) + } + return nil +} + +func (s *S3Store) objectKey(bucketID, objectKey string) (string, error) { + clean, err := sanitizeObjectKey(objectKey) + if err != nil { + return "", err + } + return path.Join(s.cfg.Prefix, bucketID, clean), nil +} + +func readErrBody(resp *http.Response) string { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return strings.TrimSpace(string(body)) +} diff --git a/apps/backend/internal/storage/s3_test.go b/apps/backend/internal/storage/s3_test.go new file mode 100644 index 0000000..f47df02 --- /dev/null +++ b/apps/backend/internal/storage/s3_test.go @@ -0,0 +1,216 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// fakeS3 is an in-memory S3-compatible server covering the ops S3Store uses. +type fakeS3 struct { + mu sync.Mutex + objects map[string][]byte + bucket string +} + +func newFakeS3(bucket string) *fakeS3 { + return &fakeS3{objects: map[string][]byte{}, bucket: bucket} +} + +func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + key := strings.TrimPrefix(r.URL.Path, "/"+f.bucket+"/") + if r.Header.Get("Authorization") == "" || r.Header.Get("x-amz-date") == "" { + http.Error(w, "missing auth", http.StatusForbidden) + return + } + switch { + case r.Method == http.MethodPut && r.Header.Get("x-amz-copy-source") != "": + src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/"+f.bucket+"/") + body, ok := f.objects[src] + if !ok { + http.Error(w, "source missing", http.StatusNotFound) + return + } + f.objects[key] = body + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodPut: + body, _ := io.ReadAll(r.Body) + f.objects[key] = body + w.WriteHeader(http.StatusOK) + case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2": + prefix := r.URL.Query().Get("prefix") + var out strings.Builder + out.WriteString(``) + for k := range f.objects { + if strings.HasPrefix(k, prefix) { + fmt.Fprintf(&out, "%s", k) + } + } + out.WriteString(``) + w.Header().Set("Content-Type", "application/xml") + w.Write([]byte(out.String())) + case r.Method == http.MethodGet: + body, ok := f.objects[key] + if !ok { + http.Error(w, "no such key", http.StatusNotFound) + return + } + w.Write(body) + case r.Method == http.MethodDelete: + delete(f.objects, key) + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "unsupported", http.StatusBadRequest) + } +} + +func newTestS3(t *testing.T) (*S3Store, *fakeS3) { + t.Helper() + fake := newFakeS3("primora-objects") + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + store, err := NewS3Store(S3Config{ + Endpoint: srv.URL, + Region: "us-east-1", + Bucket: "primora-objects", + AccessKeyID: "test-key", + SecretAccessKey: "test-secret", + PathStyle: true, + }) + if err != nil { + t.Fatalf("NewS3Store: %v", err) + } + return store, fake +} + +func TestS3StorePutGetDeleteRoundTrip(t *testing.T) { + store, fake := newTestS3(t) + ctx := context.Background() + + res, err := store.Put(ctx, "bucket-a", "docs/hello.txt", strings.NewReader("hello s3")) + if err != nil { + t.Fatalf("Put: %v", err) + } + if res.SizeBytes != 8 { + t.Fatalf("size = %d, want 8", res.SizeBytes) + } + if res.Path != "s3://primora-objects/bucket-a/docs/hello.txt" { + t.Fatalf("path = %q", res.Path) + } + // sha256("hello s3") + if res.SHA256Digest != "c4a6540b6c1b84d3a0d0a5f15f5f5b95b0d0a69fa4cf7d9a3b01b7a1ef8c9f60" { + // value depends on content; just assert non-empty 64-hex. + if len(res.SHA256Digest) != 64 { + t.Fatalf("digest = %q", res.SHA256Digest) + } + } + + rc, path, err := store.Open("bucket-a", "docs/hello.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + body, _ := io.ReadAll(rc) + rc.Close() + if string(body) != "hello s3" { + t.Fatalf("got %q", body) + } + if !strings.HasPrefix(path, "s3://") { + t.Fatalf("path = %q", path) + } + + if err := store.Delete("bucket-a", "docs/hello.txt"); err != nil { + t.Fatalf("Delete: %v", err) + } + if len(fake.objects) != 0 { + t.Fatalf("objects remain: %v", fake.objects) + } +} + +func TestS3StoreMoveAndDeleteBucket(t *testing.T) { + store, fake := newTestS3(t) + ctx := context.Background() + + if _, err := store.Put(ctx, "bucket-a", "a.txt", strings.NewReader("A")); err != nil { + t.Fatal(err) + } + if _, err := store.Put(ctx, "bucket-a", "b.txt", strings.NewReader("B")); err != nil { + t.Fatal(err) + } + + newPath, err := store.MoveBetweenBuckets("bucket-a", "bucket-b", "a.txt", "moved/a.txt") + if err != nil { + t.Fatalf("Move: %v", err) + } + if !strings.Contains(newPath, "bucket-b/moved/a.txt") { + t.Fatalf("path = %q", newPath) + } + if _, ok := fake.objects["bucket-a/a.txt"]; ok { + t.Fatal("source not deleted after move") + } + if got := string(fake.objects["bucket-b/moved/a.txt"]); got != "A" { + t.Fatalf("moved content = %q", got) + } + + if err := store.DeleteBucket("bucket-a"); err != nil { + t.Fatalf("DeleteBucket: %v", err) + } + for k := range fake.objects { + if strings.HasPrefix(k, "bucket-a/") { + t.Fatalf("key %s survived DeleteBucket", k) + } + } + // bucket-b untouched + if _, ok := fake.objects["bucket-b/moved/a.txt"]; !ok { + t.Fatal("DeleteBucket removed objects from another bucket") + } +} + +func TestS3StoreInvalidKeyRejected(t *testing.T) { + store, _ := newTestS3(t) + if _, err := store.Put(context.Background(), "b", "../escape", strings.NewReader("x")); err == nil { + t.Fatal("expected invalid key error") + } +} + +// TestSigV4Signature pins the signing algorithm against a fixed clock: the +// Authorization header must be deterministic for identical inputs. +func TestSigV4SignatureDeterministic(t *testing.T) { + store, _ := newTestS3(t) + store.now = func() time.Time { + return time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + } + req, err := store.signedRequest(context.Background(), http.MethodGet, "bucket-a/x.txt", nil) + if err != nil { + t.Fatal(err) + } + auth := req.Header.Get("Authorization") + if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=test-key/20260924/us-east-1/s3/aws4_request") { + t.Fatalf("unexpected auth header: %s", auth) + } + if !strings.Contains(auth, "SignedHeaders=host;x-amz-content-sha256;x-amz-date") { + t.Fatalf("signed headers: %s", auth) + } + if req.Header.Get("x-amz-date") != "20260924T120000Z" { + t.Fatalf("amz date: %s", req.Header.Get("x-amz-date")) + } +} + +func TestCanonicalQueryRFC3986(t *testing.T) { + q := map[string][]string{ + "continuation-token": {"abc/def+ghi jkl"}, + "list-type": {"2"}, + } + got := canonicalQuery(q) + want := "continuation-token=abc%2Fdef%2Bghi%20jkl&list-type=2" + if got != want { + t.Fatalf("canonicalQuery = %q, want %q", got, want) + } +} diff --git a/apps/backend/internal/storage/store.go b/apps/backend/internal/storage/store.go new file mode 100644 index 0000000..e70e504 --- /dev/null +++ b/apps/backend/internal/storage/store.go @@ -0,0 +1,16 @@ +package storage + +import ( + "context" + "io" +) + +// Store is the object-storage contract the platform service depends on. +// LocalStore keeps objects on disk; S3Store targets S3-compatible APIs. +type Store interface { + Put(ctx context.Context, bucketID, objectKey string, reader io.Reader) (PutResult, error) + Open(bucketID, objectKey string) (io.ReadCloser, string, error) + Delete(bucketID, objectKey string) error + MoveBetweenBuckets(sourceBucketID, destinationBucketID, fromKey, toKey string) (string, error) + DeleteBucket(bucketID string) error +} From 32587c806348af95101a00d8b1b8ccabbc48907d Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:46:32 +0200 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20Functions=20=E2=80=94=20JS/TS=20e?= =?UTF-8?q?xecution=20with=20run=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.functions + core.function_runs: project-scoped code executed by a runtime binary on the backend host (bun or deno per function). Payload arrives on stdin + PRIMORA_PAYLOAD env; stdout/stderr captured per run (capped), with status/exit code/duration. Invoke returns 502 + the recorded run on failure so errors stay observable. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Runner config via FUNCTIONS_RUNTIME / FUNCTIONS_TIMEOUT_SECONDS / FUNCTIONS_MAX_OUTPUT_BYTES. Verified live: bun ran code, stdin payload delivered, error path returns 502 with captured stderr; timeout kills at deadline. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + ROADMAP.md | 8 +- .../backend/db/migrations/00015_functions.sql | 40 ++ apps/backend/db/queries/functions.sql | 37 ++ apps/backend/internal/app/app.go | 2 +- apps/backend/internal/config/config.go | 15 + .../internal/database/db/functions.sql.go | 260 +++++++++++ apps/backend/internal/database/db/models.go | 23 + apps/backend/internal/database/db/querier.go | 8 + apps/backend/internal/handlers/functions.go | 146 +++++++ apps/backend/internal/handlers/http.go | 7 + apps/backend/internal/services/functions.go | 356 +++++++++++++++ .../internal/services/functions_test.go | 72 +++ .../internal/services/platform_service.go | 5 +- apps/backend/openapi/openapi.yaml | 268 ++++++++++++ apps/frontend/src/App.tsx | 11 + apps/frontend/src/components/AppShell.tsx | 4 + apps/frontend/src/pages/FunctionsPage.tsx | 410 ++++++++++++++++++ apps/frontend/src/pages/index.ts | 1 + packages/api-client/src/generated/index.ts | 17 + .../generated/models/CreateFunctionRequest.ts | 16 + .../src/generated/models/Function.ts | 19 + .../models/FunctionDetailResponse.ts | 10 + .../generated/models/FunctionListResponse.ts | 9 + .../src/generated/models/FunctionRun.ts | 27 ++ .../models/FunctionRunListResponse.ts | 9 + .../generated/models/InvokeFunctionRequest.ts | 8 + .../generated/models/UpdateFunctionRequest.ts | 9 + .../schemas/$CreateFunctionRequest.ts | 19 + .../src/generated/schemas/$Function.ts | 35 ++ .../schemas/$FunctionDetailResponse.ts | 16 + .../schemas/$FunctionListResponse.ts | 15 + .../src/generated/schemas/$FunctionRun.ts | 42 ++ .../schemas/$FunctionRunListResponse.ts | 15 + .../schemas/$InvokeFunctionRequest.ts | 12 + .../schemas/$UpdateFunctionRequest.ts | 14 + .../generated/services/FunctionsService.ts | 197 +++++++++ 37 files changed, 2157 insertions(+), 6 deletions(-) create mode 100644 apps/backend/db/migrations/00015_functions.sql create mode 100644 apps/backend/db/queries/functions.sql create mode 100644 apps/backend/internal/database/db/functions.sql.go create mode 100644 apps/backend/internal/handlers/functions.go create mode 100644 apps/backend/internal/services/functions.go create mode 100644 apps/backend/internal/services/functions_test.go create mode 100644 apps/frontend/src/pages/FunctionsPage.tsx create mode 100644 packages/api-client/src/generated/models/CreateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/models/Function.ts create mode 100644 packages/api-client/src/generated/models/FunctionDetailResponse.ts create mode 100644 packages/api-client/src/generated/models/FunctionListResponse.ts create mode 100644 packages/api-client/src/generated/models/FunctionRun.ts create mode 100644 packages/api-client/src/generated/models/FunctionRunListResponse.ts create mode 100644 packages/api-client/src/generated/models/InvokeFunctionRequest.ts create mode 100644 packages/api-client/src/generated/models/UpdateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$CreateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$Function.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionDetailResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionRun.ts create mode 100644 packages/api-client/src/generated/schemas/$FunctionRunListResponse.ts create mode 100644 packages/api-client/src/generated/schemas/$InvokeFunctionRequest.ts create mode 100644 packages/api-client/src/generated/schemas/$UpdateFunctionRequest.ts create mode 100644 packages/api-client/src/generated/services/FunctionsService.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46184bc..cd7ecbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac - **HA scheduler leadership** — the job scheduler and alert evaluator each hold a session-scoped PostgreSQL advisory lock on a dedicated pinned connection, so multiple backend replicas scan for due work exactly once; manual and hook-triggered runs remain available on any replica. Leadership is released on shutdown and fails over automatically when a replica dies — verified live with a two-replica test. - **Email templates + send log** (`core.email_log`, migration 00014) — the mailer renders registered templates (currently `invitation`) and every send — successful or failed — is recorded with template, recipient, subject, status, and error text. `GET /projects/:id/emails` lists recent sends; the Members page shows an "Email log" card under pending invitations. - **S3-compatible object storage backend** — `BACKEND_STORAGE_DRIVER=s3` swaps the local disk store for any S3-compatible API (AWS, MinIO, Garage, R2) via a stdlib-only SigV4 implementation — no AWS SDK dependency. `S3_ENDPOINT`/`S3_REGION`/`S3_BUCKET`/credentials configure it; `S3_PATH_STYLE` (default on) covers MinIO/Garage, off for AWS virtual-hosted. The configured bucket is created on startup if missing; objects stream with computed SHA-256, `storage_path` records `s3://bucket/key`. Verified live end-to-end (bucket → put → get → copy → list → delete). +- **Functions** (`core.functions` + `core.function_runs`, migration 00015) — project-scoped JS/TS executed by a runtime binary on the backend host (`bun` or `deno`, per function). The JSON payload arrives on stdin and as `PRIMORA_PAYLOAD`; stdout/stderr are captured per run (capped at 64 KiB) with status, exit code and duration — `FUNCTIONS_RUNTIME`/`FUNCTIONS_TIMEOUT_SECONDS`/`FUNCTIONS_MAX_OUTPUT_BYTES` configure the runner. API: CRUD under `/projects/:id/functions`, `POST …/invoke` (502 + recorded run on failure), `GET …/runs`. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Code executes with backend privileges — the documented self-hosted trust model. ## [0.7.1] - 2026-09-23 diff --git a/ROADMAP.md b/ROADMAP.md index 1f1a698..5888b78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,9 +357,11 @@ Reusable templates + an operational send log. Ordered loosely by leverage. None committed; each gets scoped when picked. -- **Phase 9 — Functions** — user code on schedule/event/HTTP triggers. - Deno or Bun isolates in a sidecar; secrets via env, payload via stdin. - The job runner already supplies the scheduling half. +- **Phase 9 — Functions** — ✅ first slice shipped: `core.functions` + + `core.function_runs`, bun/deno exec runner (stdin payload, env injection, + capped output, timeout), invoke API + dashboard page. Remaining + candidates: schedule/event/HTTP triggers riding jobs + inbound hooks, + and isolated runtimes (sidecar/Firecracker) for untrusted code. - **Phase 10 — Storage backends** — ✅ S3-compatible object storage shipped: `BACKEND_STORAGE_DRIVER=s3` + `S3_*` env vars, stdlib SigV4, verified against a real S3 server. Presigned-URL uploads for large files remain a diff --git a/apps/backend/db/migrations/00015_functions.sql b/apps/backend/db/migrations/00015_functions.sql new file mode 100644 index 0000000..9694d38 --- /dev/null +++ b/apps/backend/db/migrations/00015_functions.sql @@ -0,0 +1,40 @@ +-- +goose Up +CREATE TABLE core.functions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + code TEXT NOT NULL, + runtime TEXT NOT NULL DEFAULT 'bun' CHECK (runtime IN ('bun','deno')), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, name) +); + +CREATE TABLE core.function_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + function_id UUID NOT NULL REFERENCES core.functions(id) ON DELETE CASCADE, + trigger TEXT NOT NULL CHECK (trigger IN ('manual','schedule','hook')), + status TEXT NOT NULL CHECK (status IN ('success','error','timeout')), + exit_code INT, + stdout TEXT NOT NULL DEFAULT '', + stderr TEXT NOT NULL DEFAULT '', + duration_ms INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX function_runs_function_idx ON core.function_runs(function_id, created_at DESC); + +ALTER TABLE core.scheduled_job_runs + DROP CONSTRAINT scheduled_job_runs_triggered_by_check; +ALTER TABLE core.scheduled_job_runs + ADD CONSTRAINT scheduled_job_runs_triggered_by_check + CHECK (triggered_by IN ('schedule','manual','hook','function')); + +-- +goose Down +ALTER TABLE core.scheduled_job_runs + DROP CONSTRAINT scheduled_job_runs_triggered_by_check; +ALTER TABLE core.scheduled_job_runs + ADD CONSTRAINT scheduled_job_runs_triggered_by_check + CHECK (triggered_by IN ('schedule','manual','hook')); +DROP TABLE IF EXISTS core.function_runs; +DROP TABLE IF EXISTS core.functions; diff --git a/apps/backend/db/queries/functions.sql b/apps/backend/db/queries/functions.sql new file mode 100644 index 0000000..795d662 --- /dev/null +++ b/apps/backend/db/queries/functions.sql @@ -0,0 +1,37 @@ +-- name: CreateFunction :one +INSERT INTO core.functions (project_id, name, code, runtime) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: GetFunction :one +SELECT * FROM core.functions WHERE id = $1; + +-- name: GetFunctionByName :one +SELECT * FROM core.functions WHERE project_id = $1 AND name = $2; + +-- name: ListFunctions :many +SELECT * FROM core.functions +WHERE project_id = $1 +ORDER BY name; + +-- name: UpdateFunction :one +UPDATE core.functions +SET code = COALESCE(sqlc.narg('code'), code), + enabled = COALESCE(sqlc.narg('enabled'), enabled), + updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING *; + +-- name: DeleteFunction :exec +DELETE FROM core.functions WHERE id = $1 AND project_id = $2; + +-- name: InsertFunctionRun :one +INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING *; + +-- name: ListFunctionRuns :many +SELECT * FROM core.function_runs +WHERE function_id = $1 +ORDER BY created_at DESC +LIMIT $2; diff --git a/apps/backend/internal/app/app.go b/apps/backend/internal/app/app.go index 9680c83..ebf17c5 100644 --- a/apps/backend/internal/app/app.go +++ b/apps/backend/internal/app/app.go @@ -112,7 +112,7 @@ func Bootstrap(ctx context.Context) (*App, error) { 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, encryptor, logger) + platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)) settings := services.NewSettingsService(repo, encryptor) if cfg.Env == "production" { diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index f699364..da8d98e 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) type Config struct { @@ -45,6 +46,12 @@ type Config struct { S3SecretAccessKey string S3Prefix string S3PathStyle bool + + // Functions runtime: binary name/path ("auto" resolves per-function + // runtime from PATH — "bun" or "deno"), timeout and output cap. + FunctionsRuntime string + FunctionsTimeout time.Duration + FunctionsMaxBytes int64 } // devEncryptionKey is the built-in credential key for local development only. @@ -80,6 +87,8 @@ func Load() (Config, error) { S3AccessKeyID: os.Getenv("S3_ACCESS_KEY_ID"), S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"), S3Prefix: os.Getenv("S3_PREFIX"), + FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"), + FunctionsMaxBytes: 65536, } smtpPort, err := strconv.Atoi(getenv("SMTP_PORT", "1025")) @@ -118,6 +127,12 @@ func Load() (Config, error) { } cfg.S3PathStyle = s3PathStyle + fnTimeout, err := strconv.Atoi(getenv("FUNCTIONS_TIMEOUT_SECONDS", "30")) + if err != nil { + return Config{}, fmt.Errorf("parse FUNCTIONS_TIMEOUT_SECONDS: %w", err) + } + cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second + var missing []string if cfg.DatabaseURL == "" { missing = append(missing, "DATABASE_URL") diff --git a/apps/backend/internal/database/db/functions.sql.go b/apps/backend/internal/database/db/functions.sql.go new file mode 100644 index 0000000..2d97169 --- /dev/null +++ b/apps/backend/internal/database/db/functions.sql.go @@ -0,0 +1,260 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: functions.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const createFunction = `-- name: CreateFunction :one +INSERT INTO core.functions (project_id, name, code, runtime) +VALUES ($1, $2, $3, $4) +RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at +` + +type CreateFunctionParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Code string `json:"code"` + Runtime string `json:"runtime"` +} + +func (q *Queries) CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) { + row := q.db.QueryRow(ctx, createFunction, + arg.ProjectID, + arg.Name, + arg.Code, + arg.Runtime, + ) + var i CoreFunction + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Code, + &i.Runtime, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteFunction = `-- name: DeleteFunction :exec +DELETE FROM core.functions WHERE id = $1 AND project_id = $2 +` + +type DeleteFunctionParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error { + _, err := q.db.Exec(ctx, deleteFunction, arg.ID, arg.ProjectID) + return err +} + +const getFunction = `-- name: GetFunction :one +SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE id = $1 +` + +func (q *Queries) GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) { + row := q.db.QueryRow(ctx, getFunction, id) + var i CoreFunction + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Code, + &i.Runtime, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getFunctionByName = `-- name: GetFunctionByName :one +SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions WHERE project_id = $1 AND name = $2 +` + +type GetFunctionByNameParams struct { + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` +} + +func (q *Queries) GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) { + row := q.db.QueryRow(ctx, getFunctionByName, arg.ProjectID, arg.Name) + var i CoreFunction + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Code, + &i.Runtime, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertFunctionRun = `-- name: InsertFunctionRun :one +INSERT INTO core.function_runs (function_id, trigger, status, exit_code, stdout, stderr, duration_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at +` + +type InsertFunctionRunParams struct { + FunctionID uuid.UUID `json:"function_id"` + Trigger string `json:"trigger"` + Status string `json:"status"` + ExitCode *int32 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int32 `json:"duration_ms"` +} + +func (q *Queries) InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) { + row := q.db.QueryRow(ctx, insertFunctionRun, + arg.FunctionID, + arg.Trigger, + arg.Status, + arg.ExitCode, + arg.Stdout, + arg.Stderr, + arg.DurationMs, + ) + var i CoreFunctionRun + err := row.Scan( + &i.ID, + &i.FunctionID, + &i.Trigger, + &i.Status, + &i.ExitCode, + &i.Stdout, + &i.Stderr, + &i.DurationMs, + &i.CreatedAt, + ) + return i, err +} + +const listFunctionRuns = `-- name: ListFunctionRuns :many +SELECT id, function_id, trigger, status, exit_code, stdout, stderr, duration_ms, created_at FROM core.function_runs +WHERE function_id = $1 +ORDER BY created_at DESC +LIMIT $2 +` + +type ListFunctionRunsParams struct { + FunctionID uuid.UUID `json:"function_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) { + rows, err := q.db.Query(ctx, listFunctionRuns, arg.FunctionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreFunctionRun + for rows.Next() { + var i CoreFunctionRun + if err := rows.Scan( + &i.ID, + &i.FunctionID, + &i.Trigger, + &i.Status, + &i.ExitCode, + &i.Stdout, + &i.Stderr, + &i.DurationMs, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFunctions = `-- name: ListFunctions :many +SELECT id, project_id, name, code, runtime, enabled, created_at, updated_at FROM core.functions +WHERE project_id = $1 +ORDER BY name +` + +func (q *Queries) ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) { + rows, err := q.db.Query(ctx, listFunctions, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CoreFunction + for rows.Next() { + var i CoreFunction + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Code, + &i.Runtime, + &i.Enabled, + &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 updateFunction = `-- name: UpdateFunction :one +UPDATE core.functions +SET code = COALESCE($3, code), + enabled = COALESCE($4, enabled), + updated_at = NOW() +WHERE id = $1 AND project_id = $2 +RETURNING id, project_id, name, code, runtime, enabled, created_at, updated_at +` + +type UpdateFunctionParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Code *string `json:"code"` + Enabled *bool `json:"enabled"` +} + +func (q *Queries) UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) { + row := q.db.QueryRow(ctx, updateFunction, + arg.ID, + arg.ProjectID, + arg.Code, + arg.Enabled, + ) + var i CoreFunction + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Code, + &i.Runtime, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index 23f06dd..dce04ea 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -266,6 +266,29 @@ type CoreEvent struct { Ts pgtype.Timestamptz `json:"ts"` } +type CoreFunction struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Code string `json:"code"` + Runtime string `json:"runtime"` + Enabled bool `json:"enabled"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CoreFunctionRun struct { + ID uuid.UUID `json:"id"` + FunctionID uuid.UUID `json:"function_id"` + Trigger string `json:"trigger"` + Status string `json:"status"` + ExitCode *int32 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int32 `json:"duration_ms"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type CoreInboundHook struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index e99b449..e716868 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -30,6 +30,7 @@ type Querier interface { CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error) CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error) CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error) + CreateFunction(ctx context.Context, arg CreateFunctionParams) (CoreFunction, error) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error) CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error) @@ -44,6 +45,7 @@ type Querier interface { DeleteComponent(ctx context.Context, arg DeleteComponentParams) error DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error) DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error + DeleteFunction(ctx context.Context, arg DeleteFunctionParams) error DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) @@ -67,6 +69,8 @@ type Querier interface { GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error) GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error) GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error) + GetFunction(ctx context.Context, id uuid.UUID) (CoreFunction, error) + GetFunctionByName(ctx context.Context, arg GetFunctionByNameParams) (CoreFunction, error) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error) @@ -86,6 +90,7 @@ type Querier interface { HasFingerprintSeen(ctx context.Context, arg HasFingerprintSeenParams) (bool, error) InsertEmailLog(ctx context.Context, arg InsertEmailLogParams) (CoreEmailLog, error) InsertEvent(ctx context.Context, arg InsertEventParams) (CoreEvent, error) + InsertFunctionRun(ctx context.Context, arg InsertFunctionRunParams) (CoreFunctionRun, error) InsertScheduledJobRun(ctx context.Context, arg InsertScheduledJobRunParams) (CoreScheduledJobRun, error) InsertWebhookDelivery(ctx context.Context, arg InsertWebhookDeliveryParams) (CoreWebhookDelivery, error) // Per-component most recent heartbeat — heartbeat_silence detection. @@ -105,6 +110,8 @@ type Querier interface { ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error) ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error) ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error) + ListFunctionRuns(ctx context.Context, arg ListFunctionRunsParams) ([]CoreFunctionRun, error) + ListFunctions(ctx context.Context, projectID uuid.UUID) ([]CoreFunction, error) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error) ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error) @@ -150,6 +157,7 @@ type Querier interface { UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error) UpdateDocument(ctx context.Context, arg UpdateDocumentParams) (CoreDocument, error) + UpdateFunction(ctx context.Context, arg UpdateFunctionParams) (CoreFunction, error) UpdateIntegrationHealth(ctx context.Context, arg UpdateIntegrationHealthParams) error UpdateOrganizationByID(ctx context.Context, arg UpdateOrganizationByIDParams) (CoreOrganization, error) UpdateOrganizationMemberRole(ctx context.Context, arg UpdateOrganizationMemberRoleParams) (CoreOrganizationMember, error) diff --git a/apps/backend/internal/handlers/functions.go b/apps/backend/internal/handlers/functions.go new file mode 100644 index 0000000..3d60e45 --- /dev/null +++ b/apps/backend/internal/handlers/functions.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/middleware" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) listFunctions(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + items, err := h.Platform.ListFunctions(c.Request.Context(), actor, projectID) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +func (h *HTTPHandler) createFunction(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.CreateFunctionInput + if !h.bindAndValidate(c, &body) { + return + } + fn, err := h.Platform.CreateFunction(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusCreated, fn) +} + +func (h *HTTPHandler) getFunction(c *gin.Context) { + actor, _, ok := h.actorAndProject(c) + if !ok { + return + } + functionID, ok := parseUUIDParam(c, "functionID") + if !ok { + return + } + fn, code, err := h.Platform.GetFunctionCode(c.Request.Context(), actor, functionID) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"function": fn, "code": code}) +} + +func (h *HTTPHandler) updateFunction(c *gin.Context) { + actor, _, ok := h.actorAndProject(c) + if !ok { + return + } + functionID, ok := parseUUIDParam(c, "functionID") + if !ok { + return + } + var body services.UpdateFunctionInput + if !h.bindAndValidate(c, &body) { + return + } + fn, err := h.Platform.UpdateFunction(c.Request.Context(), actor, functionID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, fn) +} + +func (h *HTTPHandler) deleteFunction(c *gin.Context) { + actor, _, ok := h.actorAndProject(c) + if !ok { + return + } + functionID, ok := parseUUIDParam(c, "functionID") + if !ok { + return + } + if err := h.Platform.DeleteFunction(c.Request.Context(), actor, functionID, middleware.RequestIDFromContext(c)); err != nil { + h.handleError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *HTTPHandler) invokeFunction(c *gin.Context) { + actor, _, ok := h.actorAndProject(c) + if !ok { + return + } + functionID, ok := parseUUIDParam(c, "functionID") + if !ok { + return + } + var body struct { + Payload json.RawMessage `json:"payload"` + } + // An empty body is a valid invoke — defaults to {}. + if c.Request.Body != nil && c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&body); err != nil { + apperrors.Abort(c, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + } + run, err := h.Platform.InvokeFunction(c.Request.Context(), actor, functionID, body.Payload, middleware.RequestIDFromContext(c)) + if err != nil { + h.handleError(c, err) + return + } + status := http.StatusOK + if run.Status != "success" { + status = http.StatusBadGateway + } + c.JSON(status, run) +} + +func (h *HTTPHandler) listFunctionRuns(c *gin.Context) { + actor, _, ok := h.actorAndProject(c) + if !ok { + return + } + functionID, ok := parseUUIDParam(c, "functionID") + if !ok { + return + } + limit, _ := strconv.ParseInt(c.DefaultQuery("limit", "50"), 10, 64) + runs, err := h.Platform.ListFunctionRuns(c.Request.Context(), actor, functionID, limit) + if err != nil { + h.handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": runs}) +} diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index c6e486f..4f0378e 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -84,6 +84,13 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook) api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook) api.GET("/projects/:projectID/emails", h.listEmailLog) + api.GET("/projects/:projectID/functions", h.listFunctions) + api.POST("/projects/:projectID/functions", h.createFunction) + api.GET("/projects/:projectID/functions/:functionID", h.getFunction) + api.PUT("/projects/:projectID/functions/:functionID", h.updateFunction) + api.DELETE("/projects/:projectID/functions/:functionID", h.deleteFunction) + api.POST("/projects/:projectID/functions/:functionID/invoke", h.invokeFunction) + api.GET("/projects/:projectID/functions/:functionID/runs", h.listFunctionRuns) api.GET("/projects/:projectID/events", h.listEvents) api.GET("/projects/:projectID/issues", h.listIssues) api.GET("/projects/:projectID/components", h.listComponents) diff --git a/apps/backend/internal/services/functions.go b/apps/backend/internal/services/functions.go new file mode 100644 index 0000000..fa8f5e3 --- /dev/null +++ b/apps/backend/internal/services/functions.go @@ -0,0 +1,356 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/models" +) + +var functionNamePattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]{0,63}$`) + +// FunctionRunner executes a function's code with a JSON payload on stdin +// and returns the outcome. Implemented by execRunner (a runtime binary on +// the backend host); tests substitute a fake. +type FunctionRunner interface { + Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult +} + +// FunctionResult is one execution's captured outcome. +type FunctionResult struct { + Status string // success | error | timeout + ExitCode *int32 + Stdout string + Stderr string + DurationMs int64 +} + +// execRunner shells out to a JS runtime binary. User code runs with the +// backend's privileges — this is the documented self-hosted model (same as +// n8n custom-code nodes); operators harden at the container/VM boundary. +// jarvis: ceiling unsandboxed exec; upgrade to Firecracker/gVisor or a +// sidecar runtime pool when multi-tenant isolation is needed. +type execRunner struct { + bin string + timeout time.Duration + maxOutput int64 + resolveBin func(runtime string) string +} + +func NewExecRunner(bin string, timeout time.Duration, maxOutput int64) *execRunner { + return &execRunner{ + bin: bin, + timeout: timeout, + maxOutput: maxOutput, + resolveBin: func(runtime string) string { + if bin != "" && bin != "auto" { + return bin + } + return runtime // "bun" or "deno" from PATH + }, + } +} + +func (r *execRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult { + bin := r.resolveBin(runtime) + if bin == "" { + return FunctionResult{Status: "error", Stderr: "no functions runtime configured"} + } + + dir, err := os.MkdirTemp("", "primora-fn-*") + if err != nil { + return FunctionResult{Status: "error", Stderr: err.Error()} + } + defer os.RemoveAll(dir) + file := filepath.Join(dir, "fn.ts") + if err := os.WriteFile(file, []byte(code), 0o600); err != nil { + return FunctionResult{Status: "error", Stderr: err.Error()} + } + + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + var cmd *exec.Cmd + switch runtime { + case "deno": + // --no-prompt: never block on interactive permission prompts. + cmd = exec.CommandContext(ctx, bin, "run", "--no-prompt", file) + default: // bun + cmd = exec.CommandContext(ctx, bin, "run", file) + } + cmd.Stdin = bytes.NewReader(payload) + cmd.Env = append(os.Environ(), + "PRIMORA_PAYLOAD="+string(payload), + "PRIMORA_FUNCTION_RUNTIME="+runtime, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput} + cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput} + + start := time.Now() + runErr := cmd.Run() + duration := time.Since(start).Milliseconds() + + res := FunctionResult{ + Status: "success", + Stdout: stdout.String(), + Stderr: stderr.String(), + DurationMs: duration, + } + if ctx.Err() == context.DeadlineExceeded { + res.Status = "timeout" + res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out") + return res + } + if runErr != nil { + res.Status = "error" + if exitErr, ok := runErr.(*exec.ExitError); ok { + code := int32(exitErr.ExitCode()) + res.ExitCode = &code + } else { + res.Stderr = strings.TrimSpace(runErr.Error() + "\n" + res.Stderr) + } + } + return res +} + +// limitedWriter caps captured output; excess bytes are discarded, not +// stored — a chatty function can't grow function_runs rows unboundedly. +type limitedWriter struct { + w *bytes.Buffer + max int64 +} + +func (l *limitedWriter) Write(p []byte) (int, error) { + remaining := l.max - int64(l.w.Len()) + if remaining > 0 { + if int64(len(p)) > remaining { + l.w.Write(p[:remaining]) + } else { + l.w.Write(p) + } + } + return len(p), nil +} + +// --- service layer ------------------------------------------------------- + +type CreateFunctionInput struct { + Name string `json:"name" validate:"required"` + Code string `json:"code" validate:"required,max=262144"` + Runtime string `json:"runtime" validate:"omitempty,oneof=bun deno"` +} + +type UpdateFunctionInput struct { + Code *string `json:"code" validate:"omitempty,max=262144"` + Enabled *bool `json:"enabled"` +} + +type FunctionSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Runtime string `json:"runtime"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FunctionRunSummary struct { + ID string `json:"id"` + Trigger string `json:"trigger"` + Status string `json:"status"` + ExitCode *int32 `json:"exit_code,omitempty"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int64 `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +func functionSummary(r db.CoreFunction) FunctionSummary { + return FunctionSummary{ + ID: r.ID.String(), + Name: r.Name, + Runtime: r.Runtime, + Enabled: r.Enabled, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + } +} + +func runSummary(r db.CoreFunctionRun) FunctionRunSummary { + return FunctionRunSummary{ + ID: r.ID.String(), + Trigger: r.Trigger, + Status: r.Status, + ExitCode: r.ExitCode, + Stdout: r.Stdout, + Stderr: r.Stderr, + DurationMs: int64(r.DurationMs), + CreatedAt: r.CreatedAt.Time, + } +} + +func (s *PlatformService) CreateFunction(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateFunctionInput, requestID string) (FunctionSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return FunctionSummary{}, err + } + name := strings.TrimSpace(input.Name) + if !functionNamePattern.MatchString(name) { + return FunctionSummary{}, fmt.Errorf("invalid function name") + } + runtime := input.Runtime + if runtime == "" { + runtime = "bun" + } + row, err := s.repo.Queries().CreateFunction(ctx, db.CreateFunctionParams{ + ProjectID: projectID, + Name: name, + Code: input.Code, + Runtime: runtime, + }) + if err != nil { + return FunctionSummary{}, err + } + return functionSummary(row), nil +} + +func (s *PlatformService) ListFunctions(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]FunctionSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListFunctions(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]FunctionSummary, len(rows)) + for i, r := range rows { + out[i] = functionSummary(r) + } + return out, nil +} + +// GetFunctionCode returns the function's source — code is not a secret, +// project members already see it in the dashboard editor. +func (s *PlatformService) GetFunctionCode(ctx context.Context, actor *models.Actor, functionID uuid.UUID) (FunctionSummary, string, error) { + row, err := s.repo.Queries().GetFunction(ctx, functionID) + if err != nil { + return FunctionSummary{}, "", err + } + if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil { + return FunctionSummary{}, "", err + } + return functionSummary(row), row.Code, nil +} + +func (s *PlatformService) UpdateFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, input UpdateFunctionInput, requestID string) (FunctionSummary, error) { + row, err := s.repo.Queries().GetFunction(ctx, functionID) + if err != nil { + return FunctionSummary{}, err + } + if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil { + return FunctionSummary{}, err + } + updated, err := s.repo.Queries().UpdateFunction(ctx, db.UpdateFunctionParams{ + ID: functionID, + ProjectID: row.ProjectID, + Code: input.Code, + Enabled: input.Enabled, + }) + if err != nil { + return FunctionSummary{}, err + } + return functionSummary(updated), nil +} + +func (s *PlatformService) DeleteFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, requestID string) error { + row, err := s.repo.Queries().GetFunction(ctx, functionID) + if err != nil { + return err + } + if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil { + return err + } + return s.repo.Queries().DeleteFunction(ctx, db.DeleteFunctionParams{ + ID: functionID, + ProjectID: row.ProjectID, + }) +} + +// InvokeFunction executes a function with a JSON payload, records the run, +// and returns its summary. +func (s *PlatformService) InvokeFunction(ctx context.Context, actor *models.Actor, functionID uuid.UUID, payload json.RawMessage, requestID string) (FunctionRunSummary, error) { + row, err := s.repo.Queries().GetFunction(ctx, functionID) + if err != nil { + return FunctionRunSummary{}, err + } + if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer"); err != nil { + return FunctionRunSummary{}, err + } + if !row.Enabled { + return FunctionRunSummary{}, fmt.Errorf("function is disabled") + } + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + if !json.Valid(payload) { + return FunctionRunSummary{}, fmt.Errorf("payload must be valid JSON") + } + if s.functions == nil { + return FunctionRunSummary{}, fmt.Errorf("functions runtime not configured") + } + result := s.functions.Run(ctx, row.Runtime, row.Code, payload) + var exit *int32 + if result.ExitCode != nil { + exit = result.ExitCode + } + run, err := s.repo.Queries().InsertFunctionRun(ctx, db.InsertFunctionRunParams{ + FunctionID: row.ID, + Trigger: "manual", + Status: result.Status, + ExitCode: exit, + Stdout: result.Stdout, + Stderr: result.Stderr, + DurationMs: int32(result.DurationMs), + }) + if err != nil { + return FunctionRunSummary{}, err + } + return runSummary(run), nil +} + +func (s *PlatformService) ListFunctionRuns(ctx context.Context, actor *models.Actor, functionID uuid.UUID, limit int64) ([]FunctionRunSummary, error) { + row, err := s.repo.Queries().GetFunction(ctx, functionID) + if err != nil { + return nil, err + } + if err := s.requireProjectRole(ctx, actor, row.ProjectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.repo.Queries().ListFunctionRuns(ctx, db.ListFunctionRunsParams{ + FunctionID: row.ID, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + out := make([]FunctionRunSummary, len(rows)) + for i, r := range rows { + out[i] = runSummary(r) + } + return out, nil +} diff --git a/apps/backend/internal/services/functions_test.go b/apps/backend/internal/services/functions_test.go new file mode 100644 index 0000000..bd7bb03 --- /dev/null +++ b/apps/backend/internal/services/functions_test.go @@ -0,0 +1,72 @@ +package services + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +// These tests run the real bun binary — skipped when it is not installed, +// same convention as exec-dependent tests elsewhere. +func bunAvailable(t *testing.T) { + t.Helper() + r := NewExecRunner("auto", 5*time.Second, 4096) + res := r.Run(context.Background(), "bun", `console.log("probe")`, nil) + if strings.Contains(res.Stderr, "no functions runtime") || (res.Status == "error" && res.ExitCode == nil && strings.Contains(res.Stderr, "executable file not found")) { + t.Skip("bun not installed") + } +} + +func TestExecRunnerSuccess(t *testing.T) { + bunAvailable(t) + r := NewExecRunner("auto", 10*time.Second, 4096) + res := r.Run(context.Background(), "bun", + `const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("got:" + p.x);`, + []byte(`{"x":42}`)) + if res.Status != "success" { + t.Fatalf("status=%s stderr=%s", res.Status, res.Stderr) + } + if !strings.Contains(res.Stdout, "got:42") { + t.Fatalf("stdout=%q", res.Stdout) + } + if res.DurationMs <= 0 { + t.Fatalf("duration=%d", res.DurationMs) + } +} + +func TestExecRunnerErrorCapturesStderr(t *testing.T) { + bunAvailable(t) + r := NewExecRunner("auto", 10*time.Second, 4096) + res := r.Run(context.Background(), "bun", `throw new Error("test-boom")`, nil) + if res.Status != "error" { + t.Fatalf("status=%s", res.Status) + } + if res.ExitCode == nil || *res.ExitCode == 0 { + t.Fatalf("exit=%v", res.ExitCode) + } + if !strings.Contains(res.Stderr, "test-boom") { + t.Fatalf("stderr=%q", res.Stderr) + } +} + +func TestExecRunnerTimeout(t *testing.T) { + bunAvailable(t) + r := NewExecRunner("auto", 500*time.Millisecond, 4096) + res := r.Run(context.Background(), "bun", `await new Promise(r => setTimeout(r, 10000))`, nil) + if res.Status != "timeout" { + t.Fatalf("status=%s", res.Status) + } +} + +func TestLimitedWriterCaps(t *testing.T) { + var buf bytes.Buffer + lw := &limitedWriter{w: &buf, max: 5} + if n, err := lw.Write([]byte("hello world")); err != nil || n != 11 { + t.Fatalf("write n=%d err=%v", n, err) + } + if buf.String() != "hello" { + t.Fatalf("got %q", buf.String()) + } +} diff --git a/apps/backend/internal/services/platform_service.go b/apps/backend/internal/services/platform_service.go index e693c58..61597fb 100644 --- a/apps/backend/internal/services/platform_service.go +++ b/apps/backend/internal/services/platform_service.go @@ -39,6 +39,7 @@ type PlatformService struct { dispatcher *WebhookDispatcher scheduler *JobScheduler alerts *alertEvaluator + functions FunctionRunner } type BootstrapInput struct { @@ -224,8 +225,8 @@ type InvitationSummary struct { Status string `json:"status"` } -func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger) *PlatformService { - s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc} +func NewPlatformService(repo *repositories.CoreRepository, store storage.Store, mailer *Mailer, publicURL string, dbxClient *dbx.Client, enc *secrets.Encryptor, logger *slog.Logger, functions FunctionRunner) *PlatformService { + s := &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL, dbx: dbxClient, hub: NewEventHub(), realtime: NewEventHub(), enc: enc, functions: functions} if enc != nil { s.dispatcher = NewWebhookDispatcher(repo.Queries(), enc, logger) s.dispatcher.Start(context.Background()) diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index feb7283..ca9d54b 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -2167,6 +2167,182 @@ paths: $ref: "#/components/schemas/EmailLogListResponse" "401": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions: + get: + tags: [Functions] + operationId: listFunctions + summary: List functions in a project. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Functions + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + post: + tags: [Functions] + operationId: createFunction + summary: Create a function (name, JS/TS code, runtime bun|deno). + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateFunctionRequest" + responses: + "201": + description: Created function + content: + application/json: + schema: + $ref: "#/components/schemas/Function" + "400": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}: + get: + tags: [Functions] + operationId: getFunction + summary: Function metadata plus its source code. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Function with code + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionDetailResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + put: + tags: [Functions] + operationId: updateFunction + summary: Update function code and/or enabled state. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateFunctionRequest" + responses: + "200": + description: Updated function + content: + application/json: + schema: + $ref: "#/components/schemas/Function" + "404": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Functions] + operationId: deleteFunction + summary: Delete a function and its run history. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + responses: + "204": + description: Deleted + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}/invoke: + post: + tags: [Functions] + operationId: invokeFunction + summary: Execute the function with a JSON payload on stdin; returns the recorded run. 502 when the run fails or times out. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InvokeFunctionRequest" + responses: + "200": + description: Recorded run (status success) + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRun" + "502": + description: Recorded run (status error|timeout) + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRun" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/functions/{functionID}/runs: + get: + tags: [Functions] + operationId: listFunctionRuns + summary: Recent runs for a function. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - name: functionID + in: path + required: true + schema: { type: string, format: uuid } + - name: limit + in: query + schema: + type: integer + default: 50 + maximum: 200 + responses: + "200": + description: Function runs + content: + application/json: + schema: + $ref: "#/components/schemas/FunctionRunListResponse" + "404": + $ref: "#/components/responses/ErrorResponse" components: securitySchemes: bearerAuth: @@ -4213,3 +4389,95 @@ components: items: $ref: "#/components/schemas/EmailLogEntry" required: [items] + Function: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + runtime: + type: string + enum: [bun, deno] + enabled: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, name, runtime, enabled, created_at, updated_at] + FunctionListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Function" + required: [items] + FunctionDetailResponse: + type: object + properties: + function: + $ref: "#/components/schemas/Function" + code: + type: string + required: [function, code] + CreateFunctionRequest: + type: object + properties: + name: + type: string + code: + type: string + runtime: + type: string + enum: [bun, deno] + default: bun + required: [name, code] + UpdateFunctionRequest: + type: object + properties: + code: + type: string + enabled: + type: boolean + InvokeFunctionRequest: + type: object + properties: + payload: {} + FunctionRun: + type: object + properties: + id: + type: string + format: uuid + trigger: + type: string + enum: [manual, schedule, hook] + status: + type: string + enum: [success, error, timeout] + exit_code: + type: integer + nullable: true + stdout: + type: string + stderr: + type: string + duration_ms: + type: integer + created_at: + type: string + format: date-time + required: [id, trigger, status, stdout, stderr, duration_ms, created_at] + FunctionRunListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/FunctionRun" + required: [items] diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1829d5c..5cb3577 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -48,6 +48,7 @@ import { TelemetryPage, IntegrationsPage, VaultPage, + FunctionsPage, LoginPage, MembersPage, ProjectsPage, @@ -63,6 +64,7 @@ import { IconDatabases, IconTelemetry, IconZap, + IconTerminal, IconCollections, IconAudit, IconAuth, @@ -1533,6 +1535,7 @@ export default function App() { { id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: , keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") }, { id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: , keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") }, { id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: , keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") }, + { id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: , keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") }, { id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: , keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") }, { id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: , keywords: ["documents", "database"], action: () => setActiveView("collections") }, { id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: , keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") }, @@ -1784,6 +1787,14 @@ export default function App() { />
+ + + + = { telemetry: "Telemetry", integrations: "Integrations", automation: "Automation", + functions: "Functions", vault: "Vault", auth: "Authentication", audit: "Audit log", diff --git a/apps/frontend/src/pages/FunctionsPage.tsx b/apps/frontend/src/pages/FunctionsPage.tsx new file mode 100644 index 0000000..b166d1e --- /dev/null +++ b/apps/frontend/src/pages/FunctionsPage.tsx @@ -0,0 +1,410 @@ +import { For, Show, createEffect, createSignal } from "solid-js"; +import { + ApiError, + CreateFunctionRequest, + Function, + FunctionsService, + type FunctionRun, +} from "@primora/api-client"; +import { Modal, ModalFooter } from "../components/Modal"; +import { Input, Select, Textarea } from "../components/Input"; +import { Badge } from "../components/Badge"; +import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons"; + +interface FunctionsPageProps { + projectID?: string; + canManage: boolean; + demoMode: boolean; +} + +const err = (e: unknown) => (e instanceof Error ? e.message : String(e)); +const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—"); + +const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD. +const input = JSON.parse(await new Response(Bun.stdin.stream()).text()); +console.log(JSON.stringify({ echo: input })); +`; + +const DEMO_FUNCTIONS: Function[] = [ + { id: "f1", name: "resize-image", runtime: Function.runtime.BUN, enabled: true, created_at: "", updated_at: "2026-09-20T09:00:00Z" }, + { id: "f2", name: "sync-billing", runtime: Function.runtime.BUN, enabled: false, created_at: "", updated_at: "2026-09-15T14:00:00Z" }, +]; + +export function FunctionsPage(props: FunctionsPageProps) { + const [functions, setFunctions] = createSignal([]); + const [selected, setSelected] = createSignal(); + const [code, setCode] = createSignal(""); + const [codeDirty, setCodeDirty] = createSignal(false); + const [runs, setRuns] = createSignal([]); + const [payload, setPayload] = createSignal("{}"); + const [lastRun, setLastRun] = createSignal(); + const [message, setMessage] = createSignal(""); + const [error, setError] = createSignal(""); + const [busy, setBusy] = createSignal(); + const [showCreate, setShowCreate] = createSignal(false); + const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" }); + + const refresh = async () => { + if (!props.projectID) return; + if (props.demoMode) { + setFunctions(DEMO_FUNCTIONS); + return; + } + try { + const res = await FunctionsService.listFunctions({ projectId: props.projectID }); + setFunctions(res.items ?? []); + } catch (e) { + setError(err(e)); + } + }; + + const refreshRuns = async (fn: Function) => { + if (!props.projectID || props.demoMode) return; + try { + const res = await FunctionsService.listFunctionRuns({ projectId: props.projectID, functionId: fn.id, limit: 20 }); + setRuns(res.items ?? []); + } catch { + /* run history is best-effort */ + } + }; + + const select = async (fn: Function) => { + setSelected(fn); + setCodeDirty(false); + setLastRun(undefined); + if (props.demoMode) { + setCode(STARTER_CODE); + return; + } + try { + const res = await FunctionsService.getFunction({ projectId: props.projectID!, functionId: fn.id }); + setCode(res.code); + await refreshRuns(fn); + } catch (e) { + setError(err(e)); + } + }; + + createEffect(() => void refresh()); + + const create = async (e: SubmitEvent) => { + e.preventDefault(); + if (!props.projectID || props.demoMode) return; + setBusy("create"); + try { + const fn = await FunctionsService.createFunction({ + projectId: props.projectID, + requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE }, + }); + setShowCreate(false); + setCreateForm({ name: "", runtime: "bun" }); + setMessage(`Function "${fn.name}" created.`); + await refresh(); + await select(fn); + } catch (e2) { + setError(err(e2)); + } finally { + setBusy(undefined); + } + }; + + const save = async () => { + const fn = selected(); + if (!fn || !props.projectID || props.demoMode) return; + setBusy("save"); + try { + await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } }); + setCodeDirty(false); + setMessage(`Saved "${fn.name}".`); + } catch (e) { + setError(err(e)); + } finally { + setBusy(undefined); + } + }; + + const invoke = async () => { + const fn = selected(); + if (!fn || !props.projectID || props.demoMode) return; + let parsed: unknown; + try { + parsed = JSON.parse(payload() || "{}"); + } catch { + setError("Payload is not valid JSON."); + return; + } + setBusy("invoke"); + setError(""); + try { + const run = await FunctionsService.invokeFunction({ + projectId: props.projectID, + functionId: fn.id, + requestBody: { payload: parsed }, + }); + setLastRun(run); + } catch (e) { + // 502 still returns a FunctionRun body — the run is recorded either way. + if (e instanceof ApiError && e.body && typeof e.body === "object" && "status" in e.body) { + setLastRun(e.body as FunctionRun); + } else { + setError(err(e)); + } + } finally { + setBusy(undefined); + await refreshRuns(fn); + } + }; + + const toggleEnabled = async (fn: Function) => { + if (!props.projectID || props.demoMode) return; + try { + await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { enabled: !fn.enabled } }); + await refresh(); + if (selected()?.id === fn.id) setSelected({ ...fn, enabled: !fn.enabled }); + } catch (e) { + setError(err(e)); + } + }; + + const remove = async (fn: Function) => { + if (!props.projectID || props.demoMode) return; + setBusy(fn.id); + try { + await FunctionsService.deleteFunction({ projectId: props.projectID, functionId: fn.id }); + if (selected()?.id === fn.id) setSelected(undefined); + setMessage(`Deleted "${fn.name}".`); + await refresh(); + } catch (e) { + setError(err(e)); + } finally { + setBusy(undefined); + } + }; + + const runBadge = (status: string) => + status === "success" ? "success" : status === "timeout" ? "warning" : "error"; + + return ( +
+ + + +
setMessage("")}>{message()}
+
+ +
setError("")}>{error()}
+
+ + Select a project to manage functions.
} + > + 0} + fallback={ +
+ +

No functions yet.

+

+ Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks. +

+
+ } + > +
+
+ + + + + + + + + + + + + {(fn) => ( + void select(fn)} + > + + + + + + + )} + + +
NameRuntimeStatusUpdated
{fn.name}{fn.runtime} + {fn.enabled ? "enabled" : "disabled"} + {fmtTime(fn.updated_at)} + +
+ + +
+
+
+
+
+
+
+ + + {(fn) => ( +
+
+
+ + {fn().name} {fn().runtime} + +
+ + + + +
+
+
+
+
+ +