mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: inbound webhooks — public ingest endpoints
core.inbound_hooks (migration 00013): POST /api/v1/hooks/<token> accepts external events. event mode republishes as inbound.received through the signed dispatcher + realtime; job mode triggers a scheduled-job run with the received body as payload. Optional X-Primora-Signature HMAC verify. Dashboard card on the Integrations page. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
2f4de543a9
commit
45a1621909
26 files changed
+1264
-9
No files matched your search
@@ -0,0 +1,29 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE core.inbound_hooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
mode TEXT NOT NULL DEFAULT 'event' CHECK (mode IN ('event','job')),
|
||||
job_id UUID REFERENCES core.scheduled_jobs(id) ON DELETE CASCADE,
|
||||
secret BYTEA NOT NULL DEFAULT ''::bytea,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_received_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, name)
|
||||
);
|
||||
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual','hook'));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
DROP CONSTRAINT scheduled_job_runs_triggered_by_check;
|
||||
ALTER TABLE core.scheduled_job_runs
|
||||
ADD CONSTRAINT scheduled_job_runs_triggered_by_check
|
||||
CHECK (triggered_by IN ('schedule','manual'));
|
||||
DROP TABLE IF EXISTS core.inbound_hooks;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- name: CreateInboundHook :one
|
||||
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, secret, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListInboundHooks :many
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name;
|
||||
|
||||
-- name: GetInboundHookByToken :one
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: GetInboundHook :one
|
||||
SELECT * FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
|
||||
-- name: TouchInboundHook :exec
|
||||
UPDATE core.inbound_hooks
|
||||
SET last_received_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: DeleteInboundHook :exec
|
||||
DELETE FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2;
|
||||
@@ -0,0 +1,173 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: inbound_hooks.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createInboundHook = `-- name: CreateInboundHook :one
|
||||
INSERT INTO core.inbound_hooks (project_id, name, token, mode, job_id, secret, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateInboundHookParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, createInboundHook,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Token,
|
||||
arg.Mode,
|
||||
arg.JobID,
|
||||
arg.Secret,
|
||||
arg.Enabled,
|
||||
)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteInboundHook = `-- name: DeleteInboundHook :exec
|
||||
DELETE FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type DeleteInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteInboundHook, arg.ID, arg.ProjectID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getInboundHook = `-- name: GetInboundHook :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks
|
||||
WHERE id = $1 AND project_id = $2
|
||||
`
|
||||
|
||||
type GetInboundHookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHook, arg.ID, arg.ProjectID)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getInboundHookByToken = `-- name: GetInboundHookByToken :one
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error) {
|
||||
row := q.db.QueryRow(ctx, getInboundHookByToken, token)
|
||||
var i CoreInboundHook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listInboundHooks = `-- name: ListInboundHooks :many
|
||||
SELECT id, project_id, name, token, mode, job_id, secret, enabled, last_received_at, created_at, updated_at FROM core.inbound_hooks
|
||||
WHERE project_id = $1
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error) {
|
||||
rows, err := q.db.Query(ctx, listInboundHooks, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CoreInboundHook
|
||||
for rows.Next() {
|
||||
var i CoreInboundHook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Token,
|
||||
&i.Mode,
|
||||
&i.JobID,
|
||||
&i.Secret,
|
||||
&i.Enabled,
|
||||
&i.LastReceivedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const touchInboundHook = `-- name: TouchInboundHook :exec
|
||||
UPDATE core.inbound_hooks
|
||||
SET last_received_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) TouchInboundHook(ctx context.Context, id uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, touchInboundHook, id)
|
||||
return err
|
||||
}
|
||||
@@ -255,6 +255,20 @@ type CoreEvent struct {
|
||||
Ts pgtype.Timestamptz `json:"ts"`
|
||||
}
|
||||
|
||||
type CoreInboundHook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
JobID pgtype.UUID `json:"job_id"`
|
||||
Secret []byte `json:"secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt pgtype.Timestamptz `json:"last_received_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreIntegration struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -30,6 +30,7 @@ type Querier interface {
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (CoreCollection, error)
|
||||
CreateDBConnection(ctx context.Context, arg CreateDBConnectionParams) (CoreDbConnection, error)
|
||||
CreateDocument(ctx context.Context, arg CreateDocumentParams) (CoreDocument, error)
|
||||
CreateInboundHook(ctx context.Context, arg CreateInboundHookParams) (CoreInboundHook, error)
|
||||
CreateIntegration(ctx context.Context, arg CreateIntegrationParams) (CoreIntegration, error)
|
||||
CreateInvitation(ctx context.Context, arg CreateInvitationParams) (CoreProjectInvitation, error)
|
||||
CreateOrganization(ctx context.Context, arg CreateOrganizationParams) (CoreOrganization, error)
|
||||
@@ -43,6 +44,7 @@ type Querier interface {
|
||||
DeleteComponent(ctx context.Context, arg DeleteComponentParams) error
|
||||
DeleteDBConnection(ctx context.Context, arg DeleteDBConnectionParams) (CoreDbConnection, error)
|
||||
DeleteDocument(ctx context.Context, arg DeleteDocumentParams) error
|
||||
DeleteInboundHook(ctx context.Context, arg DeleteInboundHookParams) error
|
||||
DeleteIntegration(ctx context.Context, arg DeleteIntegrationParams) (CoreIntegration, error)
|
||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
@@ -65,6 +67,8 @@ type Querier interface {
|
||||
GetDBConnectionByID(ctx context.Context, id uuid.UUID) (CoreDbConnection, error)
|
||||
GetDBConnectionByName(ctx context.Context, arg GetDBConnectionByNameParams) (CoreDbConnection, error)
|
||||
GetDocumentByID(ctx context.Context, arg GetDocumentByIDParams) (CoreDocument, error)
|
||||
GetInboundHook(ctx context.Context, arg GetInboundHookParams) (CoreInboundHook, error)
|
||||
GetInboundHookByToken(ctx context.Context, token string) (CoreInboundHook, error)
|
||||
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
|
||||
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
|
||||
@@ -99,6 +103,7 @@ type Querier interface {
|
||||
ListEnabledAlertRules(ctx context.Context) ([]CoreAlertRule, error)
|
||||
ListErrorGroups(ctx context.Context, arg ListErrorGroupsParams) ([]ListErrorGroupsRow, error)
|
||||
ListEvents(ctx context.Context, arg ListEventsParams) ([]ListEventsRow, error)
|
||||
ListInboundHooks(ctx context.Context, projectID uuid.UUID) ([]CoreInboundHook, error)
|
||||
ListIntegrations(ctx context.Context, projectID uuid.UUID) ([]CoreIntegration, error)
|
||||
ListInvitationsForOrganization(ctx context.Context, organizationID uuid.UUID) ([]ListInvitationsForOrganizationRow, error)
|
||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||
@@ -138,6 +143,7 @@ type Querier interface {
|
||||
SweepExpiredOrgAuditLogs(ctx context.Context) (int64, error)
|
||||
SweepExpiredWebhookDeliveries(ctx context.Context) (int64, error)
|
||||
TouchAPIKey(ctx context.Context, id uuid.UUID) error
|
||||
TouchInboundHook(ctx context.Context, id uuid.UUID) error
|
||||
UpdateAlertRule(ctx context.Context, arg UpdateAlertRuleParams) (CoreAlertRule, error)
|
||||
UpdateBucketByID(ctx context.Context, arg UpdateBucketByIDParams) (CoreBucket, error)
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (CoreCollection, error)
|
||||
|
||||
@@ -79,6 +79,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.DELETE("/collections/:collectionID/documents/:documentID", h.deleteDocument)
|
||||
api.OPTIONS("/ingest", h.ingestOptions)
|
||||
api.POST("/ingest", h.ingest)
|
||||
api.POST("/hooks/:token", h.receiveInboundHook)
|
||||
api.GET("/projects/:projectID/inbound-hooks", h.listInboundHooks)
|
||||
api.POST("/projects/:projectID/inbound-hooks", h.createInboundHook)
|
||||
api.DELETE("/projects/:projectID/inbound-hooks/:hookID", h.deleteInboundHook)
|
||||
api.GET("/projects/:projectID/events", h.listEvents)
|
||||
api.GET("/projects/:projectID/issues", h.listIssues)
|
||||
api.GET("/projects/:projectID/components", h.listComponents)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
const maxInboundBody = 256 * 1024 // external POSTs capped at 256 KiB
|
||||
|
||||
func (h *HTTPHandler) inboundError(c *gin.Context, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "hook not found")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "invalid signature"):
|
||||
apperrors.Abort(c, http.StatusUnauthorized, "unauthorized", msg)
|
||||
case strings.Contains(msg, "invalid") || strings.Contains(msg, "already exists") || strings.Contains(msg, "disabled") || strings.Contains(msg, "requires") || strings.Contains(msg, "not found in this project"):
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
default:
|
||||
h.handleError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listInboundHooks(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListInboundHooks(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createInboundHook(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.CreateInboundHookInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.CreateInboundHook(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteInboundHook(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hookID, err := uuid.Parse(c.Param("hookID"))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", "invalid hook id")
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteInboundHook(c.Request.Context(), actor, projectID, hookID, middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// receiveInboundHook is the public ingest endpoint — the URL token is the
|
||||
// credential, so no actor is required.
|
||||
func (h *HTTPHandler) receiveInboundHook(c *gin.Context) {
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxInboundBody))
|
||||
if err != nil {
|
||||
apperrors.Abort(c, http.StatusRequestEntityTooLarge, "too_large", "body exceeds 256 KiB")
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ReceiveInboundHook(c.Request.Context(), c.Param("token"), c.GetHeader("X-Primora-Signature"), body)
|
||||
if err != nil {
|
||||
h.inboundError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, result)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// Inbound hooks are project-scoped public ingest endpoints:
|
||||
// POST /api/v1/hooks/<token>. A hook either republishes the body as an
|
||||
// `inbound.received` domain event (webhooks + realtime fan out for free) or
|
||||
// triggers a scheduled-job run with the body as the payload. When a secret is
|
||||
// set, callers must sign the raw body with X-Primora-Signature — the same
|
||||
// HMAC-SHA256 scheme outbound deliveries use.
|
||||
|
||||
const (
|
||||
WebhookEventInboundReceived = "inbound.received"
|
||||
|
||||
InboundModeEvent = "event"
|
||||
InboundModeJob = "job"
|
||||
)
|
||||
|
||||
type InboundHookSummary struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id,omitempty"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastReceivedAt *time.Time `json:"last_received_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateInboundHookInput struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Mode string `json:"mode"`
|
||||
JobID string `json:"job_id"`
|
||||
Secret string `json:"secret"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type InboundReceiveResult struct {
|
||||
Received bool `json:"received"`
|
||||
Event string `json:"event,omitempty"`
|
||||
RunID string `json:"run_id,omitempty"`
|
||||
}
|
||||
|
||||
func inboundHookSummary(row db.CoreInboundHook, publicURL string) InboundHookSummary {
|
||||
out := InboundHookSummary{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Name: row.Name,
|
||||
Token: row.Token,
|
||||
URL: strings.TrimSuffix(publicURL, "/") + "/api/v1/hooks/" + row.Token,
|
||||
Mode: row.Mode,
|
||||
HasSecret: len(row.Secret) > 0,
|
||||
Enabled: row.Enabled,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}
|
||||
if row.JobID.Valid {
|
||||
out.JobID = uuid.UUID(row.JobID.Bytes).String()
|
||||
}
|
||||
if row.LastReceivedAt.Valid {
|
||||
t := row.LastReceivedAt.Time
|
||||
out.LastReceivedAt = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListInboundHooks(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListInboundHooks(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]InboundHookSummary, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = inboundHookSummary(r, s.publicURL)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) CreateInboundHook(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateInboundHookInput, requestID string) (InboundHookSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
if !projectSecretNameRe.MatchString(input.Name) {
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid hook name %q — use [A-Za-z_][A-Za-z0-9_]*", input.Name)
|
||||
}
|
||||
mode := input.Mode
|
||||
if mode == "" {
|
||||
mode = InboundModeEvent
|
||||
}
|
||||
var jobID pgtype.UUID
|
||||
if mode == InboundModeJob {
|
||||
parsed, err := uuid.Parse(input.JobID)
|
||||
if err != nil {
|
||||
return InboundHookSummary{}, errors.New("job mode requires a valid job_id")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, parsed)
|
||||
if err != nil || job.ProjectID != projectID {
|
||||
return InboundHookSummary{}, errors.New("job not found in this project")
|
||||
}
|
||||
jobID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
} else if mode != InboundModeEvent {
|
||||
return InboundHookSummary{}, fmt.Errorf("invalid mode %q — event or job", input.Mode)
|
||||
}
|
||||
secretBytes := []byte{}
|
||||
if input.Secret != "" {
|
||||
if s.enc == nil {
|
||||
return InboundHookSummary{}, errors.New("encryption is not configured — cannot store hook secrets")
|
||||
}
|
||||
var err error
|
||||
if secretBytes, err = s.enc.Encrypt([]byte(input.Secret)); err != nil {
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
}
|
||||
token := "prm_hook_" + randHex(16)
|
||||
enabled := true
|
||||
if input.Enabled != nil {
|
||||
enabled = *input.Enabled
|
||||
}
|
||||
row, err := s.repo.Queries().CreateInboundHook(ctx, db.CreateInboundHookParams{
|
||||
ProjectID: projectID,
|
||||
Name: input.Name,
|
||||
Token: token,
|
||||
Mode: mode,
|
||||
JobID: jobID,
|
||||
Secret: secretBytes,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "inbound_hooks_project_id_name_key") {
|
||||
return InboundHookSummary{}, fmt.Errorf("an inbound hook named %s already exists", input.Name)
|
||||
}
|
||||
return InboundHookSummary{}, err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.created", "inbound_hook", row.ID.String(), map[string]any{
|
||||
"name": row.Name, "mode": row.Mode,
|
||||
}))
|
||||
}
|
||||
return inboundHookSummary(row, s.publicURL), nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteInboundHook(ctx context.Context, actor *models.Actor, projectID, hookID uuid.UUID, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.repo.Queries().GetInboundHook(ctx, db.GetInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Queries().DeleteInboundHook(ctx, db.DeleteInboundHookParams{ID: hookID, ProjectID: projectID}); err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
||||
if err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "inbound_hook.deleted", "inbound_hook", hookID.String(), nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReceiveInboundHook handles POST /api/v1/hooks/:token — unauthenticated; the
|
||||
// token is the credential. Signature check applies when a secret is set.
|
||||
func (s *PlatformService) ReceiveInboundHook(ctx context.Context, token string, signature string, body []byte) (InboundReceiveResult, error) {
|
||||
hook, err := s.repo.Queries().GetInboundHookByToken(ctx, token)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err // pgx.ErrNoRows → 404
|
||||
}
|
||||
if !hook.Enabled {
|
||||
return InboundReceiveResult{}, errors.New("hook is disabled")
|
||||
}
|
||||
if len(hook.Secret) > 0 {
|
||||
if s.enc == nil {
|
||||
return InboundReceiveResult{}, errors.New("encryption is not configured")
|
||||
}
|
||||
plain, err := s.enc.Decrypt(hook.Secret)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
want := "sha256=" + signBody(string(plain), body)
|
||||
if subtle.ConstantTimeCompare([]byte(signature), []byte(want)) != 1 {
|
||||
return InboundReceiveResult{}, errors.New("invalid signature")
|
||||
}
|
||||
}
|
||||
var payload map[string]any
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return InboundReceiveResult{}, fmt.Errorf("invalid JSON body: %w", err)
|
||||
}
|
||||
}
|
||||
_ = s.repo.Queries().TouchInboundHook(ctx, hook.ID)
|
||||
|
||||
switch hook.Mode {
|
||||
case InboundModeJob:
|
||||
if s.scheduler == nil {
|
||||
return InboundReceiveResult{}, errors.New("scheduler unavailable")
|
||||
}
|
||||
if !hook.JobID.Valid {
|
||||
return InboundReceiveResult{}, errors.New("hook has no job configured")
|
||||
}
|
||||
job, err := s.repo.Queries().GetScheduledJobByID(ctx, hook.JobID.Bytes)
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
if payload != nil {
|
||||
raw, _ := json.Marshal(payload)
|
||||
job.Payload = raw // received body replaces the configured payload
|
||||
}
|
||||
run, err := s.scheduler.enqueue(ctx, job, "hook")
|
||||
if err != nil {
|
||||
return InboundReceiveResult{}, err
|
||||
}
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventJobRun, RunID: run.ID.String()}, nil
|
||||
default:
|
||||
s.publishEvent(ctx, hook.ProjectID, WebhookEventInboundReceived, map[string]any{
|
||||
"hook_id": hook.ID.String(),
|
||||
"hook_name": hook.Name,
|
||||
"payload": payload,
|
||||
})
|
||||
return InboundReceiveResult{Received: true, Event: WebhookEventInboundReceived}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func randHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ var webhookEventTypes = map[string]bool{
|
||||
WebhookEventTest: true,
|
||||
WebhookEventAlertFired: true,
|
||||
WebhookEventAlertResolved: true,
|
||||
WebhookEventInboundReceived: true,
|
||||
WebhookEventJobRun: true,
|
||||
WebhookEventDocumentCreated: true,
|
||||
WebhookEventDocumentUpdated: true,
|
||||
|
||||
@@ -2041,6 +2041,107 @@ paths:
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/inbound-hooks:
|
||||
get:
|
||||
tags: [Inbound]
|
||||
operationId: listInboundHooks
|
||||
summary: Lists inbound webhook endpoints for the project.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Inbound hooks
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundHookListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
post:
|
||||
tags: [Inbound]
|
||||
operationId: createInboundHook
|
||||
summary: Creates an inbound hook. `event` mode republishes bodies as `inbound.received` domain events; `job` mode triggers a scheduled-job run with the body as its payload. Set `secret` to require `X-Primora-Signature` (HMAC-SHA256 over the raw body).
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateInboundHookRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Inbound hook created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundHook"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/inbound-hooks/{hookID}:
|
||||
delete:
|
||||
tags: [Inbound]
|
||||
operationId: deleteInboundHook
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- name: hookID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"204":
|
||||
description: Inbound hook deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/hooks/{token}:
|
||||
post:
|
||||
tags: [Inbound]
|
||||
operationId: receiveInboundHook
|
||||
summary: Public ingest endpoint — the URL token is the credential. Body must be JSON (max 256 KiB); sign with X-Primora-Signature when the hook has a secret.
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: X-Primora-Signature
|
||||
in: header
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"202":
|
||||
description: Accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundReceiveResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -3612,7 +3713,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.*, object.*). Empty means all events.
|
||||
description: Event filter (issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.*, object.*). Empty means all events.
|
||||
enabled:
|
||||
type: boolean
|
||||
has_secret:
|
||||
@@ -3634,7 +3735,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [url]
|
||||
@@ -3651,7 +3752,7 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enum: [issue.created, deploy.marker, webhook.test, job.run, alert.fired, alert.resolved, inbound.received, document.created, document.updated, document.deleted, object.created, object.updated, object.deleted]
|
||||
enabled:
|
||||
type: boolean
|
||||
WebhookCreateResponse:
|
||||
@@ -3989,3 +4090,72 @@ components:
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [name, kind]
|
||||
InboundHook:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
project_id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
description: Full ingest URL — POST here from the external system.
|
||||
mode:
|
||||
type: string
|
||||
enum: [event, job]
|
||||
job_id:
|
||||
type: string
|
||||
has_secret:
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
last_received_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, project_id, name, token, url, mode, has_secret, enabled, created_at]
|
||||
InboundHookListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InboundHook"
|
||||
required: [items]
|
||||
CreateInboundHookRequest:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
mode:
|
||||
type: string
|
||||
enum: [event, job]
|
||||
default: event
|
||||
job_id:
|
||||
type: string
|
||||
description: Required when mode=job — scheduled job to trigger.
|
||||
secret:
|
||||
type: string
|
||||
description: Optional — stored encrypted; callers must then sign bodies with X-Primora-Signature.
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [name]
|
||||
InboundReceiveResponse:
|
||||
type: object
|
||||
properties:
|
||||
received:
|
||||
type: boolean
|
||||
event:
|
||||
type: string
|
||||
run_id:
|
||||
type: string
|
||||
required: [received]
|
||||
@@ -1,11 +1,16 @@
|
||||
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||
import {
|
||||
AlertsService,
|
||||
AutomationService,
|
||||
CreateInboundHookRequest,
|
||||
CreateIntegrationRequest,
|
||||
InboundService,
|
||||
IntegrationsService,
|
||||
UpsertAlertRuleRequest,
|
||||
WebhooksService,
|
||||
type AlertRule,
|
||||
type InboundHook,
|
||||
type ScheduledJob,
|
||||
type Integration,
|
||||
type IntegrationAnalytics,
|
||||
type Webhook,
|
||||
@@ -63,9 +68,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
|
||||
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
|
||||
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
|
||||
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
|
||||
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
|
||||
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
|
||||
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
|
||||
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
|
||||
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", secret: "" });
|
||||
const [copiedUrl, setCopiedUrl] = createSignal("");
|
||||
const [alertForm, setAlertForm] = createSignal({
|
||||
name: "",
|
||||
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
|
||||
@@ -87,14 +97,18 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [int, hooks, rules] = await Promise.all([
|
||||
const [int, hooks, rules, inbound, jobs] = await Promise.all([
|
||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
|
||||
]);
|
||||
setIntegrations(int.items ?? []);
|
||||
setWebhooks(hooks.items ?? []);
|
||||
setAlertRules(rules.items ?? []);
|
||||
setInboundHooks(inbound.items ?? []);
|
||||
setScheduledJobs(jobs.items ?? []);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
@@ -244,6 +258,38 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
"Alert rule deleted",
|
||||
);
|
||||
|
||||
const createInboundHook = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = inboundForm();
|
||||
void run("create-inbound", async () => {
|
||||
await InboundService.createInboundHook({
|
||||
projectId: props.projectID!,
|
||||
requestBody: {
|
||||
name: form.name.trim(),
|
||||
mode: form.mode,
|
||||
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
|
||||
secret: form.secret.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setShowCreateInbound(false);
|
||||
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", secret: "" });
|
||||
}, "Inbound hook created");
|
||||
};
|
||||
|
||||
const deleteInboundHook = (id: string) =>
|
||||
run(
|
||||
`del-inbound-${id}`,
|
||||
() => InboundService.deleteInboundHook({ projectId: props.projectID!, hookId: id }),
|
||||
"Inbound hook deleted",
|
||||
);
|
||||
|
||||
const copyHookUrl = (hook: InboundHook) => {
|
||||
void navigator.clipboard?.writeText(hook.url).then(() => {
|
||||
setCopiedUrl(hook.id);
|
||||
setTimeout(() => setCopiedUrl(""), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
const testWebhook = (id: string) =>
|
||||
run(
|
||||
`test-hook-${id}`,
|
||||
@@ -623,6 +669,78 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush mt-4">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Inbound hooks</div>
|
||||
<div class="card-header-description">
|
||||
Public ingest endpoints — POST from external systems to republish as <code>inbound.received</code>
|
||||
events or trigger a scheduled job.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateInbound(true)}>
|
||||
<IconPlus class="w-4 h-4" />
|
||||
Add hook
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={inboundHooks().length > 0}
|
||||
fallback={<p class="text-text-2 text-sm p-4">No inbound hooks yet.</p>}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Mode</th>
|
||||
<th>Security</th>
|
||||
<th>Last received</th>
|
||||
<th style="text-align:right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={inboundHooks()}>
|
||||
{(hook) => (
|
||||
<tr>
|
||||
<td class="font-medium">{hook.name}</td>
|
||||
<td>
|
||||
<Badge variant="neutral">{hook.mode === "job" ? "job trigger" : "event"}</Badge>
|
||||
<Show when={!hook.enabled}>
|
||||
{" "}<Badge variant="warning">disabled</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td>
|
||||
<Show when={hook.has_secret} fallback={<span class="text-text-2">token only</span>}>
|
||||
<Badge variant="success">signed</Badge>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="text-text-2">{fmtTime(hook.last_received_at)}</td>
|
||||
<td style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => copyHookUrl(hook)} title={hook.url}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
{copiedUrl() === hook.id ? "Copied" : "URL"}
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onClick={() => void deleteInboundHook(hook.id)}
|
||||
disabled={busy() === `del-inbound-${hook.id}`}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.canManage}>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
@@ -808,6 +926,54 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
|
||||
<form onSubmit={createInboundHook} class="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="github_ci"
|
||||
required
|
||||
value={inboundForm().name}
|
||||
onInput={(e) => setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||
/>
|
||||
<Select
|
||||
label="On receive"
|
||||
value={inboundForm().mode}
|
||||
onChange={(e) => setInboundForm((c) => ({ ...c, mode: e.currentTarget.value as CreateInboundHookRequest.mode }))}
|
||||
options={[
|
||||
{ value: "event", label: "Publish inbound.received event" },
|
||||
{ value: "job", label: "Trigger a scheduled job" },
|
||||
]}
|
||||
/>
|
||||
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
|
||||
<Select
|
||||
label="Job"
|
||||
value={inboundForm().job_id}
|
||||
onChange={(e) => setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))}
|
||||
options={[
|
||||
{ value: "", label: "Select a job…" },
|
||||
...scheduledJobs().map((j) => ({ value: j.id, label: j.name })),
|
||||
]}
|
||||
/>
|
||||
<p class="text-text-2 text-xs">The received body becomes the job's payload for that run.</p>
|
||||
</Show>
|
||||
<Input
|
||||
label="Secret"
|
||||
type="password"
|
||||
placeholder="Optional — callers must sign X-Primora-Signature"
|
||||
value={inboundForm().secret}
|
||||
onInput={(e) => setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateInbound(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create-inbound"}>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user