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:
Tomas DvorakandDevin committed 2026-09-24 19:15:12 +02:00
1 parent 2f4de543a9
commit 45a1621909
26 files changed
+1264 -9

No files matched your search

+1
View File
@@ -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
View File
@@ -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;
+26
View File
@@ -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)
+4
View File
@@ -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)
+97
View File
@@ -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)
}
+249
View File
@@ -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,
+173 -3
View File
@@ -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]
+167 -1
View File
@@ -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`,
},
});
}
}