Skip to content
Merged
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
117 changes: 116 additions & 1 deletion internal/openrouter/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,124 @@ func fetchUserDataFromEndpoint(auth *Auth, pagePath, routerState, operation stri
return nil, fmt.Errorf("%s failed after %d attempts", operation, maxRetries)
}

// currentUserPath is OpenRouter's private frontend REST endpoint for the
// signed-in user's account state. As of 2026-08 it replaces the getCurrentUserSA
// server action, which was removed when OpenRouter migrated their private
// frontend from Next.js Server Actions to REST + React Query. It returns
// {"data": {...}} carrying email and the user-scope privacy toggles.
const currentUserPath = "/api/frontend/v1/private/users/current"

// fetchCurrentUser reads account state from the private frontend REST API.
//
// This is strictly preferable to the server-action path it replaces: it needs no
// action hash, so it removes the dependency on scraping OpenRouter's minified
// client bundle -- the layer that broke twice in 2026-08 (bundle relocation,
// then the server-action removal itself).
func fetchCurrentUser(auth *Auth) (map[string]any, error) {
cookies := auth.GetCookies()
url := config.BaseURL + currentUserPath

for attempt := 1; attempt <= maxRetries; attempt++ {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", config.BaseURL+"/activity")
for _, c := range cookies {
req.AddCookie(c)
}

resp, err := httpClient.Do(req)
if err != nil {
slog.Warn("fetch_current_user error", "attempt", attempt, "error", err)
if attempt < maxRetries {
_ = netretry.Sleep(context.Background(), attempt, retryCfg)
continue
}
return nil, &RequestResponseError{
Operation: "fetch_current_user",
Method: req.Method,
URL: url,
RequestHeaders: flattenHeaders(req.Header),
Err: err,
}
}

body, _ := io.ReadAll(resp.Body)
resp.Body.Close()

if resp.StatusCode != 200 {
slog.Warn("fetch_current_user failed", "attempt", attempt, "status", resp.StatusCode)
if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries {
_ = netretry.Sleep(context.Background(), attempt, retryCfg)
continue
}
return nil, &RequestResponseError{
Operation: "fetch_current_user",
Method: req.Method,
URL: url,
RequestHeaders: flattenHeaders(req.Header),
ResponseStatus: resp.StatusCode,
ResponseHeaders: flattenHeaders(resp.Header),
ResponseBody: string(body),
Err: fmt.Errorf("fetch_current_user failed: status %d", resp.StatusCode),
}
}

data, err := parseCurrentUserResponse(body)
if err == nil && data != nil {
return data, nil
}
slog.Warn("fetch_current_user could not parse response", "attempt", attempt, "error", err)
if attempt < maxRetries {
_ = netretry.Sleep(context.Background(), attempt, retryCfg)
continue
}
return nil, &RequestResponseError{
Operation: "fetch_current_user_parse",
Method: req.Method,
URL: url,
RequestHeaders: flattenHeaders(req.Header),
ResponseStatus: resp.StatusCode,
ResponseHeaders: flattenHeaders(resp.Header),
ResponseBody: string(body),
Err: err,
}
}

return nil, fmt.Errorf("fetch_current_user failed after %d attempts", maxRetries)
}

// parseCurrentUserResponse unwraps the {"data": {...}} envelope. Kept pure so
// the response contract is unit-testable without a live session.
func parseCurrentUserResponse(body []byte) (map[string]any, error) {
var envelope struct {
Data map[string]any `json:"data"`
Error any `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("current_user response is not json: %w", err)
}
if envelope.Error != nil {
return nil, fmt.Errorf("current_user returned error: %v", envelope.Error)
}
if len(envelope.Data) == 0 {
return nil, fmt.Errorf("current_user response had no data")
}
return envelope.Data, nil
}

// FetchActivityData fetches user data including email and privacy toggles.
// It tries the /activity endpoint first, then falls back to /workspaces/default/observability.
//
// Order: the private REST endpoint first, then the legacy server-action paths
// (/activity, then /workspaces/default/observability) as fallbacks. The legacy
// paths are retained because they cost nothing when the primary succeeds and
// they are the only recourse if OpenRouter reverts the migration.
func FetchActivityData(auth *Auth) (map[string]any, error) {
if data, restErr := fetchCurrentUser(auth); restErr == nil && data != nil {
return data, nil
} else if restErr != nil {
slog.Warn("current_user endpoint failed, trying legacy server-action paths", "error", restErr)
}

data, err := fetchUserDataFromEndpoint(auth, "/activity", activityRouterState, "fetch_activity_data")
if err == nil && data != nil {
return data, nil
Expand Down
80 changes: 80 additions & 0 deletions internal/openrouter/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package openrouter

import "testing"

// ---------------------------------------------------------------------------
// Private frontend REST contract
//
// Regression guard for the 2026-08-25 migration: OpenRouter removed the
// getCurrentUserSA server action and moved account state to
// GET /api/frontend/v1/private/users/current. Response bodies below are real
// shapes captured from that endpoint, with identifiers redacted.
// ---------------------------------------------------------------------------

const currentUserOK = `{"data":{
"email":"test@example.com",
"clerk_user_id":"REDACTED",
"enable_training":false,
"enable_free_model_training":false,
"enable_free_model_publication":false,
"enforce_zdr":false,
"is_broadcast_enabled":false,
"is_private_logging_enabled":false,
"lock_privacy_settings":false,
"subscription_plan":"standard"
}}`

func TestParseCurrentUserResponse_OK(t *testing.T) {
data, err := parseCurrentUserResponse([]byte(currentUserOK))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["email"] != "test@example.com" {
t.Errorf("email = %v, want test@example.com", data["email"])
}
// Every user-scope required toggle must survive the unwrap as a real bool,
// since CheckPrivacyToggles treats a missing toggle as not-verified.
for _, k := range []string{
"enable_training", "enable_free_model_training", "enable_free_model_publication",
"enforce_zdr", "is_broadcast_enabled", "is_private_logging_enabled",
} {
v, ok := data[k]
if !ok {
t.Errorf("toggle %q missing from unwrapped data", k)
continue
}
if _, isBool := v.(bool); !isBool {
t.Errorf("toggle %q = %T(%v), want bool", k, v, v)
}
}
}

func TestParseCurrentUserResponse_Unauthorized(t *testing.T) {
// Real body observed when the session JWT has expired. Clerk mints
// 60-second tokens, so this is a live failure mode, not a hypothetical.
body := `{"error":{"message":"No user or org id found in auth cookie","code":401}}`
if _, err := parseCurrentUserResponse([]byte(body)); err == nil {
t.Fatal("expected an error for a 401 envelope, got nil")
}
}

func TestParseCurrentUserResponse_Rejects(t *testing.T) {
cases := []struct {
name string
body string
}{
{"empty data", `{"data":{}}`},
{"null data", `{"data":null}`},
{"no envelope", `{}`},
// A signed-out request can return an HTML page rather than JSON; that
// must be an error, never an empty-but-successful toggle set.
{"html sign-in page", `<!DOCTYPE html><html><body>sign in</body></html>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := parseCurrentUserResponse([]byte(tc.body)); err == nil {
t.Errorf("expected error for %s, got nil", tc.name)
}
})
}
}
Loading