feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
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:
+3
-3
@@ -206,7 +206,7 @@ func main() {
|
||||
if err != nil || currentSettings.NotificationURL == "" {
|
||||
continue
|
||||
}
|
||||
notifier.Send(currentSettings.NotificationURL, notify.Event{
|
||||
notifier.SendSigned(currentSettings.NotificationURL, currentSettings.NotificationSecret, notify.TierSettings, notify.Event{
|
||||
Type: p.Source + "_error",
|
||||
Project: p.Source,
|
||||
Error: p.Message,
|
||||
@@ -284,7 +284,7 @@ func main() {
|
||||
statsCollector.Start()
|
||||
|
||||
// Initialize static site manager and health checker.
|
||||
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey)
|
||||
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey)
|
||||
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
|
||||
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
|
||||
if err := staticSiteHealth.Start("2m"); err != nil {
|
||||
@@ -304,7 +304,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Build API server.
|
||||
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey)
|
||||
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey)
|
||||
apiServer.SetStaticSiteManager(staticSiteMgr)
|
||||
if stackMgr != nil {
|
||||
apiServer.SetStackManager(stackMgr)
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Outgoing webhooks
|
||||
|
||||
Tinyforge posts JSON events to a configured URL when deploys and static-site
|
||||
syncs finish. Receivers can verify each request was sent by Tinyforge and
|
||||
not tampered with by checking the **HMAC-SHA256** signature on the body.
|
||||
|
||||
## Tiers and resolution
|
||||
|
||||
A single global URL is rarely enough — different teams own different
|
||||
projects, and operators often want to route prod failures somewhere noisier
|
||||
than dev failures. Tinyforge supports four tiers:
|
||||
|
||||
| Tier | Where set | Used for |
|
||||
|-----------|----------------------------------------|-------------------------|
|
||||
| `stage` | Stage edit form | Per-stage deploys |
|
||||
| `project` | Project edit form | All stages of a project |
|
||||
| `site` | Static-site detail page | Static-site sync events |
|
||||
| `settings`| Settings → Integrations | Global fallback |
|
||||
|
||||
Resolution order:
|
||||
|
||||
* **Deploys**: `stage → project → settings`
|
||||
* **Sites**: `site → settings`
|
||||
|
||||
The most-specific tier with a non-empty URL wins. The signing secret
|
||||
travels with the URL that sourced it: a stage can sign even when the
|
||||
project and global URLs are unsigned.
|
||||
|
||||
## Signature scheme
|
||||
|
||||
Every request includes:
|
||||
|
||||
```
|
||||
POST /your/handler HTTP/1.1
|
||||
Content-Type: application/json
|
||||
User-Agent: Tinyforge-Webhook/1
|
||||
X-Hub-Signature-256: sha256=<hex>
|
||||
X-Tinyforge-Event: deploy_success
|
||||
X-Tinyforge-Delivery: 0f3a…-uuid
|
||||
X-Tinyforge-Timestamp: 2026-05-07T12:34:56Z
|
||||
X-Tinyforge-Tier: stage
|
||||
```
|
||||
|
||||
The signature is `HMAC-SHA256(secret, raw_body)`, hex-encoded, with the
|
||||
`sha256=` prefix — the GitHub `X-Hub-Signature-256` format. Receivers
|
||||
already built for GitHub-style webhooks (Gitea, Forgejo, n8n, Hookdeck,
|
||||
the service-to-notification-bridge generic webhook provider) verify it
|
||||
without modification.
|
||||
|
||||
When no signing secret is configured for the resolved tier, the
|
||||
signature header is omitted and the request goes out unsigned. This is
|
||||
intentional back-compat for receivers that don't speak HMAC.
|
||||
|
||||
## Receiver requirements
|
||||
|
||||
A correct verifier:
|
||||
|
||||
1. **Reads the raw body bytes** before any JSON parse / re-serialise.
|
||||
2. Computes `HMAC-SHA256(secret, body)` and compares to the value after
|
||||
`sha256=` in `X-Hub-Signature-256`.
|
||||
3. Uses a **constant-time** comparator (`hmac.compare_digest` /
|
||||
`crypto.timingSafeEqual` / `hmac.Equal`).
|
||||
4. Returns 401/403 on mismatch — Tinyforge surfaces the receiver's
|
||||
status code in the UI when the operator clicks **Send test**.
|
||||
|
||||
### Node / TypeScript
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
export function verify(secret: string, rawBody: Buffer, header: string): boolean {
|
||||
const got = header.startsWith('sha256=') ? header.slice(7) : header;
|
||||
const want = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
if (got.length !== want.length) return false;
|
||||
return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex'));
|
||||
}
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify(secret: str, raw_body: bytes, header: str) -> bool:
|
||||
got = header[7:] if header.startswith("sha256=") else header
|
||||
want = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(got, want)
|
||||
```
|
||||
|
||||
### Go
|
||||
|
||||
```go
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func verify(secret string, body []byte, header string) bool {
|
||||
got := strings.TrimPrefix(header, "sha256=")
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
want := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(got), []byte(want))
|
||||
}
|
||||
```
|
||||
|
||||
## Event payload
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "deploy_success",
|
||||
"project": "demo-app",
|
||||
"stage": "prod",
|
||||
"image_tag": "v1.4.2",
|
||||
"subdomain": "stage-prod-demo-app",
|
||||
"url": "https://stage-prod-demo-app.example.com",
|
||||
"timestamp": "2026-05-07T12:34:56Z"
|
||||
}
|
||||
```
|
||||
|
||||
`type` values:
|
||||
- `deploy_success`, `deploy_failure` — sent by the deployer.
|
||||
- `site_sync_success`, `site_sync_failure` — sent by the static-site manager.
|
||||
Use the `project` field as the site name; `stage` and `image_tag` are empty.
|
||||
- `test` — sent by the **Send test** button. Treat it as a no-op or
|
||||
surface it in your operator log; never as a real deploy event.
|
||||
|
||||
## Configuring the service-to-notification-bridge
|
||||
|
||||
If you're sending Tinyforge events to the
|
||||
[service-to-notification-bridge](https://github.com/) generic webhook
|
||||
provider:
|
||||
|
||||
1. Create a **Generic Webhook** provider.
|
||||
2. Set `auth_mode = hmac_sha256`.
|
||||
3. Paste the **same secret** Tinyforge generated (revealed via the
|
||||
Outgoing webhook panel).
|
||||
4. Set `event_type_path = type` so deploys and site syncs map to
|
||||
distinct event types in the bridge.
|
||||
5. Add `payload_mappings` for `project`, `stage`, `image_tag`, `url`,
|
||||
`error` and reference them as `{{ extra.project }}` in your
|
||||
notification templates.
|
||||
|
||||
The bridge accepts `X-Hub-Signature-256` natively (no header rename
|
||||
needed) and reads the raw body before parsing, so step 1 of the
|
||||
receiver requirements is already met.
|
||||
|
||||
## Rotating secrets
|
||||
|
||||
Click **Regenerate** in the Outgoing webhook panel to rotate. The old
|
||||
secret is invalidated immediately — update receivers in lock-step or
|
||||
expect a brief window of 401s. There is no soft rollover today.
|
||||
|
||||
To send unsigned events to a legacy receiver that can't verify, click
|
||||
**Disable signing**. Tinyforge will keep dispatching events without the
|
||||
`X-Hub-Signature-256` header until you regenerate.
|
||||
@@ -0,0 +1,403 @@
|
||||
package api
|
||||
|
||||
// Outgoing-webhook signing-secret + send-test endpoints. There are four
|
||||
// tiers — settings, project, stage, site — each exposing the same three
|
||||
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
|
||||
// event. Returning a 200 from "send test" doesn't mean the receiver
|
||||
// processed the event correctly — only that it answered with 2xx. The UI
|
||||
// surfaces the receiver's status code + body preview so operators can
|
||||
// distinguish "wired" from "wired and accepted".
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// notificationSecretResponse is what the GET / regenerate endpoints return.
|
||||
// The secret is revealed in cleartext exactly once per request — UI is
|
||||
// expected to copy or hash it for display, not store it long-term.
|
||||
type notificationSecretResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
}
|
||||
|
||||
// testEventTimeout caps how long we wait for the receiver before declaring
|
||||
// the test failed. Mirrors the production notifier's per-request timeout
|
||||
// (10s) so test results are predictive of real send behaviour.
|
||||
const testEventTimeout = 10 * time.Second
|
||||
|
||||
// buildTestEvent constructs the synthetic payload used by every "send
|
||||
// test" endpoint. Marking it as type "test" prevents a misconfigured
|
||||
// receiver from mistaking a wiring check for a real deploy event.
|
||||
func buildTestEvent(project, stage string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: "test",
|
||||
Project: project,
|
||||
Stage: stage,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global / settings tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// getSettingsNotificationSecret handles GET /api/settings/notification-secret.
|
||||
// Lazily generates a secret if one was never set (typical for sites
|
||||
// upgrading from a pre-signing build).
|
||||
func (s *Server) getSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
secret, err := s.store.EnsureSettingsNotificationSecret()
|
||||
if err != nil {
|
||||
slog.Error("get settings notification secret", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
||||
}
|
||||
|
||||
// regenerateSettingsNotificationSecret handles POST
|
||||
// /api/settings/notification-secret/regenerate. Replaces the existing
|
||||
// secret with a fresh one, invalidating signatures verified against the
|
||||
// old secret.
|
||||
func (s *Server) regenerateSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetSettingsNotificationSecret(secret); err != nil {
|
||||
slog.Error("regenerate settings notification secret", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
||||
return
|
||||
}
|
||||
slog.Info("settings notification secret rotated")
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
||||
}
|
||||
|
||||
// disableSettingsNotificationSigning handles POST
|
||||
// /api/settings/notification-secret/disable. Clears the secret so further
|
||||
// outgoing notifications are unsigned. Useful for receivers that don't
|
||||
// support HMAC verification.
|
||||
func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.SetSettingsNotificationSecret(""); err != nil {
|
||||
slog.Error("disable settings notification signing", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
||||
}
|
||||
|
||||
// settingsNotificationTest handles POST /api/settings/notification-test.
|
||||
// Sends a synthetic test event to the global webhook URL using the global
|
||||
// secret. No tier resolution — that's the whole point: each tier's test
|
||||
// button proves *that* tier is wired correctly.
|
||||
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
||||
return
|
||||
}
|
||||
if settings.NotificationURL == "" {
|
||||
respondError(w, http.StatusBadRequest, "no global notification URL configured")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
||||
defer cancel()
|
||||
result := s.notifier.SendSyncForTest(
|
||||
ctx, settings.NotificationURL, settings.NotificationSecret, notify.TierSettings,
|
||||
buildTestEvent("__tinyforge__", ""),
|
||||
)
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
secret, err := s.store.EnsureProjectNotificationSecret(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
slog.Error("get project notification secret", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
||||
}
|
||||
|
||||
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
||||
return
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
|
||||
slog.Error("regenerate project notification secret", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
||||
return
|
||||
}
|
||||
slog.Info("project notification secret rotated", "project", id)
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
||||
}
|
||||
|
||||
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
||||
}
|
||||
|
||||
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
project, err := s.store.GetProjectByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
||||
return
|
||||
}
|
||||
url, secret, tier := resolveProjectTestTarget(project, settings)
|
||||
if url == "" {
|
||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
||||
defer cancel()
|
||||
result := s.notifier.SendSyncForTest(
|
||||
ctx, url, secret, tier,
|
||||
buildTestEvent(project.Name, ""),
|
||||
)
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
|
||||
// resolution but without a stage in scope. Used by the project-level test
|
||||
// button so the operator sees exactly what a project-only event would do.
|
||||
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
||||
if project.NotificationURL != "" {
|
||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
stageID := chi.URLParam(r, "stage")
|
||||
secret, err := s.store.EnsureStageNotificationSecret(stageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage")
|
||||
return
|
||||
}
|
||||
slog.Error("get stage notification secret", "stage", stageID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
||||
}
|
||||
|
||||
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
stageID := chi.URLParam(r, "stage")
|
||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
||||
return
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
|
||||
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
||||
return
|
||||
}
|
||||
slog.Info("stage notification secret rotated", "stage", stageID)
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
||||
}
|
||||
|
||||
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
||||
stageID := chi.URLParam(r, "stage")
|
||||
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
||||
}
|
||||
|
||||
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
stageID := chi.URLParam(r, "stage")
|
||||
stage, err := s.store.GetStageByID(stageID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stage")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
||||
return
|
||||
}
|
||||
project, err := s.store.GetProjectByID(projectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
||||
return
|
||||
}
|
||||
// Reuse the production resolver so the test button exercises the exact
|
||||
// fall-through logic a real deploy would.
|
||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
||||
if url == "" {
|
||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
||||
defer cancel()
|
||||
result := s.notifier.SendSyncForTest(
|
||||
ctx, url, secret, tier,
|
||||
buildTestEvent(project.Name, stage.Name),
|
||||
)
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
|
||||
// than imported to avoid an api → deployer dependency cycle and to keep the
|
||||
// test-endpoint code self-contained. If divergence becomes a risk we can
|
||||
// move this into a shared internal/notify subpackage.
|
||||
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
||||
if stage.NotificationURL != "" {
|
||||
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
|
||||
}
|
||||
if project.NotificationURL != "" {
|
||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static-site tier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
slog.Error("get static site notification secret", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
||||
}
|
||||
|
||||
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
||||
return
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
||||
slog.Error("regenerate static site notification secret", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
||||
return
|
||||
}
|
||||
slog.Info("static site notification secret rotated", "site", id)
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
||||
}
|
||||
|
||||
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
||||
}
|
||||
|
||||
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
site, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
||||
return
|
||||
}
|
||||
settings, err := s.store.GetSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
||||
return
|
||||
}
|
||||
url, secret, tier := resolveSiteTestTarget(site, settings)
|
||||
if url == "" {
|
||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
||||
defer cancel()
|
||||
result := s.notifier.SendSyncForTest(
|
||||
ctx, url, secret, tier,
|
||||
buildTestEvent(site.Name, ""),
|
||||
)
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
||||
if site.NotificationURL != "" {
|
||||
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
|
||||
// projectRequest is the expected JSON body for creating/updating a project.
|
||||
type projectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Registry string `json:"registry"`
|
||||
Image string `json:"image"`
|
||||
Port int `json:"port"`
|
||||
Healthcheck string `json:"healthcheck"`
|
||||
Env string `json:"env"`
|
||||
Volumes string `json:"volumes"`
|
||||
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Registry string `json:"registry"`
|
||||
Image string `json:"image"`
|
||||
Port int `json:"port"`
|
||||
Healthcheck string `json:"healthcheck"`
|
||||
Env string `json:"env"`
|
||||
Volumes string `json:"volumes"`
|
||||
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
|
||||
NotificationURL *string `json:"notification_url,omitempty"`
|
||||
}
|
||||
|
||||
// listProjects handles GET /api/projects.
|
||||
@@ -157,6 +158,9 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
|
||||
if req.NpmAccessListID != nil {
|
||||
updated.NpmAccessListID = *req.NpmAccessListID
|
||||
}
|
||||
if req.NotificationURL != nil {
|
||||
updated.NotificationURL = *req.NotificationURL
|
||||
}
|
||||
|
||||
if err := s.store.UpdateProject(updated); err != nil {
|
||||
slog.Error("failed to update project", "error", err)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/dns"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
@@ -33,6 +34,7 @@ type Server struct {
|
||||
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
|
||||
proxyProvider proxy.Provider
|
||||
deployer DeployTriggerer
|
||||
notifier *notify.Notifier
|
||||
webhook *webhook.Handler
|
||||
eventBus *events.Bus
|
||||
encKey [32]byte
|
||||
@@ -61,6 +63,7 @@ func NewServer(
|
||||
npmClient *npm.Client,
|
||||
proxyProvider proxy.Provider,
|
||||
deployer DeployTriggerer,
|
||||
notifier *notify.Notifier,
|
||||
webhookHandler *webhook.Handler,
|
||||
eventBus *events.Bus,
|
||||
encKey [32]byte,
|
||||
@@ -73,6 +76,7 @@ func NewServer(
|
||||
npm: npmClient,
|
||||
proxyProvider: proxyProvider,
|
||||
deployer: deployer,
|
||||
notifier: notifier,
|
||||
webhook: webhookHandler,
|
||||
eventBus: eventBus,
|
||||
encKey: encKey,
|
||||
@@ -242,11 +246,23 @@ func (s *Server) Router() chi.Router {
|
||||
r.Get("/webhook", s.getProjectWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
||||
|
||||
// Per-project outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
||||
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
|
||||
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
|
||||
r.Post("/notification-test", s.projectNotificationTest)
|
||||
|
||||
// Stage endpoints.
|
||||
r.Post("/stages", s.createStage)
|
||||
r.Put("/stages/{stage}", s.updateStage)
|
||||
r.Delete("/stages/{stage}", s.deleteStage)
|
||||
|
||||
// Per-stage outgoing-webhook signing & test.
|
||||
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
|
||||
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
|
||||
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
|
||||
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
|
||||
|
||||
// Stage env override endpoints.
|
||||
r.Post("/stages/{stage}/env", s.createStageEnv)
|
||||
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
|
||||
@@ -309,6 +325,12 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/start", s.startStaticSite)
|
||||
r.Get("/webhook", s.getStaticSiteWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
||||
|
||||
// Per-site outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
||||
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
|
||||
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
|
||||
r.Post("/notification-test", s.staticSiteNotificationTest)
|
||||
r.Post("/secrets", s.createStaticSiteSecret)
|
||||
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
||||
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
||||
@@ -394,6 +416,12 @@ func (s *Server) Router() chi.Router {
|
||||
// Settings endpoints.
|
||||
r.Put("/settings", s.updateSettings)
|
||||
|
||||
// Global outgoing-webhook signing & test.
|
||||
r.Get("/settings/notification-secret", s.getSettingsNotificationSecret)
|
||||
r.Post("/settings/notification-secret/regenerate", s.regenerateSettingsNotificationSecret)
|
||||
r.Post("/settings/notification-secret/disable", s.disableSettingsNotificationSigning)
|
||||
r.Post("/settings/notification-test", s.settingsNotificationTest)
|
||||
|
||||
// Docker management.
|
||||
r.Post("/docker/prune-images", s.pruneImages)
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"network": settings.Network,
|
||||
"subdomain_pattern": settings.SubdomainPattern,
|
||||
"notification_url": settings.NotificationURL,
|
||||
"has_notification_secret": settings.NotificationSecret != "",
|
||||
"npm_url": settings.NpmURL,
|
||||
"npm_email": settings.NpmEmail,
|
||||
"has_npm_password": settings.NpmPassword != "",
|
||||
|
||||
@@ -48,21 +48,22 @@ func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
// ── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
type createStaticSiteRequest struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Domain string `json:"domain"`
|
||||
Mode string `json:"mode"`
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
SyncTrigger string `json:"sync_trigger"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
GiteaURL string `json:"gitea_url"`
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Domain string `json:"domain"`
|
||||
Mode string `json:"mode"`
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
SyncTrigger string `json:"sync_trigger"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
NotificationURL *string `json:"notification_url,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -115,6 +116,9 @@ func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
StorageLimitMB: req.StorageLimitMB,
|
||||
Status: "idle",
|
||||
}
|
||||
if req.NotificationURL != nil {
|
||||
site.NotificationURL = *req.NotificationURL
|
||||
}
|
||||
|
||||
created, err := s.store.CreateStaticSite(site)
|
||||
if err != nil {
|
||||
@@ -180,6 +184,9 @@ func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
existing.TagPattern = req.TagPattern
|
||||
existing.StorageEnabled = req.StorageEnabled
|
||||
existing.StorageLimitMB = req.StorageLimitMB
|
||||
if req.NotificationURL != nil {
|
||||
existing.NotificationURL = *req.NotificationURL
|
||||
}
|
||||
|
||||
// Update access token only if a new one is provided.
|
||||
if req.AccessToken != "" {
|
||||
|
||||
@@ -181,7 +181,8 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
|
||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error())
|
||||
d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID)
|
||||
|
||||
d.notifier.Send(settings.NotificationURL, notify.Event{
|
||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
||||
d.notifier.SendSigned(url, secret, tier, notify.Event{
|
||||
Type: "deploy_failure",
|
||||
Project: project.Name,
|
||||
Stage: stage.Name,
|
||||
@@ -202,7 +203,8 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
|
||||
|
||||
d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
|
||||
|
||||
d.notifier.Send(settings.NotificationURL, notify.Event{
|
||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
||||
d.notifier.SendSigned(url, secret, tier, notify.Event{
|
||||
Type: "deploy_success",
|
||||
Project: project.Name,
|
||||
Stage: stage.Name,
|
||||
@@ -214,6 +216,21 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDeployTarget picks the most-specific (URL, secret, tier) for a
|
||||
// deploy notification: stage > project > global. An empty URL at a tier
|
||||
// means "fall through to the next" — never "send unsigned to nowhere". The
|
||||
// secret is always paired with the URL that sourced it, so a stage can sign
|
||||
// even when project and global are unsigned (and vice versa).
|
||||
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
||||
if stage.NotificationURL != "" {
|
||||
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
|
||||
}
|
||||
if project.NotificationURL != "" {
|
||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
|
||||
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
|
||||
// It validates inputs, creates a deploy record, and delegates to runDeploy.
|
||||
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package deployer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// TestResolveDeployTarget locks the stage→project→global precedence. The
|
||||
// most-specific tier with a non-empty URL wins, and the secret travels
|
||||
// with the URL that sourced it (so a stage can sign even when project and
|
||||
// global are unsigned). A regression here misroutes notifications and
|
||||
// silently leaks events to the wrong receiver — worth catching.
|
||||
func TestResolveDeployTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
stage store.Stage
|
||||
project store.Project
|
||||
settings store.Settings
|
||||
wantURL string
|
||||
wantSec string
|
||||
wantTier notify.Tier
|
||||
}{
|
||||
{
|
||||
name: "stage wins when set",
|
||||
stage: store.Stage{NotificationURL: "https://stage.example/wh", NotificationSecret: "stage-key"},
|
||||
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
||||
wantURL: "https://stage.example/wh",
|
||||
wantSec: "stage-key",
|
||||
wantTier: notify.TierStage,
|
||||
},
|
||||
{
|
||||
name: "stage URL empty → project wins",
|
||||
stage: store.Stage{NotificationURL: "", NotificationSecret: "stage-key"}, // secret without URL ignored
|
||||
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
||||
wantURL: "https://project.example/wh",
|
||||
wantSec: "project-key",
|
||||
wantTier: notify.TierProject,
|
||||
},
|
||||
{
|
||||
name: "stage and project empty → global wins",
|
||||
stage: store.Stage{},
|
||||
project: store.Project{},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
||||
wantURL: "https://global.example/wh",
|
||||
wantSec: "global-key",
|
||||
wantTier: notify.TierSettings,
|
||||
},
|
||||
{
|
||||
name: "all empty → returns settings tier with empty URL (caller skips)",
|
||||
stage: store.Stage{},
|
||||
project: store.Project{},
|
||||
settings: store.Settings{},
|
||||
wantURL: "",
|
||||
wantSec: "",
|
||||
wantTier: notify.TierSettings,
|
||||
},
|
||||
{
|
||||
name: "stage signs even when global is unsigned",
|
||||
stage: store.Stage{
|
||||
NotificationURL: "https://stage.example/wh",
|
||||
NotificationSecret: "stage-only-key",
|
||||
},
|
||||
project: store.Project{},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh"},
|
||||
wantURL: "https://stage.example/wh",
|
||||
wantSec: "stage-only-key",
|
||||
wantTier: notify.TierStage,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotSec, gotTier := resolveDeployTarget(tc.stage, tc.project, tc.settings)
|
||||
if gotURL != tc.wantURL {
|
||||
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
|
||||
}
|
||||
if gotSec != tc.wantSec {
|
||||
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
|
||||
}
|
||||
if gotTier != tc.wantTier {
|
||||
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+199
-17
@@ -3,17 +3,28 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Event represents a deployment notification payload.
|
||||
// Event represents a deployment / site-sync notification payload.
|
||||
//
|
||||
// Field naming preserves backwards compatibility with the original
|
||||
// deploy_success/deploy_failure events; site events reuse Project for the
|
||||
// site name and leave Stage/ImageTag empty.
|
||||
type Event struct {
|
||||
Type string `json:"type"` // "deploy_success" or "deploy_failure"
|
||||
Type string `json:"type"` // deploy_success, deploy_failure, site_sync_success, site_sync_failure, test
|
||||
Project string `json:"project"`
|
||||
Stage string `json:"stage"`
|
||||
ImageTag string `json:"image_tag"`
|
||||
@@ -23,8 +34,54 @@ type Event struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Notifier sends webhook notifications for deploy events.
|
||||
// Notifications are fire-and-forget — failures are logged but do not propagate.
|
||||
// Tier identifies which configuration layer supplied the URL+secret used for
|
||||
// a particular dispatch. Recorded in logs and the test-endpoint response so
|
||||
// operators can debug fall-through behaviour.
|
||||
type Tier string
|
||||
|
||||
const (
|
||||
TierSettings Tier = "settings"
|
||||
TierProject Tier = "project"
|
||||
TierStage Tier = "stage"
|
||||
TierSite Tier = "site"
|
||||
)
|
||||
|
||||
// Header names for outgoing webhooks. The signature header name matches
|
||||
// GitHub/Gitea/Forgejo so receivers built for those providers (and the
|
||||
// service-to-notification-bridge generic webhook provider) verify out of the
|
||||
// box. The X-Tinyforge-* headers are advisory and not covered by the HMAC.
|
||||
const (
|
||||
HeaderSignature = "X-Hub-Signature-256"
|
||||
HeaderEvent = "X-Tinyforge-Event"
|
||||
HeaderDelivery = "X-Tinyforge-Delivery"
|
||||
HeaderTimestamp = "X-Tinyforge-Timestamp"
|
||||
HeaderTier = "X-Tinyforge-Tier"
|
||||
)
|
||||
|
||||
// userAgent is reported on every outgoing webhook request so operators can
|
||||
// filter their access logs by source. Versioned tag is added later if/when
|
||||
// we wire build-time variables; for now a static identifier is enough.
|
||||
const userAgent = "Tinyforge-Webhook/1"
|
||||
|
||||
// TestResult is what /api/.../notification-test returns to the UI: the
|
||||
// receiver's status code, latency, a short response preview, and whether a
|
||||
// signature was sent (so the operator can tell at a glance if signing is
|
||||
// configured for this tier).
|
||||
type TestResult struct {
|
||||
URL string `json:"url"`
|
||||
Tier Tier `json:"tier"`
|
||||
StatusCode int `json:"status_code"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
SignatureSent bool `json:"signature_sent"`
|
||||
DeliveryID string `json:"delivery_id"`
|
||||
ResponseSnippet string `json:"response_snippet"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier sends webhook notifications for deploy and site-sync events.
|
||||
// Notifications are fire-and-forget by default — failures are logged but do
|
||||
// not propagate. SendSyncForTest is the exception, used only by the manual
|
||||
// test endpoint.
|
||||
type Notifier struct {
|
||||
httpClient *http.Client
|
||||
wg sync.WaitGroup
|
||||
@@ -44,9 +101,20 @@ func (n *Notifier) Drain() {
|
||||
n.wg.Wait()
|
||||
}
|
||||
|
||||
// Send sends a notification event to the given webhook URL in a background goroutine.
|
||||
// It does not block the caller. Errors are logged, not returned.
|
||||
// Send dispatches an unsigned event to the given URL in the background.
|
||||
// Retained for callsites that don't yet have access to a signing secret;
|
||||
// new code should prefer SendSigned which records the resolution tier.
|
||||
func (n *Notifier) Send(webhookURL string, event Event) {
|
||||
n.SendSigned(webhookURL, "", TierSettings, event)
|
||||
}
|
||||
|
||||
// SendSigned dispatches an event, signing it with HMAC-SHA256 if secret is
|
||||
// non-empty. The signature is computed over the exact JSON bytes sent on the
|
||||
// wire (so receivers must verify the raw body, not a re-serialised copy).
|
||||
//
|
||||
// Empty secret => unsigned send (no X-Hub-Signature-256 header), preserving
|
||||
// the legacy behaviour for receivers that pre-date HMAC support.
|
||||
func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event) {
|
||||
if webhookURL == "" {
|
||||
return
|
||||
}
|
||||
@@ -54,40 +122,154 @@ func (n *Notifier) Send(webhookURL string, event Event) {
|
||||
if event.Timestamp == "" {
|
||||
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
delivery := uuid.NewString()
|
||||
|
||||
n.wg.Add(1)
|
||||
go func() {
|
||||
defer n.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := n.doSend(ctx, webhookURL, event); err != nil {
|
||||
slog.Warn("notify: failed to send webhook", "url", webhookURL, "error", err)
|
||||
|
||||
_, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
|
||||
// URL host only — never log the secret or full URL with user-info.
|
||||
host := safeHost(webhookURL)
|
||||
if err != nil {
|
||||
slog.Warn("notify: webhook send failed",
|
||||
"tier", tier, "host", host, "delivery", delivery,
|
||||
"event", event.Type, "signed", secret != "", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("notify: webhook dispatched",
|
||||
"tier", tier, "host", host, "delivery", delivery,
|
||||
"event", event.Type, "signed", secret != "")
|
||||
}()
|
||||
}
|
||||
|
||||
// doSend performs the actual HTTP POST to the webhook URL.
|
||||
func (n *Notifier) doSend(ctx context.Context, webhookURL string, event Event) error {
|
||||
// SendSyncForTest performs a synchronous, single-shot send for the "Send
|
||||
// test" UI button. Returns a TestResult describing what the receiver
|
||||
// answered with so the operator can confirm wiring without watching server
|
||||
// logs. Errors are reported via the Error field rather than the returned
|
||||
// error to keep the API ergonomic for the handler.
|
||||
func (n *Notifier) SendSyncForTest(ctx context.Context, webhookURL, secret string, tier Tier, event Event) TestResult {
|
||||
if event.Timestamp == "" {
|
||||
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
delivery := uuid.NewString()
|
||||
result := TestResult{
|
||||
URL: webhookURL,
|
||||
Tier: tier,
|
||||
SignatureSent: secret != "",
|
||||
DeliveryID: delivery,
|
||||
}
|
||||
|
||||
if webhookURL == "" {
|
||||
result.Error = "no webhook URL configured for this tier"
|
||||
return result
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
if resp != nil {
|
||||
result.StatusCode = resp.StatusCode
|
||||
result.ResponseSnippet = resp.BodyPreview
|
||||
}
|
||||
return result
|
||||
}
|
||||
result.StatusCode = resp.StatusCode
|
||||
result.ResponseSnippet = resp.BodyPreview
|
||||
return result
|
||||
}
|
||||
|
||||
// sendResponse captures the small subset of the receiver's response we want
|
||||
// to surface back to the operator (status + a body preview). Distinct from
|
||||
// http.Response so callers don't accidentally hold an unread body.
|
||||
type sendResponse struct {
|
||||
StatusCode int
|
||||
BodyPreview string
|
||||
}
|
||||
|
||||
// doSend performs the HTTP POST, signs the body if a secret is configured,
|
||||
// and returns either a sendResponse (for the test path) or an error.
|
||||
//
|
||||
// The request body bytes are computed once so the HMAC signature matches
|
||||
// exactly what travels on the wire. Receivers MUST verify against the raw
|
||||
// body, not a re-serialised copy.
|
||||
func (n *Notifier) doSend(ctx context.Context, webhookURL, secret string, tier Tier, delivery string, event Event) (*sendResponse, error) {
|
||||
body, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal notification: %w", err)
|
||||
return nil, fmt.Errorf("marshal notification: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create notification request: %w", err)
|
||||
return nil, fmt.Errorf("create notification request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set(HeaderEvent, event.Type)
|
||||
req.Header.Set(HeaderDelivery, delivery)
|
||||
req.Header.Set(HeaderTimestamp, event.Timestamp)
|
||||
req.Header.Set(HeaderTier, string(tier))
|
||||
if secret != "" {
|
||||
req.Header.Set(HeaderSignature, "sha256="+sign(secret, body))
|
||||
}
|
||||
|
||||
resp, err := n.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send notification: %w", err)
|
||||
return nil, fmt.Errorf("send notification: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("notification webhook returned status %d", resp.StatusCode)
|
||||
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
out := &sendResponse{
|
||||
StatusCode: resp.StatusCode,
|
||||
BodyPreview: string(preview),
|
||||
}
|
||||
|
||||
return nil
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return out, fmt.Errorf("notification webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sign returns the lowercase-hex HMAC-SHA256 of body using secret as the
|
||||
// key. The "sha256=" prefix is added by the caller to match GitHub's
|
||||
// X-Hub-Signature-256 wire format.
|
||||
func sign(secret string, body []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifySignature is the receiver-side counterpart to sign(). Exported so
|
||||
// our own tests (and any future incoming-webhook receiver in this repo) can
|
||||
// re-use the exact construction without duplicating the HMAC code.
|
||||
//
|
||||
// signatureHeader accepts either the raw hex digest or the GitHub-style
|
||||
// "sha256=<hex>" envelope.
|
||||
func VerifySignature(secret string, body []byte, signatureHeader string) bool {
|
||||
if secret == "" || signatureHeader == "" {
|
||||
return false
|
||||
}
|
||||
got := signatureHeader
|
||||
if len(got) > 7 && got[:7] == "sha256=" {
|
||||
got = got[7:]
|
||||
}
|
||||
want := sign(secret, body)
|
||||
// hmac.Equal is the constant-time comparator; bytes.Equal would leak
|
||||
// timing information about the first differing byte.
|
||||
return hmac.Equal([]byte(got), []byte(want))
|
||||
}
|
||||
|
||||
// safeHost extracts the host (and optional port) from a webhook URL for
|
||||
// logging. Returns the input unchanged if parsing fails so we never silently
|
||||
// swallow a malformed URL — operators see the failure mode either way.
|
||||
func safeHost(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "(unparseable)"
|
||||
}
|
||||
return u.Host
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package notify_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
)
|
||||
|
||||
// TestSignedRoundTrip is the canonical "the receiver can verify what we
|
||||
// sent" check. Sender signs the body with a secret; the test server reads
|
||||
// the raw body and the X-Hub-Signature-256 header and verifies via
|
||||
// VerifySignature. A regression here means receivers built against our
|
||||
// docs would silently reject real notifications.
|
||||
func TestSignedRoundTrip(t *testing.T) {
|
||||
const secret = "super-secret-test-key-not-used-in-prod"
|
||||
|
||||
var receivedBody []byte
|
||||
var receivedSig string
|
||||
var receivedEvent string
|
||||
var receivedDelivery string
|
||||
var receivedTier string
|
||||
var receivedTimestamp string
|
||||
done := make(chan struct{})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = body
|
||||
receivedSig = r.Header.Get(notify.HeaderSignature)
|
||||
receivedEvent = r.Header.Get(notify.HeaderEvent)
|
||||
receivedDelivery = r.Header.Get(notify.HeaderDelivery)
|
||||
receivedTier = r.Header.Get(notify.HeaderTier)
|
||||
receivedTimestamp = r.Header.Get(notify.HeaderTimestamp)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := notify.New()
|
||||
n.SendSigned(srv.URL, secret, notify.TierStage, notify.Event{
|
||||
Type: "deploy_success",
|
||||
Project: "demo",
|
||||
Stage: "prod",
|
||||
})
|
||||
n.Drain()
|
||||
<-done
|
||||
|
||||
if !notify.VerifySignature(secret, receivedBody, receivedSig) {
|
||||
t.Fatalf("receiver could not verify signature: header=%q body=%q", receivedSig, receivedBody)
|
||||
}
|
||||
if receivedEvent != "deploy_success" {
|
||||
t.Errorf("event header = %q, want deploy_success", receivedEvent)
|
||||
}
|
||||
if receivedDelivery == "" {
|
||||
t.Errorf("delivery ID header missing")
|
||||
}
|
||||
if receivedTier != string(notify.TierStage) {
|
||||
t.Errorf("tier header = %q, want %q", receivedTier, notify.TierStage)
|
||||
}
|
||||
if receivedTimestamp == "" {
|
||||
t.Errorf("timestamp header missing")
|
||||
}
|
||||
|
||||
// Sanity: payload roundtrips through JSON unchanged.
|
||||
var got notify.Event
|
||||
if err := json.Unmarshal(receivedBody, &got); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if got.Project != "demo" || got.Stage != "prod" {
|
||||
t.Errorf("body fields lost in transit: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnsignedSendOmitsSignatureHeader covers the back-compat path: a
|
||||
// caller that hasn't yet generated a signing secret should still be able to
|
||||
// dispatch events, just without the signature header. Existing receivers
|
||||
// must not break when a Tinyforge instance upgrades but hasn't enabled
|
||||
// signing yet.
|
||||
func TestUnsignedSendOmitsSignatureHeader(t *testing.T) {
|
||||
var sigHeader string
|
||||
done := make(chan struct{})
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
sigHeader = r.Header.Get(notify.HeaderSignature)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := notify.New()
|
||||
n.SendSigned(srv.URL, "", notify.TierSettings, notify.Event{Type: "test"})
|
||||
n.Drain()
|
||||
<-done
|
||||
|
||||
if sigHeader != "" {
|
||||
t.Errorf("expected no signature header on unsigned send, got %q", sigHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyRejectsTamperedBody is the negative half of the round-trip:
|
||||
// flipping a single byte in the signed body must fail verification.
|
||||
// Catches accidental MAC truncation / wrong hash family / non-constant-time
|
||||
// compares (the last only weakly, but the round-trip already guards
|
||||
// correctness; this just locks the contract).
|
||||
func TestVerifyRejectsTamperedBody(t *testing.T) {
|
||||
const secret = "abc"
|
||||
body := []byte(`{"type":"deploy_success"}`)
|
||||
sig := "sha256=" + hexEncode(hmacSha256(secret, body))
|
||||
|
||||
if !notify.VerifySignature(secret, body, sig) {
|
||||
t.Fatalf("control: legit signature failed to verify")
|
||||
}
|
||||
|
||||
tampered := append([]byte(nil), body...)
|
||||
tampered[1] = 'X' // flip one byte
|
||||
if notify.VerifySignature(secret, tampered, sig) {
|
||||
t.Errorf("verifier accepted tampered body — signature scheme is broken")
|
||||
}
|
||||
|
||||
if notify.VerifySignature("wrong-secret", body, sig) {
|
||||
t.Errorf("verifier accepted wrong secret")
|
||||
}
|
||||
if notify.VerifySignature(secret, body, "") {
|
||||
t.Errorf("verifier accepted empty signature header")
|
||||
}
|
||||
if notify.VerifySignature("", body, sig) {
|
||||
t.Errorf("verifier accepted empty secret")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendSyncForTestReturnsReceiverStatus is the "send test" UI button
|
||||
// contract: when the receiver returns a non-2xx status, we must surface
|
||||
// both the status code and the body preview rather than swallowing them.
|
||||
// Operators rely on this to debug mis-pointed receivers.
|
||||
func TestSendSyncForTestReturnsReceiverStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("invalid signature"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := notify.New()
|
||||
res := n.SendSyncForTest(context.Background(), srv.URL, "secret", notify.TierProject, notify.Event{Type: "test"})
|
||||
|
||||
if res.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status_code = %d, want 403", res.StatusCode)
|
||||
}
|
||||
if res.ResponseSnippet != "invalid signature" {
|
||||
t.Errorf("response_snippet = %q, want 'invalid signature'", res.ResponseSnippet)
|
||||
}
|
||||
if !res.SignatureSent {
|
||||
t.Errorf("signature_sent should be true when secret is provided")
|
||||
}
|
||||
if res.Tier != notify.TierProject {
|
||||
t.Errorf("tier = %q, want project", res.Tier)
|
||||
}
|
||||
if res.Error == "" {
|
||||
t.Errorf("Error field should be set on 4xx response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendSyncForTestEmptyURL is the guard for the test endpoint when no
|
||||
// URL is configured at any tier. The handler relies on Error being non-empty
|
||||
// to render the "no URL configured" message, so this contract must hold.
|
||||
func TestSendSyncForTestEmptyURL(t *testing.T) {
|
||||
n := notify.New()
|
||||
res := n.SendSyncForTest(context.Background(), "", "secret", notify.TierSettings, notify.Event{Type: "test"})
|
||||
if res.Error == "" {
|
||||
t.Errorf("Error field should be set when URL is empty")
|
||||
}
|
||||
if res.StatusCode != 0 {
|
||||
t.Errorf("StatusCode should remain 0 when no request was made, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentSendsAllArrive guards the WaitGroup contract on Drain — a
|
||||
// regression where Drain returns before in-flight goroutines complete would
|
||||
// drop notifications during graceful shutdown.
|
||||
func TestConcurrentSendsAllArrive(t *testing.T) {
|
||||
const fanout = 20
|
||||
var (
|
||||
mu sync.Mutex
|
||||
received int
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
received++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
n := notify.New()
|
||||
for i := 0; i < fanout; i++ {
|
||||
n.SendSigned(srv.URL, "key", notify.TierSettings, notify.Event{Type: "test"})
|
||||
}
|
||||
n.Drain()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if received != fanout {
|
||||
t.Errorf("received %d sends, want %d", received, fanout)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------
|
||||
|
||||
// hmacSha256 + hexEncode duplicate the production sign() body so the
|
||||
// negative tests don't depend on the un-exported helper. If the exported
|
||||
// VerifySignature contract changes, this is the canary.
|
||||
func hmacSha256(secret string, body []byte) []byte {
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write(body)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
const hexdigits = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, x := range b {
|
||||
out[i*2] = hexdigits[x>>4]
|
||||
out[i*2+1] = hexdigits[x&0x0f]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/staticsite/deno"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
@@ -26,6 +27,7 @@ type Manager struct {
|
||||
docker *docker.Client
|
||||
proxyProvider proxy.Provider
|
||||
eventBus *events.Bus
|
||||
notifier *notify.Notifier
|
||||
encKey [32]byte
|
||||
}
|
||||
|
||||
@@ -35,6 +37,7 @@ func NewManager(
|
||||
dockerClient *docker.Client,
|
||||
proxyProvider proxy.Provider,
|
||||
eventBus *events.Bus,
|
||||
notifier *notify.Notifier,
|
||||
encKey [32]byte,
|
||||
) *Manager {
|
||||
return &Manager{
|
||||
@@ -42,6 +45,7 @@ func NewManager(
|
||||
docker: dockerClient,
|
||||
proxyProvider: proxyProvider,
|
||||
eventBus: eventBus,
|
||||
notifier: notifier,
|
||||
encKey: encKey,
|
||||
}
|
||||
}
|
||||
@@ -623,7 +627,9 @@ func (m *Manager) removeContainerByName(ctx context.Context, name string) {
|
||||
}
|
||||
|
||||
// updateStatus updates the site status in the database.
|
||||
// On failure, it also publishes an event to the event log.
|
||||
// On failure, it also publishes an event to the event log. On terminal
|
||||
// state transitions (deployed / failed), it dispatches an outgoing
|
||||
// notification using the per-site URL+secret with fall-through to global.
|
||||
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
|
||||
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
|
||||
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
|
||||
@@ -638,6 +644,59 @@ func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
|
||||
}
|
||||
m.publishEvent(id, siteName, "failed: "+errMsg)
|
||||
}
|
||||
|
||||
if status == "deployed" || status == "failed" {
|
||||
m.dispatchSiteNotification(id, status, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchSiteNotification emits a site_sync_success or site_sync_failure
|
||||
// event to the configured outgoing webhook. Resolution: per-site URL+secret
|
||||
// first, falling through to the global settings.notification_url/secret.
|
||||
// Always best-effort — failures are logged but never block status updates.
|
||||
func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) {
|
||||
if m.notifier == nil {
|
||||
return
|
||||
}
|
||||
site, err := m.store.GetStaticSiteByID(siteID)
|
||||
if err != nil {
|
||||
slog.Warn("static site: notify lookup failed", "site", siteID, "error", err)
|
||||
return
|
||||
}
|
||||
settings, err := m.store.GetSettings()
|
||||
if err != nil {
|
||||
slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
url, secret, tier := resolveSiteTarget(site, settings)
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
|
||||
eventType := "site_sync_success"
|
||||
if status == "failed" {
|
||||
eventType = "site_sync_failure"
|
||||
}
|
||||
siteURL := ""
|
||||
if site.Domain != "" {
|
||||
siteURL = "https://" + site.Domain
|
||||
}
|
||||
m.notifier.SendSigned(url, secret, tier, notify.Event{
|
||||
Type: eventType,
|
||||
Project: site.Name,
|
||||
URL: siteURL,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site
|
||||
// URL beats global, secret travels with the URL that sourced it.
|
||||
func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
||||
if site.NotificationURL != "" {
|
||||
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
||||
}
|
||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
||||
}
|
||||
|
||||
// publishEvent publishes a static site status event on the event bus
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package staticsite
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/notify"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// TestResolveSiteTarget locks the per-site → global precedence for static
|
||||
// site sync notifications. Distinct from the deploy resolver because there
|
||||
// is no project tier between site and settings; a regression that swapped
|
||||
// the order would silently route per-site events to the global receiver.
|
||||
func TestResolveSiteTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
site store.StaticSite
|
||||
settings store.Settings
|
||||
wantURL string
|
||||
wantSec string
|
||||
wantTier notify.Tier
|
||||
}{
|
||||
{
|
||||
name: "site wins when URL set",
|
||||
site: store.StaticSite{NotificationURL: "https://site.example/wh", NotificationSecret: "site-key"},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
||||
wantURL: "https://site.example/wh",
|
||||
wantSec: "site-key",
|
||||
wantTier: notify.TierSite,
|
||||
},
|
||||
{
|
||||
name: "site URL empty → global wins",
|
||||
site: store.StaticSite{},
|
||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
||||
wantURL: "https://global.example/wh",
|
||||
wantSec: "global-key",
|
||||
wantTier: notify.TierSettings,
|
||||
},
|
||||
{
|
||||
name: "both empty → empty URL with settings tier",
|
||||
site: store.StaticSite{},
|
||||
settings: store.Settings{},
|
||||
wantURL: "",
|
||||
wantSec: "",
|
||||
wantTier: notify.TierSettings,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotURL, gotSec, gotTier := resolveSiteTarget(tc.site, tc.settings)
|
||||
if gotURL != tc.wantURL {
|
||||
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
|
||||
}
|
||||
if gotSec != tc.wantSec {
|
||||
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
|
||||
}
|
||||
if gotTier != tc.wantTier {
|
||||
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ type Project struct {
|
||||
Healthcheck string `json:"healthcheck"`
|
||||
Env string `json:"env"` // JSON-encoded map
|
||||
Volumes string `json:"volumes"` // JSON-encoded map
|
||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
||||
WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly
|
||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
||||
WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly
|
||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -27,8 +29,9 @@ type Stage struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
EnableProxy bool `json:"enable_proxy"`
|
||||
PromoteFrom string `json:"promote_from"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
|
||||
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
|
||||
CreatedAt string `json:"created_at"`
|
||||
@@ -54,7 +57,8 @@ type Settings struct {
|
||||
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
|
||||
Network string `json:"network"`
|
||||
SubdomainPattern string `json:"subdomain_pattern"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationURL string `json:"notification_url"`
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
NpmURL string `json:"npm_url"`
|
||||
NpmEmail string `json:"npm_email"`
|
||||
NpmPassword string `json:"npm_password"`
|
||||
@@ -250,7 +254,9 @@ type StaticSite struct {
|
||||
Error string `json:"error"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
||||
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
|
||||
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
|
||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
+47
-10
@@ -31,7 +31,7 @@ func generateWebhookSecret() string {
|
||||
|
||||
// projectCols is the canonical column list for projects queries.
|
||||
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
|
||||
npm_access_list_id, webhook_secret, created_at, updated_at`
|
||||
npm_access_list_id, webhook_secret, notification_url, notification_secret, created_at, updated_at`
|
||||
|
||||
// CreateProject inserts a new project and returns it. A webhook secret is
|
||||
// generated automatically if one is not already set on the input.
|
||||
@@ -47,9 +47,9 @@ func (s *Store) CreateProject(p Project) (Project, error) {
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO projects (`+projectCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
|
||||
p.NpmAccessListID, p.WebhookSecret, p.CreatedAt, p.UpdatedAt,
|
||||
p.NpmAccessListID, p.WebhookSecret, p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Project{}, fmt.Errorf("insert project: %w", err)
|
||||
@@ -63,7 +63,7 @@ func (s *Store) GetProjectByID(id string) (Project, error) {
|
||||
err := s.db.QueryRow(
|
||||
`SELECT `+projectCols+` FROM projects WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
|
||||
err := s.db.QueryRow(
|
||||
`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret,
|
||||
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Project{}, ErrNotFound
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan project: %w", err)
|
||||
}
|
||||
projects = append(projects, p)
|
||||
@@ -129,7 +129,7 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan project: %w", err)
|
||||
}
|
||||
projects = append(projects, p)
|
||||
@@ -138,15 +138,16 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
||||
}
|
||||
|
||||
// UpdateProject updates an existing project's mutable fields. Webhook secret
|
||||
// is intentionally not updated here — use SetProjectWebhookSecret instead.
|
||||
// and notification_secret are intentionally not updated here — use the
|
||||
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
|
||||
func (s *Store) UpdateProject(p Project) error {
|
||||
p.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?,
|
||||
npm_access_list_id=?, updated_at=?
|
||||
npm_access_list_id=?, notification_url=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
|
||||
p.NpmAccessListID, p.UpdatedAt, p.ID,
|
||||
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project: %w", err)
|
||||
@@ -193,6 +194,42 @@ func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
|
||||
// secret. Empty string disables HMAC signing for this project (notifications
|
||||
// still send unsigned, falling through to the parent tier's secret if any).
|
||||
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set project notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
|
||||
// secret, generating one lazily if missing. Used when an operator first opens
|
||||
// the outgoing-webhook panel for a project that predates this feature.
|
||||
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
|
||||
project, err := s.GetProjectByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if project.NotificationSecret != "" {
|
||||
return project.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetProjectNotificationSecret(id, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
|
||||
func (s *Store) DeleteProject(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
|
||||
@@ -10,6 +10,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
||||
var wildcardDNS, npmRemote, backupEnabled int
|
||||
err := s.db.QueryRow(
|
||||
`SELECT domain, server_ip, public_ip, network, subdomain_pattern, notification_url,
|
||||
notification_secret,
|
||||
npm_url, npm_email, npm_password, polling_interval,
|
||||
base_volume_path, ssl_certificate_id, stale_threshold_days,
|
||||
allowed_volume_paths, wildcard_dns, dns_provider,
|
||||
@@ -22,6 +23,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
||||
updated_at
|
||||
FROM settings WHERE id = 1`,
|
||||
).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
|
||||
&st.NotificationSecret,
|
||||
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.PollingInterval,
|
||||
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
|
||||
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
|
||||
@@ -59,6 +61,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE settings SET
|
||||
domain=?, server_ip=?, public_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
||||
notification_secret=?,
|
||||
npm_url=?, npm_email=?, npm_password=?, polling_interval=?,
|
||||
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
|
||||
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
|
||||
@@ -71,6 +74,7 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
updated_at=?
|
||||
WHERE id = 1`,
|
||||
st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL,
|
||||
st.NotificationSecret,
|
||||
st.NpmURL, st.NpmEmail, st.NpmPassword, st.PollingInterval,
|
||||
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
|
||||
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
|
||||
@@ -87,3 +91,17 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
|
||||
// signing secret on the singleton settings row. Pass an empty string to
|
||||
// disable signing globally (notifications still send, just without HMAC).
|
||||
func (s *Store) SetSettingsNotificationSecret(secret string) error {
|
||||
_, err := s.db.Exec(
|
||||
`UPDATE settings SET notification_secret=?, updated_at=? WHERE id = 1`,
|
||||
secret, Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings notification secret: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, cpu_limit, memory_limit, created_at, updated_at`
|
||||
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
|
||||
|
||||
// CreateStage inserts a new stage for a project.
|
||||
func (s *Store) CreateStage(st Stage) (Stage, error) {
|
||||
@@ -17,9 +17,10 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
|
||||
st.UpdatedAt = st.CreatedAt
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
||||
st.NotificationSecret,
|
||||
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -57,6 +58,7 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
|
||||
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
|
||||
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
||||
&st.NotificationSecret,
|
||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
||||
@@ -80,6 +82,8 @@ func (s *Store) UpdateStage(st Stage) error {
|
||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
||||
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
|
||||
)
|
||||
// notification_secret is intentionally not updated here — use the
|
||||
// dedicated SetStageNotificationSecret rotation helper.
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stage: %w", err)
|
||||
}
|
||||
@@ -103,6 +107,41 @@ func (s *Store) DeleteStage(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
|
||||
// secret. Empty string disables HMAC signing for this stage (notifications
|
||||
// still send unsigned, falling through to project/global resolution).
|
||||
func (s *Store) SetStageNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set stage notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
|
||||
// secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
|
||||
stage, err := s.GetStageByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stage.NotificationSecret != "" {
|
||||
return stage.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetStageNotificationSecret(id, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// BoolToInt converts a bool to an integer for SQLite storage.
|
||||
func BoolToInt(b bool) int {
|
||||
if b {
|
||||
@@ -117,6 +156,7 @@ func scanStage(rows *sql.Rows) (Stage, error) {
|
||||
var autoDeploy, confirm, enableProxy int
|
||||
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
||||
&st.NotificationSecret,
|
||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
||||
if err != nil {
|
||||
return Stage{}, fmt.Errorf("scan stage: %w", err)
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
||||
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
||||
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
|
||||
storage_enabled, storage_limit_mb, webhook_secret, created_at, updated_at`
|
||||
storage_enabled, storage_limit_mb, webhook_secret,
|
||||
notification_url, notification_secret,
|
||||
created_at, updated_at`
|
||||
|
||||
// CreateStaticSite inserts a new static site and returns it. A webhook secret
|
||||
// is generated automatically if one is not already set on the input.
|
||||
@@ -29,13 +31,15 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO static_sites (`+staticSiteCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||
site.WebhookSecret, site.CreatedAt, site.UpdatedAt,
|
||||
site.WebhookSecret,
|
||||
site.NotificationURL, site.NotificationSecret,
|
||||
site.CreatedAt, site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
||||
@@ -103,18 +107,21 @@ func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite
|
||||
}
|
||||
|
||||
// UpdateStaticSite updates an existing static site's configuration fields.
|
||||
// notification_secret is intentionally not updated here — use the dedicated
|
||||
// SetStaticSiteNotificationSecret rotation helper.
|
||||
func (s *Store) UpdateStaticSite(site StaticSite) error {
|
||||
site.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
|
||||
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
|
||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=?
|
||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
|
||||
notification_url=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
|
||||
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||
site.UpdatedAt, site.ID,
|
||||
site.NotificationURL, site.UpdatedAt, site.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update static site: %w", err)
|
||||
@@ -228,7 +235,9 @@ func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.WebhookSecret,
|
||||
&site.NotificationURL, &site.NotificationSecret,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, err
|
||||
@@ -248,7 +257,9 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.WebhookSecret,
|
||||
&site.NotificationURL, &site.NotificationSecret,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
||||
@@ -258,6 +269,58 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
|
||||
// signing secret. Empty string disables HMAC signing for this site
|
||||
// (notifications still send unsigned, falling through to global resolution).
|
||||
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
|
||||
secret, Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set static site notification secret: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
|
||||
// signing secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
|
||||
site, err := s.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if site.NotificationSecret != "" {
|
||||
return site.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
|
||||
// secret, generating one lazily if missing.
|
||||
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
|
||||
st, err := s.GetSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if st.NotificationSecret != "" {
|
||||
return st.NotificationSecret, nil
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.SetSettingsNotificationSecret(secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret.
|
||||
// Returns ErrNotFound if no site has this secret (including empty).
|
||||
func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) {
|
||||
|
||||
+23
-8
@@ -138,6 +138,15 @@ func (s *Store) runMigrations() error {
|
||||
// retention in hours. 0 in either disables collection.
|
||||
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
||||
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
||||
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
|
||||
// tokens (matches the inbound webhook_secret pattern). Empty = no
|
||||
// signing; existing rows stay unsigned on upgrade for back-compat.
|
||||
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
@@ -284,7 +293,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
healthcheck TEXT NOT NULL DEFAULT '',
|
||||
env TEXT NOT NULL DEFAULT '{}',
|
||||
volumes TEXT NOT NULL DEFAULT '{}',
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -299,8 +310,9 @@ CREATE TABLE IF NOT EXISTS stages (
|
||||
confirm INTEGER NOT NULL DEFAULT 0,
|
||||
enable_proxy INTEGER NOT NULL DEFAULT 1,
|
||||
promote_from TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
subdomain TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
cpu_limit REAL NOT NULL DEFAULT 0,
|
||||
memory_limit INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
@@ -326,6 +338,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
network TEXT NOT NULL DEFAULT 'tinyforge',
|
||||
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
npm_url TEXT NOT NULL DEFAULT '',
|
||||
npm_email TEXT NOT NULL DEFAULT '',
|
||||
npm_password TEXT NOT NULL DEFAULT '',
|
||||
@@ -487,11 +500,13 @@ CREATE TABLE IF NOT EXISTS static_sites (
|
||||
container_id TEXT NOT NULL DEFAULT '',
|
||||
proxy_route_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
notification_url TEXT NOT NULL DEFAULT '',
|
||||
notification_secret TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_site_secrets (
|
||||
|
||||
@@ -346,6 +346,80 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlR
|
||||
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
||||
}
|
||||
|
||||
// ── Outgoing-webhook signing & test ────────────────────────────────
|
||||
|
||||
export interface NotificationSecretResponse {
|
||||
secret: string;
|
||||
has_secret: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
url: string;
|
||||
tier: 'settings' | 'project' | 'stage' | 'site';
|
||||
status_code: number;
|
||||
latency_ms: number;
|
||||
signature_sent: boolean;
|
||||
delivery_id: string;
|
||||
response_snippet: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Settings (global) tier.
|
||||
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>('/api/settings/notification-secret');
|
||||
}
|
||||
export function regenerateSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>('/api/settings/notification-secret/regenerate');
|
||||
}
|
||||
export function disableSettingsNotificationSigning(): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>('/api/settings/notification-secret/disable');
|
||||
}
|
||||
export function testSettingsNotification(): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>('/api/settings/notification-test');
|
||||
}
|
||||
|
||||
// Project tier.
|
||||
export function getProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret`);
|
||||
}
|
||||
export function regenerateProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableProjectNotificationSigning(projectId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/disable`);
|
||||
}
|
||||
export function testProjectNotification(projectId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/projects/${projectId}/notification-test`);
|
||||
}
|
||||
|
||||
// Stage tier.
|
||||
export function getStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret`);
|
||||
}
|
||||
export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableStageNotificationSigning(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`);
|
||||
}
|
||||
export function testStageNotification(projectId: string, stageId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/projects/${projectId}/stages/${stageId}/notification-test`);
|
||||
}
|
||||
|
||||
// Static-site tier.
|
||||
export function getStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return get<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret`);
|
||||
}
|
||||
export function regenerateStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/regenerate`);
|
||||
}
|
||||
export function disableStaticSiteNotificationSigning(siteId: string): Promise<NotificationSecretResponse> {
|
||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/disable`);
|
||||
}
|
||||
export function testStaticSiteNotification(siteId: string): Promise<NotificationTestResult> {
|
||||
return post<NotificationTestResult>(`/api/sites/${siteId}/notification-test`);
|
||||
}
|
||||
|
||||
// ── Proxy Routes ───────────────────────────────────────────────────
|
||||
|
||||
export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<!--
|
||||
OutgoingWebhookPanel
|
||||
|
||||
Operator-facing controls for an outgoing webhook tier (global / project /
|
||||
stage / site). Three concerns in vertical flow:
|
||||
1. Signing state — secret reveal, copy, regenerate, disable.
|
||||
2. Send-test — fires a synthetic event and renders the receiver's
|
||||
response inline (status code, latency, body preview).
|
||||
3. Inheritance hint — when the parent didn't set a URL, shows that
|
||||
this tier will fall through to the next-most-general tier.
|
||||
|
||||
Parent supplies the four async callbacks; the panel doesn't know which
|
||||
tier it lives on. Mirrors the inbound-webhook WebhookPanel ergonomics so
|
||||
operators see a consistent visual language for every webhook surface.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
IconCopy, IconRefresh, IconLoader, IconKey, IconShield, IconCheck, IconAlert
|
||||
} from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import type { NotificationSecretResponse, NotificationTestResult } from '$lib/api';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
// True when *this tier* has a URL configured (passed in by the
|
||||
// parent that owns the URL field). Drives whether "send test" can
|
||||
// fire and whether we render the inheritance hint.
|
||||
hasUrl: boolean;
|
||||
// Optional human-readable name of the tier we'd fall through to if
|
||||
// the URL is empty (e.g. "global settings" when on a project page).
|
||||
fallbackLabel?: string;
|
||||
fetchSecret: () => Promise<NotificationSecretResponse>;
|
||||
regenerateSecret: () => Promise<NotificationSecretResponse>;
|
||||
disableSigning: () => Promise<NotificationSecretResponse>;
|
||||
sendTest: () => Promise<NotificationTestResult>;
|
||||
}
|
||||
|
||||
let {
|
||||
title, description, hasUrl, fallbackLabel,
|
||||
fetchSecret, regenerateSecret, disableSigning, sendTest,
|
||||
}: Props = $props();
|
||||
|
||||
// Initial load is on-demand: showing the secret is an explicit
|
||||
// operator action, not a passive read of the page. This keeps the
|
||||
// secret out of the network response unless the operator asks for it.
|
||||
let secret = $state('');
|
||||
let hasSecret = $state(false);
|
||||
let revealed = $state(false);
|
||||
|
||||
let loading = $state(false);
|
||||
let regenerating = $state(false);
|
||||
let disabling = $state(false);
|
||||
let testing = $state(false);
|
||||
|
||||
let confirmRegenerate = $state(false);
|
||||
let confirmDisable = $state(false);
|
||||
|
||||
let testResult = $state<NotificationTestResult | null>(null);
|
||||
|
||||
// Tier presence-check happens once on mount so the "signing on/off"
|
||||
// pill is correct without revealing the secret.
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const res = await fetchSecret();
|
||||
hasSecret = res.has_secret;
|
||||
// Don't store the secret yet — only when revealed.
|
||||
} catch {
|
||||
// Silent — the panel still renders with hasSecret=false.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetchSecret();
|
||||
secret = res.secret;
|
||||
hasSecret = res.has_secret;
|
||||
revealed = true;
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.loadFailed'));
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
confirmRegenerate = false;
|
||||
regenerating = true;
|
||||
try {
|
||||
const res = await regenerateSecret();
|
||||
secret = res.secret;
|
||||
hasSecret = true;
|
||||
revealed = true;
|
||||
toasts.success($t('outgoingWebhook.regenerated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.regenerateFailed'));
|
||||
} finally {
|
||||
regenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
confirmDisable = false;
|
||||
disabling = true;
|
||||
try {
|
||||
await disableSigning();
|
||||
secret = '';
|
||||
hasSecret = false;
|
||||
revealed = false;
|
||||
toasts.info($t('outgoingWebhook.disabled'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.disableFailed'));
|
||||
} finally {
|
||||
disabling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
testing = true;
|
||||
testResult = null;
|
||||
try {
|
||||
testResult = await sendTest();
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('outgoingWebhook.testFailed'));
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy(value: string, successKey: string) {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value).then(
|
||||
() => toasts.info($t(successKey)),
|
||||
() => toasts.error($t('outgoingWebhook.copyFailed')),
|
||||
);
|
||||
}
|
||||
|
||||
// Coarse classification of the test result so the status pill picks
|
||||
// the right colour band: 2xx green, 4xx amber, 5xx / network red.
|
||||
function resultClass(r: NotificationTestResult): string {
|
||||
if (r.error && !r.status_code) return 'failure';
|
||||
if (r.status_code >= 200 && r.status_code < 300) return 'success';
|
||||
if (r.status_code >= 400 && r.status_code < 500) return 'warn';
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{title}</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{description}</p>
|
||||
</div>
|
||||
<!-- Signing-state pill: shown at all times so an operator can tell
|
||||
"signed" vs "unsigned" without revealing the secret. -->
|
||||
{#if hasSecret}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-success-light)] px-2.5 py-1 text-xs font-medium text-[var(--color-success)]">
|
||||
<IconShield size={12} />
|
||||
{$t('outgoingWebhook.signingOn')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--surface-card-hover)] px-2.5 py-1 text-xs font-medium text-[var(--text-tertiary)]">
|
||||
<IconKey size={12} />
|
||||
{$t('outgoingWebhook.signingOff')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inheritance hint: when this tier has no URL, real events fall
|
||||
through to the parent tier. Surfacing this prevents the surprise
|
||||
of "I configured the URL but events don't arrive". -->
|
||||
{#if !hasUrl}
|
||||
<div class="mb-4 flex items-start gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 text-sm text-[var(--text-secondary)]">
|
||||
<IconAlert size={16} />
|
||||
<span>
|
||||
{#if fallbackLabel}
|
||||
{$t('outgoingWebhook.fallbackTo', { label: fallbackLabel })}
|
||||
{:else}
|
||||
{$t('outgoingWebhook.noUrlConfigured')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Secret control row.
|
||||
- When unrevealed, render an opaque placeholder + a "Reveal" CTA
|
||||
so the operator commits to the action of pulling the secret
|
||||
on screen.
|
||||
- When revealed, show the cleartext + copy. -->
|
||||
<div class="mb-2 text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.signingSecret')}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<code class="flex-1 truncate rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] px-3 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{#if revealed && secret}
|
||||
{secret}
|
||||
{:else if hasSecret}
|
||||
<span class="select-none text-[var(--text-tertiary)]">••••••••••••••••••••••••••••••••</span>
|
||||
{:else}
|
||||
<span class="select-none italic text-[var(--text-tertiary)]">{$t('outgoingWebhook.noSecret')}</span>
|
||||
{/if}
|
||||
</code>
|
||||
{#if revealed && secret}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleCopy(secret, 'outgoingWebhook.copied')}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] active:animate-press"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
{$t('outgoingWebhook.copy')}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleReveal}
|
||||
disabled={loading}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if loading}<IconLoader size={16} />{:else}<IconKey size={16} />{/if}
|
||||
{hasSecret ? $t('outgoingWebhook.reveal') : $t('outgoingWebhook.generate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action row: regenerate / disable. Both are destructive (regenerate
|
||||
invalidates every receiver verifying the old secret; disable lets
|
||||
subsequent events go out unsigned), so each opens a modal
|
||||
ConfirmDialog rather than relying on a slim inline confirm strip
|
||||
that's easy to miss next to the other buttons. -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmRegenerate = true)}
|
||||
disabled={regenerating}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if regenerating}<IconLoader size={14} />{:else}<IconRefresh size={14} />{/if}
|
||||
{$t('outgoingWebhook.regenerate')}
|
||||
</button>
|
||||
{#if hasSecret}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (confirmDisable = true)}
|
||||
disabled={disabling}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if disabling}<IconLoader size={14} />{/if}
|
||||
{$t('outgoingWebhook.disable')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Send-test row. Disabled when no URL is configured *and* there's
|
||||
no fallback (otherwise the resolver would surface the inherited
|
||||
URL, and the test should reflect what a real event would do). -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-[var(--text-primary)]">{$t('outgoingWebhook.sendTestTitle')}</div>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('outgoingWebhook.sendTestHelp')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleTest}
|
||||
disabled={testing}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
||||
>
|
||||
{#if testing}<IconLoader size={16} />{/if}
|
||||
{testing ? $t('outgoingWebhook.sending') : $t('outgoingWebhook.sendTest')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if testResult}
|
||||
{@const cls = resultClass(testResult)}
|
||||
<div
|
||||
class="mt-4 overflow-hidden rounded-lg border"
|
||||
class:border-[var(--color-success)]={cls === 'success'}
|
||||
class:border-[var(--color-warning)]={cls === 'warn'}
|
||||
class:border-[var(--color-danger)]={cls === 'failure'}
|
||||
>
|
||||
<!-- Result header: status pill + latency + signed badge -->
|
||||
<div class="flex flex-wrap items-center gap-3 px-4 py-3"
|
||||
class:bg-[var(--color-success-light)]={cls === 'success'}
|
||||
class:bg-[var(--color-warning-light)]={cls === 'warn'}
|
||||
class:bg-[var(--color-danger-light)]={cls === 'failure'}
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 font-mono text-sm font-semibold"
|
||||
class:text-[var(--color-success)]={cls === 'success'}
|
||||
class:text-[var(--color-warning)]={cls === 'warn'}
|
||||
class:text-[var(--color-danger)]={cls === 'failure'}
|
||||
>
|
||||
{#if cls === 'success'}<IconCheck size={14} />{:else}<IconAlert size={14} />{/if}
|
||||
{testResult.status_code || $t('outgoingWebhook.networkError')}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-secondary)]">
|
||||
{testResult.latency_ms}ms
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.tier')}: <span class="font-mono">{testResult.tier}</span>
|
||||
</span>
|
||||
{#if testResult.signature_sent}
|
||||
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]">
|
||||
<IconShield size={12} />
|
||||
{$t('outgoingWebhook.signed')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('outgoingWebhook.unsigned')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Result body: delivery ID (so operators can grep their
|
||||
receiver logs) + response preview if any. -->
|
||||
<div class="space-y-2 px-4 py-3">
|
||||
{#if testResult.error}
|
||||
<div class="text-sm text-[var(--color-danger)]">
|
||||
{testResult.error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs">
|
||||
<span class="text-[var(--text-tertiary)]">{$t('outgoingWebhook.deliveryId')}:</span>
|
||||
<code class="ml-1 font-mono text-[var(--text-secondary)]">{testResult.delivery_id}</code>
|
||||
</div>
|
||||
{#if testResult.response_snippet}
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||
{$t('outgoingWebhook.responseBody')}
|
||||
</div>
|
||||
<pre class="max-h-40 overflow-auto rounded border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-2 font-mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{testResult.response_snippet}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal confirms for both destructive actions. Mounted outside the panel
|
||||
card so backdrop + scale-in animation cover the page, not just the
|
||||
panel rectangle (matches how the rest of Tinyforge surfaces deletes). -->
|
||||
<ConfirmDialog
|
||||
open={confirmRegenerate}
|
||||
title={$t('outgoingWebhook.confirmRegenerateTitle')}
|
||||
message={$t('outgoingWebhook.confirmRegenerate')}
|
||||
confirmLabel={$t('outgoingWebhook.regenerate')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleRegenerate}
|
||||
oncancel={() => (confirmRegenerate = false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDisable}
|
||||
title={$t('outgoingWebhook.confirmDisableTitle')}
|
||||
message={$t('outgoingWebhook.confirmDisable')}
|
||||
confirmLabel={$t('outgoingWebhook.disable')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDisable}
|
||||
oncancel={() => (confirmDisable = false)}
|
||||
/>
|
||||
@@ -120,6 +120,16 @@
|
||||
"projectDetail": {
|
||||
"webhookTitle": "Project webhook",
|
||||
"webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.",
|
||||
"outgoingWebhookTitle": "Outgoing webhook (project)",
|
||||
"outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.",
|
||||
"outgoingFallbackGlobal": "the global integrations setting",
|
||||
"notificationUrlLabel": "Outgoing webhook URL",
|
||||
"notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.",
|
||||
"stageNotificationUrlLabel": "Outgoing webhook URL (this stage)",
|
||||
"stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.",
|
||||
"stageOutgoingTitle": "Outgoing webhook (stage)",
|
||||
"stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.",
|
||||
"stageFallbackLabel": "the project or global settings",
|
||||
"deleteProject": "Delete Project",
|
||||
"envVars": "Environment Variables",
|
||||
"volumes": "Volume Mounts",
|
||||
@@ -618,6 +628,11 @@
|
||||
"sites": {
|
||||
"webhookTitle": "Site webhook",
|
||||
"webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.",
|
||||
"outgoingUrlTitle": "Outgoing webhook URL (this site)",
|
||||
"outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.",
|
||||
"outgoingWebhookTitle": "Outgoing webhook (site)",
|
||||
"outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.",
|
||||
"outgoingFallbackGlobal": "the global integrations setting",
|
||||
"title": "Static Sites",
|
||||
"addSite": "New Site",
|
||||
"newSite": "New Static Site",
|
||||
@@ -1168,6 +1183,43 @@
|
||||
"confirmYes": "Regenerate",
|
||||
"confirmNo": "Cancel"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Signed",
|
||||
"signingOff": "Unsigned",
|
||||
"signingSecret": "HMAC signing secret",
|
||||
"noSecret": "No signing secret — outgoing events are not signed.",
|
||||
"reveal": "Reveal",
|
||||
"generate": "Generate",
|
||||
"copy": "Copy",
|
||||
"copied": "Signing secret copied to clipboard",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"loadFailed": "Failed to load signing secret",
|
||||
"regenerate": "Regenerate",
|
||||
"regenerated": "Signing secret regenerated",
|
||||
"regenerateFailed": "Failed to regenerate signing secret",
|
||||
"confirmRegenerateTitle": "Rotate signing secret?",
|
||||
"confirmRegenerate": "The current secret is invalidated immediately. Every receiver verifying it must be updated in lock-step or it will start rejecting events.",
|
||||
"confirmDisableTitle": "Disable HMAC signing?",
|
||||
"confirmDisable": "Future events go out without the X-Hub-Signature-256 header. Receivers that require signatures will reject them.",
|
||||
"confirmYes": "Confirm",
|
||||
"confirmNo": "Cancel",
|
||||
"disable": "Disable signing",
|
||||
"disabled": "Signing disabled",
|
||||
"disableFailed": "Failed to disable signing",
|
||||
"sendTestTitle": "Send a test event",
|
||||
"sendTestHelp": "Fires a synthetic \"test\" event to the resolved URL using the current secret.",
|
||||
"sendTest": "Send test",
|
||||
"sending": "Sending…",
|
||||
"testFailed": "Failed to send test event",
|
||||
"tier": "Tier",
|
||||
"signed": "Signed",
|
||||
"unsigned": "Unsigned",
|
||||
"deliveryId": "Delivery",
|
||||
"responseBody": "Response body",
|
||||
"networkError": "Network error",
|
||||
"fallbackTo": "No URL set on this tier — events will fall through to {label}.",
|
||||
"noUrlConfigured": "No URL set. Configure one above before sending a test."
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Maintenance",
|
||||
"thresholds": "Thresholds",
|
||||
|
||||
@@ -120,6 +120,16 @@
|
||||
"projectDetail": {
|
||||
"webhookTitle": "Webhook проекта",
|
||||
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (проект)",
|
||||
"outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"notificationUrlLabel": "URL исходящего webhook",
|
||||
"notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.",
|
||||
"stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)",
|
||||
"stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.",
|
||||
"stageOutgoingTitle": "Исходящий webhook (стейдж)",
|
||||
"stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.",
|
||||
"stageFallbackLabel": "проектной или глобальной настройки",
|
||||
"deleteProject": "Удалить проект",
|
||||
"envVars": "Переменные окружения",
|
||||
"volumes": "Тома",
|
||||
@@ -618,6 +628,11 @@
|
||||
"sites": {
|
||||
"webhookTitle": "Webhook сайта",
|
||||
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
|
||||
"outgoingUrlTitle": "URL исходящего webhook (этот сайт)",
|
||||
"outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.",
|
||||
"outgoingWebhookTitle": "Исходящий webhook (сайт)",
|
||||
"outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.",
|
||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
||||
"title": "Статические сайты",
|
||||
"addSite": "Новый сайт",
|
||||
"newSite": "Новый статический сайт",
|
||||
@@ -1168,6 +1183,43 @@
|
||||
"confirmYes": "Перегенерировать",
|
||||
"confirmNo": "Отмена"
|
||||
},
|
||||
"outgoingWebhook": {
|
||||
"signingOn": "Подпись включена",
|
||||
"signingOff": "Без подписи",
|
||||
"signingSecret": "HMAC-секрет",
|
||||
"noSecret": "Секрет не задан — исходящие события отправляются без подписи.",
|
||||
"reveal": "Показать",
|
||||
"generate": "Сгенерировать",
|
||||
"copy": "Копировать",
|
||||
"copied": "Секрет скопирован в буфер обмена",
|
||||
"copyFailed": "Не удалось скопировать",
|
||||
"loadFailed": "Не удалось загрузить секрет",
|
||||
"regenerate": "Перегенерировать",
|
||||
"regenerated": "Секрет перегенерирован",
|
||||
"regenerateFailed": "Не удалось перегенерировать секрет",
|
||||
"confirmRegenerateTitle": "Перегенерировать секрет?",
|
||||
"confirmRegenerate": "Текущий секрет инвалидируется немедленно. Все получатели должны быть обновлены синхронно — иначе начнут отклонять события.",
|
||||
"confirmDisableTitle": "Отключить HMAC-подпись?",
|
||||
"confirmDisable": "Будущие события пойдут без заголовка X-Hub-Signature-256. Получатели, требующие подпись, начнут их отклонять.",
|
||||
"confirmYes": "Подтвердить",
|
||||
"confirmNo": "Отмена",
|
||||
"disable": "Отключить подпись",
|
||||
"disabled": "Подпись отключена",
|
||||
"disableFailed": "Не удалось отключить подпись",
|
||||
"sendTestTitle": "Отправить тестовое событие",
|
||||
"sendTestHelp": "Отправляет синтетическое событие \"test\" на разрешённый URL с текущим секретом.",
|
||||
"sendTest": "Отправить тест",
|
||||
"sending": "Отправка…",
|
||||
"testFailed": "Не удалось отправить тестовое событие",
|
||||
"tier": "Уровень",
|
||||
"signed": "Подписано",
|
||||
"unsigned": "Без подписи",
|
||||
"deliveryId": "Доставка",
|
||||
"responseBody": "Тело ответа",
|
||||
"networkError": "Сетевая ошибка",
|
||||
"fallbackTo": "URL не задан на этом уровне — события унаследуются от {label}.",
|
||||
"noUrlConfigured": "URL не задан. Настройте его выше перед тестом."
|
||||
},
|
||||
"settingsMaintenance": {
|
||||
"title": "Обслуживание",
|
||||
"thresholds": "Пороги",
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Project {
|
||||
env: string;
|
||||
volumes: string;
|
||||
npm_access_list_id: number;
|
||||
notification_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ export interface Stage {
|
||||
enable_proxy: boolean;
|
||||
promote_from: string;
|
||||
subdomain: string;
|
||||
notification_url: string;
|
||||
cpu_limit: number;
|
||||
memory_limit: number;
|
||||
created_at: string;
|
||||
@@ -391,6 +393,7 @@ export interface StaticSite {
|
||||
error: string;
|
||||
storage_enabled: boolean;
|
||||
storage_limit_mb: number;
|
||||
notification_url: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user