59b8781659
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>
363 lines
13 KiB
Go
363 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"log/slog"
|
|
"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
|
|
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
|
|
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, 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,
|
|
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,
|
|
users: userRepo,
|
|
accessLogs: accessRepo,
|
|
gen: auth.NewGenerator(),
|
|
ttl: deps.TokenTTL,
|
|
pub: deps.AccessPublisher,
|
|
invitations: deps.InvitationPublisher,
|
|
publicBaseURL: deps.PublicBaseURL,
|
|
},
|
|
rsvps: &rsvpHandler{
|
|
logger: deps.Logger,
|
|
guests: guestRepo,
|
|
tokens: tokenRepo,
|
|
events: eventRepo,
|
|
rsvps: rsvpRepo,
|
|
accessLogs: accessRepo,
|
|
scorer: deps.FraudScorer,
|
|
pub: deps.RSVPPublisher,
|
|
},
|
|
activity: &activityHandler{
|
|
events: eventRepo,
|
|
rsvps: rsvpRepo,
|
|
accessLogs: accessRepo,
|
|
},
|
|
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 }
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /health", s.health.live)
|
|
mux.HandleFunc("GET /health/ready", s.health.ready)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.Handle("GET /me", authed(http.HandlerFunc(s.me.get)))
|
|
mux.Handle("POST /auth/ws-ticket", authed(http.HandlerFunc(s.wsTicket.issue)))
|
|
|
|
// 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)))
|
|
|
|
// 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")
|
|
})
|
|
|
|
var h http.Handler = mux
|
|
h = corsMiddleware(h)
|
|
h = loggingMiddleware(s.logger)(h)
|
|
h = recoverMiddleware(s.logger)(h)
|
|
return h
|
|
}
|
|
|
|
// Permissive CORS for the dev frontend on a different origin. In production
|
|
// the frontend is served from the same domain so this is largely a no-op.
|
|
func corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
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, Authorization, X-Device-Fingerprint")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|