feat: inbound webhooks — public ingest endpoints

core.inbound_hooks (migration 00013): POST /api/v1/hooks/<token> accepts
external events. event mode republishes as inbound.received through the
signed dispatcher + realtime; job mode triggers a scheduled-job run with
the received body as payload. Optional X-Primora-Signature HMAC verify.
Dashboard card on the Integrations page.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-24 19:15:12 +02:00
1 parent 2f4de543a9
commit 45a1621909
26 files changed
+1264 -9

No files matched your search

+167 -1
View File
@@ -1,11 +1,16 @@
import { For, Show, createEffect, createSignal } from "solid-js";
import {
AlertsService,
AutomationService,
CreateInboundHookRequest,
CreateIntegrationRequest,
InboundService,
IntegrationsService,
UpsertAlertRuleRequest,
WebhooksService,
type AlertRule,
type InboundHook,
type ScheduledJob,
type Integration,
type IntegrationAnalytics,
type Webhook,
@@ -63,9 +68,14 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
const [expandedIntegration, setExpandedIntegration] = createSignal<string>();
const [expandedWebhook, setExpandedWebhook] = createSignal<string>();
const [alertRules, setAlertRules] = createSignal<AlertRule[]>([]);
const [inboundHooks, setInboundHooks] = createSignal<InboundHook[]>([]);
const [scheduledJobs, setScheduledJobs] = createSignal<ScheduledJob[]>([]);
const [showCreateIntegration, setShowCreateIntegration] = createSignal(false);
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", secret: "" });
const [copiedUrl, setCopiedUrl] = createSignal("");
const [alertForm, setAlertForm] = createSignal({
name: "",
kind: UpsertAlertRuleRequest.kind.HEARTBEAT_SILENCE as UpsertAlertRuleRequest.kind,
@@ -87,14 +97,18 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
setLoading(true);
setError("");
try {
const [int, hooks, rules] = await Promise.all([
const [int, hooks, rules, inbound, jobs] = await Promise.all([
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
]);
setIntegrations(int.items ?? []);
setWebhooks(hooks.items ?? []);
setAlertRules(rules.items ?? []);
setInboundHooks(inbound.items ?? []);
setScheduledJobs(jobs.items ?? []);
} catch (e) {
setError(err(e));
} finally {
@@ -244,6 +258,38 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
"Alert rule deleted",
);
const createInboundHook = (e: Event) => {
e.preventDefault();
const form = inboundForm();
void run("create-inbound", async () => {
await InboundService.createInboundHook({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
mode: form.mode,
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
secret: form.secret.trim() || undefined,
},
});
setShowCreateInbound(false);
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", secret: "" });
}, "Inbound hook created");
};
const deleteInboundHook = (id: string) =>
run(
`del-inbound-${id}`,
() => InboundService.deleteInboundHook({ projectId: props.projectID!, hookId: id }),
"Inbound hook deleted",
);
const copyHookUrl = (hook: InboundHook) => {
void navigator.clipboard?.writeText(hook.url).then(() => {
setCopiedUrl(hook.id);
setTimeout(() => setCopiedUrl(""), 1500);
});
};
const testWebhook = (id: string) =>
run(
`test-hook-${id}`,
@@ -623,6 +669,78 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</Show>
</div>
<div class="card card-flush mt-4">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Inbound hooks</div>
<div class="card-header-description">
Public ingest endpoints — POST from external systems to republish as <code>inbound.received</code>
events or trigger a scheduled job.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreateInbound(true)}>
<IconPlus class="w-4 h-4" />
Add hook
</button>
</Show>
</div>
<Show
when={inboundHooks().length > 0}
fallback={<p class="text-text-2 text-sm p-4">No inbound hooks yet.</p>}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Mode</th>
<th>Security</th>
<th>Last received</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={inboundHooks()}>
{(hook) => (
<tr>
<td class="font-medium">{hook.name}</td>
<td>
<Badge variant="neutral">{hook.mode === "job" ? "job trigger" : "event"}</Badge>
<Show when={!hook.enabled}>
{" "}<Badge variant="warning">disabled</Badge>
</Show>
</td>
<td>
<Show when={hook.has_secret} fallback={<span class="text-text-2">token only</span>}>
<Badge variant="success">signed</Badge>
</Show>
</td>
<td class="text-text-2">{fmtTime(hook.last_received_at)}</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => copyHookUrl(hook)} title={hook.url}>
<IconCopy class="w-4 h-4" />
{copiedUrl() === hook.id ? "Copied" : "URL"}
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => void deleteInboundHook(hook.id)}
disabled={busy() === `del-inbound-${hook.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
<Show when={props.canManage}>
<div class="card mt-4">
<div class="card-header">
@@ -808,6 +926,54 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
</div>
</form>
</Modal>
<Modal open={showCreateInbound()} onClose={() => setShowCreateInbound(false)} title="Add inbound hook" size="md">
<form onSubmit={createInboundHook} class="space-y-4">
<Input
label="Name"
placeholder="github_ci"
required
value={inboundForm().name}
onInput={(e) => setInboundForm((c) => ({ ...c, name: e.currentTarget.value }))}
/>
<Select
label="On receive"
value={inboundForm().mode}
onChange={(e) => setInboundForm((c) => ({ ...c, mode: e.currentTarget.value as CreateInboundHookRequest.mode }))}
options={[
{ value: "event", label: "Publish inbound.received event" },
{ value: "job", label: "Trigger a scheduled job" },
]}
/>
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
<Select
label="Job"
value={inboundForm().job_id}
onChange={(e) => setInboundForm((c) => ({ ...c, job_id: e.currentTarget.value }))}
options={[
{ value: "", label: "Select a job…" },
...scheduledJobs().map((j) => ({ value: j.id, label: j.name })),
]}
/>
<p class="text-text-2 text-xs">The received body becomes the job's payload for that run.</p>
</Show>
<Input
label="Secret"
type="password"
placeholder="Optional — callers must sign X-Primora-Signature"
value={inboundForm().secret}
onInput={(e) => setInboundForm((c) => ({ ...c, secret: e.currentTarget.value }))}
/>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onClick={() => setShowCreateInbound(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-inbound"}>
Create
</button>
</div>
</form>
</Modal>
</div>
);
}