mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
notifyd reached github.com/hanzoai/kms/sdk/go/kmsclient transitively through
base v1.5.8's plugins/platform. That repo is archived and renamed (kms-v1), so
the module resolves only from the proxy's immutable cache: it cannot take a
push, therefore it cannot take a security fix. Holding secrets code there is
the worst version of that problem.
base v1.5.16 already speaks native ZAP to luxfi/kms, so the bump removes the
dependency outright rather than pinning a patched copy of it. Nothing here
imported the SDK directly; the whole reach was through base.
Two changes the bump forces:
- hanzoai/tasks v1.52.9 (required by base v1.5.16) renamed client.Options
.HostPort to .Address. Same meaning, and it now also accepts a unix socket
path.
- KMSEndpoint is no longer handed to the platform plugin. Base rejects an
http(s) KMS endpoint outright, while notify's own kmsbridge deliberately
rewrites zap:// to http:// because it speaks HTTP. Feeding base the
normalized HTTP endpoint would panic MustRegister at boot. Unset, base uses
its in-cluster ZAP default; notify's own secret reads are untouched.
go vet and the full test suite are clean before and after under CGO_ENABLED=0,
which is how Base ships and now enforces at runtime: 50 packages and 133 test
names, identical on both sides.
Co-authored-by: Hanzo Dev <[email protected]>
181 lines
6.4 KiB
Go
181 lines
6.4 KiB
Go
// notifyd — Hanzo Notify daemon.
|
|
//
|
|
// One binary. One process. One listener.
|
|
//
|
|
// ingress → gateway → notifyd
|
|
//
|
|
// Storage + auth + HTTP router come from hanzoai/base. The library at
|
|
// the module root (github.com/hanzoai/notify) provides the 33 provider
|
|
// impls; this binary wraps it as a per-tenant service with metering,
|
|
// templates, an event catalog, and a hanzoai/tasks-backed async path.
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/plugins/platform"
|
|
|
|
"github.com/hanzoai/notify/internal/boot"
|
|
"github.com/hanzoai/notify/internal/kmsbridge"
|
|
"github.com/hanzoai/notify/internal/routes"
|
|
"github.com/hanzoai/notify/internal/schema"
|
|
"github.com/hanzoai/notify/internal/tasks"
|
|
"github.com/hanzoai/notify/internal/tenant"
|
|
"github.com/hanzoai/notify/internal/unsubscribe"
|
|
)
|
|
|
|
func main() {
|
|
app := base.New()
|
|
|
|
// IAM is an HTTP service, so its endpoint still goes through the
|
|
// normalization seam (kmsbridge.NormalizeEndpoint).
|
|
iamEndpoint := mustNormalize("IAM_ENDPOINT", envOr("IAM_ENDPOINT", "https://hanzo.id"))
|
|
|
|
// Platform plugin: IAM JWT validation, X-Org-Id injection, KMS
|
|
// helpers. Same registration shape as hanzoai/auto.
|
|
//
|
|
// KMSEndpoint is deliberately left unset. Base speaks native ZAP to
|
|
// luxfi/kms and rejects an http(s) endpoint outright, while notify's own
|
|
// kmsbridge speaks HTTP — so the normalized HTTP endpoint that the rest of
|
|
// this process uses is exactly what must NOT be handed to base. Unset,
|
|
// base uses its own in-cluster ZAP default.
|
|
platform.MustRegister(app, platform.PlatformConfig{
|
|
IAMEndpoint: iamEndpoint,
|
|
IAMClientID: os.Getenv("IAM_CLIENT_ID"),
|
|
IAMClientSecret: os.Getenv("IAM_CLIENT_SECRET"),
|
|
IAMApp: envOr("IAM_APP", "hanzo-notify"),
|
|
})
|
|
|
|
schema.MustRegister(app)
|
|
|
|
// KMS client. Nil when KMS_ENDPOINT is unset — the resolver then
|
|
// falls back to env-var credentials, which is the local-dev path.
|
|
kmsClient := boot.NewKMSClient()
|
|
resolver := tenant.New(app, kmsClient)
|
|
// Multi-brand Plivo resolver. Only constructed when KMS is wired —
|
|
// the per-brand fallback to the Hanzo default depends on KMS
|
|
// reads. Nil here means the /v1/notify/brand/plivo* surface returns
|
|
// 503 (the routes guard for this) which is the right local-dev
|
|
// behaviour.
|
|
var plivoResolver *tenant.PlivoResolver
|
|
if kmsClient != nil {
|
|
if pr, err := tenant.NewPlivoResolver(kmsClient); err != nil {
|
|
log.Fatalf("notifyd: plivo resolver: %v", err)
|
|
} else {
|
|
plivoResolver = pr
|
|
}
|
|
}
|
|
// Multi-provider chain resolver. KMS-only — every chain provider
|
|
// (Plivo, Twilio, SES, SendGrid) MUST be brand-scoped at runtime,
|
|
// so we never construct it from env vars. When KMS is absent we
|
|
// ship without the chain and Activities falls back to the single-
|
|
// provider tenant.Resolver path (env creds, useful only for local
|
|
// dev).
|
|
var chainResolver *tenant.ChainResolver
|
|
if kmsClient != nil {
|
|
if cr, err := tenant.NewChainResolver(kmsClient); err != nil {
|
|
log.Fatalf("notifyd: chain resolver: %v", err)
|
|
} else {
|
|
chainResolver = cr
|
|
}
|
|
}
|
|
activities := tasks.NewActivitiesWithChain(app, resolver, chainResolver)
|
|
|
|
// Tasks worker. TASKS_ADDR empty → no async — POST /v1/notify/send
|
|
// without ?sync=true then returns 503. This is intentional: per
|
|
// hanzoai/tasks/CONTRACT.md §3, production MUST set TASKS_ADDR; a
|
|
// silent sync fallback would mask the misconfiguration.
|
|
var worker *tasks.Worker
|
|
if addr := os.Getenv("TASKS_ADDR"); addr != "" {
|
|
w, err := tasks.New(tasks.Config{
|
|
Address: addr,
|
|
// CONTRACT.md §6: namespace = per-tenant org slug. Notify is
|
|
// multi-tenant in principle but ships with one shared worker
|
|
// today; per-org namespaces land in the follow-up once the
|
|
// hanzo tenant traffic scales (tracked: hanzoai/notify#TODO).
|
|
Namespace: envOr("TASKS_NAMESPACE", "default"),
|
|
TaskQueue: envOr("TASKS_QUEUE", "notify-send"),
|
|
}, activities)
|
|
if err != nil {
|
|
log.Fatalf("notifyd: worker: %v", err)
|
|
}
|
|
worker = w
|
|
|
|
// Lifecycle: Start at serve time (after migrations), Stop at
|
|
// terminate. Failure to start is non-fatal so reads / sync sends
|
|
// keep working; async sends then return 503 until tasksd is back.
|
|
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
if err := worker.Start(); err != nil {
|
|
app.Logger().Warn("notifyd: worker start failed; async sends will 503",
|
|
"err", err)
|
|
}
|
|
return e.Next()
|
|
})
|
|
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
|
|
worker.Stop()
|
|
return e.Next()
|
|
})
|
|
}
|
|
|
|
// dispatcher is the typed interface routes/send.go reads. Passing a
|
|
// (*tasks.Worker)(nil) into an interface field is NOT the same as a
|
|
// nil interface — guard against the typed-nil trap explicitly so the
|
|
// route's `cfg.Dispatcher == nil` check works as intended.
|
|
var dispatcher tasks.Dispatcher
|
|
if worker != nil {
|
|
dispatcher = worker
|
|
}
|
|
|
|
// Unsubscribe HMAC signer. Required for the §5.1 one-click email
|
|
// unsubscribe endpoints. Fail-closed boot in production: empty
|
|
// NOTIFY_UNSUBSCRIBE_SECRET → crash so the misconfiguration is
|
|
// loud. Test mode (the route test harness sets NOTIFY_TEST_MODE)
|
|
// degrades to nil signer; the routes then return 503 for the
|
|
// unsubscribe paths only, leaving the rest of the surface working.
|
|
var unsubSigner *unsubscribe.Signer
|
|
if secret := os.Getenv("NOTIFY_UNSUBSCRIBE_SECRET"); secret != "" {
|
|
s, err := unsubscribe.NewSigner(secret)
|
|
if err != nil {
|
|
log.Fatalf("notifyd: unsubscribe signer: %v", err)
|
|
}
|
|
unsubSigner = s
|
|
} else if os.Getenv("NOTIFY_TEST_MODE") == "" {
|
|
log.Fatalf("notifyd: NOTIFY_UNSUBSCRIBE_SECRET is required (set NOTIFY_TEST_MODE=1 to skip)")
|
|
}
|
|
|
|
routes.MustRegister(app, routes.Config{
|
|
Resolver: resolver,
|
|
ChainResolver: chainResolver,
|
|
Dispatcher: dispatcher,
|
|
KMSClient: kmsClient,
|
|
PlivoResolver: plivoResolver,
|
|
UnsubscribeSigner: unsubSigner,
|
|
})
|
|
|
|
if err := app.Start(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// envOr returns the env var if set, otherwise the default.
|
|
func envOr(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
// mustNormalize resolves an endpoint through kmsbridge.NormalizeEndpoint
|
|
// and crashes loudly on a bad value — a malformed KMS/IAM endpoint is a
|
|
// boot-time misconfiguration, not something to discover at first send.
|
|
func mustNormalize(name, raw string) string {
|
|
v, err := kmsbridge.NormalizeEndpoint(raw)
|
|
if err != nil {
|
|
log.Fatalf("notifyd: %s: %v", name, err)
|
|
}
|
|
return v
|
|
}
|