From 48bd87bd6e8a69b127f6b17f4aab4e42e10bd76c Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 19:05:31 +0900 Subject: [PATCH 1/6] chore(api): regenerate client with v1 entitlements endpoint Picks up GET /v1/organizations/{slug}/entitlements from platform#31128. Also fixes nullable type mismatch in auth config (int -> float32) caused by upstream spec change. --- pkg/api/client.gen.go | 211 ++++++++++++++++++++++++++++++++++++ pkg/api/types.gen.go | 245 ++++++++++++++++++++++++++++++++++++++++-- pkg/config/auth.go | 4 +- 3 files changed, 447 insertions(+), 13 deletions(-) diff --git a/pkg/api/client.gen.go b/pkg/api/client.gen.go index 1c7d29e1dc..5d7941a128 100644 --- a/pkg/api/client.gen.go +++ b/pkg/api/client.gen.go @@ -149,6 +149,9 @@ type ClientInterface interface { // V1GetAnOrganization request V1GetAnOrganization(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetOrganizationEntitlements request + V1GetOrganizationEntitlements(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1ListOrganizationMembers request V1ListOrganizationMembers(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -161,6 +164,9 @@ type ClientInterface interface { // V1GetAllProjectsForOrganization request V1GetAllProjectsForOrganization(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1GetProfile request + V1GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1ListAllProjects request V1ListAllProjects(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -944,6 +950,18 @@ func (c *Client) V1GetAnOrganization(ctx context.Context, slug string, reqEditor return c.Client.Do(req) } +func (c *Client) V1GetOrganizationEntitlements(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1GetOrganizationEntitlementsRequest(c.Server, slug) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1ListOrganizationMembers(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1ListOrganizationMembersRequest(c.Server, slug) if err != nil { @@ -992,6 +1010,18 @@ func (c *Client) V1GetAllProjectsForOrganization(ctx context.Context, slug strin return c.Client.Do(req) } +func (c *Client) V1GetProfile(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1GetProfileRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1ListAllProjects(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1ListAllProjectsRequest(c.Server) if err != nil { @@ -4141,6 +4171,40 @@ func NewV1GetAnOrganizationRequest(server string, slug string) (*http.Request, e return req, nil } +// NewV1GetOrganizationEntitlementsRequest generates requests for V1GetOrganizationEntitlements +func NewV1GetOrganizationEntitlementsRequest(server string, slug string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "slug", runtime.ParamLocationPath, slug) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/organizations/%s/entitlements", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewV1ListOrganizationMembersRequest generates requests for V1ListOrganizationMembers func NewV1ListOrganizationMembersRequest(server string, slug string) (*http.Request, error) { var err error @@ -4377,6 +4441,33 @@ func NewV1GetAllProjectsForOrganizationRequest(server string, slug string, param return req, nil } +// NewV1GetProfileRequest generates requests for V1GetProfile +func NewV1GetProfileRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/profile") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewV1ListAllProjectsRequest generates requests for V1ListAllProjects func NewV1ListAllProjectsRequest(server string) (*http.Request, error) { var err error @@ -10906,6 +10997,9 @@ type ClientWithResponsesInterface interface { // V1GetAnOrganizationWithResponse request V1GetAnOrganizationWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetAnOrganizationResponse, error) + // V1GetOrganizationEntitlementsWithResponse request + V1GetOrganizationEntitlementsWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetOrganizationEntitlementsResponse, error) + // V1ListOrganizationMembersWithResponse request V1ListOrganizationMembersWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1ListOrganizationMembersResponse, error) @@ -10918,6 +11012,9 @@ type ClientWithResponsesInterface interface { // V1GetAllProjectsForOrganizationWithResponse request V1GetAllProjectsForOrganizationWithResponse(ctx context.Context, slug string, params *V1GetAllProjectsForOrganizationParams, reqEditors ...RequestEditorFn) (*V1GetAllProjectsForOrganizationResponse, error) + // V1GetProfileWithResponse request + V1GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1GetProfileResponse, error) + // V1ListAllProjectsWithResponse request V1ListAllProjectsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllProjectsResponse, error) @@ -11763,6 +11860,28 @@ func (r V1GetAnOrganizationResponse) StatusCode() int { return 0 } +type V1GetOrganizationEntitlementsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1ListEntitlementsResponse +} + +// Status returns HTTPResponse.Status +func (r V1GetOrganizationEntitlementsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1GetOrganizationEntitlementsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1ListOrganizationMembersResponse struct { Body []byte HTTPResponse *http.Response @@ -11850,6 +11969,28 @@ func (r V1GetAllProjectsForOrganizationResponse) StatusCode() int { return 0 } +type V1GetProfileResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *V1ProfileResponse +} + +// Status returns HTTPResponse.Status +func (r V1GetProfileResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1GetProfileResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1ListAllProjectsResponse struct { Body []byte HTTPResponse *http.Response @@ -15089,6 +15230,15 @@ func (c *ClientWithResponses) V1GetAnOrganizationWithResponse(ctx context.Contex return ParseV1GetAnOrganizationResponse(rsp) } +// V1GetOrganizationEntitlementsWithResponse request returning *V1GetOrganizationEntitlementsResponse +func (c *ClientWithResponses) V1GetOrganizationEntitlementsWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1GetOrganizationEntitlementsResponse, error) { + rsp, err := c.V1GetOrganizationEntitlements(ctx, slug, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1GetOrganizationEntitlementsResponse(rsp) +} + // V1ListOrganizationMembersWithResponse request returning *V1ListOrganizationMembersResponse func (c *ClientWithResponses) V1ListOrganizationMembersWithResponse(ctx context.Context, slug string, reqEditors ...RequestEditorFn) (*V1ListOrganizationMembersResponse, error) { rsp, err := c.V1ListOrganizationMembers(ctx, slug, reqEditors...) @@ -15125,6 +15275,15 @@ func (c *ClientWithResponses) V1GetAllProjectsForOrganizationWithResponse(ctx co return ParseV1GetAllProjectsForOrganizationResponse(rsp) } +// V1GetProfileWithResponse request returning *V1GetProfileResponse +func (c *ClientWithResponses) V1GetProfileWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1GetProfileResponse, error) { + rsp, err := c.V1GetProfile(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1GetProfileResponse(rsp) +} + // V1ListAllProjectsWithResponse request returning *V1ListAllProjectsResponse func (c *ClientWithResponses) V1ListAllProjectsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*V1ListAllProjectsResponse, error) { rsp, err := c.V1ListAllProjects(ctx, reqEditors...) @@ -17121,6 +17280,32 @@ func ParseV1GetAnOrganizationResponse(rsp *http.Response) (*V1GetAnOrganizationR return response, nil } +// ParseV1GetOrganizationEntitlementsResponse parses an HTTP response from a V1GetOrganizationEntitlementsWithResponse call +func ParseV1GetOrganizationEntitlementsResponse(rsp *http.Response) (*V1GetOrganizationEntitlementsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1GetOrganizationEntitlementsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1ListEntitlementsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseV1ListOrganizationMembersResponse parses an HTTP response from a V1ListOrganizationMembersWithResponse call func ParseV1ListOrganizationMembersResponse(rsp *http.Response) (*V1ListOrganizationMembersResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -17215,6 +17400,32 @@ func ParseV1GetAllProjectsForOrganizationResponse(rsp *http.Response) (*V1GetAll return response, nil } +// ParseV1GetProfileResponse parses an HTTP response from a V1GetProfileWithResponse call +func ParseV1GetProfileResponse(rsp *http.Response) (*V1GetProfileResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1GetProfileResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest V1ProfileResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseV1ListAllProjectsResponse parses an HTTP response from a V1ListAllProjectsWithResponse call func ParseV1ListAllProjectsResponse(rsp *http.Response) (*V1ListAllProjectsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 5b3dd02903..eb77eefff1 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -598,13 +598,13 @@ const ( // Defines values for ListProjectAddonsResponseSelectedAddonsType. const ( - AuthMfaPhone ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_phone" - AuthMfaWebAuthn ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_web_authn" - ComputeInstance ListProjectAddonsResponseSelectedAddonsType = "compute_instance" - CustomDomain ListProjectAddonsResponseSelectedAddonsType = "custom_domain" - Ipv4 ListProjectAddonsResponseSelectedAddonsType = "ipv4" - LogDrain ListProjectAddonsResponseSelectedAddonsType = "log_drain" - Pitr ListProjectAddonsResponseSelectedAddonsType = "pitr" + ListProjectAddonsResponseSelectedAddonsTypeAuthMfaPhone ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_phone" + ListProjectAddonsResponseSelectedAddonsTypeAuthMfaWebAuthn ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_web_authn" + ListProjectAddonsResponseSelectedAddonsTypeComputeInstance ListProjectAddonsResponseSelectedAddonsType = "compute_instance" + ListProjectAddonsResponseSelectedAddonsTypeCustomDomain ListProjectAddonsResponseSelectedAddonsType = "custom_domain" + ListProjectAddonsResponseSelectedAddonsTypeIpv4 ListProjectAddonsResponseSelectedAddonsType = "ipv4" + ListProjectAddonsResponseSelectedAddonsTypeLogDrain ListProjectAddonsResponseSelectedAddonsType = "log_drain" + ListProjectAddonsResponseSelectedAddonsTypePitr ListProjectAddonsResponseSelectedAddonsType = "pitr" ) // Defines values for ListProjectAddonsResponseSelectedAddonsVariantId0. @@ -1370,6 +1370,80 @@ const ( SmartGroup V1CreateProjectBodyRegionSelection1Type = "smartGroup" ) +// Defines values for V1ListEntitlementsResponseEntitlementsFeatureKey. +const ( + V1ListEntitlementsResponseEntitlementsFeatureKeyAssistantAdvanceModel V1ListEntitlementsResponseEntitlementsFeatureKey = "assistant.advance_model" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthAdvancedAuthSettings V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.advanced_auth_settings" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthCustomJwtTemplate V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.custom_jwt_template" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthHooks V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.hooks" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthLeakedPasswordProtection V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.leaked_password_protection" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaEnhancedSecurity V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.mfa_enhanced_security" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaPhone V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.mfa_phone" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthMfaWebAuthn V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.mfa_web_authn" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPasswordHibp V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.password_hibp" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPerformanceSettings V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.performance_settings" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthPlatformSso V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.platform.sso" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthSaml2 V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.saml_2" + V1ListEntitlementsResponseEntitlementsFeatureKeyAuthUserSessions V1ListEntitlementsResponseEntitlementsFeatureKey = "auth.user_sessions" + V1ListEntitlementsResponseEntitlementsFeatureKeyBackupRestoreToNewProject V1ListEntitlementsResponseEntitlementsFeatureKey = "backup.restore_to_new_project" + V1ListEntitlementsResponseEntitlementsFeatureKeyBackupRetentionDays V1ListEntitlementsResponseEntitlementsFeatureKey = "backup.retention_days" + V1ListEntitlementsResponseEntitlementsFeatureKeyBranchingLimit V1ListEntitlementsResponseEntitlementsFeatureKey = "branching_limit" + V1ListEntitlementsResponseEntitlementsFeatureKeyBranchingPersistent V1ListEntitlementsResponseEntitlementsFeatureKey = "branching_persistent" + V1ListEntitlementsResponseEntitlementsFeatureKeyCustomDomain V1ListEntitlementsResponseEntitlementsFeatureKey = "custom_domain" + V1ListEntitlementsResponseEntitlementsFeatureKeyDedicatedPooler V1ListEntitlementsResponseEntitlementsFeatureKey = "dedicated_pooler" + V1ListEntitlementsResponseEntitlementsFeatureKeyFunctionMaxCount V1ListEntitlementsResponseEntitlementsFeatureKey = "function.max_count" + V1ListEntitlementsResponseEntitlementsFeatureKeyFunctionSizeLimitMb V1ListEntitlementsResponseEntitlementsFeatureKey = "function.size_limit_mb" + V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesComputeUpdateAvailableSizes V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.compute_update_available_sizes" + V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesDiskModifications V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.disk_modifications" + V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesHighAvailability V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.high_availability" + V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesOrioledb V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.orioledb" + V1ListEntitlementsResponseEntitlementsFeatureKeyInstancesReadReplicas V1ListEntitlementsResponseEntitlementsFeatureKey = "instances.read_replicas" + V1ListEntitlementsResponseEntitlementsFeatureKeyIntegrationsGithubConnections V1ListEntitlementsResponseEntitlementsFeatureKey = "integrations.github_connections" + V1ListEntitlementsResponseEntitlementsFeatureKeyIpv4 V1ListEntitlementsResponseEntitlementsFeatureKey = "ipv4" + V1ListEntitlementsResponseEntitlementsFeatureKeyLogDrains V1ListEntitlementsResponseEntitlementsFeatureKey = "log_drains" + V1ListEntitlementsResponseEntitlementsFeatureKeyLogRetentionDays V1ListEntitlementsResponseEntitlementsFeatureKey = "log.retention_days" + V1ListEntitlementsResponseEntitlementsFeatureKeyObservabilityDashboardAdvancedMetrics V1ListEntitlementsResponseEntitlementsFeatureKey = "observability.dashboard_advanced_metrics" + V1ListEntitlementsResponseEntitlementsFeatureKeyPitrAvailableVariants V1ListEntitlementsResponseEntitlementsFeatureKey = "pitr.available_variants" + V1ListEntitlementsResponseEntitlementsFeatureKeyProjectCloning V1ListEntitlementsResponseEntitlementsFeatureKey = "project_cloning" + V1ListEntitlementsResponseEntitlementsFeatureKeyProjectPausing V1ListEntitlementsResponseEntitlementsFeatureKey = "project_pausing" + V1ListEntitlementsResponseEntitlementsFeatureKeyProjectRestoreAfterExpiry V1ListEntitlementsResponseEntitlementsFeatureKey = "project_restore_after_expiry" + V1ListEntitlementsResponseEntitlementsFeatureKeyProjectScopedRoles V1ListEntitlementsResponseEntitlementsFeatureKey = "project_scoped_roles" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxBytesPerSecond V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_bytes_per_second" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxChannelsPerClient V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_channels_per_client" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxConcurrentUsers V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_concurrent_users" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxEventsPerSecond V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_events_per_second" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxJoinsPerSecond V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_joins_per_second" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxPayloadSizeInKb V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_payload_size_in_kb" + V1ListEntitlementsResponseEntitlementsFeatureKeyRealtimeMaxPresenceEventsPerSecond V1ListEntitlementsResponseEntitlementsFeatureKey = "realtime.max_presence_events_per_second" + V1ListEntitlementsResponseEntitlementsFeatureKeyReplicationEtl V1ListEntitlementsResponseEntitlementsFeatureKey = "replication.etl" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityAuditLogsDays V1ListEntitlementsResponseEntitlementsFeatureKey = "security.audit_logs_days" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityEnforceMfa V1ListEntitlementsResponseEntitlementsFeatureKey = "security.enforce_mfa" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityMemberRoles V1ListEntitlementsResponseEntitlementsFeatureKey = "security.member_roles" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityPrivateLink V1ListEntitlementsResponseEntitlementsFeatureKey = "security.private_link" + V1ListEntitlementsResponseEntitlementsFeatureKeySecurityQuestionnaire V1ListEntitlementsResponseEntitlementsFeatureKey = "security.questionnaire" + V1ListEntitlementsResponseEntitlementsFeatureKeySecuritySoc2Report V1ListEntitlementsResponseEntitlementsFeatureKey = "security.soc2_report" + V1ListEntitlementsResponseEntitlementsFeatureKeyStorageIcebergCatalog V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.iceberg_catalog" + V1ListEntitlementsResponseEntitlementsFeatureKeyStorageImageTransformations V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.image_transformations" + V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSize V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size" + V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSizeConfigurable V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size.configurable" + V1ListEntitlementsResponseEntitlementsFeatureKeyStorageVectorBuckets V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.vector_buckets" + V1ListEntitlementsResponseEntitlementsFeatureKeyVanitySubdomain V1ListEntitlementsResponseEntitlementsFeatureKey = "vanity_subdomain" +) + +// Defines values for V1ListEntitlementsResponseEntitlementsFeatureType. +const ( + V1ListEntitlementsResponseEntitlementsFeatureTypeBoolean V1ListEntitlementsResponseEntitlementsFeatureType = "boolean" + V1ListEntitlementsResponseEntitlementsFeatureTypeNumeric V1ListEntitlementsResponseEntitlementsFeatureType = "numeric" + V1ListEntitlementsResponseEntitlementsFeatureTypeSet V1ListEntitlementsResponseEntitlementsFeatureType = "set" +) + +// Defines values for V1ListEntitlementsResponseEntitlementsType. +const ( + V1ListEntitlementsResponseEntitlementsTypeBoolean V1ListEntitlementsResponseEntitlementsType = "boolean" + V1ListEntitlementsResponseEntitlementsTypeNumeric V1ListEntitlementsResponseEntitlementsType = "numeric" + V1ListEntitlementsResponseEntitlementsTypeSet V1ListEntitlementsResponseEntitlementsType = "set" +) + // Defines values for V1OrganizationSlugResponseAllowedReleaseChannels. const ( V1OrganizationSlugResponseAllowedReleaseChannelsAlpha V1OrganizationSlugResponseAllowedReleaseChannels = "alpha" @@ -1976,6 +2050,7 @@ type AuthConfigResponse struct { OauthServerAllowDynamicRegistration bool `json:"oauth_server_allow_dynamic_registration"` OauthServerAuthorizationPath nullable.Nullable[string] `json:"oauth_server_authorization_path"` OauthServerEnabled bool `json:"oauth_server_enabled"` + PasskeyEnabled bool `json:"passkey_enabled"` PasswordHibpEnabled nullable.Nullable[bool] `json:"password_hibp_enabled"` PasswordMinLength nullable.Nullable[int] `json:"password_min_length"` PasswordRequiredCharacters nullable.Nullable[AuthConfigResponsePasswordRequiredCharacters] `json:"password_required_characters"` @@ -1997,10 +2072,10 @@ type AuthConfigResponse struct { SecurityRefreshTokenReuseInterval nullable.Nullable[int] `json:"security_refresh_token_reuse_interval"` SecuritySbForwardedForEnabled nullable.Nullable[bool] `json:"security_sb_forwarded_for_enabled"` SecurityUpdatePasswordRequireReauthentication nullable.Nullable[bool] `json:"security_update_password_require_reauthentication"` - SessionsInactivityTimeout nullable.Nullable[int] `json:"sessions_inactivity_timeout"` + SessionsInactivityTimeout nullable.Nullable[float32] `json:"sessions_inactivity_timeout"` SessionsSinglePerUser nullable.Nullable[bool] `json:"sessions_single_per_user"` SessionsTags nullable.Nullable[string] `json:"sessions_tags"` - SessionsTimebox nullable.Nullable[int] `json:"sessions_timebox"` + SessionsTimebox nullable.Nullable[float32] `json:"sessions_timebox"` SiteUrl nullable.Nullable[string] `json:"site_url"` SmsAutoconfirm nullable.Nullable[bool] `json:"sms_autoconfirm"` SmsMaxFrequency nullable.Nullable[int] `json:"sms_max_frequency"` @@ -2032,6 +2107,9 @@ type AuthConfigResponse struct { SmtpSenderName nullable.Nullable[string] `json:"smtp_sender_name"` SmtpUser nullable.Nullable[string] `json:"smtp_user"` UriAllowList nullable.Nullable[string] `json:"uri_allow_list"` + WebauthnRpDisplayName nullable.Nullable[string] `json:"webauthn_rp_display_name"` + WebauthnRpId nullable.Nullable[string] `json:"webauthn_rp_id"` + WebauthnRpOrigins nullable.Nullable[string] `json:"webauthn_rp_origins"` } // AuthConfigResponseDbMaxPoolSizeUnit defines model for AuthConfigResponse.DbMaxPoolSizeUnit. @@ -3893,6 +3971,7 @@ type UpdateAuthConfigBody struct { OauthServerAllowDynamicRegistration nullable.Nullable[bool] `json:"oauth_server_allow_dynamic_registration,omitempty"` OauthServerAuthorizationPath nullable.Nullable[string] `json:"oauth_server_authorization_path,omitempty"` OauthServerEnabled nullable.Nullable[bool] `json:"oauth_server_enabled,omitempty"` + PasskeyEnabled *bool `json:"passkey_enabled,omitempty"` PasswordHibpEnabled nullable.Nullable[bool] `json:"password_hibp_enabled,omitempty"` PasswordMinLength nullable.Nullable[int] `json:"password_min_length,omitempty"` PasswordRequiredCharacters nullable.Nullable[UpdateAuthConfigBodyPasswordRequiredCharacters] `json:"password_required_characters,omitempty"` @@ -3913,10 +3992,10 @@ type UpdateAuthConfigBody struct { SecurityRefreshTokenReuseInterval nullable.Nullable[int] `json:"security_refresh_token_reuse_interval,omitempty"` SecuritySbForwardedForEnabled nullable.Nullable[bool] `json:"security_sb_forwarded_for_enabled,omitempty"` SecurityUpdatePasswordRequireReauthentication nullable.Nullable[bool] `json:"security_update_password_require_reauthentication,omitempty"` - SessionsInactivityTimeout nullable.Nullable[int] `json:"sessions_inactivity_timeout,omitempty"` + SessionsInactivityTimeout nullable.Nullable[float32] `json:"sessions_inactivity_timeout,omitempty"` SessionsSinglePerUser nullable.Nullable[bool] `json:"sessions_single_per_user,omitempty"` SessionsTags nullable.Nullable[string] `json:"sessions_tags,omitempty"` - SessionsTimebox nullable.Nullable[int] `json:"sessions_timebox,omitempty"` + SessionsTimebox nullable.Nullable[float32] `json:"sessions_timebox,omitempty"` SiteUrl nullable.Nullable[string] `json:"site_url,omitempty"` SmsAutoconfirm nullable.Nullable[bool] `json:"sms_autoconfirm,omitempty"` SmsMaxFrequency nullable.Nullable[int] `json:"sms_max_frequency,omitempty"` @@ -3948,6 +4027,9 @@ type UpdateAuthConfigBody struct { SmtpSenderName nullable.Nullable[string] `json:"smtp_sender_name,omitempty"` SmtpUser nullable.Nullable[string] `json:"smtp_user,omitempty"` UriAllowList nullable.Nullable[string] `json:"uri_allow_list,omitempty"` + WebauthnRpDisplayName nullable.Nullable[string] `json:"webauthn_rp_display_name,omitempty"` + WebauthnRpId nullable.Nullable[string] `json:"webauthn_rp_id,omitempty"` + WebauthnRpOrigins nullable.Nullable[string] `json:"webauthn_rp_origins,omitempty"` } // UpdateAuthConfigBodyDbMaxPoolSizeUnit defines model for UpdateAuthConfigBody.DbMaxPoolSizeUnit. @@ -4454,6 +4536,52 @@ type V1GetUsageApiRequestsCountResponse_Error struct { union json.RawMessage } +// V1ListEntitlementsResponse defines model for V1ListEntitlementsResponse. +type V1ListEntitlementsResponse struct { + Entitlements []struct { + Config V1ListEntitlementsResponse_Entitlements_Config `json:"config"` + Feature struct { + Key V1ListEntitlementsResponseEntitlementsFeatureKey `json:"key"` + Type V1ListEntitlementsResponseEntitlementsFeatureType `json:"type"` + } `json:"feature"` + HasAccess bool `json:"hasAccess"` + Type V1ListEntitlementsResponseEntitlementsType `json:"type"` + } `json:"entitlements"` +} + +// V1ListEntitlementsResponseEntitlementsConfig0 defines model for . +type V1ListEntitlementsResponseEntitlementsConfig0 struct { + Enabled bool `json:"enabled"` +} + +// V1ListEntitlementsResponseEntitlementsConfig1 defines model for . +type V1ListEntitlementsResponseEntitlementsConfig1 struct { + Enabled bool `json:"enabled"` + Unit string `json:"unit"` + Unlimited bool `json:"unlimited"` + Value float32 `json:"value"` +} + +// V1ListEntitlementsResponseEntitlementsConfig2 defines model for . +type V1ListEntitlementsResponseEntitlementsConfig2 struct { + Enabled bool `json:"enabled"` + Set []string `json:"set"` +} + +// V1ListEntitlementsResponse_Entitlements_Config defines model for V1ListEntitlementsResponse.Entitlements.Config. +type V1ListEntitlementsResponse_Entitlements_Config struct { + union json.RawMessage +} + +// V1ListEntitlementsResponseEntitlementsFeatureKey defines model for V1ListEntitlementsResponse.Entitlements.Feature.Key. +type V1ListEntitlementsResponseEntitlementsFeatureKey string + +// V1ListEntitlementsResponseEntitlementsFeatureType defines model for V1ListEntitlementsResponse.Entitlements.Feature.Type. +type V1ListEntitlementsResponseEntitlementsFeatureType string + +// V1ListEntitlementsResponseEntitlementsType defines model for V1ListEntitlementsResponse.Entitlements.Type. +type V1ListEntitlementsResponseEntitlementsType string + // V1ListMigrationsResponse defines model for V1ListMigrationsResponse. type V1ListMigrationsResponse = []struct { Name *string `json:"name,omitempty"` @@ -4519,6 +4647,13 @@ type V1PostgrestConfigResponse struct { MaxRows int `json:"max_rows"` } +// V1ProfileResponse defines model for V1ProfileResponse. +type V1ProfileResponse struct { + GotrueId string `json:"gotrue_id"` + PrimaryEmail string `json:"primary_email"` + Username string `json:"username"` +} + // V1ProjectAdvisorsResponse defines model for V1ProjectAdvisorsResponse. type V1ProjectAdvisorsResponse struct { Lints []struct { @@ -6591,6 +6726,94 @@ func (t *V1GetUsageApiRequestsCountResponse_Error) UnmarshalJSON(b []byte) error return err } +// AsV1ListEntitlementsResponseEntitlementsConfig0 returns the union data inside the V1ListEntitlementsResponse_Entitlements_Config as a V1ListEntitlementsResponseEntitlementsConfig0 +func (t V1ListEntitlementsResponse_Entitlements_Config) AsV1ListEntitlementsResponseEntitlementsConfig0() (V1ListEntitlementsResponseEntitlementsConfig0, error) { + var body V1ListEntitlementsResponseEntitlementsConfig0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1ListEntitlementsResponseEntitlementsConfig0 overwrites any union data inside the V1ListEntitlementsResponse_Entitlements_Config as the provided V1ListEntitlementsResponseEntitlementsConfig0 +func (t *V1ListEntitlementsResponse_Entitlements_Config) FromV1ListEntitlementsResponseEntitlementsConfig0(v V1ListEntitlementsResponseEntitlementsConfig0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1ListEntitlementsResponseEntitlementsConfig0 performs a merge with any union data inside the V1ListEntitlementsResponse_Entitlements_Config, using the provided V1ListEntitlementsResponseEntitlementsConfig0 +func (t *V1ListEntitlementsResponse_Entitlements_Config) MergeV1ListEntitlementsResponseEntitlementsConfig0(v V1ListEntitlementsResponseEntitlementsConfig0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsV1ListEntitlementsResponseEntitlementsConfig1 returns the union data inside the V1ListEntitlementsResponse_Entitlements_Config as a V1ListEntitlementsResponseEntitlementsConfig1 +func (t V1ListEntitlementsResponse_Entitlements_Config) AsV1ListEntitlementsResponseEntitlementsConfig1() (V1ListEntitlementsResponseEntitlementsConfig1, error) { + var body V1ListEntitlementsResponseEntitlementsConfig1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1ListEntitlementsResponseEntitlementsConfig1 overwrites any union data inside the V1ListEntitlementsResponse_Entitlements_Config as the provided V1ListEntitlementsResponseEntitlementsConfig1 +func (t *V1ListEntitlementsResponse_Entitlements_Config) FromV1ListEntitlementsResponseEntitlementsConfig1(v V1ListEntitlementsResponseEntitlementsConfig1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1ListEntitlementsResponseEntitlementsConfig1 performs a merge with any union data inside the V1ListEntitlementsResponse_Entitlements_Config, using the provided V1ListEntitlementsResponseEntitlementsConfig1 +func (t *V1ListEntitlementsResponse_Entitlements_Config) MergeV1ListEntitlementsResponseEntitlementsConfig1(v V1ListEntitlementsResponseEntitlementsConfig1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsV1ListEntitlementsResponseEntitlementsConfig2 returns the union data inside the V1ListEntitlementsResponse_Entitlements_Config as a V1ListEntitlementsResponseEntitlementsConfig2 +func (t V1ListEntitlementsResponse_Entitlements_Config) AsV1ListEntitlementsResponseEntitlementsConfig2() (V1ListEntitlementsResponseEntitlementsConfig2, error) { + var body V1ListEntitlementsResponseEntitlementsConfig2 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromV1ListEntitlementsResponseEntitlementsConfig2 overwrites any union data inside the V1ListEntitlementsResponse_Entitlements_Config as the provided V1ListEntitlementsResponseEntitlementsConfig2 +func (t *V1ListEntitlementsResponse_Entitlements_Config) FromV1ListEntitlementsResponseEntitlementsConfig2(v V1ListEntitlementsResponseEntitlementsConfig2) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeV1ListEntitlementsResponseEntitlementsConfig2 performs a merge with any union data inside the V1ListEntitlementsResponse_Entitlements_Config, using the provided V1ListEntitlementsResponseEntitlementsConfig2 +func (t *V1ListEntitlementsResponse_Entitlements_Config) MergeV1ListEntitlementsResponseEntitlementsConfig2(v V1ListEntitlementsResponseEntitlementsConfig2) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t V1ListEntitlementsResponse_Entitlements_Config) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *V1ListEntitlementsResponse_Entitlements_Config) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsV1ServiceHealthResponseInfo0 returns the union data inside the V1ServiceHealthResponse_Info as a V1ServiceHealthResponseInfo0 func (t V1ServiceHealthResponse_Info) AsV1ServiceHealthResponseInfo0() (V1ServiceHealthResponseInfo0, error) { var body V1ServiceHealthResponseInfo0 diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 46df702d67..1c021d3742 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -629,8 +629,8 @@ func (m *mfa) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { } func (s sessions) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { - body.SessionsTimebox = nullable.NewNullableWithValue(int(s.Timebox.Hours())) - body.SessionsInactivityTimeout = nullable.NewNullableWithValue(int(s.InactivityTimeout.Hours())) + body.SessionsTimebox = nullable.NewNullableWithValue(float32(s.Timebox.Hours())) + body.SessionsInactivityTimeout = nullable.NewNullableWithValue(float32(s.InactivityTimeout.Hours())) } func (s *sessions) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { From 483e5ed64ba610d1f21402ce804c3c4ecdb4d8fd Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 19:06:10 +0900 Subject: [PATCH 2/6] feat(utils): add plan_gate utilities for entitlement-aware billing links --- internal/utils/plan_gate.go | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 internal/utils/plan_gate.go diff --git a/internal/utils/plan_gate.go b/internal/utils/plan_gate.go new file mode 100644 index 0000000000..5c31fb7c19 --- /dev/null +++ b/internal/utils/plan_gate.go @@ -0,0 +1,54 @@ +package utils + +import ( + "context" + "fmt" + "net/http" +) + +func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) { + resp, err := GetSupabase().V1GetProjectWithResponse(ctx, projectRef) + if err != nil { + return "", fmt.Errorf("failed to get project: %w", err) + } + if resp.JSON200 == nil { + return "", fmt.Errorf("unexpected get project status %d: %s", resp.StatusCode(), string(resp.Body)) + } + return resp.JSON200.OrganizationSlug, nil +} + +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 +// and sets CmdSuggestion with a billing upgrade link. Best-effort: never returns errors. +// Only triggers on 402 Payment Required (not 403, which could be a permissions issue). +func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) { + if statusCode != http.StatusPaymentRequired { + return + } + + orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef) + if err != nil { + CmdSuggestion = "This feature may require a plan upgrade. Check your organization's billing settings in the Supabase dashboard." + return + } + + billingURL := GetOrgBillingURL(orgSlug) + + resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug) + if err != nil || resp.JSON200 == nil { + CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) + return + } + + for _, e := range resp.JSON200.Entitlements { + if string(e.Feature.Key) == featureKey && !e.HasAccess { + CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) + return + } + } + + CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) +} From a2da2aae6e388b2dbc4b657e9c03c0c8c33f1a29 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 19:06:52 +0900 Subject: [PATCH 3/6] test(utils): add tests for plan_gate utilities --- internal/utils/plan_gate_test.go | 136 +++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 internal/utils/plan_gate_test.go diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go new file mode 100644 index 0000000000..c0f8fc0239 --- /dev/null +++ b/internal/utils/plan_gate_test.go @@ -0,0 +1,136 @@ +package utils + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/supabase/cli/internal/testing/apitest" +) + +var projectJSON = map[string]interface{}{ + "ref": "test-ref", + "organization_slug": "my-org", + "name": "test", + "region": "us-east-1", + "created_at": "2024-01-01T00:00:00Z", + "status": "ACTIVE_HEALTHY", + "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, +} + +func TestGetOrgSlugFromProjectRef(t *testing.T) { + ref := apitest.RandomProjectRef() + + t.Run("returns org slug on success", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusOK). + JSON(projectJSON) + slug, err := GetOrgSlugFromProjectRef(context.Background(), ref) + assert.NoError(t, err) + assert.Equal(t, "my-org", slug) + }) + + t.Run("returns error on not found", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusNotFound) + _, err := GetOrgSlugFromProjectRef(context.Background(), ref) + assert.ErrorContains(t, err, "unexpected get project status 404") + }) + + t.Run("returns error on network failure", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + ReplyError(assert.AnError) + _, err := GetOrgSlugFromProjectRef(context.Background(), ref) + assert.ErrorContains(t, err, "failed to get project") + }) +} + +func TestGetOrgBillingURL(t *testing.T) { + url := GetOrgBillingURL("my-org") + assert.Equal(t, GetSupabaseDashboardURL()+"/org/my-org/billing", url) +} + +func entitlementsJSON(featureKey string, hasAccess bool) map[string]interface{} { + return map[string]interface{}{ + "entitlements": []map[string]interface{}{ + { + "feature": map[string]interface{}{"key": featureKey, "type": "numeric"}, + "hasAccess": hasAccess, + "type": "numeric", + "config": map[string]interface{}{"enabled": hasAccess, "value": 0, "unlimited": false, "unit": "count"}, + }, + }, + } +} + +func TestSuggestUpgradeOnError(t *testing.T) { + ref := apitest.RandomProjectRef() + + t.Run("sets specific suggestion on 402 with gated feature", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusOK). + JSON(projectJSON) + gock.New(DefaultApiHost). + Get("/v1/organizations/my-org/entitlements"). + Reply(http.StatusOK). + JSON(entitlementsJSON("branching_limit", false)) + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + assert.Contains(t, CmdSuggestion, "does not have access") + }) + + t.Run("sets generic suggestion when entitlements lookup fails", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusOK). + JSON(projectJSON) + gock.New(DefaultApiHost). + Get("/v1/organizations/my-org/entitlements"). + Reply(http.StatusInternalServerError) + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + assert.Contains(t, CmdSuggestion, "may require a plan upgrade") + }) + + t.Run("sets fallback suggestion when project lookup fails", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusNotFound) + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.Contains(t, CmdSuggestion, "plan upgrade") + assert.NotContains(t, CmdSuggestion, "/org/") + }) + + t.Run("skips suggestion on 403 forbidden", func(t *testing.T) { + CmdSuggestion = "" + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) + assert.Empty(t, CmdSuggestion) + }) + + t.Run("skips suggestion on non-billing status codes", func(t *testing.T) { + CmdSuggestion = "" + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) + assert.Empty(t, CmdSuggestion) + }) + + t.Run("skips suggestion on success status codes", func(t *testing.T) { + CmdSuggestion = "" + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + assert.Empty(t, CmdSuggestion) + }) +} From 4249a97216461fa4c251f5ca0a563259a7e5d13f Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 19:09:28 +0900 Subject: [PATCH 4/6] feat(branches): suggest billing upgrade on plan-gated errors Wire SuggestUpgradeOnError into branches create and update error paths. When the API returns 402, the CLI now fetches the org's entitlements and displays a direct billing upgrade link. Create uses branching_limit (Free plan gate), update uses branching_persistent (persistent branches gate). --- internal/branches/create/create.go | 1 + internal/branches/create/create_test.go | 41 +++++++++++++++++++++++++ internal/branches/update/update.go | 2 ++ internal/branches/update/update_test.go | 41 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go index 5ee14aacd9..50e731d803 100644 --- a/internal/branches/create/create.go +++ b/internal/branches/create/create.go @@ -30,6 +30,7 @@ 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 { + utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()) return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body)) } diff --git a/internal/branches/create/create_test.go b/internal/branches/create/create_test.go index 60dbfc5279..c08a10286c 100644 --- a/internal/branches/create/create_test.go +++ b/internal/branches/create/create_test.go @@ -77,4 +77,45 @@ func TestCreateCommand(t *testing.T) { // Check error assert.ErrorContains(t, err, "unexpected create branch status 503:") }) + + t.Run("suggests upgrade on payment required", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { utils.CmdSuggestion = "" }) + // Mock branches create returns 402 + gock.New(utils.DefaultApiHost). + Post("/v1/projects/" + flags.ProjectRef + "/branches"). + Reply(http.StatusPaymentRequired). + JSON(map[string]interface{}{"message": "branching requires a paid plan"}) + // Mock project lookup for SuggestUpgradeOnError + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + flags.ProjectRef). + Reply(http.StatusOK). + JSON(map[string]interface{}{ + "ref": flags.ProjectRef, + "organization_slug": "test-org", + "name": "test", + "region": "us-east-1", + "created_at": "2024-01-01T00:00:00Z", + "status": "ACTIVE_HEALTHY", + "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, + }) + // Mock entitlements + gock.New(utils.DefaultApiHost). + Get("/v1/organizations/test-org/entitlements"). + Reply(http.StatusOK). + JSON(map[string]interface{}{ + "entitlements": []map[string]interface{}{ + { + "feature": map[string]interface{}{"key": "branching_limit", "type": "numeric"}, + "hasAccess": false, + "type": "numeric", + "config": map[string]interface{}{"enabled": false, "value": 0, "unlimited": false, "unit": "count"}, + }, + }, + }) + 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/test-org/billing") + }) } diff --git a/internal/branches/update/update.go b/internal/branches/update/update.go index a467ae1d2a..8ad8c1e381 100644 --- a/internal/branches/update/update.go +++ b/internal/branches/update/update.go @@ -10,6 +10,7 @@ import ( "github.com/supabase/cli/internal/branches/list" "github.com/supabase/cli/internal/branches/pause" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/api" ) @@ -22,6 +23,7 @@ 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 { + utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()) return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body)) } fmt.Fprintln(os.Stderr, "Updated preview branch:") diff --git a/internal/branches/update/update_test.go b/internal/branches/update/update_test.go index 18382e94e0..57548ce47d 100644 --- a/internal/branches/update/update_test.go +++ b/internal/branches/update/update_test.go @@ -106,4 +106,45 @@ func TestUpdateBranch(t *testing.T) { err := Run(context.Background(), flags.ProjectRef, api.UpdateBranchBody{}, nil) assert.ErrorContains(t, err, "unexpected update branch status 503:") }) + + t.Run("suggests upgrade on payment required for persistent", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { utils.CmdSuggestion = "" }) + // Mock branch update returns 402 + gock.New(utils.DefaultApiHost). + Patch("/v1/branches/" + flags.ProjectRef). + Reply(http.StatusPaymentRequired). + JSON(map[string]interface{}{"message": "Persistent branches are not available on your plan"}) + // Mock project lookup for SuggestUpgradeOnError + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + flags.ProjectRef). + Reply(http.StatusOK). + JSON(map[string]interface{}{ + "ref": flags.ProjectRef, + "organization_slug": "test-org", + "name": "test", + "region": "us-east-1", + "created_at": "2024-01-01T00:00:00Z", + "status": "ACTIVE_HEALTHY", + "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, + }) + // Mock entitlements + gock.New(utils.DefaultApiHost). + Get("/v1/organizations/test-org/entitlements"). + Reply(http.StatusOK). + JSON(map[string]interface{}{ + "entitlements": []map[string]interface{}{ + { + "feature": map[string]interface{}{"key": "branching_persistent", "type": "boolean"}, + "hasAccess": false, + "type": "boolean", + "config": map[string]interface{}{"enabled": false}, + }, + }, + }) + persistent := true + err := Run(context.Background(), flags.ProjectRef, api.UpdateBranchBody{Persistent: &persistent}, nil) + assert.ErrorContains(t, err, "unexpected update branch status 402") + assert.Contains(t, utils.CmdSuggestion, "/org/test-org/billing") + }) } From 55244798142bfa6bbfcfe751ff40d526bc15177b Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 19:14:09 +0900 Subject: [PATCH 5/6] fix(utils): self-review fixes for plan_gate - Rename package-level test var to avoid collision risk - Add dashboard URL to project-lookup-failed fallback message - Add test for hasAccess:true edge case --- internal/utils/plan_gate.go | 2 +- internal/utils/plan_gate_test.go | 25 +++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/utils/plan_gate.go b/internal/utils/plan_gate.go index 5c31fb7c19..f5c1842845 100644 --- a/internal/utils/plan_gate.go +++ b/internal/utils/plan_gate.go @@ -31,7 +31,7 @@ func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, s orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef) if err != nil { - CmdSuggestion = "This feature may require a plan upgrade. Check your organization's billing settings in the Supabase dashboard." + CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(GetSupabaseDashboardURL())) return } diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go index c0f8fc0239..dee3ef7865 100644 --- a/internal/utils/plan_gate_test.go +++ b/internal/utils/plan_gate_test.go @@ -10,7 +10,7 @@ import ( "github.com/supabase/cli/internal/testing/apitest" ) -var projectJSON = map[string]interface{}{ +var planGateProjectJSON = map[string]interface{}{ "ref": "test-ref", "organization_slug": "my-org", "name": "test", @@ -28,7 +28,7 @@ func TestGetOrgSlugFromProjectRef(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusOK). - JSON(projectJSON) + JSON(planGateProjectJSON) slug, err := GetOrgSlugFromProjectRef(context.Background(), ref) assert.NoError(t, err) assert.Equal(t, "my-org", slug) @@ -80,7 +80,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusOK). - JSON(projectJSON) + JSON(planGateProjectJSON) gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusOK). @@ -96,7 +96,7 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusOK). - JSON(projectJSON) + JSON(planGateProjectJSON) gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) @@ -113,9 +113,26 @@ func TestSuggestUpgradeOnError(t *testing.T) { Reply(http.StatusNotFound) SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) assert.Contains(t, CmdSuggestion, "plan upgrade") + assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL()) assert.NotContains(t, CmdSuggestion, "/org/") }) + t.Run("sets generic suggestion when feature has access", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusOK). + JSON(planGateProjectJSON) + gock.New(DefaultApiHost). + Get("/v1/organizations/my-org/entitlements"). + Reply(http.StatusOK). + JSON(entitlementsJSON("branching_limit", true)) + SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + assert.Contains(t, CmdSuggestion, "may require a plan upgrade") + }) + t.Run("skips suggestion on 403 forbidden", func(t *testing.T) { CmdSuggestion = "" SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) From 3f3117ebf2d668cdb4424e95f7a6b8532651b1d8 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 6 Apr 2026 23:34:24 +0900 Subject: [PATCH 6/6] chore: retrigger CI