From 8462ee8dc67eacea270389f07935e65a7f0359c1 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 24 Jul 2026 18:03:18 +0800 Subject: [PATCH 1/5] feat(cli-go): consume entitlement_required envelope in SuggestUpgradeOnError --- .../cli-go/internal/branches/create/create.go | 4 +- .../internal/branches/create/create_test.go | 20 +++++ .../cli-go/internal/branches/update/update.go | 4 +- .../internal/hostnames/activate/activate.go | 4 + .../internal/hostnames/create/create.go | 4 + apps/cli-go/internal/hostnames/get/get.go | 4 + .../internal/hostnames/reverify/reverify.go | 4 + apps/cli-go/internal/sso/create/create.go | 4 +- apps/cli-go/internal/sso/list/list.go | 4 +- apps/cli-go/internal/sso/remove/remove.go | 4 +- apps/cli-go/internal/sso/update/update.go | 8 +- apps/cli-go/internal/utils/plan_gate.go | 76 ++++++++++++---- apps/cli-go/internal/utils/plan_gate_test.go | 90 +++++++++++++++++-- .../vanity_subdomains/activate/activate.go | 4 +- .../internal/vanity_subdomains/check/check.go | 2 +- .../internal/vanity_subdomains/get/get.go | 4 + 16 files changed, 198 insertions(+), 42 deletions(-) diff --git a/apps/cli-go/internal/branches/create/create.go b/apps/cli-go/internal/branches/create/create.go index 8c0ac98c71..1d7ff9aff5 100644 --- a/apps/cli-go/internal/branches/create/create.go +++ b/apps/cli-go/internal/branches/create/create.go @@ -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)) } diff --git a/apps/cli-go/internal/branches/create/create_test.go b/apps/cli-go/internal/branches/create/create_test.go index c08a10286c..736ddf2964 100644 --- a/apps/cli-go/internal/branches/create/create_test.go +++ b/apps/cli-go/internal/branches/create/create_test.go @@ -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") + }) } diff --git a/apps/cli-go/internal/branches/update/update.go b/apps/cli-go/internal/branches/update/update.go index 7abbabb9af..7f926eab97 100644 --- a/apps/cli-go/internal/branches/update/update.go +++ b/apps/cli-go/internal/branches/update/update.go @@ -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)) } diff --git a/apps/cli-go/internal/hostnames/activate/activate.go b/apps/cli-go/internal/hostnames/activate/activate.go index 11a5e41248..3cfb567650 100644 --- a/apps/cli-go/internal/hostnames/activate/activate.go +++ b/apps/cli-go/internal/hostnames/activate/activate.go @@ -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" ) @@ -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) diff --git a/apps/cli-go/internal/hostnames/create/create.go b/apps/cli-go/internal/hostnames/create/create.go index ef6f01e603..15753fe81d 100644 --- a/apps/cli-go/internal/hostnames/create/create.go +++ b/apps/cli-go/internal/hostnames/create/create.go @@ -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" ) @@ -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) diff --git a/apps/cli-go/internal/hostnames/get/get.go b/apps/cli-go/internal/hostnames/get/get.go index ded7c8622f..88235275e4 100644 --- a/apps/cli-go/internal/hostnames/get/get.go +++ b/apps/cli-go/internal/hostnames/get/get.go @@ -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" ) @@ -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) diff --git a/apps/cli-go/internal/hostnames/reverify/reverify.go b/apps/cli-go/internal/hostnames/reverify/reverify.go index f38f38fd49..6c6c610f98 100644 --- a/apps/cli-go/internal/hostnames/reverify/reverify.go +++ b/apps/cli-go/internal/hostnames/reverify/reverify.go @@ -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" ) @@ -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) diff --git a/apps/cli-go/internal/sso/create/create.go b/apps/cli-go/internal/sso/create/create.go index 3716e5ee73..c471580ea8 100644 --- a/apps/cli-go/internal/sso/create/create.go +++ b/apps/cli-go/internal/sso/create/create.go @@ -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") diff --git a/apps/cli-go/internal/sso/list/list.go b/apps/cli-go/internal/sso/list/list.go index 57ff92dd51..62de936929 100644 --- a/apps/cli-go/internal/sso/list/list.go +++ b/apps/cli-go/internal/sso/list/list.go @@ -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.") diff --git a/apps/cli-go/internal/sso/remove/remove.go b/apps/cli-go/internal/sso/remove/remove.go index a13d946c82..0145760167 100644 --- a/apps/cli-go/internal/sso/remove/remove.go +++ b/apps/cli-go/internal/sso/remove/remove.go @@ -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) diff --git a/apps/cli-go/internal/sso/update/update.go b/apps/cli-go/internal/sso/update/update.go index 6a4dc9b294..c2a206434f 100644 --- a/apps/cli-go/internal/sso/update/update.go +++ b/apps/cli-go/internal/sso/update/update.go @@ -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) @@ -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)) } diff --git a/apps/cli-go/internal/utils/plan_gate.go b/apps/cli-go/internal/utils/plan_gate.go index 7267a2de39..c666725557 100644 --- a/apps/cli-go/internal/utils/plan_gate.go +++ b/apps/cli-go/internal/utils/plan_gate.go @@ -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) { @@ -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 } diff --git a/apps/cli-go/internal/utils/plan_gate_test.go b/apps/cli-go/internal/utils/plan_gate_test.go index c00f00a381..5f85d5076f 100644 --- a/apps/cli-go/internal/utils/plan_gate_test.go +++ b/apps/cli-go/internal/utils/plan_gate_test.go @@ -90,8 +90,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "branching_limit", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.True(t, got) + assert.Equal(t, "branching_limit", feature) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "does not have access") @@ -101,8 +102,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "vanity_subdomain", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest) + feature, slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest, nil) assert.True(t, got) + assert.Equal(t, "vanity_subdomain", feature) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "does not have access") @@ -112,7 +114,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "auth.saml_2", false) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound, nil) assert.True(t, got) assert.Equal(t, "my-org", slug) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") @@ -128,7 +130,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Equal(t, "my-org", slug) assert.Empty(t, CmdSuggestion) @@ -140,7 +142,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Empty(t, slug) assert.Empty(t, CmdSuggestion) @@ -150,7 +152,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) mockEntitlementsCheck(ref, "branching_limit", true) - slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + _, slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, nil) assert.False(t, got) assert.Equal(t, "my-org", slug) assert.Empty(t, CmdSuggestion) @@ -158,22 +160,92 @@ func TestSuggestUpgradeOnError(t *testing.T) { t.Run("skips on 503 server error", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips on 200", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips on 201", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated) + _, _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated, nil) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) } + +func TestSuggestUpgradeEnvelope(t *testing.T) { + envelope := `{"message":"x","error":{"code":"entitlement_required","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + + t.Run("envelope sets suggestion with zero API calls", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + feature, slug, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(envelope)) + assert.True(t, gated) + assert.Equal(t, "custom_domain", feature) + assert.Equal(t, "acme", slug) + assert.Contains(t, CmdSuggestion, "org/acme/billing") + assert.Contains(t, CmdSuggestion, "does not have access") + }) + + t.Run("envelope feature wins over call-site featureKey", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_persistent","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + feature, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "branching_limit", http.StatusPaymentRequired, []byte(body)) + assert.True(t, gated) + assert.Equal(t, "branching_persistent", feature) + }) + + t.Run("envelope without upgrade_url falls back to entitlements", func(t *testing.T) { + ref := apitest.RandomProjectRef() + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "branching_limit", false) + body := `{"message":"x","error":{"code":"entitlement_required","feature":"branching_limit"}}` + feature, slug, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(body)) + assert.True(t, gated) + assert.Equal(t, "branching_limit", feature) + assert.Equal(t, "my-org", slug) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + }) + + t.Run("malformed body falls back to entitlements", func(t *testing.T) { + ref := apitest.RandomProjectRef() + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "branching_limit", false) + _, _, gated := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired, []byte(`not json`)) + assert.True(t, gated) + }) + + t.Run("no envelope and empty featureKey is a no-op with zero API calls", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusNotFound, []byte(`{"message":"not found"}`)) + assert.False(t, gated) + assert.Empty(t, CmdSuggestion) + }) + + t.Run("non-gate error code is ignored", func(t *testing.T) { + t.Cleanup(func() { CmdSuggestion = "" }) + body := `{"message":"x","error":{"code":"validation_failed","feature":"custom_domain","upgrade_url":"https://supabase.com/dashboard/org/acme/billing"}}` + _, _, gated := SuggestUpgradeOnError(context.Background(), "ref", "", http.StatusBadRequest, []byte(body)) + assert.False(t, gated) + assert.Empty(t, CmdSuggestion) + }) +} + +func TestOrgSlugFromBillingURL(t *testing.T) { + cases := map[string]string{ + "https://supabase.com/dashboard/org/acme/billing": "acme", + "https://supabase.com/dashboard/org/acme": "acme", + "https://example.com/nope": "", + } + for in, want := range cases { + assert.Equal(t, want, orgSlugFromBillingURL(in), in) + } +} diff --git a/apps/cli-go/internal/vanity_subdomains/activate/activate.go b/apps/cli-go/internal/vanity_subdomains/activate/activate.go index 18b953953b..379f75f8f0 100644 --- a/apps/cli-go/internal/vanity_subdomains/activate/activate.go +++ b/apps/cli-go/internal/vanity_subdomains/activate/activate.go @@ -19,8 +19,8 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed activate vanity subdomain: %w", err) } else if resp.JSON201 == nil { - if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()); isGated { - telemetry.TrackUpgradeSuggested(ctx, "vanity_subdomain", orgSlug) + if feature, orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode(), resp.Body); isGated { + telemetry.TrackUpgradeSuggested(ctx, feature, orgSlug) } return errors.Errorf("unexpected activate vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } diff --git a/apps/cli-go/internal/vanity_subdomains/check/check.go b/apps/cli-go/internal/vanity_subdomains/check/check.go index fa64a1e463..a22a70b00b 100644 --- a/apps/cli-go/internal/vanity_subdomains/check/check.go +++ b/apps/cli-go/internal/vanity_subdomains/check/check.go @@ -18,7 +18,7 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed to check vanity subdomain: %w", err) } else if resp.JSON201 == nil { - utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()) + utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode(), resp.Body) return errors.Errorf("unexpected check vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty { diff --git a/apps/cli-go/internal/vanity_subdomains/get/get.go b/apps/cli-go/internal/vanity_subdomains/get/get.go index 303e9edec1..3897f50bc3 100644 --- a/apps/cli-go/internal/vanity_subdomains/get/get.go +++ b/apps/cli-go/internal/vanity_subdomains/get/get.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -15,6 +16,9 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to get vanity subdomain: %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 vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty { From fb87425130ef2916ba57b420b6f80b5d8171c5ad Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 24 Jul 2026 18:20:47 +0800 Subject: [PATCH 2/5] feat(cli): consume entitlement_required envelope in legacySuggestUpgrade --- .../branches/create/create.handler.ts | 16 +- .../create/create.integration.test.ts | 26 +++ .../branches/update/update.handler.ts | 14 +- .../legacy/commands/sso/add/add.handler.ts | 1 + .../legacy/commands/sso/list/list.handler.ts | 6 +- .../commands/sso/remove/remove.handler.ts | 6 +- .../commands/sso/update/update.handler.ts | 7 +- .../activate/activate.handler.ts | 6 +- .../check-availability.handler.ts | 6 +- .../legacy/shared/legacy-upgrade-suggest.ts | 212 ++++++++++++------ .../legacy-upgrade-suggest.unit.test.ts | 143 ++++++++++++ 11 files changed, 358 insertions(+), 85 deletions(-) diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..c819dca8a8 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -1,6 +1,5 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -16,7 +15,10 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, @@ -99,17 +101,15 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.catch((cause) => // Mirror Go's `create.go:34-37`: on any non-201 status (including - // gated 4xx), run the entitlement check; `legacySuggestUpgrade` + // gated 4xx), run the plan-gate check; `legacySuggestUpgrade` // is a no-op for 2xx/5xx itself, so we can call it unconditionally. Effect.gen(function* () { - const status = - HttpClientError.isHttpClientError(cause) && cause.response !== undefined - ? cause.response.status - : 0; + const response = legacyGateResponse(cause); yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "branching_limit", - statusCode: status, + statusCode: response?.status ?? 0, + response, }); return yield* mapCreateErrorRaw(cause); }), diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index 8fb2a9e5d5..d010f98897 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -259,6 +259,32 @@ describe("legacy branches create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("envelope on 402 suggests upgrade with no extra API calls", () => { + const { layer, out, analytics, api } = setup({ + status: 402, + response: { + message: "Branching is supported only on the Pro plan or above", + error: { + code: "entitlement_required", + feature: "branching_limit", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + } as unknown as CreatedBranch, + }); + return Effect.gen(function* () { + yield* Effect.exit(legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") })); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toContain("/branches"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "branching_limit", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("does NOT fire upgrade suggested on 500 (Go skips 5xx)", () => { const { layer, analytics } = setup({ status: 500 }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 5dc3085c74..72df6193e0 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -1,6 +1,5 @@ import type { V1UpdateABranchConfigInput, V1UpdateABranchConfigOutput } from "@supabase/api/effect"; import { Effect, Option } from "effect"; -import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -16,7 +15,10 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesUpdateNetworkError, LegacyBranchesUpdateUnexpectedStatusError, @@ -72,16 +74,14 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function Effect.tapError(() => patching?.fail() ?? Effect.void), Effect.catch((cause) => Effect.gen(function* () { - const status = - HttpClientError.isHttpClientError(cause) && cause.response !== undefined - ? cause.response.status - : 0; + const response = legacyGateResponse(cause); // Mirrors Go's `update.go:26` — pass the resolved branch's project // ref so the entitlements check is scoped to the branch's org. yield* legacySuggestUpgrade({ projectRef: branchRef, featureKey: "branching_persistent", - statusCode: status, + statusCode: response?.status ?? 0, + response, }); return yield* mapUpdateError(cause); }), diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index e1951e7b6c..29b3592ba9 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -141,6 +141,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, + response, }); yield* creating?.fail() ?? Effect.void; if (response.status === 404) { diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 408e43d23b..9f3a49d8f2 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -14,7 +14,10 @@ import { import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoListNetworkError, LegacySsoListSamlDisabledError, @@ -41,6 +44,7 @@ const handleListError = (ref: string, cause: SupabaseApiError) => projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c3a35f0ef3..c7cc0bf8e6 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -9,7 +9,10 @@ import { encodeGoJson, encodeToml, encodeYaml } from "../../../shared/legacy-go- import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoRemoveNetworkError, LegacySsoRemoveNotFoundError, @@ -33,6 +36,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index aba1d6833f..df89673d0f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -22,7 +22,10 @@ import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/leg import { resolveLegacyAccessToken } from "../../../shared/legacy-resolve-token.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacySsoMutexFlagError, LegacySsoUpdateAttributeMappingFileError, @@ -94,6 +97,7 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, + response: legacyGateResponse(cause), }); if (mapped.status === 404) { return yield* Effect.fail( @@ -267,6 +271,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, + response, }); yield* fetching?.fail() ?? Effect.void; return yield* Effect.fail( diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 27877ce2c5..291ee8bb4d 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -61,6 +64,7 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, + response: legacyGateResponse(cause), }); } return yield* Effect.fail(mapped); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index d17836e693..fb8b25695a 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -2,7 +2,10 @@ import { Effect, Option } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; -import { legacySuggestUpgrade } from "../../../shared/legacy-upgrade-suggest.ts"; +import { + legacyGateResponse, + legacySuggestUpgrade, +} from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -64,6 +67,7 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, + response: legacyGateResponse(cause), trackAnalytics: false, }); } diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 0466e726ec..6b67270840 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -2,7 +2,9 @@ import { styleText } from "node:util"; import { Effect, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { resolveLegacyAccessToken } from "./legacy-resolve-token.ts"; @@ -23,32 +25,85 @@ function readString(obj: unknown, key: string): string { return ""; } +interface LegacyPlanGateEnvelope { + readonly feature: string; + readonly upgradeUrl: string; +} + +/** + * Parses the management API's plan-gate error body, + * `{ message, error: { code: "entitlement_required", feature, upgrade_url } }`. + * The `packages/api` codegen intentionally emits no non-2xx schemas, so this + * shape is hand-validated here (the Go twin uses the generated + * `api.PlanGateErrorBody`). + */ +function parseLegacyPlanGateEnvelope(body: unknown): LegacyPlanGateEnvelope | undefined { + if (typeof body !== "object" || body === null) return undefined; + const error = (body as { readonly error?: unknown }).error; + if (typeof error !== "object" || error === null) return undefined; + const code = readString(error, "code"); + const feature = readString(error, "feature"); + const upgradeUrl = readString(error, "upgrade_url"); + if (code !== "entitlement_required" || feature === "" || upgradeUrl === "") { + return undefined; + } + return { feature, upgradeUrl }; +} + +function legacyOrgSlugFromUpgradeUrl(upgradeUrl: string): string { + const match = /\/org\/([^/]+)/.exec(upgradeUrl); + return match?.[1] ?? ""; +} + +/** + * Extracts the failed HttpClientResponse from a caught SupabaseApiError so + * call sites can hand `legacySuggestUpgrade` the body without repeating the + * isHttpClientError narrowing. + */ +export function legacyGateResponse( + cause: unknown, +): HttpClientResponse.HttpClientResponse | undefined { + return HttpClientError.isHttpClientError(cause) ? cause.response : undefined; +} + /** * Reproduces `apps/cli-go/internal/utils/plan_gate.go:SuggestUpgradeOnError`: * * - Skip non-4xx statuses (2xx / 5xx). - * - GET `/v1/projects/{ref}` → `organization_slug`. - * - GET `/v1/organizations/{slug}/entitlements` → look for the requested feature - * key with `hasAccess: false`. + * - Envelope first: when `response` carries the `entitlement_required` body, + * print the billing-link suggestion from its `upgrade_url` with zero extra + * API calls; telemetry `feature_key` comes from the envelope and `org_slug` + * from the URL's `/org//` segment. + * - Fallback (only when `featureKey` is provided): GET `/v1/projects/{ref}` + * → `organization_slug`, then GET `/v1/organizations/{slug}/entitlements` + * → look for the requested feature key with `hasAccess: false`. * - On gated: write the billing-link suggestion to stderr (text mode only, - * matches Go's `CmdSuggestion` print) **and** fire the `cli_upgrade_suggested` - * telemetry event with `{feature_key, org_slug}` properties. + * matches Go's `CmdSuggestion` print) **and** fire the + * `cli_upgrade_suggested` telemetry event with `{feature_key, org_slug}`. * - * Never fails the caller; lookup errors swallow into a no-op suggestion. + * Never fails the caller; lookup and parse errors swallow into a no-op. * - * Bypasses the typed Management API client to GET `/v1/projects/{ref}` and - * `/v1/organizations/{slug}/entitlements` directly via `HttpClient`. The - * generated `V1GetProjectOutput` schema enforces `ref: isMinLength(20)`, which - * the cli-e2e replay fixtures cannot satisfy (they embed the literal 15-char - * `__PROJECT_REF__` placeholder in response bodies). Strict decode would fail - * silently inside `Effect.option`, the entitlements GET would be skipped, and - * parity with Go's request log would break. Same workaround used by - * `legacy-linked-project-cache.layer.ts`. + * The fallback bypasses the typed Management API client to GET + * `/v1/projects/{ref}` and `/v1/organizations/{slug}/entitlements` directly + * via `HttpClient`. The generated `V1GetProjectOutput` schema enforces + * `ref: isMinLength(20)`, which the cli-e2e replay fixtures cannot satisfy + * (they embed the literal 15-char `__PROJECT_REF__` placeholder in response + * bodies). Strict decode would fail silently inside `Effect.option`, the + * entitlements GET would be skipped, and parity with Go's request log would + * break. Same workaround used by `legacy-linked-project-cache.layer.ts`. */ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; - readonly featureKey: string; + /** + * Entitlements-fallback feature key. Omit for envelope-only sites (the + * domains family and vanity get): their gates are add-on or read-path + * checks the plan-level entitlement key cannot represent, so the fallback + * would false-positive on unrelated 4xxs. + */ + readonly featureKey?: string; readonly statusCode: number; + /** The failed response; enables the zero-round-trip envelope path. */ + readonly response?: HttpClientResponse.HttpClientResponse; /** * Whether to fire the `cli_upgrade_suggested` analytics event when a gate is * detected. Defaults to `true`. Pass `false` for Go call-sites that invoke @@ -66,59 +121,86 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { const cliConfig = yield* LegacyCliConfig; const httpClient = yield* HttpClient.HttpClient; - const tokenOpt = yield* resolveLegacyAccessToken; - const authHeader: ( - req: HttpClientRequest.HttpClientRequest, - ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) - ? HttpClientRequest.bearerToken(tokenOpt.value) - : (req) => req; - - const projectReq = HttpClientRequest.get( - `${cliConfig.apiUrl}/v1/projects/${opts.projectRef}`, - ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); - const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); - if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; - } - const projectBody = yield* projectResp.value.json.pipe(Effect.option); - if (projectBody._tag === "None") { - return; - } - const orgSlug = readString(projectBody.value, "organization_slug"); - if (orgSlug.length === 0) { - return; - } + let gate: + | { readonly billingUrl: string; readonly feature: string; readonly orgSlug: string } + | undefined; - const entReq = HttpClientRequest.get( - `${cliConfig.apiUrl}/v1/organizations/${orgSlug}/entitlements`, - ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); - const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); - if (entResp._tag === "None" || entResp.value.status !== 200) { - return; - } - const entBody = yield* entResp.value.json.pipe(Effect.option); - if (entBody._tag === "None") { - return; - } - const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; - if (!Array.isArray(entitlements)) { - return; + if (opts.response !== undefined) { + const body = yield* opts.response.json.pipe(Effect.option); + const envelope = Option.isSome(body) ? parseLegacyPlanGateEnvelope(body.value) : undefined; + if (envelope !== undefined) { + gate = { + billingUrl: envelope.upgradeUrl, + feature: envelope.feature, + orgSlug: legacyOrgSlugFromUpgradeUrl(envelope.upgradeUrl), + }; + } } - const gated = entitlements.some((entry: unknown) => { - if (typeof entry !== "object" || entry === null) return false; - const feature = (entry as { feature?: unknown }).feature; - if (typeof feature !== "object" || feature === null) return false; - const key = (feature as { key?: unknown }).key; - const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; - return key === opts.featureKey && hasAccess === false; - }); - if (!gated) { - return; + if (gate === undefined) { + if (opts.featureKey === undefined) { + return; + } + + const tokenOpt = yield* resolveLegacyAccessToken; + const authHeader: ( + req: HttpClientRequest.HttpClientRequest, + ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) + ? HttpClientRequest.bearerToken(tokenOpt.value) + : (req) => req; + + const projectReq = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/projects/${opts.projectRef}`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); + if (projectResp._tag === "None" || projectResp.value.status !== 200) { + return; + } + const projectBody = yield* projectResp.value.json.pipe(Effect.option); + if (projectBody._tag === "None") { + return; + } + const orgSlug = readString(projectBody.value, "organization_slug"); + if (orgSlug.length === 0) { + return; + } + + const entReq = HttpClientRequest.get( + `${cliConfig.apiUrl}/v1/organizations/${orgSlug}/entitlements`, + ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); + const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); + if (entResp._tag === "None" || entResp.value.status !== 200) { + return; + } + const entBody = yield* entResp.value.json.pipe(Effect.option); + if (entBody._tag === "None") { + return; + } + const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; + if (!Array.isArray(entitlements)) { + return; + } + + const gated = entitlements.some((entry: unknown) => { + if (typeof entry !== "object" || entry === null) return false; + const feature = (entry as { feature?: unknown }).feature; + if (typeof feature !== "object" || feature === null) return false; + const key = (feature as { key?: unknown }).key; + const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; + return key === opts.featureKey && hasAccess === false; + }); + if (!gated) { + return; + } + + gate = { + billingUrl: legacyBillingUrl(cliConfig.profile, orgSlug), + feature: opts.featureKey, + orgSlug, + }; } - const url = legacyBillingUrl(cliConfig.profile, orgSlug); - const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", url)}`; + const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", gate.billingUrl)}`; if (output.format === "text") { yield* output.raw(suggestion + "\n", "stderr"); @@ -126,8 +208,8 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { if (opts.trackAnalytics !== false) { yield* analytics.capture(EventUpgradeSuggested, { - [PropFeatureKey]: opts.featureKey, - [PropOrgSlug]: orgSlug, + [PropFeatureKey]: gate.feature, + [PropOrgSlug]: gate.orgSlug, }); } }); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts index 103ab98348..8b4246bd7c 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.unit.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; import { @@ -224,4 +226,145 @@ describe("legacySuggestUpgrade", () => { expect(out.stderrText).toContain("Upgrade your plan:"); }).pipe(Effect.provide(layer)); }); + + const ENVELOPE_BODY = { + message: "Branching is supported only on the Pro plan or above", + error: { + code: "entitlement_required", + feature: "branching_persistent", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }; + + function gateResponse(status: number, body: unknown) { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/x/branches"); + return legacyJsonResponse(request, status, body); + } + + it.live("envelope path suggests with zero API calls and envelope-derived telemetry", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + }); + expect(api.requests).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "branching_persistent", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path works without a featureKey (envelope-only sites)", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 400, + response: gateResponse(400, { + message: "add-on required", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }), + }); + expect(api.requests).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("no envelope and no featureKey is a no-op with zero API calls", () => { + const { layer, out, analytics, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 404, + response: gateResponse(404, { message: "not found" }), + }); + expect(api.requests).toHaveLength(0); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope without upgrade_url falls back to the entitlements round-trip", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: gateResponse(402, { + message: "x", + error: { code: "entitlement_required", feature: "branching_limit" }, + }), + }); + expect(api.requests).toHaveLength(2); + expect(out.stderrText).toContain("/org/test-org/billing"); + }).pipe(Effect.provide(layer)); + }); + + it.live("unparseable body falls back to the entitlements round-trip", () => { + const { layer, out, api } = setup(); + return Effect.gen(function* () { + const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/x/branches"); + const malformed = HttpClientResponse.fromWeb( + request, + new Response("not json", { + status: 402, + headers: { "content-type": "application/json" }, + }), + ); + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + response: malformed, + }); + expect(api.requests).toHaveLength(2); + expect(out.stderrText).toContain("/org/test-org/billing"); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path respects json output mode (no stderr, telemetry fires)", () => { + const { layer, out, analytics } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + }); + expect(out.stderrText).toBe(""); + expect(analytics.captured).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("envelope path respects trackAnalytics=false", () => { + const { layer, out, analytics } = setup(); + return Effect.gen(function* () { + yield* legacySuggestUpgrade({ + projectRef: LEGACY_VALID_REF, + statusCode: 402, + response: gateResponse(402, ENVELOPE_BODY), + trackAnalytics: false, + }); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); }); From d4ac838008fa14cf18798fa0cbbaf7db502f5dab Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 24 Jul 2026 18:36:10 +0800 Subject: [PATCH 3/5] feat(cli): surface upgrade hint on domains commands and vanity get --- apps/cli/AGENTS.md | 12 ++--- .../branches/create/create.handler.ts | 23 ++------ .../create/create.integration.test.ts | 5 +- .../branches/update/update.handler.ts | 25 +++------ .../legacy/commands/domains/SIDE_EFFECTS.md | 9 ++-- .../domains/activate/activate.handler.ts | 3 +- .../activate/activate.integration.test.ts | 37 +++++++++++-- .../commands/domains/create/create.handler.ts | 3 +- .../domains/create/create.integration.test.ts | 54 +++++++++++++++++-- .../commands/domains/get/get.handler.ts | 3 +- .../domains/get/get.integration.test.ts | 50 +++++++++++++++-- .../domains/reverify/reverify.handler.ts | 3 +- .../reverify/reverify.integration.test.ts | 37 +++++++++++-- .../vanity-subdomains/get/SIDE_EFFECTS.md | 7 +-- .../vanity-subdomains/get/get.handler.ts | 3 +- .../vanity-subdomains.integration.test.ts | 50 +++++++++++++++++ .../legacy/shared/legacy-upgrade-suggest.ts | 24 +++++++++ 17 files changed, 281 insertions(+), 67 deletions(-) diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index d4830ccc9e..6596169854 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -295,12 +295,12 @@ The legacy shell sends the same PostHog events to the same product analytics pip - **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`. - **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal//` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested`. The current Go custom events that legacy ports must reproduce when natively ported: - | Command | Event | Identity / groups | Go source | - | ------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | - | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | - | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | - | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | - | `sso/{list,create,update,remove}`, `branches/{create,update}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch | 7 call-sites under `internal/{sso,branches}/` | + | Command | Event | Identity / groups | Go source | + | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | + | `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` | + | `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` | + | `start` | `cli_stack_started` | none — fired after stack health check passes | `internal/start/start.go:1245` | + | `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (`SuggestUpgradeOnError` is envelope-first; hostnames + vanity get are envelope-only) | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` | Reference pattern for login: `next/commands/login/login.handler.ts:38-62`. diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index c819dca8a8..13bbbcad29 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -15,10 +15,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { - legacyGateResponse, - legacySuggestUpgrade, -} from "../../../shared/legacy-upgrade-suggest.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesCreateCancelledError, LegacyBranchesCreateNetworkError, @@ -99,20 +96,10 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.catch((cause) => - // Mirror Go's `create.go:34-37`: on any non-201 status (including - // gated 4xx), run the plan-gate check; `legacySuggestUpgrade` - // is a no-op for 2xx/5xx itself, so we can call it unconditionally. - Effect.gen(function* () { - const response = legacyGateResponse(cause); - yield* legacySuggestUpgrade({ - projectRef: ref, - featureKey: "branching_limit", - statusCode: response?.status ?? 0, - response, - }); - return yield* mapCreateErrorRaw(cause); - }), + // Mirror Go's `create.go:34-37`: on any non-201 status (including + // gated 4xx), run the plan-gate check before mapping the error. + Effect.catch( + legacyGateMapError({ projectRef: ref, featureKey: "branching_limit" }, mapCreateErrorRaw), ), ); yield* creating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index d010f98897..90894d8fe7 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -272,7 +272,10 @@ describe("legacy branches create integration", () => { } as unknown as CreatedBranch, }); return Effect.gen(function* () { - yield* Effect.exit(legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") })); + const exit = yield* Effect.exit( + legacyBranchesCreate({ ...baseFlags, name: Option.some("feat-x") }), + ); + expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain("/branches"); expect(out.stderrText).toContain("/org/env-org/billing"); diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 72df6193e0..6e8ba5c59c 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -15,10 +15,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; -import { - legacyGateResponse, - legacySuggestUpgrade, -} from "../../../shared/legacy-upgrade-suggest.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyBranchesUpdateNetworkError, LegacyBranchesUpdateUnexpectedStatusError, @@ -72,19 +69,13 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function }) .pipe( Effect.tapError(() => patching?.fail() ?? Effect.void), - Effect.catch((cause) => - Effect.gen(function* () { - const response = legacyGateResponse(cause); - // Mirrors Go's `update.go:26` — pass the resolved branch's project - // ref so the entitlements check is scoped to the branch's org. - yield* legacySuggestUpgrade({ - projectRef: branchRef, - featureKey: "branching_persistent", - statusCode: response?.status ?? 0, - response, - }); - return yield* mapUpdateError(cause); - }), + // Mirrors Go's `update.go:26` — pass the resolved branch's project + // ref so the entitlements check is scoped to the branch's org. + Effect.catch( + legacyGateMapError( + { projectRef: branchRef, featureKey: "branching_persistent" }, + mapUpdateError, + ), ), ); yield* patching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index 5dada1097d..6d6b607d8c 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -49,11 +49,10 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — Go marks no `domains` flag telemetry-safe) | - -No custom events: the Go `internal/hostnames` package emits no `phtelemetry.*` calls. +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (all redacted — Go marks no `domains` flag telemetry-safe) | +| `cli_upgrade_suggested` | `create`/`get`/`activate`/`reverify` 4xx carrying the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts index 534050089c..de7496961a 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsActivateFlags } from "./activate.command.ts"; const mapActivateError = mapLegacyDomainsHttpError("activate"); @@ -27,7 +28,7 @@ export const legacyDomainsActivate = Effect.fn("legacy.domains.activate")(functi output.format === "text" ? yield* output.task("Activating custom hostname...") : undefined; const response = yield* api.v1.activateCustomHostname({ ref }).pipe( Effect.tapError(() => activating?.fail() ?? Effect.void), - Effect.catch(mapActivateError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapActivateError)), ); yield* activating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts index 950aeb2a95..d12f40f0d5 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,14 +38,16 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-activate-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ - response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + response: { status: opts.status ?? 201, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); @@ -55,16 +57,45 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; describe("legacy domains activate integration", () => { + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("prints the completion status to stderr in text mode", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/create/create.handler.ts b/apps/cli/src/legacy/commands/domains/create/create.handler.ts index 8d3a10b585..35fdb7e6fb 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.handler.ts @@ -10,6 +10,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { verifyLegacyCname } from "../domains.cname.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsCreateFlags } from "./create.command.ts"; const mapCreateError = mapLegacyDomainsHttpError("create"); @@ -45,7 +46,7 @@ export const legacyDomainsCreate = Effect.fn("legacy.domains.create")(function* .updateHostnameConfig({ ref, custom_hostname: flags.customHostname }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.catch(mapCreateError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapCreateError)), ); yield* creating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts index edb4843753..c541d03a8d 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, buildLegacyTestRuntime, @@ -45,6 +45,7 @@ interface SetupOpts { readonly cname?: "ok" | "transport-fail" | "no-cname" | "mismatch" | "status-error"; readonly apiStatus?: number; readonly apiNetwork?: "fail"; + readonly apiResponse?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-create-int-"); @@ -70,21 +71,25 @@ function setup(opts: SetupOpts = {}) { if (opts.apiNetwork === "fail") { return Effect.fail(legacyTransportFailure(request)); } - return Effect.succeed(legacyJsonResponse(request, opts.apiStatus ?? 201, HOSTNAME_RESPONSE)); + return Effect.succeed( + legacyJsonResponse(request, opts.apiStatus ?? 201, opts.apiResponse ?? HOSTNAME_RESPONSE), + ); }, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); + const analytics = mockAnalytics(); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); const layer = buildLegacyTestRuntime({ out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } function flags(over: Partial<{ includeRawOutput: boolean }> = {}) { @@ -112,6 +117,49 @@ describe("legacy domains create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + apiStatus: 400, + apiResponse: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(2); + expect(postedToInitialize(api)).toBe(true); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 400 without envelope produces no upgrade hint", () => { + const { layer, out, analytics, api } = setup({ + apiStatus: 400, + apiResponse: { message: "invalid hostname" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsCreate(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(2); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("fails before any POST when the CNAME lookup transport fails", () => { const { layer, api } = setup({ cname: "transport-fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/get/get.handler.ts b/apps/cli/src/legacy/commands/domains/get/get.handler.ts index d60e6fd13f..70e76bc476 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsGetFlags } from "./get.command.ts"; const mapGetError = mapLegacyDomainsHttpError("get"); @@ -31,7 +32,7 @@ export const legacyDomainsGet = Effect.fn("legacy.domains.get")(function* ( : undefined; const response = yield* api.v1.getHostnameConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapGetError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapGetError)), ); yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index 45cbd2ba3b..b7eb71d565 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,13 +38,14 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; - readonly response?: typeof V1GetHostnameConfigOutput.Type; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-get-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ response: { status: opts.status ?? 200, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, @@ -56,11 +57,12 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; @@ -301,6 +303,48 @@ describe("legacy domains get integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 404 without envelope produces no upgrade hint", () => { + const { layer, out, analytics, api } = setup({ + status: 404, + response: { message: "not found" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("maps an HTTP error without a spinner in json mode", () => { const { layer, out } = setup({ format: "json", status: 503 }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts index 83a527763a..9b338ecc2a 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.handler.ts @@ -7,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { emitLegacyHostnameResult } from "../domains.emit.ts"; import { mapLegacyDomainsHttpError } from "../domains.errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import type { LegacyDomainsReverifyFlags } from "./reverify.command.ts"; const mapReverifyError = mapLegacyDomainsHttpError("re-verify"); @@ -27,7 +28,7 @@ export const legacyDomainsReverify = Effect.fn("legacy.domains.reverify")(functi output.format === "text" ? yield* output.task("Re-verifying custom hostname...") : undefined; const response = yield* api.v1.verifyDnsConfig({ ref }).pipe( Effect.tapError(() => reverifying?.fail() ?? Effect.void), - Effect.catch(mapReverifyError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapReverifyError)), ); yield* reverifying?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts index f590ce361c..e6dfddd66e 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts @@ -2,7 +2,7 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -38,14 +38,16 @@ interface SetupOpts { readonly goOutput?: GoOutput; readonly status?: number; readonly network?: "fail"; + readonly response?: unknown; } const tempRoot = useLegacyTempWorkdir("supabase-domains-reverify-int-"); function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); + const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ - response: { status: opts.status ?? 201, body: HOSTNAME_RESPONSE }, + response: { status: opts.status ?? 201, body: opts.response ?? HOSTNAME_RESPONSE }, network: opts.network, }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); @@ -55,16 +57,45 @@ function setup(opts: SetupOpts = {}) { out, api, cliConfig, + analytics, telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); - return { layer, out, api, telemetry, linkedProjectCache }; + return { layer, out, api, analytics, telemetry, linkedProjectCache }; } const baseFlags = { projectRef: Option.none(), includeRawOutput: false }; describe("legacy domains reverify integration", () => { + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const { layer, out, analytics, api } = setup({ + status: 400, + response: { + message: + "Custom domains require the Custom Domain add-on, available on the Pro plan and above.", + error: { + code: "entitlement_required", + feature: "custom_domain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "custom_domain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.live("prints the initializing status to stderr in text mode", () => { const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 73b367ec63..afbced1a8d 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -39,9 +39,10 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ----------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| Event | When | Notable properties / groups | +| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | +| `cli_upgrade_suggested` | 4xx carrying the `entitlement_required` envelope | `feature_key` (from the envelope), `org_slug` (parsed from `upgrade_url`); envelope-only, no entitlements fallback | ## Output diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts index 47cfc2908e..192725f606 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/get/get.handler.ts @@ -13,6 +13,7 @@ import { encodeYaml, } from "../../../shared/legacy-go-output.encoders.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { legacyGateMapError } from "../../../shared/legacy-upgrade-suggest.ts"; import { LegacyVanitySubdomainsGetNetworkError, LegacyVanitySubdomainsGetUnexpectedStatusError, @@ -44,7 +45,7 @@ export const legacyVanitySubdomainsGet = Effect.fn("legacy.vanity-subdomains.get output.format === "text" ? yield* output.task("Getting vanity subdomain...") : undefined; const response = yield* api.v1.getVanitySubdomainConfig({ ref }).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch(mapGetError), + Effect.catch(legacyGateMapError({ projectRef: ref }, mapGetError)), ); yield* fetching?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 40c484acfe..30f4670eed 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -111,6 +111,56 @@ describe("legacy vanity-subdomains get", () => { }).pipe(Effect.provide(layer)); }); + it.live("suggests upgrade from entitlement_required envelope on 400", () => { + const out = mockOutput({ format: "text" }); + const analytics = mockAnalytics(); + const api = mockLegacyPlatformApi({ + response: { + status: 400, + body: { + message: "This feature requires the Pro, Team, or Enterprise organization plan.", + error: { + code: "entitlement_required", + feature: "vanity_subdomain", + upgrade_url: "https://supabase.com/dashboard/org/env-org/billing", + }, + }, + }, + }); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(out.stderrText).toContain("Upgrade your plan:"); + expect(out.stderrText).toContain("/org/env-org/billing"); + expect(analytics.captured).toEqual([ + { + event: "cli_upgrade_suggested", + properties: { feature_key: "vanity_subdomain", org_slug: "env-org" }, + }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("plain 404 without envelope produces no upgrade hint", () => { + const out = mockOutput({ format: "text" }); + const analytics = mockAnalytics(); + const api = mockLegacyPlatformApi({ + response: { status: 404, body: { message: "not found" } }, + }); + const layer = runtimeWith({ out, api, analytics }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toHaveLength(1); + expect(analytics.captured).toHaveLength(0); + expect(out.stderrText).not.toContain("Upgrade your plan:"); + }).pipe(Effect.provide(layer)); + }); + it.live("omits the subdomain line in text mode when none is configured", () => { const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SAMPLE_GET_NO_DOMAIN } }); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 6b67270840..ab45e9c360 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -1,5 +1,6 @@ import { styleText } from "node:util"; +import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -66,6 +67,29 @@ export function legacyGateResponse( return HttpClientError.isHttpClientError(cause) ? cause.response : undefined; } +/** + * Builds an `Effect.catch` handler that runs the plan-gate check on the caught + * cause before delegating to the handler's error mapper. For sites whose gate + * is only detectable via the server envelope (the domains family, vanity get), + * omit `featureKey`. + */ +export const legacyGateMapError = + ( + opts: { readonly projectRef: string; readonly featureKey?: string }, + mapError: (cause: SupabaseApiError) => Effect.Effect, + ) => + (cause: SupabaseApiError) => + Effect.gen(function* () { + const response = legacyGateResponse(cause); + yield* legacySuggestUpgrade({ + projectRef: opts.projectRef, + featureKey: opts.featureKey, + statusCode: response?.status ?? 0, + response, + }); + return yield* mapError(cause); + }); + /** * Reproduces `apps/cli-go/internal/utils/plan_gate.go:SuggestUpgradeOnError`: * From 25fb7d4e86d1b52ad4aacfb9302b520a88c39a6d Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 24 Jul 2026 18:56:36 +0800 Subject: [PATCH 4/5] fix(cli): treat empty featureKey as envelope-only like Go --- apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index ab45e9c360..cf20b1bd55 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -162,7 +162,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { } if (gate === undefined) { - if (opts.featureKey === undefined) { + if (opts.featureKey === undefined || opts.featureKey === "") { return; } From 6acc94bc7ec483c449004447023c26e6f8bfa9d0 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Sat, 25 Jul 2026 12:14:29 +0800 Subject: [PATCH 5/5] chore(cli): trim legacy-upgrade-suggest comments --- .../legacy/shared/legacy-upgrade-suggest.ts | 60 ++++--------------- 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index cf20b1bd55..0ff7eb7f2c 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -31,13 +31,7 @@ interface LegacyPlanGateEnvelope { readonly upgradeUrl: string; } -/** - * Parses the management API's plan-gate error body, - * `{ message, error: { code: "entitlement_required", feature, upgrade_url } }`. - * The `packages/api` codegen intentionally emits no non-2xx schemas, so this - * shape is hand-validated here (the Go twin uses the generated - * `api.PlanGateErrorBody`). - */ +// Hand-validated: the packages/api codegen emits no non-2xx schemas. function parseLegacyPlanGateEnvelope(body: unknown): LegacyPlanGateEnvelope | undefined { if (typeof body !== "object" || body === null) return undefined; const error = (body as { readonly error?: unknown }).error; @@ -56,23 +50,12 @@ function legacyOrgSlugFromUpgradeUrl(upgradeUrl: string): string { return match?.[1] ?? ""; } -/** - * Extracts the failed HttpClientResponse from a caught SupabaseApiError so - * call sites can hand `legacySuggestUpgrade` the body without repeating the - * isHttpClientError narrowing. - */ export function legacyGateResponse( cause: unknown, ): HttpClientResponse.HttpClientResponse | undefined { return HttpClientError.isHttpClientError(cause) ? cause.response : undefined; } -/** - * Builds an `Effect.catch` handler that runs the plan-gate check on the caught - * cause before delegating to the handler's error mapper. For sites whose gate - * is only detectable via the server envelope (the domains family, vanity get), - * omit `featureKey`. - */ export const legacyGateMapError = ( opts: { readonly projectRef: string; readonly featureKey?: string }, @@ -91,48 +74,25 @@ export const legacyGateMapError = }); /** - * Reproduces `apps/cli-go/internal/utils/plan_gate.go:SuggestUpgradeOnError`: - * - * - Skip non-4xx statuses (2xx / 5xx). - * - Envelope first: when `response` carries the `entitlement_required` body, - * print the billing-link suggestion from its `upgrade_url` with zero extra - * API calls; telemetry `feature_key` comes from the envelope and `org_slug` - * from the URL's `/org//` segment. - * - Fallback (only when `featureKey` is provided): GET `/v1/projects/{ref}` - * → `organization_slug`, then GET `/v1/organizations/{slug}/entitlements` - * → look for the requested feature key with `hasAccess: false`. - * - On gated: write the billing-link suggestion to stderr (text mode only, - * matches Go's `CmdSuggestion` print) **and** fire the - * `cli_upgrade_suggested` telemetry event with `{feature_key, org_slug}`. - * - * Never fails the caller; lookup and parse errors swallow into a no-op. + * Ports Go's `plan_gate.go:SuggestUpgradeOnError`. Never fails the caller. * - * The fallback bypasses the typed Management API client to GET - * `/v1/projects/{ref}` and `/v1/organizations/{slug}/entitlements` directly - * via `HttpClient`. The generated `V1GetProjectOutput` schema enforces - * `ref: isMinLength(20)`, which the cli-e2e replay fixtures cannot satisfy - * (they embed the literal 15-char `__PROJECT_REF__` placeholder in response - * bodies). Strict decode would fail silently inside `Effect.option`, the - * entitlements GET would be skipped, and parity with Go's request log would - * break. Same workaround used by `legacy-linked-project-cache.layer.ts`. + * The fallback bypasses the typed API client: its strict response schemas + * reject the cli-e2e replay fixtures' placeholder refs (same workaround as + * `legacy-linked-project-cache.layer.ts`). */ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; /** - * Entitlements-fallback feature key. Omit for envelope-only sites (the - * domains family and vanity get): their gates are add-on or read-path - * checks the plan-level entitlement key cannot represent, so the fallback - * would false-positive on unrelated 4xxs. + * Entitlements-fallback key. Omit for envelope-only sites: the domains + * add-on gate has no plan-level entitlement key, so the fallback would + * false-positive on unrelated 4xxs. */ readonly featureKey?: string; readonly statusCode: number; - /** The failed response; enables the zero-round-trip envelope path. */ readonly response?: HttpClientResponse.HttpClientResponse; /** - * Whether to fire the `cli_upgrade_suggested` analytics event when a gate is - * detected. Defaults to `true`. Pass `false` for Go call-sites that invoke - * `SuggestUpgradeOnError` without a following `TrackUpgradeSuggested` - * (e.g. `vanity-subdomains check-availability`), so telemetry stays 1:1 with Go. + * Set false where the Go twin fires no `TrackUpgradeSuggested` (vanity + * check-availability), keeping telemetry 1:1. */ readonly trackAnalytics?: boolean; }) {