Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/cli-go/internal/branches/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
if err != nil {
return errors.Errorf("failed to create preview branch: %w", err)
} else if resp.JSON201 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "branching_limit", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body))
}
Expand Down
20 changes: 20 additions & 0 deletions apps/cli-go/internal/branches/create/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,24 @@ func TestCreateCommand(t *testing.T) {
assert.ErrorContains(t, err, "unexpected create branch status 402")
assert.Contains(t, utils.CmdSuggestion, "/org/test-org/billing")
})

t.Run("suggests upgrade from envelope without entitlements round-trip", func(t *testing.T) {
t.Cleanup(apitest.MockPlatformAPI(t))
t.Cleanup(func() { utils.CmdSuggestion = "" })
gock.New(utils.DefaultApiHost).
Post("/v1/projects/" + flags.ProjectRef + "/branches").
Reply(http.StatusPaymentRequired).
JSON(map[string]interface{}{
"message": "Branching is supported only on the Pro plan or above",
"error": map[string]interface{}{
"code": "entitlement_required",
"feature": "branching_limit",
"upgrade_url": "https://supabase.com/dashboard/org/acme/billing",
},
})
fsys := afero.NewMemMapFs()
err := Run(context.Background(), api.CreateBranchBody{Region: cast.Ptr("sin")}, fsys)
assert.ErrorContains(t, err, "unexpected create branch status 402")
assert.Contains(t, utils.CmdSuggestion, "org/acme/billing")
})
}
4 changes: 2 additions & 2 deletions apps/cli-go/internal/branches/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
if err != nil {
return errors.Errorf("failed to update preview branch: %w", err)
} else if resp.JSON200 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "branching_persistent", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body))
}
Expand Down
4 changes: 4 additions & 0 deletions apps/cli-go/internal/hostnames/activate/activate.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/hostnames"
"github.com/supabase/cli/internal/telemetry"
"github.com/supabase/cli/internal/utils"
)

Expand All @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error {
if err != nil {
return errors.Errorf("failed to activate custom hostname: %w", err)
} else if resp.JSON201 == nil {
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected activate hostname status %d: %s", resp.StatusCode(), string(resp.Body))
}
hostnames.PrintStatus(resp.JSON201, os.Stderr)
Expand Down
4 changes: 4 additions & 0 deletions apps/cli-go/internal/hostnames/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/hostnames"
"github.com/supabase/cli/internal/telemetry"
"github.com/supabase/cli/internal/utils"
"github.com/supabase/cli/pkg/api"
)
Expand All @@ -24,6 +25,9 @@ func Run(ctx context.Context, projectRef string, customHostname string, fsys afe
if err != nil {
return errors.Errorf("failed to create custom hostname: %w", err)
} else if resp.JSON201 == nil {
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected create hostname status %d: %s", resp.StatusCode(), string(resp.Body))
}
hostnames.PrintStatus(resp.JSON201, os.Stderr)
Expand Down
4 changes: 4 additions & 0 deletions apps/cli-go/internal/hostnames/get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/hostnames"
"github.com/supabase/cli/internal/telemetry"
"github.com/supabase/cli/internal/utils"
)

Expand All @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error {
if err != nil {
return errors.Errorf("failed to get custom hostname: %w", err)
} else if resp.JSON200 == nil {
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected get hostname status %d: %s", resp.StatusCode(), string(resp.Body))
}
hostnames.PrintStatus(resp.JSON200, os.Stderr)
Expand Down
4 changes: 4 additions & 0 deletions apps/cli-go/internal/hostnames/reverify/reverify.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/go-errors/errors"
"github.com/spf13/afero"
"github.com/supabase/cli/internal/hostnames"
"github.com/supabase/cli/internal/telemetry"
"github.com/supabase/cli/internal/utils"
)

Expand All @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error {
if err != nil {
return errors.Errorf("failed to re-verify custom hostname: %w", err)
} else if resp.JSON201 == nil {
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.Errorf("unexpected re-verify hostname status %d: %s", resp.StatusCode(), string(resp.Body))
}
hostnames.PrintStatus(resp.JSON201, os.Stderr)
Expand Down
4 changes: 2 additions & 2 deletions apps/cli-go/internal/sso/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ func Run(ctx context.Context, params RunParams) error {
}

if resp.JSON201 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
if resp.StatusCode() == http.StatusNotFound {
return errors.New("SAML 2.0 support is not enabled for this project. Please enable it through the dashboard")
Expand Down
4 changes: 2 additions & 2 deletions apps/cli-go/internal/sso/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ func Run(ctx context.Context, ref, format string) error {
}

if resp.JSON200 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
if resp.StatusCode() == http.StatusNotFound {
return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.")
Expand Down
4 changes: 2 additions & 2 deletions apps/cli-go/internal/sso/remove/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func Run(ctx context.Context, ref, providerId, format string) error {
}

if resp.JSON200 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode(), resp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
if resp.StatusCode() == http.StatusNotFound {
return errors.Errorf("An identity provider with ID %q could not be found.", providerId)
Expand Down
8 changes: 4 additions & 4 deletions apps/cli-go/internal/sso/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func Run(ctx context.Context, params RunParams) error {
}

if getResp.JSON200 == nil {
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode(), getResp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
if getResp.StatusCode() == http.StatusNotFound {
return errors.Errorf("An identity provider with ID %q could not be found.", parsed)
Expand Down Expand Up @@ -119,8 +119,8 @@ func Run(ctx context.Context, params RunParams) error {

if putResp.JSON200 == nil {
// GET branch above early-returns on failure, so this and the GET fire are mutually exclusive (max one event per invocation).
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode()); isGated {
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode(), putResp.Body); isGated {
telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug)
}
return errors.New("unexpected error fetching identity provider: " + string(putResp.Body))
}
Expand Down
76 changes: 60 additions & 16 deletions apps/cli-go/internal/utils/plan_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package utils

import (
"context"
"encoding/json"
"fmt"
"strings"

"github.com/supabase/cli/pkg/api"
)

func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) {
Expand All @@ -20,34 +24,74 @@ func GetOrgBillingURL(orgSlug string) string {
return fmt.Sprintf("%s/org/%s/billing", GetSupabaseDashboardURL(), orgSlug)
}

// SuggestUpgradeOnError checks if a failed API response is due to plan limitations
// by looking up the org's entitlements. Only sets CmdSuggestion when the entitlements
// API confirms the feature is gated (hasAccess == false). Returns the resolved org
// slug and true if a billing suggestion was shown (so callers can fire telemetry).
// Only checks on 4xx client errors; skips 2xx (success) and 5xx (server errors).
func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) (orgSlug string, isGated bool) {
func orgSlugFromBillingURL(billingURL string) string {
const marker = "/org/"
start := strings.Index(billingURL, marker)
if start < 0 {
return ""
}
rest := billingURL[start+len(marker):]
if end := strings.Index(rest, "/"); end >= 0 {
return rest[:end]
}
return rest
}

func parsePlanGateError(body []byte) (feature, upgradeURL string, ok bool) {
var envelope api.PlanGateErrorBody
if err := json.Unmarshal(body, &envelope); err != nil || envelope.Error == nil {
return "", "", false
}
if envelope.Error.Code != api.EntitlementRequired || envelope.Error.Feature == "" {
return "", "", false
}
if envelope.Error.UpgradeUrl == nil || *envelope.Error.UpgradeUrl == "" {
return "", "", false
}
return envelope.Error.Feature, *envelope.Error.UpgradeUrl, true
}

func setUpgradeSuggestion(billingURL string) {
CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL))
}

// SuggestUpgradeOnError checks whether a failed API response is a plan gate and
// sets CmdSuggestion with the billing URL when it is. The entitlement_required
// envelope on the response body is authoritative and needs no network calls;
// when absent, a non-empty featureKey falls back to the entitlements lookup.
// An empty featureKey disables the fallback (envelope-only sites). Returns the
// effective feature and org slug for telemetry, and whether a suggestion was
// shown. Only fires on 4xx.
func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int, body []byte) (gatedFeature, orgSlug string, isGated bool) {
if statusCode < 400 || statusCode >= 500 {
return
return "", "", false
}

if feature, upgradeURL, ok := parsePlanGateError(body); ok {
setUpgradeSuggestion(upgradeURL)
return feature, orgSlugFromBillingURL(upgradeURL), true
}

if featureKey == "" {
return "", "", false
}

orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef)
slug, err := GetOrgSlugFromProjectRef(ctx, projectRef)
if err != nil {
return
return "", "", false
}

resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug)
resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, slug)
if err != nil || resp.JSON200 == nil {
return
return "", slug, false
}

for _, e := range resp.JSON200.Entitlements {
if string(e.Feature.Key) == featureKey && !e.HasAccess {
billingURL := GetOrgBillingURL(orgSlug)
CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL))
isGated = true
return
setUpgradeSuggestion(GetOrgBillingURL(slug))
return featureKey, slug, true
}
}

return
return "", slug, false
}
Loading
Loading