feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s

Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.

Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.

Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.

Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).

API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.

UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.

Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.

Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
2026-05-07 02:03:32 +03:00
parent 134fe22fde
commit 0405ecd9ce
27 changed files with 2190 additions and 84 deletions
+45 -1
View File
@@ -14,6 +14,7 @@
import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types';
import { IconShield } from '$lib/components/icons';
@@ -43,6 +44,7 @@
let editStageMaxInstances = $state('1');
let editStageCpuLimit = $state('');
let editStageMemoryLimit = $state('');
let editStageNotificationUrl = $state('');
let savingStage = $state(false);
function startEditStage(stage: Stage) {
@@ -54,6 +56,7 @@
editStageMaxInstances = String(stage.max_instances);
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
editStageNotificationUrl = stage.notification_url ?? '';
}
async function handleUpdateStage() {
@@ -68,6 +71,7 @@
max_instances: parseInt(editStageMaxInstances) || 1,
cpu_limit: parseFloat(editStageCpuLimit) || 0,
memory_limit: parseInt(editStageMemoryLimit) || 0,
notification_url: editStageNotificationUrl.trim(),
});
toasts.success($t('projectDetail.stageUpdated'));
editingStageId = '';
@@ -122,6 +126,7 @@
let editHealthcheck = $state('');
let editAccessListId = $state(0);
let editAccessListName = $state('');
let editNotificationUrl = $state('');
let accessListPickerOpen = $state(false);
let accessListPickerItems = $state<EntityPickerItem[]>([]);
let loadingAccessLists = $state(false);
@@ -162,6 +167,7 @@
editHealthcheck = project.healthcheck || '';
editAccessListId = project.npm_access_list_id || 0;
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
editNotificationUrl = project.notification_url ?? '';
editing = true;
// Resolve access list name in background.
if (editAccessListId > 0) {
@@ -182,6 +188,7 @@
port: parseInt(editPort) || 0,
healthcheck: editHealthcheck.trim(),
npm_access_list_id: editAccessListId,
notification_url: editNotificationUrl.trim(),
});
toasts.success($t('projectDetail.projectUpdated'));
editing = false;
@@ -467,6 +474,7 @@
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
<FormField label={$t('projectDetail.notificationUrlLabel')} name="editNotificationUrl" bind:value={editNotificationUrl} placeholder="https://notify.example.com/webhook" helpText={$t('projectDetail.notificationUrlHelp')} />
<div class="flex flex-col gap-1.5">
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
<div class="flex items-center gap-2">
@@ -617,6 +625,15 @@
</div>
</div>
</div>
<div class="mt-3">
<FormField
label={$t('projectDetail.stageNotificationUrlLabel')}
name="editStageNotificationUrl"
bind:value={editStageNotificationUrl}
placeholder="https://notify.example.com/webhook"
helpText={$t('projectDetail.stageNotificationUrlHelp')}
/>
</div>
<div class="mt-3 flex items-center gap-2 justify-end">
<button type="button" onclick={() => { editingStageId = ''; }}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
@@ -629,6 +646,21 @@
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
</button>
</div>
<!-- Stage-scoped outgoing webhook controls. Lives inside the
edit panel so operators see signing + test alongside the
URL they're configuring; collapses on save/cancel. -->
<div class="mt-4">
<OutgoingWebhookPanel
title={$t('projectDetail.stageOutgoingTitle')}
description={$t('projectDetail.stageOutgoingDesc')}
hasUrl={!!stage.notification_url}
fallbackLabel={$t('projectDetail.stageFallbackLabel')}
fetchSecret={() => api.getStageNotificationSecret(projectId, stage.id)}
regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)}
disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)}
sendTest={() => api.testStageNotification(projectId, stage.id)}
/>
</div>
</div>
{:else}
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
@@ -768,7 +800,7 @@
</div>
{/if}
<!-- Webhook -->
<!-- Webhook (inbound: trigger deploys via this URL). -->
<WebhookPanel
title={$t('projectDetail.webhookTitle')}
description={$t('projectDetail.webhookDesc')}
@@ -776,6 +808,18 @@
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
/>
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
<OutgoingWebhookPanel
title={$t('projectDetail.outgoingWebhookTitle')}
description={$t('projectDetail.outgoingWebhookDesc')}
hasUrl={!!project.notification_url}
fallbackLabel={$t('projectDetail.outgoingFallbackGlobal')}
fetchSecret={() => api.getProjectNotificationSecret(projectId)}
regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)}
disableSigning={() => api.disableProjectNotificationSigning(projectId)}
sendTest={() => api.testProjectNotification(projectId)}
/>
<!-- Deploy History Timeline -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
@@ -1,14 +1,23 @@
<!--
Settings Integrations
Outward-facing hooks: where Tinyforge *sends* events (notification URL).
Outward-facing hooks: where Tinyforge *sends* events.
1. URL field (global / fallback) — saved via /api/settings.
2. Outgoing-webhook panel — secret rotate, disable signing, send test.
Inbound webhooks are per-project / per-site and live on their respective
detail pages — this page no longer exposes a global "master" webhook.
detail pages — this page deliberately does not surface them.
-->
<script lang="ts">
import { getSettings, updateSettings } from '$lib/api';
import {
getSettings, updateSettings,
getSettingsNotificationSecret,
regenerateSettingsNotificationSecret,
disableSettingsNotificationSigning,
testSettingsNotification,
} from '$lib/api';
import FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { IconLoader } from '$lib/components/icons';
@@ -17,6 +26,9 @@
let saving = $state(false);
let notificationUrl = $state('');
// Tracks the last persisted URL so the OutgoingWebhookPanel's hasUrl
// flag reflects what the backend actually has, not unsaved input.
let savedNotificationUrl = $state('');
let errors = $state<Record<string, string>>({});
function validateUrl(value: string): string {
@@ -29,6 +41,7 @@
try {
const settings = await getSettings();
notificationUrl = settings.notification_url ?? '';
savedNotificationUrl = notificationUrl;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.loadFailed'));
} finally {
@@ -43,6 +56,7 @@
saving = true;
try {
await updateSettings({ notification_url: notificationUrl.trim() });
savedNotificationUrl = notificationUrl.trim();
toasts.success($t('settingsGeneral.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.saveFailed'));
@@ -87,6 +101,17 @@
</div>
</div>
<!-- Outgoing: signing secret + send test -->
<OutgoingWebhookPanel
title={$t('outgoingWebhook.signingSecret')}
description={$t('settingsIntegrations.outgoingDesc')}
hasUrl={!!savedNotificationUrl}
fetchSecret={getSettingsNotificationSecret}
regenerateSecret={regenerateSettingsNotificationSecret}
disableSigning={disableSettingsNotificationSigning}
sendTest={testSettingsNotification}
/>
<!-- Inbound hooks now live per-entity. -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsIntegrations.incoming')}</h2>
+65 -1
View File
@@ -10,6 +10,7 @@
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
@@ -22,6 +23,32 @@
let confirmDelete = $state(false);
let confirmDeleteSecretId = $state<string | null>(null);
// Outgoing notification URL inline editor. The site has no full edit
// form on this page; this small input lets operators set/clear the
// per-site URL without going back to the create wizard.
let editNotificationUrl = $state('');
let savingNotificationUrl = $state(false);
async function saveNotificationUrl() {
if (!site) return;
savingNotificationUrl = true;
try {
await api.updateStaticSite(site.id, { notification_url: editNotificationUrl.trim() });
site = { ...site, notification_url: editNotificationUrl.trim() };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save notification URL';
} finally {
savingNotificationUrl = false;
}
}
// Sync the editor with the loaded site once it arrives.
$effect(() => {
if (site && editNotificationUrl === '') {
editNotificationUrl = site.notification_url ?? '';
}
});
// Secret form.
let showSecretForm = $state(false);
let secretKey = $state('');
@@ -279,7 +306,7 @@
{/if}
{/if}
<!-- Webhook -->
<!-- Webhook (inbound: triggers a re-sync from the Git provider). -->
<WebhookPanel
title={$t('sites.webhookTitle')}
description={$t('sites.webhookDesc')}
@@ -287,6 +314,43 @@
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
/>
<!-- Outgoing notification URL (per-site override; falls through to global). -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('sites.outgoingUrlDesc')}</p>
<div class="flex items-end gap-3">
<div class="flex-1">
<FormField
label=""
name="siteNotificationUrl"
bind:value={editNotificationUrl}
placeholder="https://notify.example.com/webhook"
/>
</div>
<button
type="button"
onclick={saveNotificationUrl}
disabled={savingNotificationUrl || editNotificationUrl === (site.notification_url ?? '')}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
>
{#if savingNotificationUrl}<IconLoader size={16} />{/if}
{$t('common.save')}
</button>
</div>
</div>
<!-- Outgoing webhook (where Tinyforge posts site_sync_* events). -->
<OutgoingWebhookPanel
title={$t('sites.outgoingWebhookTitle')}
description={$t('sites.outgoingWebhookDesc')}
hasUrl={!!site.notification_url}
fallbackLabel={$t('sites.outgoingFallbackGlobal')}
fetchSecret={() => api.getStaticSiteNotificationSecret(siteId!)}
regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)}
disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)}
sendTest={() => api.testStaticSiteNotification(siteId!)}
/>
<!-- Secrets -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<div class="flex items-center justify-between mb-4">