From 45a16219094ba3453a68f7a37761683859506c45 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 24 Sep 2026 19:15:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20inbound=20webhooks=20=E2=80=94=20public?= =?UTF-8?q?=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`, + }, + }); + } +}