mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
Substrate for the notification-preferences-and-channel-routing paper.
WhatsApp + push are Phase 2/3 — Channel constants for `wa` and `push`
are declared so the wire shape is forward-compatible, but the router
never returns them in Phase 1.
Substrates landed in this PR:
1. Data model — 3 new collections wired through schema/schema.go:
notification_preferences (user-scoped channel + marketing prefs),
notification_consent_log (append-only audit, FINRA 5y retention),
notification_unsubscribe_tokens (consumable HMAC tokens, PK = token).
2. Channel router — internal/router/ is a pure (prefs, category, now)
→ []channel function: no IO, no DB reads. Table-driven test walks
every cell in §3 of the paper (11 categories × 5 channels = 55
cells); the matrix copy in the test is the canonical guard against
future drift.
3. CRUD API — routes/preferences.go: GET/PUT /v1/notify/preferences
(user reads + writes own; auto-creates default row on first GET;
PUT writes prior+new state to consent log), GET /audit, plus
admin freeze/unfreeze and audit under /v1/notify/admin/... gated by
NOTIFY_ADMIN_KEY Bearer.
4. One-click email unsubscribe — internal/unsubscribe/ HMAC-SHA256
token (user_id|category|expires_at_unix) base64url-encoded;
routes/unsubscribe.go renders a no-JS confirmation page, flips
the bit on POST, mirrors with /resubscribe/{token}. Token is the
auth — the HMAC means only the email recipient can reach the
endpoint. Default 30-day TTL.
5. List-Unsubscribe header builder — internal/marketing/ produces
the RFC 8058 + RFC 2369 header pair callers inject into outbound
marketing emails. Decoupled from the existing send path so the
SES SendEmail → SendRawEmail switch can land independently.
6. STOP keyword webhook — routes/sms_inbound.go single endpoint
accepts both Plivo and Twilio (same logical payload; we read
From/Text and From/Body), matches STOP|UNSUBSCRIBE|QUIT|CANCEL|END
case-insensitive on the trimmed body, resolves by primary_phone
(E.164-normalised: strip whitespace/dashes/parens, prepend '+'),
sets marketing_globally_muted=true, zeros every marketing
subscription, writes opt_out consent_log row, replies with a
Twilio/Plivo-shaped XML confirmation envelope.
Defaults: every new user starts with every marketing category OFF;
transactional categories always deliver via email per regulation;
quiet hours 21:00–08:00 America/New_York (TCPA-compliant baseline).
Boot fail-closed: NOTIFY_UNSUBSCRIBE_SECRET is required in
production; empty crashes notifyd. NOTIFY_TEST_MODE=1 degrades the
unsubscribe routes to 503 while the rest of the surface keeps
working.
The existing /v1/notify/send surface and ProviderChain/Resolver/
PlivoResolver are not modified — every new piece composes on top.
LLM.md updated with the Phase 1 invariants + new route table rows.
99 lines
3.5 KiB
Go
99 lines
3.5 KiB
Go
// Package routes mounts Hanzo Notify's /v1/notify/* HTTP surface on
|
|
// top of hanzoai/base's router.
|
|
//
|
|
// The platform plugin handles auth (IAM JWT validation) and org-header
|
|
// injection (X-Org-Id from JWT owner claim). This package adds the
|
|
// notify-specific endpoints: send, message lookup, providers, templates,
|
|
// events, metering.
|
|
package routes
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/core"
|
|
|
|
"github.com/hanzoai/notify/internal/kmsbridge"
|
|
"github.com/hanzoai/notify/internal/tasks"
|
|
"github.com/hanzoai/notify/internal/tenant"
|
|
"github.com/hanzoai/notify/internal/unsubscribe"
|
|
)
|
|
|
|
// Config wires the routes module to its collaborators.
|
|
type Config struct {
|
|
// Resolver constructs per-tenant Notifier instances on demand —
|
|
// the single-provider, pin-an-id path. Kept alive for ?provider=
|
|
// requests and as the local-dev fallback when KMS is unwired.
|
|
Resolver *tenant.Resolver
|
|
|
|
// ChainResolver constructs the per-channel multi-provider chain
|
|
// (primary → fallback1 → fallback2). When non-nil and the caller
|
|
// did NOT pin a provider, the sync handler runs through the chain
|
|
// and records the per-attempt trace on the message row.
|
|
ChainResolver *tenant.ChainResolver
|
|
|
|
// Dispatcher enqueues async sends. Nil means async dispatch is not
|
|
// available; POST /v1/notify/send without ?sync=true then returns
|
|
// 503. The binary owns the worker's Start/Stop lifecycle; this
|
|
// package only reads the dispatch surface.
|
|
Dispatcher tasks.Dispatcher
|
|
|
|
// KMSClient is the KMS facade. Required by the brand override
|
|
// endpoints (/v1/notify/brand/plivo*). Nil → those endpoints return
|
|
// 503 (local-dev path).
|
|
KMSClient *kmsbridge.Client
|
|
|
|
// PlivoResolver handles per-brand Plivo credential resolution with
|
|
// fallback to the Hanzo default. Nil → /v1/notify/brand/plivo*
|
|
// returns 503.
|
|
PlivoResolver *tenant.PlivoResolver
|
|
|
|
// UnsubscribeSigner mints + verifies HMAC tokens for the one-click
|
|
// email unsubscribe surface (§5.1 of the notification-preferences
|
|
// paper). Nil → /v1/notify/unsubscribe and /resubscribe return 503.
|
|
UnsubscribeSigner *unsubscribe.Signer
|
|
}
|
|
|
|
// MustRegister installs the notify API on app's OnServe hook. The hook
|
|
// fires once, when the HTTP server starts.
|
|
func MustRegister(app *base.Base, cfg Config) {
|
|
if cfg.Resolver == nil {
|
|
panic("routes: Config.Resolver is required")
|
|
}
|
|
|
|
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
mountHealth(e.Router)
|
|
mountSend(e.Router, app, cfg)
|
|
mountMessages(e.Router, app)
|
|
mountProviders(e.Router, app)
|
|
mountTemplates(e.Router, app)
|
|
mountEvents(e.Router, app)
|
|
mountMetering(e.Router, app)
|
|
mountTenants(e.Router, app)
|
|
MountBrandPlivo(e.Router, app, cfg.KMSClient, cfg.PlivoResolver)
|
|
// Phase 1 — notification-preferences paper.
|
|
mountPreferences(e.Router, app)
|
|
mountUnsubscribe(e.Router, app, cfg.UnsubscribeSigner)
|
|
mountSMSInbound(e.Router, app)
|
|
return e.Next()
|
|
})
|
|
}
|
|
|
|
// orgFromRequest reads the X-Org-Id header. The platform plugin sets
|
|
// this whenever a JWT is validated; if it's missing the route returns
|
|
// 401 since the request is unauthenticated.
|
|
func orgFromRequest(e *core.RequestEvent) (string, error) {
|
|
org := e.Request.Header.Get("X-Org-Id")
|
|
if org == "" {
|
|
return "", e.JSON(http.StatusUnauthorized, map[string]string{
|
|
"error": "X-Org-Id header missing — request is unauthenticated",
|
|
})
|
|
}
|
|
return org, nil
|
|
}
|
|
|
|
// jsonOK is a small wrapper that mirrors auto's helper.
|
|
func jsonOK(e *core.RequestEvent, body any) error {
|
|
return e.JSON(http.StatusOK, body)
|
|
}
|