major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
@@ -7,6 +7,7 @@ import (
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/google/uuid"
)
@@ -359,6 +360,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
}
}
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
// /me seeds the dashboard capability matrix before /billing/capabilities
// lands, so the per-member overlay must be applied here too — otherwise a
// restricted member sees a flash of the full nav on every page load.
if perms, perr := s.Billing.MemberPermissions(r.Context(), cid, uid); perr == nil && len(perms) > 0 {
features, denied := billing.ApplyMemberPermissions(credits.Features, perms)
credits.Features = features
credits.DisabledFeatures = append(credits.DisabledFeatures, denied...)
credits.FeatureETag = billing.FeatureETag(features)
credits.MemberRestricted = true
credits.MemberDeniedFeatures = denied
}
out["credits"] = credits
}
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
@@ -180,7 +180,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"`
Name *string `json:"name"`
IsOwner bool `json:"is_owner"`
// Restricted / DeniedCount summarise the owner-set access overlay so the team
// table can badge restricted members without a request per row.
Restricted bool `json:"restricted"`
DeniedCount int `json:"denied_count"`
}
// Best effort: a missing permissions column (pre-migration) just means "no overlay".
overlays, _ := s.Auth.MemberPermissionsByCompany(r.Context(), cid)
out := make([]member, 0)
for rows.Next() {
var m member
@@ -189,6 +195,10 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
return
}
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
if !m.IsOwner {
m.DeniedCount = len(deniedKeys(overlays[m.UserID]))
m.Restricted = m.DeniedCount > 0
}
out = append(out, m)
}
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
@@ -0,0 +1,127 @@
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)
})
}
@@ -0,0 +1,72 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
)
func TestFeatureKeyForAPIPath(t *testing.T) {
cases := []struct {
path string
want string
}{
{"/api/products", "catalog.products"},
{"/api/products/", "catalog.products"},
{"/api/products/8f14e45f-ceea-467a-9e0f-000000000000", "catalog.products"},
{"/api/api-keys", "settings.api_keys"},
{"/api/api-keys/42", "settings.api_keys"},
{"/api/marketing/calendar", "marketing.content_calendar"},
{"/api/marketing/calendar/prepare", "marketing.content_calendar"},
{"/api/integrations/ai/prompts", "integrations.ai"},
{"/api/integrations/email/test", "integrations.email"},
{"/api/woocommerce/orders", "stores.woocommerce"},
{"/api/billing/checkout", "billing.checkout"},
// Unmapped paths stay open — the middleware must not guess.
{"/api/auth/me", ""},
{"/api/billing/credits", ""},
{"/api/billing/capabilities", ""},
{"/api/unknown-thing", ""},
{"/api", ""},
}
for _, tc := range cases {
if got := featureKeyForAPIPath(tc.path); got != tc.want {
t.Errorf("featureKeyForAPIPath(%q) = %q, want %q", tc.path, got, tc.want)
}
}
}
// A prefix must never match a longer sibling path segment: /api/feeds governs
// feeds.list, but /api/feeds-something-else is not a feeds route.
func TestFeatureKeyForAPIPathDoesNotMatchPartialSegments(t *testing.T) {
if got := featureKeyForAPIPath("/api/feedsomething"); got != "" {
t.Errorf("got %q, want empty", got)
}
if got := featureKeyForAPIPath("/api/products-export"); got != "" {
t.Errorf("got %q, want empty", got)
}
}
// Every key the middleware enforces must be one the owner can actually toggle,
// otherwise a route could be blocked with no way to unblock it.
func TestAPIFeatureRoutesAreGrantable(t *testing.T) {
for path, key := range apiFeatureRoutes {
if !billing.IsKnownFeatureKey(key) {
t.Errorf("%s maps to unknown feature key %q", path, key)
continue
}
if !billing.IsGrantableFeatureKey(key) {
t.Errorf("%s maps to %q, which the owner cannot toggle", path, key)
}
}
}
// The capability matrix itself must stay reachable — it is what tells the
// dashboard which surfaces to hide.
func TestAlwaysReachablePathsAreNotGated(t *testing.T) {
for _, path := range alwaysReachableAPIPaths {
if got := featureKeyForAPIPath(path); got != "" {
t.Errorf("%s resolved to feature %q but must stay open", path, got)
}
}
}
@@ -11,7 +11,7 @@ import (
"github.com/google/uuid"
)
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
// GET /api/billing/capabilities — effective plan ∩ global ∩ member features for the caller.
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
@@ -22,7 +22,10 @@ func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusUnauthorized, "company required")
return
}
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
// Member-aware: the company owner's per-member overlay narrows what this caller
// sees, so nav / route guard / feature gates all hide the same surfaces at once.
uid, _ := UserIDFromContext(r.Context())
caps, err := s.Billing.CapabilitiesForMember(r.Context(), cid, uid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load capabilities")
return
+4
View File
@@ -436,6 +436,7 @@ func (s *Server) Router() http.Handler {
r.Route("/api", func(r chi.Router) {
r.Use(s.RequireSession)
r.Use(s.RequireCompany)
r.Use(s.RequireMemberFeature)
r.Use(s.RateLimitMarketing)
r.Use(s.RateLimitAIProbes)
r.Use(s.RateLimitV1Process)
@@ -453,8 +454,11 @@ func (s *Server) Router() http.Handler {
r.Get("/team/invites", s.handleListInvites)
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
r.Get("/team/permission-catalog", s.handleGetPermissionCatalog)
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
r.Delete("/team/{userID}", s.handleRemoveMember)
r.Get("/team/{userID}/permissions", s.handleGetMemberPermissions)
r.Put("/team/{userID}/permissions", s.handlePutMemberPermissions)
r.Get("/api-keys", s.handleListAPIKeys)
r.Post("/api-keys", s.handleCreateAPIKey)
@@ -0,0 +1,168 @@
package httpapi
import (
"errors"
"net/http"
"sort"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// memberPermissionsView is the payload for GET/PUT /api/team/{userID}/permissions.
type memberPermissionsView struct {
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
// IsOwner members are never restricted — the editor renders read-only for them.
IsOwner bool `json:"is_owner"`
// Denied lists the feature keys turned off for this member (sorted, sparse).
Denied []string `json:"denied"`
// Restricted is a convenience flag mirroring len(Denied) > 0.
Restricted bool `json:"restricted"`
}
// GET /api/team/permission-catalog — the grantable feature keys grouped by dashboard
// section, annotated with what the company plan already allows.
func (s *Server) handleGetPermissionCatalog(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
if s.Billing == nil {
Error(w, http.StatusServiceUnavailable, "billing unavailable")
return
}
cid, _ := CompanyIDFromContext(r.Context())
catalog, err := s.Billing.PermissionCatalogForCompany(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to load permission catalog")
return
}
w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate")
JSON(w, http.StatusOK, catalog)
}
// GET /api/team/{userID}/permissions
func (s *Server) handleGetMemberPermissions(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
view, err := s.memberPermissionsView(r, cid, userID)
if err != nil {
writeMemberPermissionsErr(w, err)
return
}
JSON(w, http.StatusOK, view)
}
// PUT /api/team/{userID}/permissions — owner (or platform admin) only.
//
// Body: {"denied": ["stores.hub", "settings.api_keys"]}. The list replaces the stored
// overlay wholesale, so an empty list restores full (plan-limited) access.
func (s *Server) handlePutMemberPermissions(w http.ResponseWriter, r *http.Request) {
// Owner-only on purpose: company admins can manage the team, but letting a
// restricted admin edit permissions would let them lift their own restrictions.
if !s.allowCompanyOwnerOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
Denied []string `json:"denied"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
isOwner, err := s.Auth.IsCompanyOwner(r.Context(), cid, userID)
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if isOwner {
Error(w, http.StatusConflict, "the company owner cannot be restricted")
return
}
incoming := make(map[string]bool, len(body.Denied))
for _, key := range body.Denied {
incoming[key] = false
}
perms, err := billing.SanitizeMemberPermissions(incoming)
if err != nil {
// Unknown / protected keys are a client mistake — echo which one failed.
Error(w, http.StatusBadRequest, err.Error())
return
}
if err := s.Auth.SetMemberPermissions(r.Context(), cid, userID, perms); err != nil {
writeMemberPermissionsErr(w, err)
return
}
view, err := s.memberPermissionsView(r, cid, userID)
if err != nil {
writeMemberPermissionsErr(w, err)
return
}
JSON(w, http.StatusOK, view)
}
func (s *Server) memberPermissionsView(r *http.Request, companyID, userID uuid.UUID) (memberPermissionsView, error) {
var role, status string
if err := s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
WHERE company_id = $1 AND user_id = $2`, companyID, userID).Scan(&role, &status); err != nil {
return memberPermissionsView{}, auth.ErrMemberNotFound
}
perms, err := s.Auth.MemberPermissions(r.Context(), companyID, userID)
if err != nil {
return memberPermissionsView{}, err
}
isOwner, err := s.Auth.IsCompanyOwner(r.Context(), companyID, userID)
if err != nil {
return memberPermissionsView{}, err
}
denied := deniedKeys(perms)
if isOwner {
denied = nil
}
return memberPermissionsView{
UserID: userID,
Role: auth.NormalizeMembershipRole(role),
IsOwner: isOwner,
Denied: denied,
Restricted: len(denied) > 0,
}, nil
}
// deniedKeys flattens a stored overlay to the sorted list of turned-off keys,
// dropping anything that is no longer grantable (catalog changes, hand edits).
func deniedKeys(perms map[string]bool) []string {
out := make([]string, 0, len(perms))
for key, allowed := range perms {
if !allowed && billing.IsGrantableFeatureKey(key) {
out = append(out, key)
}
}
sort.Strings(out)
return out
}
func writeMemberPermissionsErr(w http.ResponseWriter, err error) {
if errors.Is(err, auth.ErrMemberNotFound) {
Error(w, http.StatusNotFound, "member not found")
return
}
Error(w, http.StatusInternalServerError, "permission update failed")
}