package httpapi import ( "net/http" "sort" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" ) // apiFeatureRoutes maps dashboard API path prefixes to the feature key that governs // them, mirroring NAV_FEATURE_BY_HREF / GATED_ROUTES in // apps/web/src/lib/plan-capabilities.ts. Longest prefix wins. // // Only surfaces an owner can actually restrict appear here: hiding a nav entry in the // browser is cosmetic, so every hidden surface needs the matching API closed too. var apiFeatureRoutes = map[string]string{ "/api/products": "catalog.products", "/api/categories": "catalog.categories", "/api/variables": "catalog.categories", "/api/attributes": "catalog.attributes", "/api/standard-fields": "catalog.standard_fields", "/api/field-groups": "catalog.standard_fields", "/api/structured-descriptions": "catalog.structured_descriptions", "/api/vector-categories": "catalog.vector_categories", "/api/feeds": "feeds.list", "/api/export-feeds": "feeds.export_feeds", "/api/files": "feeds.uploads", "/api/processing": "processing.monitor", "/api/woocommerce": "stores.woocommerce", "/api/shopify": "stores.shopify", "/api/campaigns": "marketing.campaigns", "/api/marketing/calendar": "marketing.content_calendar", "/api/seo": "marketing.seo", "/api/brand": "marketing.brand_kit", "/api/integrations/ai": "integrations.ai", "/api/integrations/email": "integrations.email", "/api/email/send": "integrations.email", "/api/api-keys": "settings.api_keys", "/api/team": "settings.team", "/api/company": "settings.company", "/api/support": "support.center", "/api/billing/checkout": "billing.checkout", "/api/billing/portal": "billing.customer_portal", "/api/billing/usage": "billing.overview", "/api/billing/plans": "billing.plans_compare", "/api/billing/credit-packs": "billing.overview", "/api/billing/stripe": "billing.overview", } // apiFeatureRoutePrefixes is apiFeatureRoutes' keys sorted longest-first so // "/api/marketing/calendar" wins over a shorter overlapping prefix. var apiFeatureRoutePrefixes = func() []string { out := make([]string, 0, len(apiFeatureRoutes)) for prefix := range apiFeatureRoutes { out = append(out, prefix) } sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) }) return out }() // featureKeyForAPIPath returns the governing feature key for a request path, or "". func featureKeyForAPIPath(path string) string { path = strings.TrimSuffix(path, "/") for _, prefix := range apiFeatureRoutePrefixes { if path == prefix || strings.HasPrefix(path, prefix+"/") || strings.HasPrefix(path, prefix+"?") { return apiFeatureRoutes[prefix] } } return "" } // alwaysReachableAPIPaths stay open regardless of the overlay: the shell needs them to // render at all, and a restricted member must still be able to read their own session, // wallet and capability matrix (that matrix is what tells the UI what to hide). var alwaysReachableAPIPaths = []string{ "/api/billing/credits", "/api/billing/capabilities", } // RequireMemberFeature enforces the company owner's per-member access overlay on the // dashboard API. Feature gating by plan stays where it is (billing.AssertFeature at the // handler level); this middleware adds the per-member layer so a hidden nav entry is // genuinely unreachable rather than just invisible. // // Denials return 403 with code "member_feature_denied" so the client can distinguish // "ask your administrator" from a plan upgrade prompt. func (s *Server) RequireMemberFeature(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.Billing == nil { next.ServeHTTP(w, r) return } path := r.URL.Path for _, open := range alwaysReachableAPIPaths { if path == open { next.ServeHTTP(w, r) return } } key := featureKeyForAPIPath(path) if key == "" { next.ServeHTTP(w, r) return } cid, okCompany := CompanyIDFromContext(r.Context()) uid, okUser := UserIDFromContext(r.Context()) if !okCompany || !okUser { next.ServeHTTP(w, r) return } perms, err := s.Billing.MemberPermissions(r.Context(), cid, uid) if err != nil { // Fail open on a lookup error: a transient DB blip must not lock the whole // tenant out of their dashboard. The UI-level gate still applies. next.ServeHTTP(w, r) return } if billing.MemberDeniesFeature(perms, key) { FieldError(w, http.StatusForbidden, "your administrator has turned off access to this area", "member_feature_denied", nil) return } next.ServeHTTP(w, r) }) }