From da7d7fae260a585b8ae0ee898a210edb45bc7386 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Mon, 28 Sep 2026 15:19:45 -0500 Subject: [PATCH 1/3] fix(products): clarify store-state plan access after API rejection --- README.md | 5 ++--- docs/command-surface.md | 4 +++- internal/api/errors.go | 7 +++++++ internal/api/errors_test.go | 40 +++++++++++++++++++++++++++++++++++++ internal/cli/run_test.go | 23 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 76012cb3..8a020126 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ preview` uses the app's public SDK key and exposes the v1 SDK response; a null `paywall_components` value indicates fallback content rather than a published dashboard paywall. -### Product store-state plans (experimental) +### Product store-state plans Manage your App Store / Google Play catalog as a reviewable plan: describe the desired state, review the computed diff, then apply it. @@ -145,8 +145,7 @@ rc products store apply "$plan_id" --yes # or: discard "$plan_id" ``` CSV/JSON input is optional (`RC_STORE_STATE_FILE` can replace `--file`); the -backend, not the local filesystem, is the durable handoff. This bulk import is -experimental and may not be available on every account yet. +backend, not the local filesystem, is the durable handoff. ### Store credential setup (experimental) diff --git a/docs/command-surface.md b/docs/command-surface.md index 235345a7..42b667f8 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -276,7 +276,9 @@ rc audit # /audit_logs with --li it reviewed; `discard` abandons it. `--file - --input-format csv|json` avoids any filesystem requirement. A future `.revenuecat` workspace may provide optional defaults, but is never a prerequisite and desired state is never - stored globally. + stored globally. These commands are in the default surface. Let the API + decide whether the active credential can use them; an API-key feature-gate + rejection gets a targeted hint only after the request fails. 9. **Apple credential setup** — `apps apple check` validates App Store Connect login, trusted-device/SMS 2FA, team selection, and read-only key access. `apps apple setup` shows the app's current configuration, asks per diff --git a/internal/api/errors.go b/internal/api/errors.go index 0de2a6c0..342dd7db 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -77,6 +77,13 @@ func (e *APIError) Hint() string { if scope := MissingScope(e.Message); scope != "" { return e.scopeHint(scope) } + msg := strings.ToLower(e.Message) + if e.Status == http.StatusForbidden && e.Type == "authorization_error" && + (e.CredentialSource == "flag" || e.CredentialSource == "env" || e.CredentialSource == "profile") && + strings.Contains(msg, "product store state plans") && + strings.Contains(msg, "not available for this project") { + return "This project needs store-state plan access enabled for API keys. Use `rc login` with OAuth or ask RevenueCat to enable access for this project." + } switch e.Type { case "unauthorized", "authentication_error": return e.authHint() diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index 5a7870e4..9e9ff869 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -54,3 +54,43 @@ func TestAPIError_UnauthorizedHintMentionsLogin(t *testing.T) { t.Errorf("hint should mention rc login, got %q", hint) } } + +func TestStoreStatePlanFeatureGateHint(t *testing.T) { + const gateMessage = "Product store state plans are currently a limited beta feature and are not available for this project." + for _, tc := range []struct { + name, source, message string + status int + wantHint bool + }{ + {"flag key gated", "flag", gateMessage, 403, true}, + {"environment key gated", "env", gateMessage, 403, true}, + {"profile key gated", "profile", gateMessage, 403, true}, + {"OAuth gated", "oauth", gateMessage, 403, false}, + {"other authorization error", "env", "You do not have permission to perform this action.", 403, false}, + {"other status", "env", gateMessage, 400, false}, + } { + t.Run(tc.name, func(t *testing.T) { + e := &api.APIError{Status: tc.status, Type: "authorization_error", Message: tc.message, CredentialSource: tc.source} + hint := e.Hint() + if got := strings.Contains(hint, "store-state plan access"); got != tc.wantHint { + t.Errorf("gate hint present = %v, want %v; hint = %q", got, tc.wantHint, hint) + } + }) + } +} + +func TestStoreStatePlanAPIKeyCanSucceedWithoutCLIGate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer sk_test" { + t.Errorf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"object":"list"}`)) + })) + defer srv.Close() + + c := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL, CredentialSource: "flag"}) + if _, err := c.StoreStatePlans.List(context.Background(), "proj_test", ""); err != nil { + t.Fatalf("API key should reach store-state endpoint: %v", err) + } +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index fa0aecde..75e27931 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -94,6 +94,29 @@ func TestWriteJSONError_HintSurfacesForUnauthorized(t *testing.T) { } } +func TestWriteJSONError_StoreStatePlanFeatureGate(t *testing.T) { + var buf bytes.Buffer + writeJSONError(&buf, &api.APIError{ + Status: 403, + Type: "authorization_error", + Message: "Product store state plans are currently a limited beta feature and are not available for this project.", + CredentialSource: "env", + }) + var got struct { + Error struct { + Type string `json:"type"` + ExitCode int `json:"exit_code"` + Hint string `json:"hint"` + } `json:"error"` + } + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Error.Type != "authorization_error" || got.Error.ExitCode != 4 || !strings.Contains(got.Error.Hint, "store-state plan access") { + t.Errorf("unexpected feature-gate error: %+v", got.Error) + } +} + func TestWriteJSONError_RetryAfterPropagates(t *testing.T) { var buf bytes.Buffer writeJSONError(&buf, &api.APIError{ From 7e547c5c174c3b5d3b64bf5998581521fa068353 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Mon, 28 Sep 2026 16:08:24 -0500 Subject: [PATCH 2/3] fix(products): name credential override in store-state gate hint --- internal/api/errors.go | 9 ++++++- internal/api/errors_test.go | 37 ++++++++++------------------- internal/cli/products_store_test.go | 30 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index 342dd7db..3fa09026 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -82,7 +82,14 @@ func (e *APIError) Hint() string { (e.CredentialSource == "flag" || e.CredentialSource == "env" || e.CredentialSource == "profile") && strings.Contains(msg, "product store state plans") && strings.Contains(msg, "not available for this project") { - return "This project needs store-state plan access enabled for API keys. Use `rc login` with OAuth or ask RevenueCat to enable access for this project." + switch e.CredentialSource { + case "flag": + return "Omit `--api-key` and run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project." + case "env": + return "Unset RC_API_KEY and run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project." + default: + return "Run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project." + } } switch e.Type { case "unauthorized", "authentication_error": diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index 9e9ff869..8dc09eb8 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -60,37 +60,24 @@ func TestStoreStatePlanFeatureGateHint(t *testing.T) { for _, tc := range []struct { name, source, message string status int - wantHint bool + wantAction string }{ - {"flag key gated", "flag", gateMessage, 403, true}, - {"environment key gated", "env", gateMessage, 403, true}, - {"profile key gated", "profile", gateMessage, 403, true}, - {"OAuth gated", "oauth", gateMessage, 403, false}, - {"other authorization error", "env", "You do not have permission to perform this action.", 403, false}, - {"other status", "env", gateMessage, 400, false}, + {"flag key gated", "flag", gateMessage, 403, "Omit `--api-key`"}, + {"environment key gated", "env", gateMessage, 403, "Unset RC_API_KEY"}, + {"profile key gated", "profile", gateMessage, 403, "Run `rc login`"}, + {"OAuth gated", "oauth", gateMessage, 403, ""}, + {"other authorization error", "env", "You do not have permission to perform this action.", 403, ""}, + {"other status", "env", gateMessage, 400, ""}, } { t.Run(tc.name, func(t *testing.T) { e := &api.APIError{Status: tc.status, Type: "authorization_error", Message: tc.message, CredentialSource: tc.source} hint := e.Hint() - if got := strings.Contains(hint, "store-state plan access"); got != tc.wantHint { - t.Errorf("gate hint present = %v, want %v; hint = %q", got, tc.wantHint, hint) + if tc.wantAction == "" && hint != "" { + t.Errorf("unexpected hint: %q", hint) + } + if tc.wantAction != "" && (!strings.Contains(hint, tc.wantAction) || !strings.Contains(hint, "store-state plan access")) { + t.Errorf("hint = %q, want action %q and store-state plan access", hint, tc.wantAction) } }) } } - -func TestStoreStatePlanAPIKeyCanSucceedWithoutCLIGate(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Authorization"); got != "Bearer sk_test" { - t.Errorf("Authorization = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"items":[],"object":"list"}`)) - })) - defer srv.Close() - - c := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL, CredentialSource: "flag"}) - if _, err := c.StoreStatePlans.List(context.Background(), "proj_test", ""); err != nil { - t.Fatalf("API key should reach store-state endpoint: %v", err) - } -} diff --git a/internal/cli/products_store_test.go b/internal/cli/products_store_test.go index 18ae24a8..9f10b475 100644 --- a/internal/cli/products_store_test.go +++ b/internal/cli/products_store_test.go @@ -57,6 +57,36 @@ func TestProductsStoreSync_AppliesAfterExplicitYes(t *testing.T) { } } +func TestProductsStoreList_AcceptedAPIKey(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/projects/proj/store_state/plans" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk_test" { + t.Errorf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"items":[],"object":"list"}`) + })) + t.Cleanup(srv.Close) + t.Setenv("RC_CONFIG_DIR", t.TempDir()) + t.Setenv("RC_BASE_URL", srv.URL) + + var out, errOut bytes.Buffer + root := NewRootCmd("test") + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"products", "store", "list", "--api-key", "sk_test", "--project-id", "proj", "--no-input", "--json"}) + if err := root.ExecuteContext(context.Background()); err != nil { + t.Fatalf("store list rejected API key: %v; stderr: %s", err, errOut.String()) + } + if requests != 1 { + t.Errorf("requests = %d, want 1", requests) + } +} + func runStoreSync(t *testing.T, planOnly bool) (requests []string, stdout, stderr string, err error) { t.Helper() getCount := 0 From ae2916af086af7f4805ef21fa1985dd29dbe71c1 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Mon, 28 Sep 2026 16:09:45 -0500 Subject: [PATCH 3/3] fix(products): account for both API-key overrides --- internal/api/errors.go | 2 +- internal/api/errors_test.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index 3fa09026..16da0184 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -84,7 +84,7 @@ func (e *APIError) Hint() string { strings.Contains(msg, "not available for this project") { switch e.CredentialSource { case "flag": - return "Omit `--api-key` and run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project." + return "Omit `--api-key` and unset RC_API_KEY if set, then run `rc login` with browser OAuth. Or ask RevenueCat to enable store-state plan access for this project." case "env": return "Unset RC_API_KEY and run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project." default: diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index 8dc09eb8..4c816a1f 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -78,6 +78,9 @@ func TestStoreStatePlanFeatureGateHint(t *testing.T) { if tc.wantAction != "" && (!strings.Contains(hint, tc.wantAction) || !strings.Contains(hint, "store-state plan access")) { t.Errorf("hint = %q, want action %q and store-state plan access", hint, tc.wantAction) } + if tc.source == "flag" && !strings.Contains(hint, "unset RC_API_KEY if set") { + t.Errorf("flag hint should account for an environment override: %q", hint) + } }) } }