Compare commits

..

11 commits

Author SHA1 Message Date
023b35e167 docs: add checker notifications overview
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-30 10:00:53 +07:00
d27fae2b27 web: add notification settings UI under /me/notifications 2026-04-30 10:00:53 +07:00
8f468bcad1 notification: add API endpoints for channels, preferences, history, and acknowledgement 2026-04-30 10:00:53 +07:00
9f9e61bac2 checker: add execution callback for notification integration 2026-04-30 10:00:53 +07:00
08c8a6bd9c notification: add dispatcher with state tracking and acknowledgement
The Dispatcher is the core notification logic: it receives execution
callbacks, detects status transitions via persisted NotificationState,
resolves user preferences by specificity (service > domain > global),
respects quiet hours, and dispatches through configured channels.

Acknowledgement support allows users to suppress repeat notifications
until the next state change.
2026-04-30 10:00:53 +07:00
ae8528d620 notification: add UnifiedPush channel sender
UnifiedPushSender posts notification payloads to a user-supplied
UnifiedPush distributor endpoint following the UnifiedPush protocol.
2026-04-30 10:00:53 +07:00
ab97185611 notification: add webhook channel sender
WebhookSender posts a JSON payload to a user-configured URL with an
optional HMAC-SHA256 signature header. Outbound requests go through a
SafeHTTP transport that blocks loopback, link-local, and private
address ranges to prevent SSRF against internal services.
2026-04-30 10:00:53 +07:00
0eacde4dd1 notification: add ChannelSender interface and email sender
Define the ChannelSender interface and NotificationPayload/Recipient
types passed to senders. Senders receive only what they need to render
or transmit a notification — no user object, no server config — so
adding a new transport cannot accidentally leak privileged data.

EmailSender reuses the existing Mailer to send Markdown-formatted
alerts.
2026-04-30 10:00:53 +07:00
d821bc27a1 notification: add storage interfaces and KV implementations 2026-04-30 10:00:53 +07:00
97ec5eca7a notification: add notification models and error sentinels 2026-04-30 10:00:53 +07:00
f606e414db checker: recover stale running executions at startup
Some checks are pending
continuous-integration/drone/push Build is running
Mark any execution still in Pending/Running state as Failed when the app
starts, so executions interrupted by a crash or kill no longer appear
stuck "running" forever in the UI.
2026-04-30 10:00:53 +07:00
27 changed files with 220 additions and 551 deletions

View file

@ -27,6 +27,7 @@ import (
"log"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@ -36,19 +37,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// maxHistoryLimit caps the ?limit= query parameter on ListHistory. The store
// loads all matching records into memory before slicing, so an unbounded limit
// is a trivial DoS vector.
// Caps ?limit= so an unbounded request can't OOM the in-memory slice.
const maxHistoryLimit = 500
// maxAnnotationLength caps the user-supplied annotation persisted with an
// acknowledgement. The string is stored in NotificationState and rendered in
// the UI; an unbounded value bloats state and is a DoS vector.
// Bounds the persisted annotation to prevent state bloat.
const maxAnnotationLength = 1024
// internalError logs the underlying error and returns a generic 500 to the
// client. Storage errors can contain keys and other implementation details;
// echoing them back leaks information.
// Storage errors may contain keys/internals; never echo them back.
func internalError(c *gin.Context, err error) {
log.Printf("notification controller: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{
@ -56,7 +51,6 @@ func internalError(c *gin.Context, err error) {
})
}
// NotificationController handles notification-related API endpoints.
type NotificationController struct {
dispatcher *notifUC.Dispatcher
registry *notifPkg.Registry
@ -65,7 +59,6 @@ type NotificationController struct {
recordStore notifUC.NotificationRecordStorage
}
// NewNotificationController creates a new NotificationController.
func NewNotificationController(
dispatcher *notifUC.Dispatcher,
registry *notifPkg.Registry,
@ -82,9 +75,6 @@ func NewNotificationController(
}
}
// ListChannelTypes returns the channel types registered by the server, so the
// UI knows which transports are available without hardcoding the list.
//
// @Summary List supported notification channel types
// @Tags notifications
// @Produce json
@ -94,10 +84,6 @@ func (nc *NotificationController) ListChannelTypes(c *gin.Context) {
c.JSON(http.StatusOK, nc.registry.Types())
}
// --- Channel CRUD ---
// ListChannels returns all notification channels for the authenticated user.
//
// @Summary List notification channels
// @Tags notifications
// @Produce json
@ -121,8 +107,6 @@ func (nc *NotificationController) ListChannels(c *gin.Context) {
c.JSON(http.StatusOK, redacted)
}
// CreateChannel creates a new notification channel.
//
// @Summary Create a notification channel
// @Tags notifications
// @Accept json
@ -159,8 +143,6 @@ func (nc *NotificationController) CreateChannel(c *gin.Context) {
c.JSON(http.StatusCreated, redacted)
}
// GetChannel returns a specific notification channel.
//
// @Summary Get a notification channel
// @Tags notifications
// @Produce json
@ -176,10 +158,7 @@ func (nc *NotificationController) GetChannel(c *gin.Context) {
c.JSON(http.StatusOK, redacted)
}
// UpdateChannel updates a notification channel. Fields absent from the request
// body are preserved from the stored channel, so a PATCH-style partial update
// does not silently zero out unrelated fields (e.g. omitting "enabled" must
// not disable the channel).
// Absent body fields are preserved so omitting one (e.g. "enabled") doesn't silently zero it.
//
// @Summary Update a notification channel
// @Tags notifications
@ -192,9 +171,7 @@ func (nc *NotificationController) GetChannel(c *gin.Context) {
func (nc *NotificationController) UpdateChannel(c *gin.Context) {
existing := middleware.MyNotificationChannel(c)
// Bind into a copy of the stored channel so json.Unmarshal only overwrites
// fields present in the body. Identity fields are then forced back to the
// stored values, regardless of what the client sent.
// Bind onto a copy so json.Unmarshal only overwrites present fields; identity fields are forced back below.
ch := *existing
if err := c.ShouldBindJSON(&ch); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
@ -204,9 +181,7 @@ func (nc *NotificationController) UpdateChannel(c *gin.Context) {
ch.Id = existing.Id
ch.UserId = existing.UserId
// Carry forward stored secrets that the redacted GET response could not
// expose to the client, so a round-trip GET → PUT does not silently wipe
// them. Sender-specific behaviour lives in ConfigMerger.
// Carry forward stored secrets so a GET → PUT round-trip does not wipe them.
merged, err := nc.registry.MergeChannelForUpdate(existing, &ch)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
@ -232,8 +207,6 @@ func (nc *NotificationController) UpdateChannel(c *gin.Context) {
c.JSON(http.StatusOK, redacted)
}
// DeleteChannel deletes a notification channel.
//
// @Summary Delete a notification channel
// @Tags notifications
// @Param channelId path string true "Channel ID"
@ -250,8 +223,6 @@ func (nc *NotificationController) DeleteChannel(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// TestChannel sends a test notification through a channel.
//
// @Summary Send a test notification
// @Tags notifications
// @Param channelId path string true "Channel ID"
@ -269,10 +240,7 @@ func (nc *NotificationController) TestChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
}
// --- Preference CRUD ---
// validateQuietHours rejects out-of-range quiet-hour values. The fields are
// optional pointers; nil is allowed. A 023 hour is required when set.
// Hours, when set, must be in 023. Timezone, when set, must be a valid IANA name.
func validateQuietHours(p *happydns.NotificationPreference) error {
if p.QuietStart != nil && (*p.QuietStart < 0 || *p.QuietStart > 23) {
return fmt.Errorf("quietStart must be between 0 and 23")
@ -280,11 +248,14 @@ func validateQuietHours(p *happydns.NotificationPreference) error {
if p.QuietEnd != nil && (*p.QuietEnd < 0 || *p.QuietEnd > 23) {
return fmt.Errorf("quietEnd must be between 0 and 23")
}
if p.Timezone != "" {
if _, err := time.LoadLocation(p.Timezone); err != nil {
return fmt.Errorf("timezone %q is not a valid IANA name", p.Timezone)
}
}
return nil
}
// ListPreferences returns all notification preferences for the authenticated user.
//
// @Summary List notification preferences
// @Tags notifications
// @Produce json
@ -303,8 +274,6 @@ func (nc *NotificationController) ListPreferences(c *gin.Context) {
c.JSON(http.StatusOK, prefs)
}
// CreatePreference creates a new notification preference.
//
// @Summary Create a notification preference
// @Tags notifications
// @Accept json
@ -336,8 +305,6 @@ func (nc *NotificationController) CreatePreference(c *gin.Context) {
c.JSON(http.StatusCreated, pref)
}
// GetPreference returns a specific notification preference.
//
// @Summary Get a notification preference
// @Tags notifications
// @Produce json
@ -348,8 +315,7 @@ func (nc *NotificationController) GetPreference(c *gin.Context) {
c.JSON(http.StatusOK, middleware.MyNotificationPreference(c))
}
// UpdatePreference updates a notification preference. Fields absent from the
// request body are preserved from the stored preference (see UpdateChannel).
// Absent body fields preserved (see UpdateChannel).
//
// @Summary Update a notification preference
// @Tags notifications
@ -384,8 +350,6 @@ func (nc *NotificationController) UpdatePreference(c *gin.Context) {
c.JSON(http.StatusOK, pref)
}
// DeletePreference deletes a notification preference.
//
// @Summary Delete a notification preference
// @Tags notifications
// @Param prefId path string true "Preference ID"
@ -402,10 +366,6 @@ func (nc *NotificationController) DeletePreference(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// --- History ---
// ListHistory returns recent notification records for the authenticated user.
//
// @Summary List notification history
// @Tags notifications
// @Produce json
@ -436,10 +396,6 @@ func (nc *NotificationController) ListHistory(c *gin.Context) {
c.JSON(http.StatusOK, records)
}
// --- Acknowledgement ---
// AcknowledgeIssue marks a checker issue as acknowledged.
//
// @Summary Acknowledge a checker issue
// @Tags checkers
// @Accept json
@ -481,8 +437,6 @@ func (nc *NotificationController) AcknowledgeIssue(c *gin.Context) {
c.JSON(http.StatusOK, state)
}
// ClearAcknowledgement removes an acknowledgement from a checker issue.
//
// @Summary Clear acknowledgement
// @Tags checkers
// @Produce json

View file

@ -36,10 +36,7 @@ const (
ctxKeyNotificationPreference = "notification_preference"
)
// NotificationChannelHandler resolves :channelId, ensures it exists and is
// owned by the authenticated user, and exposes it via MyNotificationChannel.
// Centralizing the ownership check here removes a latent bug class: any new
// per-channel endpoint cannot forget to enforce it.
// Centralizes ownership check so per-channel endpoints cannot forget it.
func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.HandlerFunc {
return func(c *gin.Context) {
user := MyUser(c)
@ -75,15 +72,11 @@ func NotificationChannelHandler(store notifUC.NotificationChannelStorage) gin.Ha
}
}
// MyNotificationChannel returns the channel resolved by NotificationChannelHandler.
// Panics if the middleware was not installed on the route — this is a wiring
// bug, not a runtime condition.
// Panics if middleware not installed — wiring bug, not runtime.
func MyNotificationChannel(c *gin.Context) *happydns.NotificationChannel {
return c.MustGet(ctxKeyNotificationChannel).(*happydns.NotificationChannel)
}
// NotificationPreferenceHandler resolves :prefId with the same contract as
// NotificationChannelHandler.
func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage) gin.HandlerFunc {
return func(c *gin.Context) {
user := MyUser(c)
@ -118,7 +111,6 @@ func NotificationPreferenceHandler(store notifUC.NotificationPreferenceStorage)
}
}
// MyNotificationPreference returns the preference resolved by NotificationPreferenceHandler.
func MyNotificationPreference(c *gin.Context) *happydns.NotificationPreference {
return c.MustGet(ctxKeyNotificationPreference).(*happydns.NotificationPreference)
}

View file

@ -35,7 +35,6 @@ import (
happydns "git.happydns.org/happyDomain/model"
)
// DeclareNotificationRoutes registers notification routes under /api/notifications.
func DeclareNotificationRoutes(
apiAuthRoutes *gin.RouterGroup,
dispatcher *notifUC.Dispatcher,
@ -61,10 +60,7 @@ func DeclareNotificationRoutes(
channelID.PUT("", nc.UpdateChannel)
channelID.DELETE("", nc.DeleteChannel)
// TestChannel triggers an outbound request — webhook/email/UnifiedPush —
// per call. Without throttling, an authenticated user could spam any
// configured endpoint at line rate. Rate-limit per user (the channel is
// owned by the user; spreading it by IP would be looser than necessary).
// Rate-limit per user: each test triggers an outbound request and channels are user-owned.
testRLStore := ratelimit.InMemoryStore(&ratelimit.InMemoryOptions{
Rate: time.Minute,
Limit: 5,

View file

@ -441,6 +441,19 @@ func (app *App) Start() {
go app.insights.Run()
}
// Reconcile executions left "running" by a previous process that
// crashed or was killed mid-run, before the scheduler starts queuing
// new work.
if recoverer, ok := app.usecases.checkerEngine.(interface {
RecoverStaleExecutions(ctx context.Context) (int, error)
}); ok {
if n, err := recoverer.RecoverStaleExecutions(context.Background()); err != nil {
log.Printf("CheckerEngine: failed to recover stale executions: %v", err)
} else if n > 0 {
log.Printf("CheckerEngine: recovered %d stale execution(s) from previous run", n)
}
}
if app.usecases.checkerScheduler != nil {
app.usecases.checkerScheduler.Start(context.Background())
}

View file

@ -33,9 +33,8 @@ import (
const ChannelTypeEmail happydns.NotificationChannelType = "email"
// EmailConfig is the per-channel configuration for the email sender.
type EmailConfig struct {
// Address overrides the user's account email. Empty means use account email.
// Empty means fall back to the user's account email.
Address string `json:"address,omitempty"`
}
@ -49,16 +48,12 @@ func (c EmailConfig) Validate() error {
return nil
}
// EmailSender sends notifications via email using the existing Mailer.
// The base URL is captured at construction so it does not need to be threaded
// through every payload — it is server identity, not per-notification data.
// baseURL is captured here — server identity, not per-notification data.
type EmailSender struct {
mailer happydns.Mailer
baseURL string
}
// NewEmailSender creates a new EmailSender. baseURL, if non-empty, is rendered
// as a "View in happyDomain" link in the email body.
func NewEmailSender(mailer happydns.Mailer, baseURL string) *EmailSender {
return &EmailSender{mailer: mailer, baseURL: baseURL}
}
@ -76,17 +71,11 @@ func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *Notificati
to := &mail.Address{Address: addr}
// Subject is an RFC 5322 header: stripping CR/LF prevents header injection
// if a domain name field ever contains them.
// Strip CR/LF to prevent RFC 5322 header injection.
safeDomain := stripCRLF(payload.DomainName)
subject := fmt.Sprintf("[happyDomain] %s: %s", safeDomain, payload.NewStatus)
// Fields populated from third-party sources (checker output produced from
// WHOIS, DNS, or remote HTTP responses) are wrapped in code spans so the
// Markdown renderer treats them as literal text. Without this, an attacker
// controlling a remote bytes path could plant a clickable link in a
// DKIM-signed mail. payload.Annotation stays unwrapped — it is authored by
// the recipient themselves, no privilege boundary to cross.
// Wrap third-party-sourced fields as Markdown code spans to neutralize injected link syntax in DKIM-signed mail; Annotation is user-authored, no boundary.
var body strings.Builder
fmt.Fprintf(&body, "## Status Change: %s -> %s\n\n", payload.OldStatus, payload.NewStatus)
fmt.Fprintf(&body, "**Domain:** %s\n\n", mdLiteral(payload.DomainName))
@ -111,16 +100,11 @@ func (s *EmailSender) Send(_ context.Context, c EmailConfig, payload *Notificati
return s.mailer.SendMail(to, subject, body.String())
}
// stripCRLF removes CR and LF bytes; intended for header field values where a
// newline would terminate the header and let an attacker append further headers.
func stripCRLF(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
// mdLiteral renders s as a Markdown code span so the renderer cannot interpret
// link syntax, headings, or other formatting in user-supplied content.
// Backticks inside s are replaced with apostrophes (good enough for display
// without resorting to multi-backtick fence accounting).
// Wraps s as a code span; backticks become apostrophes to avoid fence accounting.
func mdLiteral(s string) string {
return "`" + strings.ReplaceAll(s, "`", "'") + "`"
}

View file

@ -33,7 +33,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// httpJSONPayload is the wire format used by both webhook and UnifiedPush.
// Shared by both webhook and UnifiedPush.
type httpJSONPayload struct {
Event string `json:"event"`
Checker string `json:"checker"`
@ -60,8 +60,7 @@ func buildHTTPPayload(p *NotificationPayload, dashboardURL string) httpJSONPaylo
}
}
// postJSON marshals body, POSTs it, runs decorate (e.g. for HMAC/headers),
// and returns an error on a non-2xx response or transport failure.
// decorate runs after marshal so it can sign the exact bytes (e.g. HMAC).
func postJSON(ctx context.Context, client *http.Client, url string, body any, decorate func(*http.Request, []byte)) error {
raw, err := json.Marshal(body)
if err != nil {
@ -90,7 +89,6 @@ func postJSON(ctx context.Context, client *http.Client, url string, body any, de
return nil
}
// testPayload builds a synthetic payload used by SendTest implementations.
func testPayload(rcpt Recipient) *NotificationPayload {
return &NotificationPayload{
Recipient: rcpt,

View file

@ -32,18 +32,12 @@ import (
"time"
)
// maxResponseBodyBytes caps the bytes read from a remote endpoint's response.
// We do not need the body for routing, just to drain it for keep-alive.
// We drain the response only for keep-alive; body is unused.
const maxResponseBodyBytes = 64 * 1024
// errBlockedAddress is returned when a webhook target resolves to a
// disallowed address range (private, loopback, link-local, multicast, etc.).
var errBlockedAddress = errors.New("address resolves to a blocked range")
// validateOutboundURL parses rawURL and rejects targets that should never
// be reached from a server-side webhook: non-http(s) schemes, missing host,
// or hosts that are bare IP literals in private/loopback/link-local ranges.
// DNS-based hosts are re-checked at dial time by safeDialContext.
// Reject non-http(s), missing host, or private/loopback IP literals; DNS hosts re-checked at dial time.
func validateOutboundURL(rawURL string) (*url.URL, error) {
u, err := url.Parse(rawURL)
if err != nil {
@ -64,10 +58,6 @@ func validateOutboundURL(rawURL string) (*url.URL, error) {
return u, nil
}
// isPublicIP reports whether ip is a globally routable unicast address.
// Anything else (loopback, link-local, multicast, private RFC1918/ULA,
// IPv4 broadcast, the unspecified address, the IPv6 link-local fe80::/10,
// or the IPv4-mapped 169.254.169.254 metadata address) is rejected.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
@ -84,9 +74,7 @@ func isPublicIP(ip net.IP) bool {
return true
}
// safeDialContext wraps net.Dialer.DialContext and re-checks the resolved IP
// before allowing the connection. This catches DNS rebinding attacks and
// hostnames that resolve to internal addresses.
// Re-check resolved IP to defeat DNS rebinding.
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
@ -121,8 +109,7 @@ func safeDialContext(ctx context.Context, network, address string) (net.Conn, er
return nil, fmt.Errorf("dial %s: no usable address", address)
}
// newSafeHTTPClient returns an *http.Client that refuses to talk to private
// or loopback addresses and re-validates each redirect hop.
// Refuses private/loopback addresses and re-validates each redirect hop.
func newSafeHTTPClient(timeout time.Duration) *http.Client {
transport := &http.Transport{
DialContext: safeDialContext,

View file

@ -30,21 +30,13 @@ import (
"git.happydns.org/happyDomain/model"
)
// Recipient is the minimum identity passed through to a sender. It carries
// only what at least one transport actually needs (currently the email
// address) so other transports cannot accidentally leak the full user record
// — webhooks must never receive the user object as a side effect of being
// configured.
// Carries only what at least one transport needs, so other transports cannot leak the user record.
type Recipient struct {
// Email is the recipient's email address. May be empty for transports
// that don't need it (webhook, UnifiedPush).
// May be empty for transports that don't need it (webhook, UnifiedPush).
Email string
}
// NotificationPayload holds the data passed to channel senders. Senders
// receive only what they need to render or transmit a notification — no user
// object, no server config — so adding a new transport cannot accidentally
// leak privileged data.
// Senders receive only render-needed data — no user object, no server config — so adding a transport cannot leak privileged data.
type NotificationPayload struct {
Recipient Recipient
CheckerID string
@ -56,72 +48,38 @@ type NotificationPayload struct {
Annotation string
}
// ChannelConfig is the typed configuration for a single channel. Each sender
// implementation defines its own concrete type that satisfies this interface.
type ChannelConfig interface {
// Validate returns a non-nil error if the configuration is invalid.
Validate() error
}
// ChannelSender delivers notifications through one transport (email, webhook, ...).
// A sender owns its configuration shape: callers pass raw JSON, the sender
// decodes it. This keeps the model package free of transport details and makes
// adding a new channel a one-file change.
//
// Most implementations should not implement this directly — use TypedSender[C]
// and Adapt to get the unmarshal/validate/type-assert/SendTest boilerplate
// for free.
// Senders own their config shape so adding a transport is a one-file change.
// Most implementations should embed TypedSender[C] via Adapt rather than implementing this directly.
type ChannelSender interface {
// Type is the discriminator that links a NotificationChannel to this sender.
Type() happydns.NotificationChannelType
// DecodeConfig parses and validates raw JSON config for this channel type.
DecodeConfig(raw json.RawMessage) (ChannelConfig, error)
// Send delivers a real notification.
Send(ctx context.Context, cfg ChannelConfig, payload *NotificationPayload) error
// SendTest delivers a test notification through the sender.
SendTest(ctx context.Context, cfg ChannelConfig, user *happydns.User) error
// RedactConfig returns a version of raw safe to echo back to API clients —
// secrets stripped and replaced with presence booleans. Senders without
// secrets return raw unchanged.
// Strip secrets to presence booleans before echoing config back to clients.
RedactConfig(raw json.RawMessage) (json.RawMessage, error)
// MergeForUpdate produces the config to persist on PUT. Senders with
// secrets use it to preserve the stored secret when the client submits an
// empty value (the client never sees the secret on read, so the wire
// protocol cannot carry "no change" otherwise). Senders without secrets
// return incoming unchanged.
// Preserve stored secrets when client submits empty fields (client never sees them on read).
MergeForUpdate(existing, incoming json.RawMessage) (json.RawMessage, error)
}
// ConfigRedactor is an optional capability for transports whose config has
// secret fields. Implement on a TypedSender[C] to opt in; the adapter wires
// it into ChannelSender.RedactConfig automatically.
// Optional capability: senders with secret fields opt in by implementing this on their TypedSender.
type ConfigRedactor[C ChannelConfig] interface {
RedactConfig(cfg C) C
}
// ConfigMerger is an optional capability for transports that need to fold an
// existing stored config into an incoming update (e.g. preserve a secret the
// client never receives back).
type ConfigMerger[C ChannelConfig] interface {
MergeForUpdate(existing, incoming C) C
}
// TypedSender is the strongly-typed contract a transport implements. The
// concrete config type C is checked at compile time, so implementations are
// freed from JSON unmarshaling, validation, runtime type-asserting and from
// re-implementing SendTest. Wrap with Adapt to expose as a ChannelSender.
// Strongly-typed contract; Adapt wraps it as ChannelSender, providing JSON decode, validation, type-asserted dispatch, and SendTest.
type TypedSender[C ChannelConfig] interface {
Type() happydns.NotificationChannelType
Send(ctx context.Context, cfg C, payload *NotificationPayload) error
}
// Adapt promotes a TypedSender[C] to a ChannelSender, providing JSON decode,
// config validation, type-asserted dispatch, and SendTest in one place.
func Adapt[C ChannelConfig](s TypedSender[C]) ChannelSender {
return &typedAdapter[C]{inner: s}
}
@ -191,19 +149,16 @@ func (a *typedAdapter[C]) MergeForUpdate(existing, incoming json.RawMessage) (js
return json.Marshal(merger.MergeForUpdate(ec, ic))
}
// Registry maps channel types to their senders. Senders self-register at
// startup so adding a new transport requires no changes here.
// Senders self-register at startup; adding a transport requires no changes here.
type Registry struct {
senders map[happydns.NotificationChannelType]ChannelSender
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{senders: make(map[happydns.NotificationChannelType]ChannelSender)}
}
// Register adds a sender. Panics on duplicate type — duplicate registration is
// a programming error, not a runtime condition.
// Panics on duplicate — programming error.
func (r *Registry) Register(s ChannelSender) {
t := s.Type()
if _, exists := r.senders[t]; exists {
@ -212,13 +167,11 @@ func (r *Registry) Register(s ChannelSender) {
r.senders[t] = s
}
// Get returns the sender for the given type.
func (r *Registry) Get(t happydns.NotificationChannelType) (ChannelSender, bool) {
s, ok := r.senders[t]
return s, ok
}
// Types returns all registered channel types.
func (r *Registry) Types() []happydns.NotificationChannelType {
out := make([]happydns.NotificationChannelType, 0, len(r.senders))
for t := range r.senders {
@ -227,9 +180,6 @@ func (r *Registry) Types() []happydns.NotificationChannelType {
return out
}
// DecodeChannelConfig is a convenience: look up the sender for the channel,
// decode and validate its config. Returns ErrUnknownChannelType if the type
// has no registered sender.
func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (ChannelConfig, error) {
s, ok := r.Get(ch.Type)
if !ok {
@ -238,10 +188,7 @@ func (r *Registry) DecodeChannelConfig(ch *happydns.NotificationChannel) (Channe
return s.DecodeConfig(ch.Config)
}
// RedactChannel returns a shallow copy of ch with its Config replaced by the
// sender's redacted form. Channels of unknown types are returned unchanged so
// administrators can still observe legacy data; secret-carrying senders that
// implement ConfigRedactor get their secrets stripped here.
// Channels of unknown types are returned unchanged so administrators can still observe legacy data.
func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.NotificationChannel, error) {
if ch == nil {
return nil, nil
@ -260,8 +207,6 @@ func (r *Registry) RedactChannel(ch *happydns.NotificationChannel) (*happydns.No
return &copy, nil
}
// RedactChannels returns a fresh slice with each channel redacted. Errors
// short-circuit; callers can choose whether to log and degrade or fail.
func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happydns.NotificationChannel, error) {
out := make([]*happydns.NotificationChannel, 0, len(chs))
for _, ch := range chs {
@ -274,10 +219,7 @@ func (r *Registry) RedactChannels(chs []*happydns.NotificationChannel) ([]*happy
return out, nil
}
// MergeChannelForUpdate folds an existing stored channel's config into an
// incoming update, letting senders preserve fields the client cannot resend
// (typically secrets). Returns the merged raw config; callers should then
// validate it via DecodeConfig before persisting.
// Caller should DecodeConfig the returned raw before persisting.
func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.NotificationChannel) (json.RawMessage, error) {
s, ok := r.Get(incoming.Type)
if !ok {
@ -286,6 +228,4 @@ func (r *Registry) MergeChannelForUpdate(existing, incoming *happydns.Notificati
return s.MergeForUpdate(existing.Config, incoming.Config)
}
// ErrUnknownChannelType is returned when a channel references a type that has
// no registered sender.
var ErrUnknownChannelType = errors.New("unknown channel type")

View file

@ -33,9 +33,7 @@ import (
const ChannelTypeUnifiedPush happydns.NotificationChannelType = "unifiedpush"
// UnifiedPushConfig is the per-channel configuration for the UnifiedPush sender.
type UnifiedPushConfig struct {
// Endpoint is the push server endpoint URL provided by the distributor.
Endpoint string `json:"endpoint"`
}
@ -49,15 +47,12 @@ func (c UnifiedPushConfig) Validate() error {
return nil
}
// UnifiedPushSender sends notifications via the UnifiedPush protocol. The
// dashboard URL is captured here rather than threaded through the payload.
// dashboardURL is captured here — server identity, not per-notification data.
type UnifiedPushSender struct {
client *http.Client
dashboardURL string
}
// NewUnifiedPushSender creates a new UnifiedPushSender. dashboardURL is
// published to recipients in the JSON payload's "dashboardUrl" field.
func NewUnifiedPushSender(dashboardURL string) *UnifiedPushSender {
return &UnifiedPushSender{
client: newSafeHTTPClient(10 * time.Second),

View file

@ -35,10 +35,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// disallowedWebhookHeaders are header names that must be controlled by the
// HTTP client (transport/length) or that would let a user override our
// outbound identity. Allowing them invites smuggling, host-routing tricks,
// and content-length mismatches.
// Reserved by the HTTP client or used to spoof outbound identity (smuggling/host-routing risk).
var disallowedWebhookHeaders = map[string]struct{}{
"host": {},
"content-length": {},
@ -50,8 +47,6 @@ var disallowedWebhookHeaders = map[string]struct{}{
"trailer": {},
}
// validateHeader rejects a custom webhook header if its name is reserved or
// if either name or value contains CR/LF (header injection).
func validateHeader(k, v string) error {
if k == "" {
return errors.New("empty header name")
@ -67,19 +62,12 @@ func validateHeader(k, v string) error {
const ChannelTypeWebhook happydns.NotificationChannelType = "webhook"
// WebhookConfig is the per-channel configuration for the webhook sender.
type WebhookConfig struct {
// URL is the HTTP endpoint to POST to.
URL string `json:"url"`
// Headers are extra headers to send with each request.
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
// Secret, if set, is used to compute an HMAC-SHA256 signature header.
// HMAC-SHA256 signing key.
Secret string `json:"secret,omitempty"`
// HasSecret is set on the response side only by RedactConfig. It is never
// stored or accepted on input — the API serializer overwrites it.
// Set only by RedactConfig — never stored or accepted on input.
HasSecret bool `json:"hasSecret,omitempty"`
}
@ -98,16 +86,12 @@ func (c WebhookConfig) Validate() error {
return nil
}
// WebhookSender sends notifications via HTTP POST to a configured URL. The
// dashboard URL is captured here so it does not need to be threaded through
// every payload — it is server identity, not per-notification data.
// dashboardURL is captured here — server identity, not per-notification data.
type WebhookSender struct {
client *http.Client
dashboardURL string
}
// NewWebhookSender creates a new WebhookSender. dashboardURL is published to
// recipients in the JSON payload's "dashboardUrl" field; pass "" to omit it.
func NewWebhookSender(dashboardURL string) *WebhookSender {
return &WebhookSender{
client: newSafeHTTPClient(10 * time.Second),
@ -117,19 +101,13 @@ func NewWebhookSender(dashboardURL string) *WebhookSender {
func (s *WebhookSender) Type() happydns.NotificationChannelType { return ChannelTypeWebhook }
// RedactConfig clears the HMAC secret before the config is echoed back to the
// API client and surfaces a presence boolean instead. The transport never
// needs to send the secret outbound; clients never need to read it back.
func (s *WebhookSender) RedactConfig(cfg WebhookConfig) WebhookConfig {
cfg.HasSecret = cfg.Secret != ""
cfg.Secret = ""
return cfg
}
// MergeForUpdate preserves a previously stored secret when the client submits
// an empty one. Since RedactConfig never returns the secret, the only way the
// client could carry it back to us is by re-typing it; absence means "no
// change", not "clear it".
// Preserve stored secret on empty submit; client never receives it back, so absence means "no change".
func (s *WebhookSender) MergeForUpdate(existing, incoming WebhookConfig) WebhookConfig {
if incoming.Secret == "" {
incoming.Secret = existing.Secret
@ -142,9 +120,7 @@ func (s *WebhookSender) Send(ctx context.Context, c WebhookConfig, payload *Noti
return postJSON(ctx, s.client, c.URL, buildHTTPPayload(payload, s.dashboardURL), func(req *http.Request, body []byte) {
req.Header.Set("User-Agent", "happyDomain-Notification/1.0")
for k, v := range c.Headers {
// Defense in depth: Validate() should already have rejected
// bad headers, but skip them here too in case a stored
// channel pre-dates the validation.
// Defense in depth: catches stored channels that pre-date Validate().
if err := validateHeader(k, v); err != nil {
continue
}

View file

@ -30,13 +30,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// Channel storage layout:
// notifch|<channelId> -> full NotificationChannel (source of truth)
// notifch-user|<userId>|<channelId> -> "" (presence-only secondary index)
//
// Storing only the channel id in the index avoids the previous double-write
// (two copies of the same blob to keep in sync) while still letting
// ListChannelsByUser scan a per-user prefix without loading every channel.
// Layout: notifch|<channelId> -> full record; notifch-user|<userId>|<channelId> -> "" (index only, no double-write).
const (
notifchPrimaryPrefix = "notifch|"
@ -51,8 +45,6 @@ func notifchUserKey(userId, channelId happydns.Identifier) string {
return fmt.Sprintf("%s%s|%s", notifchUserPrefix, userId.String(), channelId.String())
}
// channelIdFromUserIndexKey extracts the channel id suffix from a
// "notifch-user|<userId>|<channelId>" key.
func channelIdFromUserIndexKey(key string) (string, bool) {
rest, ok := strings.CutPrefix(key, notifchUserPrefix)
if !ok {
@ -83,9 +75,8 @@ func (s *KVStorage) ListChannelsByUser(userId happydns.Identifier) ([]*happydns.
}
ch, err := s.GetChannel(id)
if err != nil {
// Index drift: the primary record is gone but the index entry
// remains. Log and skip rather than fail the whole list.
log.Printf("storage: channel index points to missing channel %s: %v", idStr, err)
// Index drift: skip rather than fail the whole list.
log.Printf("storage: channel index points to missing channel %q: %v", idStr, err)
continue
}
channels = append(channels, ch)
@ -113,10 +104,9 @@ func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
return err
}
if err := s.db.Put(notifchUserKey(ch.UserId, ch.Id), ""); err != nil {
// Best-effort rollback so a failed index write does not leave a primary
// record orphaned and invisible from per-user listing.
// Roll back primary so a failed index write doesn't orphan it.
if delErr := s.db.Delete(key); delErr != nil {
log.Printf("storage: orphan channel %s after index write failed (rollback also failed: %v)", ch.Id.String(), delErr)
log.Printf("storage: orphan channel %q after index write failed (rollback also failed: %v)", ch.Id.String(), delErr)
}
return err
}
@ -124,8 +114,7 @@ func (s *KVStorage) CreateChannel(ch *happydns.NotificationChannel) error {
}
func (s *KVStorage) UpdateChannel(ch *happydns.NotificationChannel) error {
// The user index is keyed by (userId, channelId) and carries no payload,
// so an Update only needs to write the primary record.
// Index has no payload, so only the primary needs writing.
return s.db.Put(notifchPrimaryKey(ch.Id), ch)
}
@ -135,13 +124,12 @@ func (s *KVStorage) DeleteChannel(channelId happydns.Identifier) error {
return err
}
// Delete the index first so a partial failure leaves the channel hidden
// from listings rather than visible-but-broken.
// Delete index first so partial failure hides the channel rather than leaving it visible-but-broken.
if err := s.db.Delete(notifchUserKey(ch.UserId, channelId)); err != nil {
return err
}
if err := s.db.Delete(notifchPrimaryKey(channelId)); err != nil {
log.Printf("storage: channel %s index removed but primary delete failed: %v", channelId.String(), err)
log.Printf("storage: channel %q index removed but primary delete failed: %v", channelId.String(), err)
return err
}
return nil

View file

@ -30,9 +30,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// Preference storage layout mirrors notification_channel.go: the primary
// "notifpref|<id>" key holds the record; "notifpref-user|<userId>|<id>" is a
// presence-only secondary index for cheap per-user listing.
// Same primary+per-user-index layout as notification_channel.go.
const (
notifprefPrimaryPrefix = "notifpref|"
@ -77,7 +75,7 @@ func (s *KVStorage) ListPreferencesByUser(userId happydns.Identifier) ([]*happyd
}
pref, err := s.GetPreference(id)
if err != nil {
log.Printf("storage: preference index points to missing preference %s: %v", idStr, err)
log.Printf("storage: preference index points to missing preference %q: %v", idStr, err)
continue
}
prefs = append(prefs, pref)
@ -106,7 +104,7 @@ func (s *KVStorage) CreatePreference(pref *happydns.NotificationPreference) erro
}
if err := s.db.Put(notifprefUserKey(pref.UserId, pref.Id), ""); err != nil {
if delErr := s.db.Delete(key); delErr != nil {
log.Printf("storage: orphan preference %s after index write failed (rollback also failed: %v)", pref.Id.String(), delErr)
log.Printf("storage: orphan preference %q after index write failed (rollback also failed: %v)", pref.Id.String(), delErr)
}
return err
}
@ -127,7 +125,7 @@ func (s *KVStorage) DeletePreference(prefId happydns.Identifier) error {
return err
}
if err := s.db.Delete(notifprefPrimaryKey(prefId)); err != nil {
log.Printf("storage: preference %s index removed but primary delete failed: %v", prefId.String(), err)
log.Printf("storage: preference %q index removed but primary delete failed: %v", prefId.String(), err)
return err
}
return nil

View file

@ -29,13 +29,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// notifStateKey builds the storage key for a notification state record.
//
// The key is constructed from explicit fields rather than CheckTarget.String()
// so that adding new fields to CheckTarget (in the external SDK) cannot
// silently change the key shape and orphan previously-stored records, and so
// that the dispatcher and the acknowledgement handler always agree on the
// exact set of fields that participate in identity.
// Built from explicit fields so SDK changes to CheckTarget can't reshape the key and orphan stored records.
func notifStateKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
return fmt.Sprintf(
"notifstate|%s|%s|%s/%s/%s",

View file

@ -329,6 +329,39 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return result, eval, nil
}
// RecoverStaleExecutions scans all executions and marks any still in Pending
// or Running state as Failed. It is intended to be called at startup to
// reconcile state left over from a previous process that crashed or was
// killed mid-execution: without it, the affected executions would remain
// "running" forever in the UI. Returns the number of executions updated.
func (e *checkerEngine) RecoverStaleExecutions(ctx context.Context) (int, error) {
iter, err := e.execStore.ListAllExecutions()
if err != nil {
return 0, fmt.Errorf("listing executions: %w", err)
}
defer iter.Close()
n := 0
for iter.Next() {
exec := iter.Item()
if exec.Status != happydns.ExecutionPending && exec.Status != happydns.ExecutionRunning {
continue
}
endTime := time.Now()
exec.Status = happydns.ExecutionFailed
exec.EndedAt = &endTime
if exec.Error == "" {
exec.Error = "execution interrupted by server restart"
}
if err := e.execStore.UpdateExecution(exec); err != nil {
log.Printf("CheckerEngine: failed to recover stale execution %s: %v", exec.Id.String(), err)
continue
}
n++
}
return n, nil
}
// RelatedLookup exposes the engine's Related resolver so controllers can
// build ReportContexts with cross-checker observations pre-resolved. Returns
// nil when discovery storage is not wired.

View file

@ -27,24 +27,19 @@ import (
"git.happydns.org/happyDomain/model"
)
// AckService manages user acknowledgements of incidents. It depends only on
// the state store: it neither sends notifications nor consults preferences.
// Depends only on the state store — no senders, no preferences.
type AckService struct {
stateStore NotificationStateStorage
locker *StateLocker
// nowFn is the clock used for AcknowledgedAt. Overridable by same-package
// tests; defaults to time.Now.
// Overridable for tests.
nowFn func() time.Time
}
// NewAckService builds an AckService bound to the state store. locker is
// shared with the Dispatcher so the two cannot race on the same state record.
// locker is shared with the Dispatcher to avoid racing on the same state record.
func NewAckService(stateStore NotificationStateStorage, locker *StateLocker) *AckService {
return &AckService{stateStore: stateStore, locker: locker, nowFn: time.Now}
}
//
// An existing state record (created by the dispatcher when an execution
// completed) is required: acknowledging an issue that the dispatcher has
// never observed is rejected with ErrNotificationStateNotFound. This avoids
@ -73,7 +68,6 @@ func (a *AckService) AcknowledgeIssue(userId happydns.Identifier, checkerID stri
return a.stateStore.PutState(state)
}
// ClearAcknowledgement removes the acknowledgement from an issue.
func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
unlock := a.locker.Lock(checkerID, target, userId)
defer unlock()
@ -90,12 +84,10 @@ func (a *AckService) ClearAcknowledgement(userId happydns.Identifier, checkerID
return a.stateStore.PutState(state)
}
// GetState returns the current notification state for a checker/target/user.
func (a *AckService) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
return a.stateStore.GetState(checkerID, target, userId)
}
// ListStatesByUser returns all notification states for a user.
func (a *AckService) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
return a.stateStore.ListStatesByUser(userId)
}

View file

@ -30,12 +30,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// Dispatcher orchestrates the response to a checker execution: load state,
// consult policy, persist the new state, and hand off any send to the Pool.
//
// It owns no I/O of its own — all storage, sending, resolving, and
// acknowledgement live in dedicated collaborators. Its single job is to glue
// them together at the seam between the checker and the notification system.
// Glue between checker execution and notification system; owns no I/O — all of it lives in collaborators.
type Dispatcher struct {
stateStore NotificationStateStorage
userStore UserGetter
@ -47,14 +42,11 @@ type Dispatcher struct {
ack *AckService
locker *StateLocker
// nowFn is the clock used for state timestamps. Overridable by same-package
// tests to drive time-dependent policy decisions deterministically.
// Overridable for tests.
nowFn func() time.Time
}
// NewDispatcher builds a Dispatcher from its collaborators. The caller is
// responsible for the lifecycle of the Pool (Start/Stop); the Dispatcher does
// not own it.
// Caller owns Pool lifecycle.
func NewDispatcher(
stateStore NotificationStateStorage,
userStore UserGetter,
@ -78,16 +70,9 @@ func NewDispatcher(
}
}
// Start delegates to the Pool. Kept on Dispatcher so existing app wiring,
// which thinks of "the dispatcher" as a lifecycle unit, doesn't change.
func (d *Dispatcher) Start() { d.pool.Start() }
func (d *Dispatcher) Stop() { d.pool.Stop() }
// Stop delegates to the Pool.
func (d *Dispatcher) Stop() { d.pool.Stop() }
// OnExecutionComplete is the callback invoked after a checker execution
// finishes. It walks the policy decision tree and either skips, advances
// state silently, or enqueues sends through the pool.
func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydns.CheckEvaluation) {
if exec == nil || exec.Status != happydns.ExecutionDone {
return
@ -100,16 +85,13 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
user, err := d.userStore.GetUser(*userId)
if err != nil {
log.Printf("notification: failed to load user %s: %v", userId, err)
log.Printf("notification: failed to load user %q: %v", userId, err)
return
}
newStatus := exec.Result.Status
// Serialise the load-modify-store sequence with the AckService so a
// concurrent acknowledgement cannot be silently overwritten, and so two
// rapid executions for the same target cannot both observe the old state
// and fire duplicate notifications.
// Serialize with AckService so concurrent updates can't wipe an ack or fire duplicates.
unlock := d.locker.Lock(exec.CheckerID, exec.Target, *userId)
defer unlock()
@ -123,9 +105,7 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
dec := decide(state, pref, newStatus, d.nowFn())
// Recovery and escalation invalidate any prior acknowledgement: the
// incident is either over or has gotten worse, so the user should be
// re-paged on the next non-suppressed alert.
// Recovery/escalation invalidates ack: incident is over or has worsened.
if dec.ClearAck {
state.ClearAcknowledgement()
}
@ -140,9 +120,7 @@ func (d *Dispatcher) OnExecutionComplete(exec *happydns.Execution, eval *happydn
payload := d.buildPayload(user, exec, eval, oldStatus, newStatus)
// Mark as notified before enqueuing so a rapid re-run of the checker
// observing the same transition will see oldStatus == newStatus and skip,
// even if the worker pool has not drained yet.
// Mark before enqueue so a rapid re-run sees oldStatus == newStatus and skips.
d.markNotified(state, newStatus)
for _, ch := range d.resolver.ResolveChannels(user, pref) {
@ -194,8 +172,7 @@ func (d *Dispatcher) buildPayload(user *happydns.User, exec *happydns.Execution,
}
}
// advanceState persists the new observed status without claiming a
// notification was sent. Used when policy suppresses the alert.
// Persist the observed status without claiming a notification was sent (policy suppressed it).
func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus happydns.Status) {
state.LastStatus = newStatus
if err := d.stateStore.PutState(state); err != nil {
@ -203,8 +180,6 @@ func (d *Dispatcher) advanceState(state *happydns.NotificationState, newStatus h
}
}
// markNotified persists the new status and stamps LastNotifiedAt. Called only
// after the dispatcher actually attempts to deliver a notification.
func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus happydns.Status) {
state.LastStatus = newStatus
state.LastNotifiedAt = d.nowFn()
@ -213,28 +188,22 @@ func (d *Dispatcher) markNotified(state *happydns.NotificationState, newStatus h
}
}
// SendTestNotification delegates to the Tester. Kept for backwards-compat with
// the existing controller wiring; new callers should depend on *Tester directly.
func (d *Dispatcher) SendTestNotification(ch *happydns.NotificationChannel, user *happydns.User) error {
return d.tester.Send(ch, user)
}
// AcknowledgeIssue delegates to the AckService.
func (d *Dispatcher) AcknowledgeIssue(userId happydns.Identifier, checkerID string, target happydns.CheckTarget, acknowledgedBy string, annotation string) error {
return d.ack.AcknowledgeIssue(userId, checkerID, target, acknowledgedBy, annotation)
}
// ClearAcknowledgement delegates to the AckService.
func (d *Dispatcher) ClearAcknowledgement(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) error {
return d.ack.ClearAcknowledgement(userId, checkerID, target)
}
// GetState delegates to the AckService.
func (d *Dispatcher) GetState(userId happydns.Identifier, checkerID string, target happydns.CheckTarget) (*happydns.NotificationState, error) {
return d.ack.GetState(userId, checkerID, target)
}
// ListStatesByUser delegates to the AckService.
func (d *Dispatcher) ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error) {
return d.ack.ListStatesByUser(userId)
}

View file

@ -27,40 +27,24 @@ import (
"git.happydns.org/happyDomain/model"
)
// decisionAction is the outcome of evaluating notification policy for a
// single status transition.
type decisionAction int
const (
// actionSkip: no observable transition; do not touch state, do not notify.
actionSkip decisionAction = iota
// actionAdvance: status changed but policy says do not notify; persist
// the new LastStatus only.
actionAdvance
// actionNotify: send the notification and stamp LastNotifiedAt.
actionNotify
)
// decision is a pure description of what to do with a status transition,
// independent of how we persist or dispatch it. The Reason is intended for
// logging and tests; callers must not branch on its exact text.
// Reason is for logging only — callers must not branch on its text.
type decision struct {
Action decisionAction
Reason string
IsRecovery bool
IsEscalation bool
// ClearAck is true when the transition resolves or worsens the incident
// enough that any prior acknowledgement should be discarded.
ClearAck bool
ClearAck bool
}
// decide is the pure policy predicate: given a transition and the user's
// preference for the target, return whether to skip, advance, or notify.
//
// A nil pref is treated as "no preference configured for this scope" and
// suppresses notifications (but still advances state on a real transition).
//
// `now` is injected so tests can pin the clock for quiet-hour checks.
// Pure predicate. nil pref means "no preference" — suppress notify, still advance state. now is injected for quiet-hour tests.
func decide(state *happydns.NotificationState, pref *happydns.NotificationPreference, newStatus happydns.Status, now time.Time) decision {
oldStatus := state.LastStatus
@ -94,9 +78,7 @@ func decide(state *happydns.NotificationState, pref *happydns.NotificationPrefer
d.Reason = "recovery suppressed by preference"
return d
}
// An active acknowledgement that hasn't just been cleared (above) means
// the user already knows: suppress repeat alerts. Recoveries always notify
// (subject to NotifyRecovery), so we only check on non-recovery here.
// Active ack means user already knows; recoveries skip this check.
if state.Acknowledged && !clearAck && !isRecovery {
d.Action = actionAdvance
d.Reason = "acknowledged"
@ -113,13 +95,18 @@ func decide(state *happydns.NotificationState, pref *happydns.NotificationPrefer
return d
}
// isQuietHour reports whether `now` (in UTC) falls within the preference's
// configured quiet window. Returns false when no window is configured.
func isQuietHour(pref *happydns.NotificationPreference, now time.Time) bool {
if pref.QuietStart == nil || pref.QuietEnd == nil {
return false
}
hour := now.UTC().Hour()
loc := time.UTC
if pref.Timezone != "" {
// Validated at write time; on a stale/invalid value we silently fall back to UTC rather than firing during what the user thinks are quiet hours.
if l, err := time.LoadLocation(pref.Timezone); err == nil {
loc = l
}
}
hour := now.In(loc).Hour()
start := *pref.QuietStart
end := *pref.QuietEnd
if start <= end {

View file

@ -202,3 +202,25 @@ func TestIsQuietHour(t *testing.T) {
})
}
}
func TestIsQuietHourTimezone(t *testing.T) {
// 02:30 UTC == 12:30 Asia/Tokyo (UTC+9), so a 9-17 quiet window in Tokyo should fire while UTC says off-hours.
now := time.Date(2026, 4, 29, 2, 30, 0, 0, time.UTC)
pref := &happydns.NotificationPreference{
QuietStart: ptr(9),
QuietEnd: ptr(17),
Timezone: "Asia/Tokyo",
}
if !isQuietHour(pref, now) {
t.Fatalf("expected quiet hour in Asia/Tokyo at local 11:30, got false")
}
pref.Timezone = ""
if isQuietHour(pref, now) {
t.Fatalf("expected non-quiet in UTC at 02:30, got true")
}
// Invalid TZ falls back to UTC.
pref.Timezone = "Not/AReal_Zone"
if isQuietHour(pref, now) {
t.Fatalf("expected fallback to UTC for invalid timezone, got quiet hour")
}
}

View file

@ -34,24 +34,15 @@ import (
)
const (
// dispatchWorkers is the number of goroutines draining the send queue.
dispatchWorkers = 4
// dispatchQueueSize bounds the number of in-flight dispatch jobs. On
// overflow, jobs are not silently dropped: an audit record is written so
// the user can see in their history that an alert was lost to back-pressure.
// On overflow, an audit record is written so back-pressure losses are visible in history.
dispatchQueueSize = 256
// sendTimeout caps a single Send call. Long enough for a polite remote,
// short enough that a wedged endpoint cannot starve workers.
// Caps a single Send so a wedged endpoint cannot starve workers.
sendTimeout = 15 * time.Second
// maxRecordErrorLen bounds the persisted error string so a verbose remote
// (or a misbehaving sender wrapping a giant body into the error) cannot
// bloat the audit log indefinitely.
// Bounds the persisted error to keep the audit log small.
maxRecordErrorLen = 512
)
// truncateError clips an error message to maxRecordErrorLen bytes for storage.
// The truncation marker is appended so operators reading history see that the
// message was cut rather than mistaking the prefix for the full error.
func truncateError(s string) string {
const marker = "…[truncated]"
if len(s) <= maxRecordErrorLen {
@ -60,16 +51,13 @@ func truncateError(s string) string {
return s[:maxRecordErrorLen-len(marker)] + marker
}
// dispatchJob is the unit of work consumed by the worker pool.
type dispatchJob struct {
channel *happydns.NotificationChannel
payload *notifPkg.NotificationPayload
user *happydns.User
}
// Pool is the asynchronous send fan-out. It owns the bounded job queue and a
// fixed worker pool that drives senders and persists audit records. The Pool
// has no policy of its own; deciding *whether* to enqueue is the caller's job.
// Async send fan-out; no policy — caller decides whether to enqueue.
type Pool struct {
registry *notifPkg.Registry
recordStore NotificationRecordStorage
@ -79,12 +67,10 @@ type Pool struct {
stopped atomic.Bool
stopOnce sync.Once
// nowFn is the clock used to stamp audit records. Overridable by
// same-package tests; defaults to time.Now.
// Overridable for tests.
nowFn func() time.Time
}
// NewPool builds a Pool. Workers are not started yet — call Start.
func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage) *Pool {
return &Pool{
registry: registry,
@ -94,7 +80,6 @@ func NewPool(registry *notifPkg.Registry, recordStore NotificationRecordStorage)
}
}
// Start spins up the workers. Must be called before Enqueue.
func (p *Pool) Start() {
for range dispatchWorkers {
p.wg.Add(1)
@ -102,9 +87,7 @@ func (p *Pool) Start() {
}
}
// Stop closes the queue and waits for in-flight sends to finish. Safe to call
// multiple times. After Stop, Enqueue is a no-op (returns false): callers that
// race a shutdown are not punished with a panic on send-to-closed-channel.
// Idempotent. Post-Stop, Enqueue is a no-op so a racing caller doesn't panic on send-to-closed-channel.
func (p *Pool) Stop() {
p.stopOnce.Do(func() {
p.stopped.Store(true)
@ -120,9 +103,7 @@ func (p *Pool) worker() {
}
}
// Enqueue submits a send for asynchronous delivery. Returns false if the pool
// is stopped or the queue is saturated; in the saturation case the failure is
// also persisted as an audit record so it surfaces in the user's history.
// On saturation, persists an audit record so the missed alert surfaces in history.
func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) bool {
if p.stopped.Load() {
return false
@ -132,9 +113,8 @@ func (p *Pool) Enqueue(ch *happydns.NotificationChannel, payload *notifPkg.Notif
case p.jobs <- job:
return true
default:
// Saturated: don't wedge the checker, but don't drop silently either —
// a missed alert is exactly the failure mode this audit log exists for.
log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %s (%q)", ch.Id, ch.Type)
// Saturated: record the miss rather than silently drop.
log.Printf("notification: dispatch queue full, recording back-pressure failure for channel %q (%q)", ch.Id, ch.Type)
p.recordSaturation(ch, payload, user)
return false
}
@ -149,12 +129,11 @@ func (p *Pool) recordSaturation(ch *happydns.NotificationChannel, payload *notif
}
}
// sendAndRecord runs the sender and writes an audit record for the result.
func (p *Pool) sendAndRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User) {
rec := newRecord(ch, payload, user, p.nowFn())
if err := p.runSend(ch, payload); err != nil {
log.Printf("notification: failed to send via %q channel %s: %v", ch.Type, ch.Id, err)
log.Printf("notification: failed to send via %q channel %q: %v", ch.Type, ch.Id, err)
rec.Success = false
rec.Error = truncateError(err.Error())
} else {
@ -180,8 +159,7 @@ func (p *Pool) runSend(ch *happydns.NotificationChannel, payload *notifPkg.Notif
return sender.Send(ctx, cfg, payload)
}
// newRecord stamps a NotificationRecord from a job. Success/Error are filled
// in by the caller after the actual send attempt.
// Caller fills Success/Error after the send attempt.
func newRecord(ch *happydns.NotificationChannel, payload *notifPkg.NotificationPayload, user *happydns.User, sentAt time.Time) *happydns.NotificationRecord {
return &happydns.NotificationRecord{
UserId: user.Id,

View file

@ -27,27 +27,21 @@ import (
"git.happydns.org/happyDomain/model"
)
// Resolver picks the active preference for a target and the channels that
// should carry an alert for that preference. It is read-only and depends on
// no transient state, so it is cheap to share between goroutines.
// Read-only, safe to share between goroutines.
type Resolver struct {
channelStore NotificationChannelStorage
prefStore NotificationPreferenceStorage
}
// NewResolver builds a Resolver bound to the channel and preference stores.
func NewResolver(channelStore NotificationChannelStorage, prefStore NotificationPreferenceStorage) *Resolver {
return &Resolver{channelStore: channelStore, prefStore: prefStore}
}
// ResolvePreference returns the most specific preference for the target.
// Specificity is service > domain > global. When the user has no matching
// preference, a synthesized default (see happydns.DefaultNotificationPreference)
// is returned so opt-in defaults flow through the policy unchanged.
// Specificity service > domain > global; falls back to DefaultNotificationPreference so opt-in defaults flow through.
func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckTarget) *happydns.NotificationPreference {
prefs, err := r.prefStore.ListPreferencesByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load preferences for user %s: %v", user.Id, err)
log.Printf("notification: failed to load preferences for user %q: %v", user.Id, err)
return happydns.DefaultNotificationPreference()
}
@ -66,12 +60,10 @@ func (r *Resolver) ResolvePreference(user *happydns.User, target happydns.CheckT
return best
}
// ResolveChannels returns the user's enabled channels, narrowed to the
// preference's allow-list when one is configured.
func (r *Resolver) ResolveChannels(user *happydns.User, pref *happydns.NotificationPreference) []*happydns.NotificationChannel {
allChannels, err := r.channelStore.ListChannelsByUser(user.Id)
if err != nil {
log.Printf("notification: failed to load channels for user %s: %v", user.Id, err)
log.Printf("notification: failed to load channels for user %q: %v", user.Id, err)
return nil
}

View file

@ -28,15 +28,7 @@ import (
"git.happydns.org/happyDomain/model"
)
// StateLocker serialises read-modify-write sequences against
// NotificationStateStorage on a per-(checker, target, user) basis. The
// dispatcher and the AckService both update state for the same key; without a
// shared lock, two concurrent updates would race and the last writer wins,
// which can wipe an acknowledgement or fire a duplicate notification.
//
// The lock is in-process; multi-replica deployments would need the storage
// layer itself to provide CAS or per-key serialisation. happyDomain runs as a
// single instance, so this is sufficient and is the cheapest correct option.
// Per-key serialization for state read-modify-write; without it, ack and dispatcher races wipe acks or fire duplicates. In-process only — single instance.
type StateLocker struct {
mu sync.Mutex
locks map[string]*stateLockEntry
@ -47,13 +39,11 @@ type stateLockEntry struct {
refCount int
}
// NewStateLocker returns a fresh locker with no contended keys.
func NewStateLocker() *StateLocker {
return &StateLocker{locks: make(map[string]*stateLockEntry)}
}
// Lock acquires the per-key mutex and returns an unlock function. Always defer
// the returned function — leaking it would leave the entry pinned in the map.
// Always defer the returned unlock — leaking it pins the map entry.
func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) func() {
key := stateLockKey(checkerID, target, userId)
@ -80,9 +70,7 @@ func (l *StateLocker) Lock(checkerID string, target happydns.CheckTarget, userId
}
}
// stateLockKey mirrors the storage key shape for notification state, so that
// the dispatcher and the AckService end up serialised on the exact tuple they
// will read/write. Mismatch here would silently re-introduce the race.
// Must match the storage tuple exactly; mismatch silently re-introduces the race.
func stateLockKey(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) string {
return fmt.Sprintf(
"%s|%s|%s/%s/%s",

View file

@ -27,7 +27,6 @@ import (
"git.happydns.org/happyDomain/model"
)
// NotificationChannelStorage provides persistence for notification channels.
type NotificationChannelStorage interface {
ListChannelsByUser(userId happydns.Identifier) ([]*happydns.NotificationChannel, error)
GetChannel(channelId happydns.Identifier) (*happydns.NotificationChannel, error)
@ -36,7 +35,6 @@ type NotificationChannelStorage interface {
DeleteChannel(channelId happydns.Identifier) error
}
// NotificationPreferenceStorage provides persistence for notification preferences.
type NotificationPreferenceStorage interface {
ListPreferencesByUser(userId happydns.Identifier) ([]*happydns.NotificationPreference, error)
GetPreference(prefId happydns.Identifier) (*happydns.NotificationPreference, error)
@ -45,7 +43,6 @@ type NotificationPreferenceStorage interface {
DeletePreference(prefId happydns.Identifier) error
}
// NotificationStateStorage provides persistence for notification state tracking.
type NotificationStateStorage interface {
GetState(checkerID string, target happydns.CheckTarget, userId happydns.Identifier) (*happydns.NotificationState, error)
PutState(state *happydns.NotificationState) error
@ -53,19 +50,16 @@ type NotificationStateStorage interface {
ListStatesByUser(userId happydns.Identifier) ([]*happydns.NotificationState, error)
}
// NotificationRecordStorage provides persistence for notification audit records.
type NotificationRecordStorage interface {
CreateRecord(rec *happydns.NotificationRecord) error
ListRecordsByUser(userId happydns.Identifier, limit int) ([]*happydns.NotificationRecord, error)
DeleteRecordsOlderThan(before time.Time) error
}
// UserGetter is a narrow interface for loading users in the notification context.
type UserGetter interface {
GetUser(id happydns.Identifier) (*happydns.User, error)
}
// DomainGetter is a narrow interface for loading domains in the notification context.
type DomainGetter interface {
GetDomain(id happydns.Identifier) (*happydns.Domain, error)
}

View file

@ -28,20 +28,15 @@ import (
"git.happydns.org/happyDomain/model"
)
// Tester sends synthetic notifications synchronously. It bypasses preferences,
// state, quiet hours, and the worker pool by design: the user explicitly asked
// to verify a single channel and wants the result inline.
// Synchronous and bypasses preferences/state/quiet-hours — user explicitly verifying one channel.
type Tester struct {
registry *notifPkg.Registry
}
// NewTester builds a Tester bound to the sender registry.
func NewTester(registry *notifPkg.Registry) *Tester {
return &Tester{registry: registry}
}
// Send delivers a test notification through the given channel and returns the
// sender error directly so the caller (typically the API) can surface it.
func (t *Tester) Send(ch *happydns.NotificationChannel, user *happydns.User) error {
sender, ok := t.registry.Get(ch.Type)
if !ok {

View file

@ -26,144 +26,69 @@ import (
"time"
)
// NotificationChannelType identifies the transport used to deliver a notification.
// Values are owned by sender implementations; the model does not enumerate them.
type NotificationChannelType string
// NotificationChannel represents a single configured notification destination.
// A user may have any number of channels, including multiple of the same Type
// (e.g. two emails to different addresses, or two webhooks to different URLs).
// Config is opaque to the model: it is decoded by the sender registered for Type.
// Config is opaque to the model: decoded by the sender registered for Type.
type NotificationChannel struct {
// Id is the channel's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the owner of the channel.
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
// Type is the transport type. Must match a registered sender.
Type NotificationChannelType `json:"type" binding:"required"`
// Name is a human-readable label for the channel.
Name string `json:"name"`
// Enabled controls whether notifications are sent through this channel.
Enabled bool `json:"enabled"`
// Config is the channel-type-specific configuration as raw JSON.
// Its shape is defined by the sender for Type.
Config json.RawMessage `json:"config" swaggertype:"object"`
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
Type NotificationChannelType `json:"type" binding:"required"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config" swaggertype:"object"`
}
// NotificationPreference controls what notifications a user receives for a given scope.
// Scope resolution: ServiceId set > DomainId set > global (both nil).
type NotificationPreference struct {
// Id is the preference's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the owner of the preference.
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
// DomainId, if set, scopes this preference to a specific domain.
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
// ServiceId, if set, scopes this preference to a specific service.
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
// ChannelIds restricts which channels to use. Empty means all enabled channels.
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
// MinStatus is the minimum severity that triggers a notification.
MinStatus Status `json:"minStatus"`
// NotifyRecovery controls whether recovery (back to OK) notifications are sent.
NotifyRecovery bool `json:"notifyRecovery"`
// QuietStart is the start hour (0-23, UTC) of a quiet window.
QuietStart *int `json:"quietStart,omitempty"`
// QuietEnd is the end hour (0-23, UTC) of a quiet window.
QuietEnd *int `json:"quietEnd,omitempty"`
// Enabled is the master switch for this preference scope.
Enabled bool `json:"enabled"`
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string" readonly:"true"`
DomainId *Identifier `json:"domainId,omitempty" swaggertype:"string"`
ServiceId *Identifier `json:"serviceId,omitempty" swaggertype:"string"`
// Empty means all enabled channels.
ChannelIds []Identifier `json:"channelIds,omitempty" swaggertype:"array,string"`
MinStatus Status `json:"minStatus"`
NotifyRecovery bool `json:"notifyRecovery"`
// Hours 0-23, interpreted in Timezone (IANA name; empty means UTC).
QuietStart *int `json:"quietStart,omitempty"`
QuietEnd *int `json:"quietEnd,omitempty"`
Timezone string `json:"timezone,omitempty"`
Enabled bool `json:"enabled"`
}
// NotificationState tracks the last notified status for a (checker, target, user) tuple.
// Used for deduplication: only state transitions trigger notifications.
type NotificationState struct {
// CheckerID identifies the checker.
CheckerID string `json:"checkerId"`
// Target is the checked scope.
Target CheckTarget `json:"target"`
// UserId is the user who owns the target.
UserId Identifier `json:"userId" swaggertype:"string"`
// LastStatus is the status from the last notification.
LastStatus Status `json:"lastStatus"`
// LastNotifiedAt is when the last notification was sent.
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
// Acknowledged indicates the user has acknowledged the current issue.
Acknowledged bool `json:"acknowledged"`
// AcknowledgedAt is when the issue was acknowledged.
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
// AcknowledgedBy describes who acknowledged (user email or "api").
CheckerID string `json:"checkerId"`
Target CheckTarget `json:"target"`
UserId Identifier `json:"userId" swaggertype:"string"`
LastStatus Status `json:"lastStatus"`
LastNotifiedAt time.Time `json:"lastNotifiedAt" format:"date-time"`
Acknowledged bool `json:"acknowledged"`
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty" format:"date-time"`
// User email or "api".
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
// Annotation is a user-provided note on the acknowledgement.
Annotation string `json:"annotation,omitempty"`
Annotation string `json:"annotation,omitempty"`
}
// NotificationRecord logs a sent notification for audit purposes.
type NotificationRecord struct {
// Id is the record's unique identifier.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
// UserId is the recipient user.
UserId Identifier `json:"userId" swaggertype:"string"`
// ChannelType is the transport used.
Id Identifier `json:"id" swaggertype:"string" readonly:"true"`
UserId Identifier `json:"userId" swaggertype:"string"`
ChannelType NotificationChannelType `json:"channelType"`
// ChannelId is the channel through which the notification was sent.
ChannelId Identifier `json:"channelId" swaggertype:"string"`
// CheckerID is the checker that triggered the notification.
CheckerID string `json:"checkerId"`
// Target is the checked scope.
Target CheckTarget `json:"target"`
// OldStatus is the previous status before the transition.
OldStatus Status `json:"oldStatus"`
// NewStatus is the new status that triggered the notification.
NewStatus Status `json:"newStatus"`
// SentAt is when the notification was dispatched.
SentAt time.Time `json:"sentAt" format:"date-time"`
// Success indicates whether the send succeeded.
Success bool `json:"success"`
// Error holds the error message if the send failed.
Error string `json:"error,omitempty"`
ChannelId Identifier `json:"channelId" swaggertype:"string"`
CheckerID string `json:"checkerId"`
Target CheckTarget `json:"target"`
OldStatus Status `json:"oldStatus"`
NewStatus Status `json:"newStatus"`
SentAt time.Time `json:"sentAt" format:"date-time"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// AcknowledgeRequest is the JSON body for acknowledging a checker issue.
type AcknowledgeRequest struct {
Annotation string `json:"annotation,omitempty"`
}
// ClearAcknowledgement resets the acknowledgement fields in place. Used both
// when a user explicitly clears an ack and when a transition (recovery or
// escalation) invalidates the existing one.
// Called both on explicit user clear and when a transition invalidates the ack.
func (s *NotificationState) ClearAcknowledgement() {
s.Acknowledged = false
s.AcknowledgedAt = nil
@ -171,13 +96,7 @@ func (s *NotificationState) ClearAcknowledgement() {
s.Annotation = ""
}
// DefaultNotificationPreference returns the implicit preference applied when
// a user has no matching preference configured for a target. It opts the user
// in to notifications at Warn and above on all of their enabled channels, so
// a freshly-onboarded user receives alerts without having to author a rule.
//
// The returned value has a zero Id and UserId; it is not persisted. Callers
// that need user-attribution should set UserId before use.
// Implicit fallback when no preference is configured: opt-in at Warn+ on all enabled channels. Returned with zero Id/UserId; not persisted.
func DefaultNotificationPreference() *NotificationPreference {
return &NotificationPreference{
MinStatus: StatusWarn,
@ -186,12 +105,7 @@ func DefaultNotificationPreference() *NotificationPreference {
}
}
// MatchesTarget reports the specificity of this preference for the given
// target: 2 for an exact service match, 1 for a domain-scoped match, 0 for a
// global preference, or -1 if this preference does not apply to the target.
//
// CheckTarget carries Domain/Service ids as plain strings while preferences
// hold *Identifier; this method is the single place that bridges the two.
// Returns 2 service / 1 domain / 0 global / -1 no-match.
func (p *NotificationPreference) MatchesTarget(target CheckTarget) int {
if p.ServiceId != nil {
if p.ServiceId.String() == target.ServiceId {

View file

@ -43,9 +43,7 @@ import type {
} from "$lib/api-base/types.gen";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
// The OpenAPI spec models Config as a free-form object, so the generated
// Writable types omit it. We extend them with Config to keep the wrapper
// callers honest about per-type config payloads.
// Extend Writable types with Config — OpenAPI models it as free-form so the generated types omit it.
export type NotificationChannelInput = HappydnsNotificationChannelWritable & {
config?: Record<string, unknown>;
};

View file

@ -63,9 +63,7 @@
let saving: boolean = $state(false);
let revealedSecrets: Record<string, boolean> = $state({});
// Reset form whenever the editor opens. We only depend on `open` so the
// reset runs exactly once per opening; other props are read via untrack
// to avoid retriggering on incidental updates (e.g. types loading).
// Reset only on open transition; other props read via untrack so type loading doesn't retrigger.
$effect(() => {
if (!open) return;
untrack(() => {
@ -90,8 +88,7 @@
});
function onTypeChange() {
// When the user picks a different type, reset config to the defaults
// for the new schema so we never send fields that don't apply.
// Reset to schema defaults so we never send fields that don't apply.
const schema = getChannelConfigSchema(type);
config = schema ? emptyConfigForSchema(schema) : {};
rawJson = JSON.stringify(config, null, 2);

View file

@ -19,8 +19,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// FieldKind drives ChannelEditor's per-field rendering. Keep the set small;
// anything more exotic should fall through to the generic JSON editor.
// Keep the set small — anything exotic falls through to the raw JSON editor.
export type FieldKind = "text" | "url" | "secret" | "headers";
export interface ChannelConfigField {
@ -35,11 +34,7 @@ export interface ChannelConfigSchema {
fields: ChannelConfigField[];
}
// Known transports shipped today. Field names match the JSON tags on
// EmailConfig / WebhookConfig / UnifiedPushConfig in
// internal/notification/*_sender.go. Adding a new sender on the backend
// only requires a one-file change here, otherwise the editor falls back
// to a raw JSON textarea.
// Field names mirror JSON tags in internal/notification/*_sender.go.
export const CHANNEL_CONFIG_SCHEMAS: Record<string, ChannelConfigSchema> = {
email: {
fields: [