mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 08:04:50 +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.
|
- **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`.
|
- **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.
|
- **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 …`.
|
- **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
|
## [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
|
- CRUD via `/projects/:id/alerts`; Integrations page manages rules next to
|
||||||
webhooks and shows live `firing` state.
|
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
|
## Future phases — candidates
|
||||||
|
|
||||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
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
|
- **Phase 10 — Storage backends** — S3-compatible object storage behind
|
||||||
the existing bucket abstraction (deferred from Phase 6), plus
|
the existing bucket abstraction (deferred from Phase 6), plus
|
||||||
presigned-URL uploads for large files.
|
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
|
- **HA mode** — leader-elected scheduler + distributed locks so two
|
||||||
backend replicas don't double-fire jobs; Postgres advisory locks are
|
backend replicas don't double-fire jobs; Postgres advisory locks are
|
||||||
enough at this scale.
|
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"`
|
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 {
|
type CoreIntegration struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Querier interface {
|
|||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||||
|
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||||
@@ -43,6 +44,7 @@ type Querier interface {
|
|||||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||||
|
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
@@ -65,6 +67,8 @@ type Querier interface {
|
|||||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||||
|
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)
|
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||||
@@ -99,6 +103,7 @@ type Querier interface {
|
|||||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||||
|
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||||
@@ -138,6 +143,7 @@ type Querier interface {
|
|||||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||||
TouchAPIKey(ctx context.Context, id uuid.UUID) 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)
|
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
|||||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||||
api.OPTIONS("/ingest", h.ingestOptions)
|
api.OPTIONS("/ingest", h.ingestOptions)
|
||||||
api.POST("/ingest", h.ingest)
|
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/events", h.listEvents)
|
||||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||||
api.GET("/projects/:projectID/components", h.listComponents)
|
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,
|
WebhookEventTest: true,
|
||||||
WebhookEventAlertFired: true,
|
WebhookEventAlertFired: true,
|
||||||
WebhookEventAlertResolved: true,
|
WebhookEventAlertResolved: true,
|
||||||
|
WebhookEventInboundReceived: true,
|
||||||
WebhookEventJobRun: true,
|
WebhookEventJobRun: true,
|
||||||
WebhookEventDocumentCreated: true,
|
WebhookEventDocumentCreated: true,
|
||||||
WebhookEventDocumentUpdated: true,
|
WebhookEventDocumentUpdated: true,
|
||||||
|
|||||||
@@ -2041,6 +2041,107 @@ paths:
|
|||||||
$ref: "#/components/responses/ErrorResponse"
|
$ref: "#/components/responses/ErrorResponse"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/ErrorResponse"
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -3612,7 +3713,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
has_secret:
|
has_secret:
|
||||||
@@ -3634,7 +3735,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
required: [url]
|
required: [url]
|
||||||
@@ -3651,7 +3752,7 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
WebhookCreateResponse:
|
WebhookCreateResponse:
|
||||||
@@ -3989,3 +4090,72 @@ components:
|
|||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
required: [name, kind]
|
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 { For, Show, createEffect, createSignal } from "solid-js";
|
||||||
import {
|
import {
|
||||||
AlertsService,
|
AlertsService,
|
||||||
|
AutomationService,
|
||||||
|
CreateInboundHookRequest,
|
||||||
CreateIntegrationRequest,
|
CreateIntegrationRequest,
|
||||||
|
InboundService,
|
||||||
IntegrationsService,
|
IntegrationsService,
|
||||||
UpsertAlertRuleRequest,
|
UpsertAlertRuleRequest,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
type AlertRule,
|
type AlertRule,
|
||||||
|
type InboundHook,
|
||||||
|
type ScheduledJob,
|
||||||
type Integration,
|
type Integration,
|
||||||
type IntegrationAnalytics,
|
type IntegrationAnalytics,
|
||||||
type Webhook,
|
type Webhook,
|
||||||
@@ -63,9 +68,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||||
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
||||||
|
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
|
||||||
|
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
|
||||||
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||||
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||||
const [showCreateAlert, setShowCreateAlert] = 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({
|
const [alertForm, setAlertForm] = createSignal({
|
||||||
name: "",
|
name: "",
|
||||||
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
|
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
|
||||||
@@ -87,14 +97,18 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const [int, hooks, rules] = await Promise.all([
|
const [int, hooks, rules, inbound, jobs] = await Promise.all([
|
||||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||||
hookSvc(props.demoMode).listWebhooks({ 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: [] }) : 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 ?? []);
|
setIntegrations(int.items ?? []);
|
||||||
setWebhooks(hooks.items ?? []);
|
setWebhooks(hooks.items ?? []);
|
||||||
setAlertRules(rules.items ?? []);
|
setAlertRules(rules.items ?? []);
|
||||||
|
setInboundHooks(inbound.items ?? []);
|
||||||
|
setScheduledJobs(jobs.items ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(err(e));
|
setError(err(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -244,6 +258,38 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
"Alert rule deleted",
|
"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) =>
|
const testWebhook = (id: string) =>
|
||||||
run(
|
run(
|
||||||
`test-hook-${id}`,
|
`test-hook-${id}`,
|
||||||
@@ -623,6 +669,78 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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}>
|
<Show when={props.canManage}>
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -808,6 +926,54 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type { CreatedApiKey } from './models/CreatedApiKey';
|
|||||||
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
export { CreateDBConnectionRequest } from './models/CreateDBConnectionRequest';
|
||||||
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
export type { CreateDeployMarkerRequest } from './models/CreateDeployMarkerRequest';
|
||||||
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
export type { CreateDocumentRequest } from './models/CreateDocumentRequest';
|
||||||
|
export { CreateInboundHookRequest } from './models/CreateInboundHookRequest';
|
||||||
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
export { CreateIntegrationRequest } from './models/CreateIntegrationRequest';
|
||||||
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
export { CreateInvitationRequest } from './models/CreateInvitationRequest';
|
||||||
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
|
export type { CreateOrganizationRequest } from './models/CreateOrganizationRequest';
|
||||||
@@ -69,6 +70,9 @@ export type { DocumentListResponse } from './models/DocumentListResponse';
|
|||||||
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
export type { ErrorEnvelope } from './models/ErrorEnvelope';
|
||||||
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
export type { EventSeriesBucket } from './models/EventSeriesBucket';
|
||||||
export type { HealthStatus } from './models/HealthStatus';
|
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 { IngestEvent } from './models/IngestEvent';
|
||||||
export type { IngestRequest } from './models/IngestRequest';
|
export type { IngestRequest } from './models/IngestRequest';
|
||||||
export type { IngestResponse } from './models/IngestResponse';
|
export type { IngestResponse } from './models/IngestResponse';
|
||||||
@@ -173,6 +177,7 @@ export { $CreatedApiKey } from './schemas/$CreatedApiKey';
|
|||||||
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
export { $CreateDBConnectionRequest } from './schemas/$CreateDBConnectionRequest';
|
||||||
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
export { $CreateDeployMarkerRequest } from './schemas/$CreateDeployMarkerRequest';
|
||||||
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
export { $CreateDocumentRequest } from './schemas/$CreateDocumentRequest';
|
||||||
|
export { $CreateInboundHookRequest } from './schemas/$CreateInboundHookRequest';
|
||||||
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
export { $CreateIntegrationRequest } from './schemas/$CreateIntegrationRequest';
|
||||||
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
export { $CreateInvitationRequest } from './schemas/$CreateInvitationRequest';
|
||||||
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
|
export { $CreateOrganizationRequest } from './schemas/$CreateOrganizationRequest';
|
||||||
@@ -205,6 +210,9 @@ export { $DocumentListResponse } from './schemas/$DocumentListResponse';
|
|||||||
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
export { $ErrorEnvelope } from './schemas/$ErrorEnvelope';
|
||||||
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
export { $EventSeriesBucket } from './schemas/$EventSeriesBucket';
|
||||||
export { $HealthStatus } from './schemas/$HealthStatus';
|
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 { $IngestEvent } from './schemas/$IngestEvent';
|
||||||
export { $IngestRequest } from './schemas/$IngestRequest';
|
export { $IngestRequest } from './schemas/$IngestRequest';
|
||||||
export { $IngestResponse } from './schemas/$IngestResponse';
|
export { $IngestResponse } from './schemas/$IngestResponse';
|
||||||
@@ -284,6 +292,7 @@ export { AutomationService } from './services/AutomationService';
|
|||||||
export { CollectionsService } from './services/CollectionsService';
|
export { CollectionsService } from './services/CollectionsService';
|
||||||
export { DatabasesService } from './services/DatabasesService';
|
export { DatabasesService } from './services/DatabasesService';
|
||||||
export { HealthService } from './services/HealthService';
|
export { HealthService } from './services/HealthService';
|
||||||
|
export { InboundService } from './services/InboundService';
|
||||||
export { InstanceService } from './services/InstanceService';
|
export { InstanceService } from './services/InstanceService';
|
||||||
export { IntegrationsService } from './services/IntegrationsService';
|
export { IntegrationsService } from './services/IntegrationsService';
|
||||||
export { OrganizationsService } from './services/OrganizationsService';
|
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.
|
* HMAC key for X-Primora-Signature. Generated when omitted.
|
||||||
*/
|
*/
|
||||||
secret?: string;
|
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;
|
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.
|
* Replace the signing secret. Omit to keep the current one.
|
||||||
*/
|
*/
|
||||||
secret?: string;
|
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;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export type Webhook = {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
url: 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>;
|
events: Array<string>;
|
||||||
enabled: boolean;
|
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