Files
zeekayandHanzo Dev bc7c81f256 deps: leave the archived KMS SDK behind with hanzoai/base v1.5.16
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]>
2026-08-06 00:38:27 -07:00

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
}