3973e4058d
Events can now have multiple users with distinct roles:
owner — manage collaborators, delete event, full access
editor — manage guests, tokens, CSV import, patch event
viewer — read-only access to everything
Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
no future event can exist without one
Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
insufficient role 403. Replaces requireEventOwner across every
shared-role handler (events.get/update, guests CRUD, tokens issue/
rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching
Collaborator endpoints
- GET /events/{id}/collaborators (viewer+)
- POST /events/{id}/collaborators (owner) — sends invite email
- PATCH /events/{id}/collaborators/{user_id} (owner) — role change
- DELETE /events/{id}/collaborators/{user_id} (owner) — refuses last owner
- DELETE /events/{id}/collaborators/pending (owner) — cancel invite
- GET /invites/{token} (public) — preview summary
- POST /invites/{token}/accept (authed) — atomic accept
Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
senders + log stub; collaborator_invite.html/txt branded template
Frontend
- TeamCard.vue on the event detail page: lists collaborators with
inline role-change + remove, pending-invites with cancel, invite
modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
or sign-in with pre-filled email, refuses mismatched accounts
Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
413 lines
15 KiB
Go
413 lines
15 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
|
|
collabs *collaboratorHandler
|
|
}
|
|
|
|
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)
|
|
collabRepo := storage.NewCollaboratorRepo(deps.DB)
|
|
inviteRepo := storage.NewInviteRepo(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, collabs: collabRepo, enforcer: enforcer},
|
|
guests: &guestHandler{guests: guestRepo, events: eventRepo, collabs: collabRepo, enforcer: enforcer},
|
|
tokens: &tokenHandler{
|
|
logger: deps.Logger,
|
|
guests: guestRepo,
|
|
tokens: tokenRepo,
|
|
events: eventRepo,
|
|
users: userRepo,
|
|
accessLogs: accessRepo,
|
|
rsvps: rsvpRepo,
|
|
collabs: collabRepo,
|
|
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,
|
|
collabs: collabRepo,
|
|
rsvps: rsvpRepo,
|
|
accessLogs: accessRepo,
|
|
},
|
|
ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets},
|
|
wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo},
|
|
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, collabs: collabRepo, 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,
|
|
},
|
|
collabs: &collaboratorHandler{
|
|
logger: deps.Logger,
|
|
events: eventRepo,
|
|
users: userRepo,
|
|
collabs: collabRepo,
|
|
invites: inviteRepo,
|
|
emails: emails,
|
|
publicBaseURL: deps.PublicBaseURL,
|
|
},
|
|
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)))
|
|
|
|
// Block C — collaborators (multi-host). All under /events/{id}/collaborators.
|
|
// requireRole inside each handler enforces the right minimum role.
|
|
mux.Handle("GET /events/{id}/collaborators",
|
|
authed(http.HandlerFunc(s.collabs.list)))
|
|
mux.Handle("POST /events/{id}/collaborators",
|
|
authed(rl("collab_invite", 50, 24*time.Hour, userIDKey, http.HandlerFunc(s.collabs.invite))))
|
|
mux.Handle("PATCH /events/{id}/collaborators/{user_id}",
|
|
authed(http.HandlerFunc(s.collabs.updateRole)))
|
|
mux.Handle("DELETE /events/{id}/collaborators/{user_id}",
|
|
authed(http.HandlerFunc(s.collabs.remove)))
|
|
mux.Handle("DELETE /events/{id}/collaborators/pending",
|
|
authed(http.HandlerFunc(s.collabs.cancelInvite)))
|
|
|
|
// Invite acceptance — preview is unauthed (the invitee may not be
|
|
// logged in yet); accept requires auth (the caller's account must
|
|
// exist + match the invited email).
|
|
mux.HandleFunc("GET /invites/{token}", s.collabs.previewInvite)
|
|
mux.Handle("POST /invites/{token}/accept",
|
|
authed(http.HandlerFunc(s.collabs.acceptInvite)))
|
|
|
|
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)))
|
|
// Block B: .ics download. Same rate-limit class as /access since the
|
|
// payload is similarly cheap and the abuse profile is identical (one
|
|
// token per attacker).
|
|
mux.Handle("GET /access/{token}/calendar.ics",
|
|
rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar)))
|
|
mux.Handle("POST /rsvp/{token}",
|
|
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
|
|
// Block A: edits are bounded by MaxRSVPEdits server-side. The redis
|
|
// limiter is a coarser guard that also throttles attempts that hit the
|
|
// edit-cap 429 path, so a hostile actor can't burn through fraud-engine
|
|
// calls on the same token.
|
|
mux.Handle("PATCH /rsvp/{token}",
|
|
rl("rsvp_edit", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.edit)))
|
|
|
|
// Host view of the edit trail for a single guest.
|
|
mux.Handle("GET /events/{id}/guests/{guest_id}/rsvp/history",
|
|
authed(http.HandlerFunc(s.rsvps.history)))
|
|
|
|
// 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)
|
|
})
|
|
}
|