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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,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
Expand Down
14 changes: 14 additions & 0 deletions internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ 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") {
switch e.CredentialSource {
case "flag":
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:
return "Run `rc login` with browser OAuth, or ask RevenueCat to enable store-state plan access for this project."
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
switch e.Type {
case "unauthorized", "authentication_error":
return e.authHint()
Expand Down
30 changes: 30 additions & 0 deletions internal/api/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,33 @@ 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
wantAction string
}{
{"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 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)
}
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)
}
})
}
}
30 changes: 30 additions & 0 deletions internal/cli/products_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading