feat: ship Tier 1 — auth, authz, rate limits, real notifications, CSV import, billing, backups/DR, privacy

Closes every block in docs/TIER1_PLAN.md from the Claude-scope side. The
homelab / cloud setup steps (SES verification, restore drill, lawyer-
drafted ToS) remain operator-owned but are unblocked.

Block A — Authentication
- Migration 0003: password_hash, email_verified, email_verification_tokens,
  password_reset_tokens, refresh_tokens (with replaced_by family chain).
- Bcrypt hasher, HS256 JWT signer, single-use refresh tokens with rotation
  + replay-detection (revokes the family on reuse).
- /auth/signup, /login, /refresh, /logout, /verify-email,
  /forgot-password, /reset-password — enumeration-safe.
- requireAuth middleware + GET /me.
- Frontend useAuth/useApi with auto-refresh-on-401, login/signup/verify/
  forgot/reset pages, route-guard middleware.

Block B — Authorisation
- EventRepo.GetForHost; Update/Delete scoped by host_id.
- All host routes behind requireAuth + ownership; cross-tenant returns
  404 (no enumeration). ?host_id removed.
- WS auth via short-lived single-use tickets (POST /auth/ws-ticket).
- Tests: TestCrossTenantIsolation — 9 probes.

Block C — Rate limiting
- Redis sliding-window via Lua (atomic ZADD+ZCARD+PEXPIRE).
- Per-route limits matching the plan (signup IP, login IP+email, RSVP/
  access by token, events/guests/tokens by user_id).
- 429 with Retry-After header and JSON body.
- Auth lockout: 5 failed logins → account locked, only password reset
  clears it.
- Frontend: useErrMessage normalises 429 + locked messaging.

Block D — Real notifications
- Migration 0004: provider_message_id, bounce_type, complained columns
  + unsubscribes (CITEXT) suppression table.
- Branded HTML + plaintext templates for verification, reset, invitation,
  confirmation, reminder. Per-page templates avoid html/template's
  contextual-escape collisions.
- Senders: SESv2, Twilio (SMS), SMTP (Mailpit-friendly), Resend HTTP.
- PickEmailSender priority Resend > SMTP > SES > Log — system boots
  cleanly in dev with Mailpit; production flips one env var.
- Webhook endpoints (Twilio status + SES SNS) — bounces add to suppression;
  signature verification stubbed pending creds.
- Auto-send: POST /tokens publishes invitation.send; notifier renders +
  delivers via the configured backend; suppression list honoured.
- Bulk + per-row invitation flow: POST /events/{id}/guests/invitations/bulk
  returns per-guest tokens so phone-only guests can be SMS'd manually.
- Unsubscribe: signed HMAC token (no TTL) + /unsubscribe/[token] page.
- WhatsApp Option A+: wa.me click-to-chat wizard with per-guest progress
  tracking, isLikelyE164 validation, edit-from-wizard.
- Token rotate (POST /tokens/rotate) invalidates the old URL — used by
  the regenerate-link flow.
- Mailpit added to docker-compose for dev inbox.

Block E — CSV import
- Streaming parser: tolerant header detection, UTF-8 BOM + UTF-16 LE/BE
  decoding, row-level validation, 5,000-row cap.
- Strict E.164 phone validation with helpful error message.
- POST /preview + /import + GET /template; preview UI on event page;
  atomic per-batch with dedup on existing emails.

Phone capture across UI
- PhoneInput component: country picker (~50 ISO codes) + national input +
  live E.164 preview + inline length validation.
- Used in Add Guest and Edit Guest modals. Smart paste-handling extracts
  country code from full E.164 strings.

Block F — Billing (Stripe)
- Migration 0005: subscriptions table (user_id → tier/status/period_end +
  Stripe customer/sub ids). Partial unique index keeps one granting sub
  per user.
- internal/billing: Tier + Limits model (Free 1/50, Pro 10/1000, Business
  ∞/5000), Stripe SDK wrapper with IgnoreAPIVersionMismatch for newer
  account API versions.
- /billing/checkout-session, /billing/portal, /billing/status,
  /webhooks/stripe (signature-verified, lifecycle events).
- Tier enforcement: 402 on POST /events, /guests, /import with
  {error, reason, tier, used, limit, upgrade_url} body.
- Frontend: useBilling composable, /dashboard/billing page (current plan,
  usage bars, tier cards), global UpgradeModal triggered by useApi's
  402 interceptor.
- Customer portal kept for self-service cancel/payment-method changes.

Block G — Backups & DR (application side)
- Every migration has a tested .down.sql.
- TestMigrationRoundtrip applies all ups → all downs → all ups against a
  fresh container; catches asymmetric down migrations.
- cmd/restore-verify: 28-check post-restore invariant tool (schema
  presence, no orphans across 10 FK relationships, email uniqueness,
  single-active subscription, row-count snapshot).
- docs/RUNBOOK_RESTORE.md: 9-step restore procedure with RTO/RPO
  targets, drill instructions, rollback path.

Block H — Privacy compliance (application side)
- Migration 0006: deleted_at + terms_accepted_at + privacy_policy_accepted_at
  on users. Partial index on email for live-only uniqueness.
- GET /me/data-export — synchronous JSON dump (user, events, guests,
  tokens, rsvps, access_logs, notifications).
- DELETE /me — soft-delete with PII scrub + refresh-token revocation;
  re-signup with same email works.
- POST /me/accept-terms — idempotent consent recording.
- Frontend /privacy + /terms placeholder pages with substantive (pending
  legal review) copy; footer links; signup terms checkbox; TermsGateModal
  for accounts created before the rollout; export + delete buttons on
  /dashboard/billing.

Tests
- All migrations verified up/down/up.
- Integration suite: TestE2EHappyPath, TestAuthFlow, TestCrossTenantIsolation,
  TestRateLimitSignup, TestLoginLockout, TestUnsubscribeFlow,
  TestSESBounceWebhook, TestTwilioStatusWebhook, TestCsvImportFlow,
  TestCsvImportAtomicRollback, TestBulkIssueInvitations, TestBulkIssueExplicitSubset,
  TestTokenIssuePublishesInvitation, TestTokenIssueWithoutGuestEmailSkipsInvitation,
  TestGuestUpdate, TestGuestDelete, TestTokenRotate, TestSMTPSenderAgainstMailpit,
  TestFreeTierEventLimit, TestFreeTierGuestLimit, TestBusinessTierBypassesLimits,
  TestDataExport, TestDeleteMe, TestAcceptTerms, TestMigrationRoundtrip.
  Full suite runs in ~120s against real Postgres + NATS + Redis + Mailpit.
- Unit suite green across internal/auth, internal/csvimport,
  internal/notification, internal/ratelimit, internal/domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-16 23:54:22 +01:00
parent a0ed34f860
commit 59b8781659
124 changed files with 13702 additions and 445 deletions
+264 -43
View File
@@ -5,63 +5,167 @@ import (
"net/http"
"time"
"github.com/redis/go-redis/v9"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/billing"
"github.com/alchemistkay/guestguard/internal/notification"
"github.com/alchemistkay/guestguard/internal/ratelimit"
"github.com/alchemistkay/guestguard/internal/storage"
)
type Server struct {
logger *slog.Logger
db *storage.DB
hub *Hub
users *userHandler
events *eventHandler
guests *guestHandler
tokens *tokenHandler
rsvps *rsvpHandler
activity *activityHandler
ws *wsHandler
health *healthHandler
logger *slog.Logger
db *storage.DB
hub *Hub
authH *authHandler
me *meHandler
events *eventHandler
guests *guestHandler
tokens *tokenHandler
rsvps *rsvpHandler
activity *activityHandler
ws *wsHandler
wsTicket *wsTicketHandler
health *healthHandler
signer *auth.JWTSigner
limiter *ratelimit.Limiter
unsub *unsubscribeHandler
webhooks *webhookHandler
csv *csvImportHandler
billing *billingHandler
stripeWH *stripeWebhookHandler
privacy *privacyHandler
}
type ServerDeps struct {
Logger *slog.Logger
DB *storage.DB
Hub *Hub
AccessPublisher accessPublisher
RSVPPublisher rsvpPublisher
FraudScorer fraudScorer
TokenTTL time.Duration
AccessPublisher accessPublisher
RSVPPublisher rsvpPublisher
InvitationPublisher invitationPublisher
FraudScorer fraudScorer
TokenTTL time.Duration
// Auth
JWTSecret string
JWTIssuer string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
EmailVerificationTTL time.Duration
PasswordResetTTL time.Duration
PublicBaseURL string
RefreshCookieDomain string
RefreshCookieSecure bool
EmailSender auth.EmailSender
WSTicketTTL time.Duration
// Rate limiting / abuse controls
Redis *redis.Client
LoginLockoutMax int // failed attempts before account lockout (default 5)
LoginFailWindow time.Duration // counter TTL (default 15 min)
// Notifications / unsubscribe
NotificationRepo *notification.Repo
SuppressionRepo *notification.SuppressionRepo
UnsubscribeSigner *notification.UnsubscribeSigner
// Billing (Block F). Nil StripeClient leaves billing disabled — the
// system still boots and runs, all users sit on the free tier with
// its limits enforced; /billing/* returns 503.
StripeClient *billing.Client
}
func NewServer(deps ServerDeps) *Server {
func NewServer(deps ServerDeps) (*Server, error) {
eventRepo := storage.NewEventRepo(deps.DB)
guestRepo := storage.NewGuestRepo(deps.DB)
tokenRepo := storage.NewTokenRepo(deps.DB)
rsvpRepo := storage.NewRSVPRepo(deps.DB)
accessRepo := storage.NewAccessLogRepo(deps.DB)
userRepo := storage.NewUserRepo(deps.DB)
verifRepo := storage.NewEmailVerificationRepo(deps.DB)
resetRepo := storage.NewPasswordResetRepo(deps.DB)
refreshRepo := storage.NewRefreshTokenRepo(deps.DB)
subRepo := storage.NewSubscriptionRepo(deps.DB)
enforcer := newTierEnforcer(subRepo, deps.PublicBaseURL)
signer, err := auth.NewJWTSigner(deps.JWTSecret, deps.AccessTokenTTL, deps.JWTIssuer)
if err != nil {
return nil, err
}
hasher := auth.NewPasswordHasher()
emails := deps.EmailSender
if emails == nil {
emails = auth.LogEmailSender{Logger: deps.Logger}
}
hub := deps.Hub
if hub == nil {
hub = NewHub(deps.Logger)
}
wsTicketTTL := deps.WSTicketTTL
if wsTicketTTL <= 0 {
wsTicketTTL = 60 * time.Second
}
wsTickets := newWSTicketStore(wsTicketTTL)
var limiter *ratelimit.Limiter
var lockout *auth.LockoutTracker
if deps.Redis != nil {
limiter = ratelimit.New(deps.Redis, "gg:rl")
lockoutMax := deps.LoginLockoutMax
if lockoutMax <= 0 {
lockoutMax = 5
}
failWindow := deps.LoginFailWindow
if failWindow <= 0 {
failWindow = 15 * time.Minute
}
lockout = auth.NewLockoutTracker(deps.Redis, lockoutMax, failWindow)
}
authH := newAuthHandler(authHandlerDeps{
Logger: deps.Logger,
Users: userRepo,
Verifications: verifRepo,
Resets: resetRepo,
Refreshes: refreshRepo,
Hasher: hasher,
Signer: signer,
Emails: emails,
Lockout: lockout,
Limiter: limiter,
PublicBaseURL: deps.PublicBaseURL,
EmailVerificationTTL: deps.EmailVerificationTTL,
PasswordResetTTL: deps.PasswordResetTTL,
RefreshTTL: deps.RefreshTokenTTL,
CookieDomain: deps.RefreshCookieDomain,
CookieSecure: deps.RefreshCookieSecure,
})
return &Server{
logger: deps.Logger,
db: deps.DB,
hub: hub,
users: &userHandler{repo: userRepo},
events: &eventHandler{repo: eventRepo},
guests: &guestHandler{guests: guestRepo, events: eventRepo},
authH: authH,
me: &meHandler{users: userRepo},
events: &eventHandler{repo: eventRepo, enforcer: enforcer},
guests: &guestHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
tokens: &tokenHandler{
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
accessLogs: accessRepo,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
logger: deps.Logger,
guests: guestRepo,
tokens: tokenRepo,
events: eventRepo,
users: userRepo,
accessLogs: accessRepo,
gen: auth.NewGenerator(),
ttl: deps.TokenTTL,
pub: deps.AccessPublisher,
invitations: deps.InvitationPublisher,
publicBaseURL: deps.PublicBaseURL,
},
rsvps: &rsvpHandler{
logger: deps.Logger,
@@ -78,9 +182,46 @@ func NewServer(deps ServerDeps) *Server {
rsvps: rsvpRepo,
accessLogs: accessRepo,
},
ws: &wsHandler{logger: deps.Logger, hub: hub},
health: &healthHandler{pool: deps.DB.Pool},
}
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo},
health: &healthHandler{pool: deps.DB.Pool},
signer: signer,
limiter: limiter,
unsub: &unsubscribeHandler{
logger: deps.Logger,
signer: deps.UnsubscribeSigner,
suppress: deps.SuppressionRepo,
},
webhooks: &webhookHandler{
logger: deps.Logger,
notifs: deps.NotificationRepo,
suppress: deps.SuppressionRepo,
},
csv: &csvImportHandler{guests: guestRepo, events: eventRepo, enforcer: enforcer},
billing: &billingHandler{
logger: deps.Logger,
stripe: deps.StripeClient,
users: userRepo,
subscriptions: subRepo,
publicBaseURL: deps.PublicBaseURL,
},
stripeWH: &stripeWebhookHandler{
logger: deps.Logger,
stripe: deps.StripeClient,
subs: subRepo,
},
privacy: &privacyHandler{
logger: deps.Logger,
users: userRepo,
events: eventRepo,
guests: guestRepo,
tokens: tokenRepo,
rsvps: rsvpRepo,
access: accessRepo,
notifs: deps.DB,
refresh: refreshRepo,
},
}, nil
}
func (s *Server) Hub() *Hub { return s.hub }
@@ -91,25 +232,104 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /health", s.health.live)
mux.HandleFunc("GET /health/ready", s.health.ready)
mux.HandleFunc("POST /users", s.users.upsert)
// Per-route rate limiters (no-op when Redis isn't wired).
authed := requireAuth(s.signer)
rl := func(name string, limit int, window time.Duration, keyFn KeyFunc, h http.Handler) http.Handler {
if s.limiter == nil {
return h
}
return s.limiter.Middleware(
ratelimit.Rule{Name: name, Limit: limit, Window: window},
keyFn,
s.logger,
)(h)
}
mux.HandleFunc("POST /events", s.events.create)
mux.HandleFunc("GET /events", s.events.list)
mux.HandleFunc("GET /events/{id}", s.events.get)
mux.HandleFunc("PATCH /events/{id}", s.events.update)
mux.HandleFunc("DELETE /events/{id}", s.events.delete)
// Anonymous auth endpoints — POST /auth/login + /auth/forgot-password
// rate-limit inside the handler (key includes the email body field).
mux.Handle("POST /auth/signup",
rl("auth_signup", 5, time.Hour, ipKey, http.HandlerFunc(s.authH.signup)))
mux.HandleFunc("POST /auth/login", s.authH.login)
mux.HandleFunc("POST /auth/refresh", s.authH.refresh)
mux.HandleFunc("POST /auth/logout", s.authH.logout)
mux.HandleFunc("POST /auth/verify-email", s.authH.verifyEmail)
mux.HandleFunc("POST /auth/forgot-password", s.authH.forgotPassword)
mux.HandleFunc("POST /auth/reset-password", s.authH.resetPassword)
mux.HandleFunc("POST /events/{id}/guests", s.guests.create)
mux.HandleFunc("GET /events/{id}/guests", s.guests.list)
mux.Handle("GET /me", authed(http.HandlerFunc(s.me.get)))
mux.Handle("POST /auth/ws-ticket", authed(http.HandlerFunc(s.wsTicket.issue)))
mux.HandleFunc("GET /events/{id}/activity", s.activity.list)
// Privacy / GDPR-style endpoints — host can export their data,
// delete their account, and record terms acceptance from the
// onboarding gate.
mux.Handle("GET /me/data-export", authed(http.HandlerFunc(s.privacy.dataExport)))
mux.Handle("DELETE /me", authed(http.HandlerFunc(s.privacy.deleteMe)))
mux.Handle("POST /me/accept-terms", authed(http.HandlerFunc(s.privacy.acceptTerms)))
mux.HandleFunc("POST /events/{id}/guests/{guest_id}/tokens", s.tokens.issue)
mux.HandleFunc("GET /access/{token}", s.tokens.access)
mux.HandleFunc("POST /rsvp/{token}", s.rsvps.submit)
// Host-facing event/guest/token writes are limited by user_id.
mux.Handle("POST /events",
authed(rl("events_create", 20, 24*time.Hour, userIDKey, http.HandlerFunc(s.events.create))))
mux.Handle("GET /events", authed(http.HandlerFunc(s.events.list)))
mux.Handle("GET /events/{id}", authed(http.HandlerFunc(s.events.get)))
mux.Handle("PATCH /events/{id}", authed(http.HandlerFunc(s.events.update)))
mux.Handle("DELETE /events/{id}", authed(http.HandlerFunc(s.events.delete)))
mux.Handle("POST /events/{id}/guests",
authed(rl("guests_create", 1000, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.create))))
mux.Handle("GET /events/{id}/guests", authed(http.HandlerFunc(s.guests.list)))
mux.Handle("PATCH /events/{id}/guests/{guest_id}",
authed(rl("guests_update", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.update))))
mux.Handle("DELETE /events/{id}/guests/{guest_id}",
authed(rl("guests_delete", 200, 24*time.Hour, userIDKey, http.HandlerFunc(s.guests.delete))))
// CSV import (Block E). Preview is cheap (no DB writes), so we keep
// its budget separate from commit's daily-row-add limit.
mux.Handle("POST /events/{id}/guests/import/preview",
authed(rl("guests_import_preview", 30, time.Hour, userIDKey, http.HandlerFunc(s.csv.preview))))
mux.Handle("POST /events/{id}/guests/import",
authed(rl("guests_import_commit", 20, 24*time.Hour, userIDKey, http.HandlerFunc(s.csv.commit))))
mux.Handle("GET /events/{id}/guests/import/template", authed(http.HandlerFunc(s.csv.template)))
mux.Handle("GET /events/{id}/activity", authed(http.HandlerFunc(s.activity.list)))
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens",
authed(rl("tokens_issue", 500, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.issue))))
mux.Handle("POST /events/{id}/guests/{guest_id}/tokens/rotate",
authed(rl("tokens_rotate", 200, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.rotate))))
mux.Handle("POST /events/{id}/guests/invitations/bulk",
authed(rl("tokens_bulk", 10, 24*time.Hour, userIDKey, http.HandlerFunc(s.tokens.bulkIssue))))
// Guest-facing endpoints — rate-limited by the access token in the URL
// path so an attacker hammering a single invitation is slowed regardless
// of their source IP.
mux.Handle("GET /access/{token}",
rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access)))
mux.Handle("POST /rsvp/{token}",
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
// WebSocket endpoint authenticates via single-use ticket on the query
// string (see POST /auth/ws-ticket).
mux.HandleFunc("GET /ws/events/{id}", s.ws.handle)
// Unsubscribe (signed token, no auth required — links live in emails).
mux.HandleFunc("GET /unsubscribe/{token}", s.unsub.preview)
mux.HandleFunc("POST /unsubscribe/{token}", s.unsub.confirm)
// Provider webhooks. Signature verification is enforced in the handler
// once GG_TWILIO_AUTH_TOKEN / GG_SES_WEBHOOK_SECRET are set.
mux.HandleFunc("POST /webhooks/twilio/status", s.webhooks.twilio)
mux.HandleFunc("POST /webhooks/ses/notifications", s.webhooks.ses)
// Billing (Block F). /billing/status is safe for everyone — returns
// free tier defaults when Stripe is unconfigured or the user has no
// subscription, so the frontend's plan page always has something to
// render. The action endpoints (checkout, portal) return 503 in dev
// without Stripe credentials.
mux.Handle("GET /billing/status", authed(http.HandlerFunc(s.billing.status)))
mux.Handle("POST /billing/checkout-session", authed(http.HandlerFunc(s.billing.checkoutSession)))
mux.Handle("POST /billing/portal", authed(http.HandlerFunc(s.billing.portalSession)))
mux.HandleFunc("POST /webhooks/stripe", s.stripeWH.handle)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not found")
})
@@ -128,9 +348,10 @@ func corsMiddleware(next http.Handler) http.Handler {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Device-Fingerprint")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Device-Fingerprint")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)