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:
+264
-43
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user