Files
mathiasandClaude Opus 5.5 f65f0b7826
CI / Lint / Test / Vet (push) Successful in 1m13s
CI / Mirror to GitHub (push) Successful in 4s
fix(audit): each ntfy alert source sends its own title (#163)
Every alert was titled "brain-capture audit degraded", including the
symlink tripwire's and governance-edit alerts. NtfyNotifier.WithTitle
returns a titled copy (nil stays nil); buildNotifier(getenv, title) is
called per source: capture audit (unchanged title), "brain symlink
tripwire", "brain governance edit". Tests assert the Title header.

Fixes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:28:38 +02:00

87 lines
2.6 KiB
Go

package audit
import (
"context"
"fmt"
"net/http"
"strings"
"time"
)
// NtfyNotifier posts alerts to an ntfy topic URL. Used to surface a
// degraded audit state (records buffered locally during a loki outage).
type NtfyNotifier struct {
topicURL string
token string
title string
http *http.Client
}
// defaultNtfyTitle is the title of a notifier built without WithTitle: the
// capture audit's degraded-state alert, its first user.
const defaultNtfyTitle = "brain-capture audit degraded"
// NewNtfyNotifier constructs a notifier for the given ntfy topic URL
// (e.g. https://ntfy.sh/my-topic). token is an optional bearer for
// protected ntfy instances; it is held here and only sent in the
// Authorization header, never logged. Returns nil when topicURL is empty.
func NewNtfyNotifier(topicURL, token string) *NtfyNotifier {
if topicURL == "" {
return nil
}
return &NtfyNotifier{
topicURL: strings.TrimRight(topicURL, "/"),
token: token,
title: defaultNtfyTitle,
http: &http.Client{Timeout: 10 * time.Second},
}
}
// WithTitle returns a copy that sends title as the ntfy Title header, so each
// alert source (the capture audit, the symlink tripwire, governance edits)
// is recognisable on the phone (hyperguild#163). A nil notifier (ntfy not
// configured) stays nil.
func (n *NtfyNotifier) WithTitle(title string) *NtfyNotifier {
if n == nil {
return nil
}
c := *n
c.title = title
return &c
}
// Notify posts a message to the ntfy topic.
func (n *NtfyNotifier) Notify(ctx context.Context, msg string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.topicURL, strings.NewReader(msg))
if err != nil {
return err
}
req.Header.Set("Title", n.title)
req.Header.Set("Priority", "high")
req.Header.Set("Tags", "warning,brain")
if n.token != "" {
req.Header.Set("Authorization", "Bearer "+n.token)
}
resp, err := n.http.Do(req)
if err != nil {
return fmt.Errorf("ntfy notify: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &RejectedError{Status: resp.StatusCode}
}
return nil
}
// RejectedError is a non-2xx ntfy response.
type RejectedError struct{ Status int }
func (e *RejectedError) Error() string { return fmt.Sprintf("ntfy notify: status %d", e.Status) }
// Permanent reports whether resending the same message cannot succeed: a 4xx
// other than 429 (rate limited). Callers may drop such a message; anything
// else (5xx, 429, transport errors) is an outage and must be retried.
func (e *RejectedError) Permanent() bool {
return e.Status >= 400 && e.Status < 500 && e.Status != http.StatusTooManyRequests
}