mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: inbound webhooks — public ingest endpoints
core.inbound_hooks (migration 00013): POST /api/v1/hooks/<token> 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>
This commit is contained in:
1 parent
2f4de543a9
commit
45a1621909
26 files changed
+1264
-9
No files matched your search
@@ -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/<token>` (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 <slug> --filter … --order …`.
|
||||
|
||||
## [0.7.1] - 2026-09-23
|
||||
|
||||
+11
-2
@@ -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>` — 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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<token>. 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)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ var webhookEventTypes = map[string]bool{
|
||||
WebhookEventTest: true,
|
||||
WebhookEventAlertFired: true,
|
||||
WebhookEventAlertResolved: true,
|
||||
WebhookEventInboundReceived: true,
|
||||
WebhookEventJobRun: true,
|
||||
WebhookEventDocumentCreated: true,
|
||||
WebhookEventDocumentUpdated: true,
|
||||
|
||||
@@ -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]
|
||||
@@ -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<string>();
|
||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
||||
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
|
||||
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
|
||||
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) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush mt-4">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Inbound hooks</div>
|
||||
<div class="card-header-description">
|
||||
Public ingest endpoints — POST from external systems to republish as <code>inbound.received</code>
|
||||
events or trigger a scheduled job.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateInbound(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Add hook
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={inboundHooks().length > 0}
|
||||
fallback={<p class="text-text-2 text-sm p-4">No inbound hooks yet.</p>}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Mode</th>
|
||||
<th>Security</th>
|
||||
<th>Last received</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={inboundHooks()}>
|
||||
{(hook) => (
|
||||
<tr>
|
||||
<td class="font-medium">{hook.name}</td>
|
||||
<td>
|
||||
<Badge variant="neutral">{hook.mode === "job" ? "job trigger" : "event"}</Badge>
|
||||
<Show when={!hook.enabled}>
|
||||
{" "}<Badge variant="warning">disabled</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={hook.has_secret} fallback={<span class="text-text-2">token only</span>}>
|
||||
<Badge variant="success">signed</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-text-2">{fmtTime(hook.last_received_at)}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => copyHookUrl(hook)} title={hook.url}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
{copiedUrl() === hook.id ? "Copied" : "URL"}
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void deleteInboundHook(hook.id)}
|
||||
disabled={busy() === `del-inbound-${hook.id}`}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.canManage}>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
@@ -808,6 +926,54 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
|
||||
<form onSubmit={createInboundHook} class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="github_ci"
|
||||
required
|
||||
value={inboundForm().name}
|
||||
onInput={(e) => setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||
/>
|
||||
<Select
|
||||
label="On receive"
|
||||
value={inboundForm().mode}
|
||||
onChange={(e) => setInboundForm((c) => ({ ...c, mode: e.currentTarget.value as CreateInboundHookRequest.mode }))}
|
||||
options={[
|
||||
{ value: "event", label: "Publish inbound.received event" },
|
||||
{ value: "job", label: "Trigger a scheduled job" },
|
||||
]}
|
||||
/>
|
||||
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
|
||||
<Select
|
||||
label="Job"
|
||||
value={inboundForm().job_id}
|
||||
onChange={(e) => setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))}
|
||||
options={[
|
||||
{ value: "", label: "Select a job…" },
|
||||
...scheduledJobs().map((j) => ({ value: j.id, label: j.name })),
|
||||
]}
|
||||
/>
|
||||
<p class="text-text-2 text-xs">The received body becomes the job's payload for that run.</p>
|
||||
</Show>
|
||||
<Input
|
||||
label="Secret"
|
||||
type="password"
|
||||
placeholder="Optional — callers must sign X-Primora-Signature"
|
||||
value={inboundForm().secret}
|
||||
onInput={(e) => setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateInbound(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create-inbound"}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<InboundHook>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<string>;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<InboundHookListResponse> {
|
||||
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<InboundHook> {
|
||||
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<void> {
|
||||
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<string, any>,
|
||||
}): CancelablePromise<InboundReceiveResponse> {
|
||||
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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user