diff --git a/README.md b/README.md index 2c372796..76012cb3 100644 --- a/README.md +++ b/README.md @@ -298,9 +298,10 @@ toolkit discovers every command with `rc commands --json` and ## Custom request headers -`RC_HEADERS` sends extra HTTP headers on every RevenueCat request (v2 API, Rico, -and the Paywall AI editor), as newline-separated `Name: Value` pairs. Use it to -route or tag traffic without baking anything into the binary: +`RC_HEADERS` sends extra HTTP headers on every CLI-originated RevenueCat request +(v2 API, signup/OAuth, v1 SDK requests, Rico, and the Paywall AI editor), as +newline-separated `Name: Value` pairs. Use it to route or tag traffic without +baking anything into the binary: ```bash RC_HEADERS=$'X-Some-Header: value' rc offerings list @@ -310,16 +311,21 @@ One header per line; supplied headers override the CLI's defaults, except `Autho ## Usage analytics -Every authenticated v2 API request carries a few headers so RevenueCat can see -which commands are used and whether the CLI is driven by a human, an agent, or -CI — derived from normal request logs, with no separate telemetry and no -identifier of any kind: +Every CLI-originated RevenueCat API request carries a few headers, including +signup, OAuth token exchange/refresh, v1 SDK requests, Rico, and Paywall AI. +RevenueCat can see which commands are used and the CLI invocation mode from +normal request logs, with no separate telemetry or user/account identifiers: - `User-Agent` — `revenuecat-cli/ ( ; go)`. - `X-RC-CLI-Command` — the command path only (e.g. `paywalls.generate`), never arguments, flag values, IDs, emails, or prompts. - `X-RC-CLI-Mode` — `interactive`, `agent` (`--json` or `--no-input`), or `ci` - (when `$CI` is set). + (when `$CI` is nonempty, taking precedence over `agent`). + +`agent` describes the flags used, not a verified AI caller or a specific AI tool. +Browser authorization requests use the browser's headers. Third-party requests +(such as Apple and Google APIs or store screenshot uploads) do not receive these +RevenueCat analytics headers. Set `DO_NOT_TRACK=1` (per [consoledonottrack.com](https://consoledonottrack.com)) to drop the `X-RC-CLI-*` headers. Requests still go through, just unlabeled. diff --git a/internal/api/oauth.go b/internal/api/oauth.go index ce9df04b..eeb60555 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -12,25 +12,38 @@ import ( "net/url" "strings" "time" + + "github.com/revenuecat/cli/internal/httpx" ) const DefaultOAuthBaseURL = "https://api.revenuecat.com" const DefaultOAuthClientID = "cmV2ZW51ZWNhdC1jbGk=" const DefaultOAuthScope = "*:*:read_write" -// OAuthService handles the token endpoint only — the browser redirect and -// callback server live in the CLI layer since they have user-facing side effects. +type OAuthOptions struct { + BaseURL string + ClientID string + UserAgent string + ExtraHeaders http.Header +} + +// Browser redirects and the callback server live in the CLI layer because +// they have user-facing side effects. type OAuthService struct { - baseURL string - clientID string - httpClient *http.Client + baseURL string + clientID string + httpClient *http.Client + userAgent string + extraHeaders http.Header } -func NewOAuthService(baseURL, clientID string) *OAuthService { +func NewOAuthService(opts OAuthOptions) *OAuthService { return &OAuthService{ - baseURL: strings.TrimRight(baseURL, "/"), - clientID: clientID, - httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(opts.BaseURL, "/"), + clientID: opts.ClientID, + userAgent: opts.UserAgent, + extraHeaders: opts.ExtraHeaders, + httpClient: &http.Client{Timeout: 30 * time.Second}, } } @@ -171,6 +184,11 @@ func (s *OAuthService) postToken(ctx context.Context, body url.Values) (*TokenRe req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") + if s.userAgent != "" { + req.Header.Set("User-Agent", s.userAgent) + } + httpx.Apply(req, s.extraHeaders) + resp, err := s.httpClient.Do(req) if err != nil { return nil, err @@ -212,6 +230,11 @@ func (s *OAuthService) postJSON(ctx context.Context, path, bearerToken string, b req.Header.Set("Authorization", "Bearer "+bearerToken) } + if s.userAgent != "" { + req.Header.Set("User-Agent", s.userAgent) + } + httpx.Apply(req, s.extraHeaders) + resp, err := s.httpClient.Do(req) if err != nil { return err diff --git a/internal/api/oauth_test.go b/internal/api/oauth_test.go index d3d21cad..56de76f9 100644 --- a/internal/api/oauth_test.go +++ b/internal/api/oauth_test.go @@ -15,8 +15,20 @@ func TestOAuthSignupEndpoints(t *testing.T) { var calls []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls = append(calls, r.Method+" "+r.URL.Path) + if got := r.Header.Get("User-Agent"); got != "rc-test/1" { + t.Errorf("%s User-Agent = %q", r.URL.Path, got) + } + if got := r.Header.Get("X-Trace"); got != "test-trace" { + t.Errorf("%s X-Trace = %q", r.URL.Path, got) + } + if r.URL.Path == "/v1/developers/provision-account" || r.URL.Path == "/v1/developers/login" || r.URL.Path == "/oauth2/token" { + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("%s unexpected Authorization = %q", r.URL.Path, got) + } + } + w.Header().Set("Content-Type", "application/json") - if r.Header.Get("X-Requested-With") != "XMLHttpRequest" { + if r.URL.Path != "/oauth2/token" && r.Header.Get("X-Requested-With") != "XMLHttpRequest" { t.Errorf("%s missing required X-Requested-With header", r.URL.Path) } switch r.URL.Path { @@ -44,6 +56,14 @@ func TestOAuthSignupEndpoints(t *testing.T) { query.Set("state", r.URL.Query().Get("state")) redirect.RawQuery = query.Encode() _, _ = fmt.Fprintf(w, `{"redirect_uri":%q}`, redirect.String()) + case "/oauth2/token": + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("client_id") != "cli-client" { + t.Errorf("unexpected client_id: %q", r.Form.Get("client_id")) + } + _, _ = fmt.Fprint(w, `{"access_token":"access","refresh_token":"refresh","expires_in":3600}`) case "/v1/developers/logout": w.WriteHeader(http.StatusOK) default: @@ -52,7 +72,12 @@ func TestOAuthSignupEndpoints(t *testing.T) { })) t.Cleanup(server.Close) - service := NewOAuthService(server.URL, "cli-client") + service := NewOAuthService(OAuthOptions{ + BaseURL: server.URL, + ClientID: "cli-client", + UserAgent: "rc-test/1", + ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "Authorization": {"Bearer override"}}, + }) ctx := context.Background() if err := service.ProvisionAccount(ctx, ProvisionAccountRequest{ Email: "dev@example.com", Name: "Developer", Password: "generated", MarketingEmailEnabled: true, @@ -74,11 +99,20 @@ func TestOAuthSignupEndpoints(t *testing.T) { t.Fatal(err) } + if _, err := service.ExchangeCode(ctx, code, "http://localhost:49152/callback", "verifier"); err != nil { + t.Fatal(err) + } + if _, err := service.Refresh(ctx, "refresh-token"); err != nil { + t.Fatal(err) + } + want := []string{ "POST /v1/developers/provision-account", "POST /v1/developers/login", "POST /v1/developers/me/oauth-authorize", "POST /v1/developers/logout", + "POST /oauth2/token", + "POST /oauth2/token", } if fmt.Sprint(calls) != fmt.Sprint(want) { t.Fatalf("calls = %v, want %v", calls, want) @@ -92,7 +126,7 @@ func TestAuthorizeWithLoginTokenRejectsMismatchedRedirect(t *testing.T) { })) t.Cleanup(server.Close) - service := NewOAuthService(server.URL, "cli-client") + service := NewOAuthService(OAuthOptions{BaseURL: server.URL, ClientID: "cli-client"}) _, err := service.AuthorizeWithLoginToken(context.Background(), "token", "http://localhost:49152/callback", "challenge", "state") if err == nil { t.Fatal("expected mismatched redirect error") diff --git a/internal/api/sdk.go b/internal/api/sdk.go index 9937c8b4..ce4e458b 100644 --- a/internal/api/sdk.go +++ b/internal/api/sdk.go @@ -9,14 +9,24 @@ import ( "net/url" "strings" "time" + + "github.com/revenuecat/cli/internal/httpx" ) const DefaultSDKBaseURL = "https://api.revenuecat.com/v1" +type SDKOptions struct { + BaseURL string + HTTPClient *http.Client + UserAgent string + ExtraHeaders http.Header +} + type SDKService struct { - baseURL *url.URL - http *http.Client - userAgent string + baseURL *url.URL + http *http.Client + userAgent string + extraHeaders http.Header } type SimulatedPurchase struct { @@ -27,8 +37,10 @@ type SimulatedPurchase struct { SDKOriginated bool `json:"sdk_originated"` } -func NewSDKService(v2BaseURL string, httpClient *http.Client, userAgent string) *SDKService { - base := v2BaseURL +func NewSDKService(opts SDKOptions) *SDKService { + httpClient := opts.HTTPClient + userAgent := opts.UserAgent + base := opts.BaseURL if base == "" || base == DefaultBaseURL { base = DefaultSDKBaseURL } else { @@ -47,7 +59,7 @@ func NewSDKService(v2BaseURL string, httpClient *http.Client, userAgent string) if userAgent == "" { userAgent = "revenuecat-cli/dev" } - return &SDKService{baseURL: u, http: httpClient, userAgent: userAgent} + return &SDKService{baseURL: u, http: httpClient, userAgent: userAgent, extraHeaders: opts.ExtraHeaders} } func (s *SDKService) Offerings(ctx context.Context, publicAPIKey, appUserID string) (json.RawMessage, error) { @@ -63,6 +75,7 @@ func (s *SDKService) Offerings(ctx context.Context, publicAPIKey, appUserID stri req.Header.Set("Authorization", "Bearer "+publicAPIKey) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", s.userAgent) + httpx.Apply(req, s.extraHeaders) resp, err := s.http.Do(req) if err != nil { return nil, err @@ -93,6 +106,7 @@ func (s *SDKService) SimulatePurchase(ctx context.Context, publicAPIKey string, req.Header.Set("X-Platform", "iOS") req.Header.Set("X-Version", "rc-cli") req.Header.Set("X-Client-Bundle-Id", "com.revenuecat.cli") + httpx.Apply(req, s.extraHeaders) resp, err := s.http.Do(req) if err != nil { return nil, err diff --git a/internal/api/sdk_test.go b/internal/api/sdk_test.go index ce052678..1b8fac27 100644 --- a/internal/api/sdk_test.go +++ b/internal/api/sdk_test.go @@ -18,12 +18,22 @@ func TestSDKOfferingsUsesPublicKeyAndV1Path(t *testing.T) { if r.Header.Get("Authorization") != "Bearer test_public" { t.Fatalf("authorization = %q", r.Header.Get("Authorization")) } + if got := r.Header.Get("User-Agent"); got != "override-ua" { + t.Errorf("User-Agent = %q", got) + } + if got := r.Header.Get("X-Trace"); got != "test-trace" { + t.Errorf("X-Trace = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"current_offering_id":"default","offerings":[]}`)) })) t.Cleanup(srv.Close) - service := api.NewSDKService(srv.URL+"/v2", nil, "test") + service := api.NewSDKService(api.SDKOptions{ + BaseURL: srv.URL + "/v2", + UserAgent: "test", + ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "User-Agent": {"override-ua"}, "Authorization": {"Bearer override"}}, + }) result, err := service.Offerings(context.Background(), "test_public", "user/one") if err != nil { t.Fatal(err) @@ -48,12 +58,22 @@ func TestSDKSimulatePurchasePostsReceipt(t *testing.T) { if body.FetchToken != "TEST_token" || body.AppUserID != "user" || body.ProductID != "monthly" || !body.SDKOriginated { t.Fatalf("unexpected body: %+v", body) } + if got := r.Header.Get("User-Agent"); got != "override-ua" { + t.Errorf("User-Agent = %q", got) + } + if got := r.Header.Get("X-Trace"); got != "test-trace" { + t.Errorf("X-Trace = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"subscriber":{"entitlements":{"premium":{"expires_date":null}}}}`)) })) t.Cleanup(srv.Close) - service := api.NewSDKService(srv.URL+"/v2", nil, "test") + service := api.NewSDKService(api.SDKOptions{ + BaseURL: srv.URL + "/v2", + UserAgent: "test", + ExtraHeaders: http.Header{"X-Trace": {"test-trace"}, "User-Agent": {"override-ua"}, "Authorization": {"Bearer override"}}, + }) result, err := service.SimulatePurchase(context.Background(), "test_public", api.SimulatedPurchase{ FetchToken: "TEST_token", AppUserID: "user", ProductID: "monthly", InitiationSource: "purchase", SDKOriginated: true, }) diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go index a0035d7e..a119c6df 100644 --- a/internal/cli/analytics.go +++ b/internal/cli/analytics.go @@ -49,7 +49,7 @@ func doNotTrack() bool { return false } -// requestHeaders assembles the extra headers sent on every v2 API request: the +// requestHeaders assembles the extra headers sent on every RevenueCat API request: the // usage-analytics headers (dropped when DO_NOT_TRACK is set — the request still // goes through, just unlabeled) plus any user-supplied RC_HEADERS, which // override so operators keep the final say. diff --git a/internal/cli/analytics_test.go b/internal/cli/analytics_test.go index 0cd52b0d..78503dcd 100644 --- a/internal/cli/analytics_test.go +++ b/internal/cli/analytics_test.go @@ -10,6 +10,9 @@ import ( "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" + "github.com/revenuecat/cli/internal/config" + "github.com/revenuecat/cli/internal/paywallai" + "github.com/revenuecat/cli/internal/rico" ) func TestCommandPath(t *testing.T) { @@ -180,3 +183,113 @@ func TestDoNotTrackStillSendsRequest(t *testing.T) { t.Errorf("User-Agent must still be sent under DO_NOT_TRACK, got %q", gotUA) } } + +func TestNonV2AnalyticsHeadersReachTheWire(t *testing.T) { + cases := []struct { + name string + globals Globals + ci string + doNotTrack string + custom string + wantMode string + wantCommand string + wantUA string + }{ + {name: "interactive", wantMode: "interactive", wantCommand: "auth.signup"}, + {name: "json", globals: Globals{JSON: true}, wantMode: "agent", wantCommand: "auth.signup"}, + {name: "no-input", globals: Globals{NoInput: true}, wantMode: "agent", wantCommand: "auth.signup"}, + {name: "ci", globals: Globals{JSON: true}, ci: "true", wantMode: "ci", wantCommand: "auth.signup"}, + {name: "do-not-track", doNotTrack: "1", custom: "X-Trace: keep-me"}, + {name: "overrides", custom: "User-Agent: custom-ua\nX-RC-CLI-Command: custom.command\nX-RC-CLI-Mode: custom-mode\nAuthorization: Bearer override", wantMode: "custom-mode", wantCommand: "custom.command", wantUA: "custom-ua"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CI", tc.ci) + t.Setenv("DO_NOT_TRACK", tc.doNotTrack) + t.Setenv("RC_HEADERS", tc.custom) + tc.globals.CommandPath = "auth.signup" + tc.globals.Version = "9.9.9" + wantUA := tc.wantUA + if wantUA == "" { + wantUA = userAgent(tc.globals.Version) + } + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + for header, want := range map[string]string{ + "User-Agent": wantUA, + headerCLICommand: tc.wantCommand, + headerCLIMode: tc.wantMode, + } { + if got := r.Header.Get(header); got != want { + t.Errorf("%s %s = %q, want %q", r.URL.Path, header, got, want) + } + } + if tc.doNotTrack != "" && r.Header.Get("X-Trace") != "keep-me" { + t.Errorf("%s lost custom header", r.URL.Path) + } + wantAuth := "Bearer access" + if r.URL.Path == "/oauth2/token" { + wantAuth = "" + } + if r.URL.Path == "/v1/subscribers/user/offerings" || r.URL.Path == "/v1/receipts" { + wantAuth = "Bearer public-key" + } + if got := r.Header.Get("Authorization"); got != wantAuth { + t.Errorf("%s Authorization = %q, want %q", r.URL.Path, got, wantAuth) + } + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/oauth2/token" { + _, _ = w.Write([]byte(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)) + } else { + _, _ = w.Write([]byte(`{}`)) + } + })) + t.Cleanup(srv.Close) + t.Setenv("RC_OAUTH_BASE_URL", srv.URL) + rt := &Runtime{Globals: &tc.globals, Config: &config.Config{AccessToken: "access", TokenType: "oauth"}} + ctx := context.Background() + if _, err := rt.oauthService().Refresh(ctx, "refresh"); err != nil { + t.Fatal(err) + } + sdk := api.NewSDKService(api.SDKOptions{BaseURL: srv.URL + "/v2", UserAgent: userAgent(rt.Globals.Version), ExtraHeaders: requestHeaders(rt.Globals)}) + if _, err := sdk.Offerings(ctx, "public-key", "user"); err != nil { + t.Fatal(err) + } + if _, err := sdk.SimulatePurchase(ctx, "public-key", api.SimulatedPurchase{}); err != nil { + t.Fatal(err) + } + rc, err := ricoClient(rt, srv.URL) + if err != nil { + t.Fatal(err) + } + if err := rc.PostFeedback(ctx, rico.FeedbackRequest{}); err != nil { + t.Fatal(err) + } + rs, err := rc.Stream(ctx, rico.RunAgentInput{}) + if err != nil { + t.Fatal(err) + } + if err := rs.Close(); err != nil { + t.Fatal(err) + } + pc, err := paywallAIClient(rt, srv.URL) + if err != nil { + t.Fatal(err) + } + if err := pc.Feedback(ctx, "session", "trace", "positive"); err != nil { + t.Fatal(err) + } + ps, err := pc.Stream(ctx, paywallai.EditorRequest{}) + if err != nil { + t.Fatal(err) + } + if err := ps.Close(); err != nil { + t.Fatal(err) + } + if len(paths) != 7 { + t.Fatalf("requests = %v, want 7 requests", paths) + } + }) + } +} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 85c57fa8..1cb874a3 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -623,7 +623,7 @@ func loginWithOAuth(ctx context.Context, rt *Runtime) error { port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) - svc := api.NewOAuthService(oauthBaseURL(), oauthClientID()) + svc := rt.oauthService() authURL := svc.AuthorizeURL(redirectURI, challenge, state) rt.Out.Info("Opening browser for authorization…") @@ -731,7 +731,7 @@ func signupWithOAuth(ctx context.Context, rt *Runtime, email, name, password str port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) - svc := api.NewOAuthService(oauthBaseURL(), oauthClientID()) + svc := rt.oauthService() say("Creating your RevenueCat account…") if err := svc.ProvisionAccount(ctx, api.ProvisionAccountRequest{ Email: email, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c1105fb9..18f13ce4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -55,12 +55,25 @@ func runCmdInConfigDir(t *testing.T, configDir string, args ...string) (stdout, } func TestAuthSignup_AgentFlowStoresDurableOAuthWithoutLeakingTemporaryCredentials(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("RC_HEADERS", "") const temporaryToken = "temporary-login-token-must-not-leak" var generatedPassword string var requests []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests = append(requests, r.Method+" "+r.URL.Path) + if got := r.Header.Get("User-Agent"); !strings.HasPrefix(got, "revenuecat-cli/test (") { + t.Errorf("%s User-Agent = %q", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Command"); got != "auth.signup" { + t.Errorf("%s command = %q, want auth.signup", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Mode"); got != "agent" { + t.Errorf("%s mode = %q, want agent", r.URL.Path, got) + } + w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/oauth2/token" && r.Header.Get("X-Requested-With") != "XMLHttpRequest" { t.Errorf("%s missing required X-Requested-With header", r.URL.Path) diff --git a/internal/cli/customers.go b/internal/cli/customers.go index 2aca6ff0..6e4a7c57 100644 --- a/internal/cli/customers.go +++ b/internal/cli/customers.go @@ -185,7 +185,11 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` if err != nil { return err } - sdk := api.NewSDKService(rt.effectiveBaseURL(), nil, userAgent(rt.Globals.Version)) + sdk := api.NewSDKService(api.SDKOptions{ + BaseURL: rt.effectiveBaseURL(), + UserAgent: userAgent(rt.Globals.Version), + ExtraHeaders: requestHeaders(rt.Globals), + }) raw, err := sdk.SimulatePurchase(cmd.Context(), publicAPIKey, api.SimulatedPurchase{ FetchToken: fetchToken, AppUserID: appUserID, ProductID: selected.StoreIdentifier, InitiationSource: "purchase", SDKOriginated: true, diff --git a/internal/cli/customers_simulate_purchase_test.go b/internal/cli/customers_simulate_purchase_test.go index 32c46369..2649c695 100644 --- a/internal/cli/customers_simulate_purchase_test.go +++ b/internal/cli/customers_simulate_purchase_test.go @@ -12,8 +12,21 @@ import ( ) func TestCustomerSimulatePurchaseUsesTestStoreReceiptFlow(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("RC_HEADERS", "") var receipt api.SimulatedPurchase server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); !strings.HasPrefix(got, "revenuecat-cli/test (") { + t.Errorf("%s User-Agent = %q", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Command"); got != "customers.simulate-purchase" { + t.Errorf("%s command = %q, want customers.simulate-purchase", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Mode"); got != "agent" { + t.Errorf("%s mode = %q, want agent", r.URL.Path, got) + } + w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/projects/proj/products": diff --git a/internal/cli/offerings.go b/internal/cli/offerings.go index 0b76b42f..1057cb97 100644 --- a/internal/cli/offerings.go +++ b/internal/cli/offerings.go @@ -236,7 +236,11 @@ ID in a terminal to pick from a list.`, } publicAPIKey = keys.Items[0].Key } - sdk := api.NewSDKService(rt.effectiveBaseURL(), nil, userAgent(rt.Globals.Version)) + sdk := api.NewSDKService(api.SDKOptions{ + BaseURL: rt.effectiveBaseURL(), + UserAgent: userAgent(rt.Globals.Version), + ExtraHeaders: requestHeaders(rt.Globals), + }) raw, err := sdk.Offerings(cmd.Context(), publicAPIKey, appUserID) if err != nil { return err diff --git a/internal/cli/offerings_verify_test.go b/internal/cli/offerings_verify_test.go index b53b65e3..cf4055ef 100644 --- a/internal/cli/offerings_verify_test.go +++ b/internal/cli/offerings_verify_test.go @@ -45,7 +45,20 @@ func TestOfferingsVerifyReturnsConfigurationGraphAndIssues(t *testing.T) { } func TestOfferingsPreviewReturnsSDKPayload(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("RC_HEADERS", "") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("User-Agent"); !strings.HasPrefix(got, "revenuecat-cli/test (") { + t.Errorf("%s User-Agent = %q", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Command"); got != "offerings.preview" { + t.Errorf("%s command = %q, want offerings.preview", r.URL.Path, got) + } + if got := r.Header.Get("X-RC-CLI-Mode"); got != "agent" { + t.Errorf("%s mode = %q, want agent", r.URL.Path, got) + } + w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/projects/proj/apps/app/public_api_keys": diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 5efc0e1e..e7e36fee 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -913,7 +913,7 @@ func paywallAIClient(rt *Runtime, baseURL string) (*paywallai.Client, error) { BaseURL: baseURL, Token: agentAuthToken(rt), UserAgent: userAgent(rt.Globals.Version), - ExtraHeaders: customHeaders(), + ExtraHeaders: requestHeaders(rt.Globals), }), nil } diff --git a/internal/cli/rico.go b/internal/cli/rico.go index 91b08c64..6ca2c04b 100644 --- a/internal/cli/rico.go +++ b/internal/cli/rico.go @@ -705,7 +705,7 @@ func ricoClient(rt *Runtime, baseURL string) (*rico.Client, error) { BaseURL: baseURL, Token: agentAuthToken(rt), UserAgent: userAgent(rt.Globals.Version), - ExtraHeaders: customHeaders(), + ExtraHeaders: requestHeaders(rt.Globals), }), nil } diff --git a/internal/cli/runtime.go b/internal/cli/runtime.go index ddd016ba..f201a18c 100644 --- a/internal/cli/runtime.go +++ b/internal/cli/runtime.go @@ -172,7 +172,7 @@ func customHeaders() http.Header { // Not goroutine-safe: the CLI is single-threaded by design; do not call from // concurrent goroutines without adding a mutex to Runtime. func (r *Runtime) silentRefresh() { - svc := api.NewOAuthService(oauthBaseURL(), oauthClientID()) + svc := r.oauthService() tr, err := svc.Refresh(r.Ctx, r.Config.RefreshToken) if err != nil { return @@ -183,6 +183,15 @@ func (r *Runtime) silentRefresh() { _ = config.Save(r.Globals.Profile, r.Config) } +func (r *Runtime) oauthService() *api.OAuthService { + return api.NewOAuthService(api.OAuthOptions{ + BaseURL: oauthBaseURL(), + ClientID: oauthClientID(), + UserAgent: userAgent(r.Globals.Version), + ExtraHeaders: requestHeaders(r.Globals), + }) +} + func oauthBaseURL() string { // Dev-only override, like the other endpoints: a release binary always // authenticates against the production OAuth host.