diff --git a/.github/workflows/build-and-sign.yml b/.github/workflows/build-and-sign.yml index 71db6da..32f70b8 100644 --- a/.github/workflows/build-and-sign.yml +++ b/.github/workflows/build-and-sign.yml @@ -68,6 +68,10 @@ jobs: extra_nix_config: | experimental-features = nix-command flakes + - name: Run Go tests + if: steps.should-build.outputs.build == 'true' + run: nix develop --command go test ./... + - name: Build container with Nix (reproducible) if: steps.should-build.outputs.build == 'true' id: build diff --git a/cmd/diag/main.go b/cmd/diag/main.go index c0e5684..138259d 100644 --- a/cmd/diag/main.go +++ b/cmd/diag/main.go @@ -1,7 +1,8 @@ -// Diagnostic tool: test privacy toggle verification with raw cookies. +// Diagnostic tool: test privacy toggle verification with an OpenRouter session. // // Usage: -// OR_COOKIES='' go run cmd/diag/main.go +// +// OR_COOKIES='' go run ./cmd/diag // // The cookie header can be copied from browser DevTools Network tab. package main @@ -22,8 +23,8 @@ func main() { jsonCreds := os.Getenv("OR_CREDS") if rawCookies == "" && jsonCreds == "" { fmt.Println("Usage:") - fmt.Println(" OR_COOKIES='' go run cmd/diag/main.go") - fmt.Println(" OR_CREDS='{\"client_token\":\"...\",\"session_id\":\"...\",\"client_uat\":\"...\"}' go run cmd/diag/main.go") + fmt.Println(" OR_COOKIES='' go run ./cmd/diag") + fmt.Println(" OR_CREDS='{\"client_token\":\"...\",\"session_id\":\"...\",\"client_uat\":\"...\",\"org_id\":\"...\"}' go run ./cmd/diag") os.Exit(1) } @@ -37,20 +38,18 @@ func main() { ClientToken string `json:"client_token"` ClientUAT string `json:"client_uat"` SessionID string `json:"session_id"` + OrgID string `json:"org_id"` } if e := json.Unmarshal([]byte(jsonCreds), &creds); e != nil { fmt.Printf("Failed to parse OR_CREDS JSON: %v\n", e) os.Exit(1) } - fmt.Printf("Session ID: %s\n", creds.SessionID) - fmt.Printf("Client token: %s...\n", creds.ClientToken[:min(30, len(creds.ClientToken))]) - // Build structured cookie data for NewAuthFromCookieData cookieData := map[string]any{ "cookies": []any{ map[string]any{"name": "__client", "value": creds.ClientToken, "domain": "clerk.openrouter.ai"}, map[string]any{"name": "__client_uat", "value": creds.ClientUAT, "domain": "openrouter.ai"}, - map[string]any{"name": "clerk_active_context", "value": creds.SessionID + ":", "domain": "openrouter.ai"}, + map[string]any{"name": "clerk_active_context", "value": creds.SessionID + ":" + creds.OrgID, "domain": "openrouter.ai"}, }, } auth, err = openrouter.NewAuthFromCookieData(cookieData) @@ -67,7 +66,6 @@ func main() { os.Exit(1) } fmt.Println("Authentication successful!") - fmt.Printf("Action hashes: %v\n", auth.GetAllActionHashes()) fmt.Println("\n=== Step 2: Fetch Activity Data ===") data, err := openrouter.FetchActivityData(auth) @@ -87,16 +85,13 @@ func main() { } fmt.Println("Activity data fetched successfully!") - pretty, _ := json.MarshalIndent(data, "", " ") - fmt.Printf("\nUser data:\n%s\n", string(pretty)) fmt.Println("\n=== Step 2b: Fetch Workspace Data ===") wsData, wsErr := openrouter.FetchWorkspaceData(auth) if wsErr != nil { fmt.Printf("Workspace data fetch failed: %v\n", wsErr) } else if wsData != nil { - wsPretty, _ := json.MarshalIndent(wsData, "", " ") - fmt.Printf("Workspace data:\n%s\n", string(wsPretty)) + fmt.Println("Workspace data fetched successfully!") } // Merge user + workspace data for toggle checking @@ -140,10 +135,6 @@ func main() { } fmt.Printf(" %-40s %s\n", name, status) } - - // List all boolean-like fields in merged response - fmt.Println("\nAll boolean fields (merged):") - listBoolFields(merged, "") } func findToggleValue(data map[string]any, name string) (bool, bool) { @@ -183,24 +174,3 @@ func searchMap(data map[string]any, target, prefix string) (bool, bool) { } return false, false } - -func listBoolFields(data map[string]any, prefix string) { - for k, v := range data { - path := k - if prefix != "" { - path = prefix + "." + k - } - switch t := v.(type) { - case bool: - fmt.Printf(" %s = %v\n", path, t) - case map[string]any: - listBoolFields(t, path) - case []any: - for i, item := range t { - if m, ok := item.(map[string]any); ok { - listBoolFields(m, fmt.Sprintf("%s[%d]", path, i)) - } - } - } - } -} diff --git a/internal/config/config.go b/internal/config/config.go index 759c4d9..0bffe46 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,10 +75,11 @@ const ( // improvement in exchange for a 1% usage discount. Must be false so // user data is not used for training or product enhancement. This // replaced the former enable_logging field. Note: this is a workspace- -// level setting fetched from workspace data, not from getCurrentUserSA. +// level setting fetched from the default workspace response, not the +// current-user response. // Ref: https://openrouter.ai/docs/guides/privacy/data-collection -// UserRequiredToggles are checked against getCurrentUserSA response. +// UserRequiredToggles are checked against the authenticated current-user API. // // Removed 2026-04-27: `always_enforce_allowed` (model-allowlist enforcement). // OpenRouter retired the field — confirmed zero references in the public diff --git a/internal/openrouter/api.go b/internal/openrouter/api.go index 80c3e09..e0767bd 100644 --- a/internal/openrouter/api.go +++ b/internal/openrouter/api.go @@ -3,6 +3,7 @@ package openrouter import ( "bytes" "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "strings" "time" @@ -19,19 +21,23 @@ import ( ) const ( - maxRetries = 5 - - managementKeysRouterState = "%5B%22%22%2C%7B%22children%22%3A%5B%22(user)%22%2C%7B%22children%22%3A%5B%22settings%22%2C%7B%22children%22%3A%5B%22management-keys%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D" - - activityRouterState = "%5B%22%22%2C%7B%22children%22%3A%5B%22(user)%22%2C%7B%22children%22%3A%5B%22activity%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D" - - observabilityPagePath = "/workspaces/default/observability" - observabilityRouterState = "%5B%22%22%2C%7B%22children%22%3A%5B%22(user)%22%2C%7B%22children%22%3A%5B%22(dashboard)%22%2C%7B%22children%22%3A%5B%22workspaces%22%2C%7B%22children%22%3A%5B%5B%22workspaceId%22%2C%22default%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22observability%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D" + currentUserAPIPath = "/api/frontend/v1/private/users/current" + userWorkspacesAPIPath = "/api/frontend/v1/private/user/workspaces" + managementKeysAPIPath = "/api/frontend/v1/private/management-keys" + workspaceAPIKeysAPIPath = "/api/frontend/v1/private/workspace-api-keys" + privacySettingsPagePath = "/settings/privacy" + managementKeysPagePath = "/settings/management-keys" + workspaceSettingsPagePath = "/workspaces/default/settings" + maxFrontendResponseBytes = 4 << 20 + maxManagementKeyPages = 200 ) +const maxRetries = 5 + var retryCfg = netretry.DefaultConfig(maxRetries) -// RequestResponseError captures full OpenRouter request/response context for failures. +// RequestResponseError captures OpenRouter request/response metadata for failures. +// Context deliberately summarizes bodies and redacts credential-bearing headers. type RequestResponseError struct { Operation string Method string @@ -71,12 +77,12 @@ func (e *RequestResponseError) Context() map[string]any { "method": e.Method, "url": e.URL, "headers": e.RequestHeaders, - "body": e.RequestBody, + "body": safeBodySummary(e.RequestBody), }, "openrouter_response": map[string]any{ "status_code": e.ResponseStatus, "headers": e.ResponseHeaders, - "body": e.ResponseBody, + "body": safeBodySummary(e.ResponseBody), }, } } @@ -95,28 +101,42 @@ func ErrorContext(err error) map[string]any { return nil } +// IsSessionAuthError reports whether OpenRouter explicitly rejected the +// refreshed browser session. Callers can distinguish this from endpoint/schema +// drift and upstream failures instead of reporting every verifier error as a +// bad cookie. +func IsSessionAuthError(err error) bool { + var requestErr *RequestResponseError + if !errors.As(err, &requestErr) { + return false + } + return requestErr.ResponseStatus == http.StatusUnauthorized || + requestErr.ResponseStatus == http.StatusForbidden +} + func flattenHeaders(h http.Header) map[string]string { if h == nil { return map[string]string{} } out := make(map[string]string, len(h)) for k, vals := range h { - out[k] = strings.Join(vals, ", ") + switch strings.ToLower(k) { + case "authorization", "cookie", "proxy-authorization", "set-cookie": + out[k] = "[REDACTED]" + default: + out[k] = strings.Join(vals, ", ") + } } return out } -var ( - escapedObjectRe = regexp.MustCompile(`\{[^{}]*\\"hash\\":\\"[0-9a-f]{64}\\"[^{}]*\}`) - escapedHashRe = regexp.MustCompile(`\\"hash\\":\\"([0-9a-f]{64})\\"`) - escapedNameRe = regexp.MustCompile(`\\"name\\":\\"([^"\\]+)\\"`) - escapedProvRe = regexp.MustCompile(`\\"is_provisioning_key\\":(true|false)`) - - plainObjectRe = regexp.MustCompile(`\{[^{}]*"hash":"[0-9a-f]{64}"[^{}]*\}`) - plainHashRe = regexp.MustCompile(`"hash":"([0-9a-f]{64})"`) - plainNameRe = regexp.MustCompile(`"name":"([^"]+)"`) - plainProvRe = regexp.MustCompile(`"is_provisioning_key":(true|false)`) -) +func safeBodySummary(body string) string { + if body == "" { + return "" + } + sum := sha256.Sum256([]byte(body)) + return fmt.Sprintf("[REDACTED: %d bytes, sha256:%x]", len(body), sum) +} // Shared HTTP client with connection pooling var httpClient = &http.Client{ @@ -128,810 +148,363 @@ var httpClient = &http.Client{ }, } -// fetchUserDataFromEndpoint fetches user data from a Next.js server component endpoint. -// Both /activity and /workspaces/default/observability return the same getCurrentUserSA -// response containing email and privacy toggles. -func fetchUserDataFromEndpoint(auth *Auth, pagePath, routerState, operation string) (map[string]any, error) { - actionHash := auth.GetActionHash("activity") - if actionHash == "" { - return nil, fmt.Errorf("no activity hash found, available: %v (%s)", auth.GetAllActionHashes(), auth.DiscoveryDiagnostics()) - } - - cookies := auth.GetCookies() - reqBody := "[]" - - for attempt := 1; attempt <= maxRetries; attempt++ { - req, _ := http.NewRequest("POST", config.BaseURL+pagePath, strings.NewReader(reqBody)) - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - req.Header.Set("Accept", "text/x-component") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Next-Action", actionHash) - req.Header.Set("Next-Router-State-Tree", routerState) - req.Header.Set("Origin", config.BaseURL) - req.Header.Set("Referer", config.BaseURL+pagePath) - - for _, c := range cookies { - req.AddCookie(c) - } +// frontendBaseURL is replaceable by package tests. Production always uses the +// attested OpenRouter origin from config. +var frontendBaseURL = config.BaseURL - resp, err := httpClient.Do(req) +// doFrontendJSON calls one of OpenRouter's cookie-authenticated frontend APIs. +// GET and PATCH requests retry transient failures. POST is deliberately attempted +// once because retrying an ambiguous management-key creation can orphan keys. +func doFrontendJSON(auth *Auth, operation, method, path, refererPath string, payload any) ([]byte, error) { + var requestBody []byte + var err error + if payload != nil { + requestBody, err = json.Marshal(payload) if err != nil { - slog.Warn(operation+" error", "attempt", attempt, "path", pagePath, "error", err) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: operation, - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: reqBody, - Err: err, - } + return nil, fmt.Errorf("%s: encode request: %w", operation, err) } + } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + attempts := maxRetries + if method == http.MethodPost { + attempts = 1 + } - if resp.StatusCode != 200 { - slog.Warn(operation+" failed", "attempt", attempt, "path", pagePath, "status", resp.StatusCode) - if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: operation, - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: reqBody, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("%s failed: status %d", operation, resp.StatusCode), - } - } + client := auth.client + if client == nil { + client = httpClient + } - // Parse response - for _, line := range strings.Split(string(body), "\n") { - if strings.Contains(line, `{"__kind":"OK"`) || strings.Contains(line, `"email"`) { - idx := strings.Index(line, "{") - if idx >= 0 { - var obj map[string]any - if err := json.Unmarshal([]byte(line[idx:]), &obj); err == nil { - if obj["__kind"] == "OK" { - if data, ok := obj["data"].(map[string]any); ok { - return data, nil - } - } - if _, hasEmail := obj["email"]; hasEmail { - return obj, nil - } - } - } - } + requestURL := strings.TrimRight(frontendBaseURL, "/") + path + for attempt := 1; attempt <= attempts; attempt++ { + var bodyReader io.Reader + if requestBody != nil { + bodyReader = bytes.NewReader(requestBody) } - slog.Warn(operation+" could not parse response", "attempt", attempt, "path", pagePath) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: operation + "_parse", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: reqBody, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("%s parse failed", operation), + req, err := http.NewRequest(method, requestURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("%s: build request: %w", operation, err) } - } - - 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) + req.Header.Set("Origin", strings.TrimRight(frontendBaseURL, "/")) + if refererPath != "" { + req.Header.Set("Referer", strings.TrimRight(frontendBaseURL, "/")+refererPath) + } + if requestBody != nil { + req.Header.Set("Content-Type", "application/json") + } + for _, cookie := range auth.GetCookies() { + req.AddCookie(cookie) } - resp, err := httpClient.Do(req) + resp, err := client.Do(req) if err != nil { - slog.Warn("fetch_current_user error", "attempt", attempt, "error", err) - if attempt < maxRetries { + slog.Warn(operation+" request failed", "attempt", attempt, "error", err) + if attempt < attempts { _ = netretry.Sleep(context.Background(), attempt, retryCfg) continue } return nil, &RequestResponseError{ - Operation: "fetch_current_user", + Operation: operation, Method: req.Method, - URL: url, + URL: req.URL.String(), RequestHeaders: flattenHeaders(req.Header), + RequestBody: string(requestBody), Err: err, } } - body, _ := io.ReadAll(resp.Body) + responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxFrontendResponseBytes+1)) resp.Body.Close() + finalURL := req.URL.String() + if resp.Request != nil && resp.Request.URL != nil { + finalURL = resp.Request.URL.String() + } - 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 - } + if readErr != nil { return nil, &RequestResponseError{ - Operation: "fetch_current_user", + Operation: operation, Method: req.Method, - URL: url, + URL: finalURL, RequestHeaders: flattenHeaders(req.Header), + RequestBody: string(requestBody), 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. -// -// 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 - } - - slog.Warn("activity endpoint failed, trying observability fallback", "error", err) - fallbackData, fallbackErr := fetchUserDataFromEndpoint(auth, observabilityPagePath, observabilityRouterState, "fetch_observability_data") - if fallbackErr == nil && fallbackData != nil { - slog.Info("observability fallback succeeded") - return fallbackData, nil - } - - // Return the original activity error since that's the primary endpoint - if err != nil { - return nil, err - } - return nil, fallbackErr -} - -// FetchWorkspaceData fetches workspace settings from the SSR page response. -// Workspace-level toggles like is_data_discount_logging_enabled are only available -// in the workspace data, not in the getCurrentUserSA response. -func FetchWorkspaceData(auth *Auth) (map[string]any, error) { - cookies := auth.GetCookies() - pagePath := "/workspaces/default/settings" - - for attempt := 1; attempt <= maxRetries; attempt++ { - req, _ := http.NewRequest("GET", config.BaseURL+pagePath, nil) - // Request RSC stream format instead of HTML to get parseable JSON - req.Header.Set("RSC", "1") - req.Header.Set("Next-Url", pagePath) - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("fetch_workspace_data error", "attempt", attempt, "error", err) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: "fetch_workspace_data", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - Err: err, + Err: fmt.Errorf("read response: %w", readErr), } } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - slog.Warn("fetch_workspace_data failed", "attempt", attempt, "status", resp.StatusCode) - if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } + if len(responseBody) > maxFrontendResponseBytes { return nil, &RequestResponseError{ - Operation: "fetch_workspace_data", + Operation: operation, Method: req.Method, - URL: req.URL.String(), + URL: finalURL, RequestHeaders: flattenHeaders(req.Header), + RequestBody: string(requestBody), ResponseStatus: resp.StatusCode, ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("fetch_workspace_data failed: status %d", resp.StatusCode), - } - } - - // Parse response - look for workspace data containing slug field. - // The response can be RSC stream (line-based) or HTML with embedded data. - bodyStr := string(body) - - // Try RSC stream format: lines like 1:{"__kind":"OK","data":{...}} - for _, line := range strings.Split(bodyStr, "\n") { - if !strings.Contains(line, `"__kind":"OK"`) { - continue - } - idx := strings.Index(line, "{") - if idx < 0 { - continue - } - var obj map[string]any - if err := json.Unmarshal([]byte(line[idx:]), &obj); err != nil { - continue - } - if obj["__kind"] != "OK" { - continue - } - data, ok := obj["data"].(map[string]any) - if !ok { - continue - } - if _, hasSlug := data["slug"]; hasSlug { - return data, nil + ResponseBody: string(responseBody[:maxFrontendResponseBytes]), + Err: fmt.Errorf("response exceeds %d bytes", maxFrontendResponseBytes), } } - // Try HTML format: workspace data embedded in script tags as escaped JSON - // Pattern: "slug":"default" with is_data_discount_logging_enabled nearby - wsRe := regexp.MustCompile(`\{[^{}]*"slug"\s*:\s*"default"[^}]*"is_data_discount_logging_enabled"\s*:\s*(true|false)[^}]*\}`) - if m := wsRe.FindString(bodyStr); m != "" { - // Unescape if needed - unescaped := strings.ReplaceAll(m, `\"`, `"`) - var data map[string]any - if err := json.Unmarshal([]byte(unescaped), &data); err == nil { - if _, hasSlug := data["slug"]; hasSlug { - return data, nil - } - } - } - - // Try broader search: find any JSON object with slug and the toggle - // The RSC stream may have the data split across push() calls in HTML - slugIdx := strings.Index(bodyStr, `"slug":"default"`) - if slugIdx < 0 { - slugIdx = strings.Index(bodyStr, `\"slug\":\"default\"`) - } - if slugIdx >= 0 { - // Search backward for opening brace, forward for the toggle - searchStart := max(0, slugIdx-500) - searchEnd := min(len(bodyStr), slugIdx+2000) - window := bodyStr[searchStart:searchEnd] - - // Look for the toggle value in this window - toggleRe := regexp.MustCompile(`"is_data_discount_logging_enabled"\s*:\s*(true|false)`) - escapedToggleRe := regexp.MustCompile(`\\"is_data_discount_logging_enabled\\":\s*(true|false)`) - if tm := toggleRe.FindStringSubmatch(window); len(tm) > 1 { - slog.Info("found workspace toggle in page data", "is_data_discount_logging_enabled", tm[1]) - return map[string]any{ - "slug": "default", - "is_data_discount_logging_enabled": tm[1] == "true", - }, nil + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + var statusErr error + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + statusErr = fmt.Errorf("OpenRouter session rejected with status %d", resp.StatusCode) + case http.StatusNotFound: + statusErr = fmt.Errorf("OpenRouter frontend API contract missing (status 404)") + default: + statusErr = fmt.Errorf("OpenRouter frontend API returned status %d", resp.StatusCode) } - if tm := escapedToggleRe.FindStringSubmatch(window); len(tm) > 1 { - slog.Info("found workspace toggle in escaped page data", "is_data_discount_logging_enabled", tm[1]) - return map[string]any{ - "slug": "default", - "is_data_discount_logging_enabled": tm[1] == "true", - }, nil - } - } - - slog.Warn("fetch_workspace_data could not parse response", "attempt", attempt) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: "fetch_workspace_data_parse", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("fetch_workspace_data parse failed"), - } - } - - return nil, fmt.Errorf("fetch_workspace_data failed after %d attempts", maxRetries) -} - -// FetchProvisioningKeys fetches all provisioning keys. -// -// Prefers the private REST API, which paginates and so returns the full set. -// The legacy path below scrapes the settings page and only ever saw the first -// page of keys, which silently under-reports on accounts with many keys. -func FetchProvisioningKeys(auth *Auth) ([]map[string]string, error) { - if keys, err := fetchProvisioningKeysREST(auth); err == nil { - return keys, nil - } else { - slog.Warn("list provisioning keys via REST failed, trying legacy page scrape", "error", err) - } - - cookies := auth.GetCookies() - - for attempt := 1; attempt <= maxRetries; attempt++ { - req, _ := http.NewRequest("GET", config.BaseURL+managementKeysPagePath, nil) - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("fetch_provisioning_keys error", "attempt", attempt, "path", managementKeysPagePath, "error", err) - if attempt < maxRetries { + if method != http.MethodPost && netretry.ShouldRetry(resp.StatusCode, nil) && attempt < attempts { _ = netretry.Sleep(context.Background(), attempt, retryCfg) continue } return nil, &RequestResponseError{ - Operation: "fetch_provisioning_keys", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - Err: err, + Operation: operation, + Method: req.Method, + URL: finalURL, + RequestHeaders: flattenHeaders(req.Header), + RequestBody: string(requestBody), + ResponseStatus: resp.StatusCode, + ResponseHeaders: flattenHeaders(resp.Header), + ResponseBody: string(responseBody), + Err: statusErr, } } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - slog.Warn("fetch_provisioning_keys failed", "attempt", attempt, "path", managementKeysPagePath, "status", resp.StatusCode) - if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if len(bytes.TrimSpace(responseBody)) > 0 && !strings.Contains(contentType, "application/json") { return nil, &RequestResponseError{ - Operation: "fetch_provisioning_keys", + Operation: operation, Method: req.Method, - URL: req.URL.String(), + URL: finalURL, RequestHeaders: flattenHeaders(req.Header), + RequestBody: string(requestBody), ResponseStatus: resp.StatusCode, ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("fetch_provisioning_keys failed: status %d", resp.StatusCode), + ResponseBody: string(responseBody), + Err: fmt.Errorf("unexpected content type %q", resp.Header.Get("Content-Type")), } } - return parseProvisioningKeysResponse(string(body)), nil + return responseBody, nil } - return nil, fmt.Errorf("fetch_provisioning_keys failed after %d attempts", maxRetries) + return nil, fmt.Errorf("%s failed after %d attempts", operation, attempts) } -func parseProvisioningKeysResponse(body string) []map[string]string { - candidates := parseProvisioningKeyCandidates(body, escapedObjectRe, escapedHashRe, escapedNameRe, escapedProvRe) - - normalized := strings.ReplaceAll(body, `\"`, `"`) - candidates = append(candidates, parseProvisioningKeyCandidates(normalized, plainObjectRe, plainHashRe, plainNameRe, plainProvRe)...) - - keys := make([]map[string]string, 0, len(candidates)) - seen := make(map[string]struct{}) - for _, key := range candidates { - hash := key["hash"] - if hash == "" { - continue - } - if _, exists := seen[hash]; exists { - continue - } - seen[hash] = struct{}{} - keys = append(keys, key) +func decodeDataObject(body []byte, operation string) (map[string]any, error) { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("%s: decode response: %w", operation, err) + } + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return nil, fmt.Errorf("%s: response missing data object", operation) } - return keys + var data map[string]any + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, fmt.Errorf("%s: decode data object: %w", operation, err) + } + if len(data) == 0 { + return nil, fmt.Errorf("%s: response data is empty or not an object", operation) + } + return data, nil } -func parseProvisioningKeyCandidates(body string, objectRe, hashRe, nameRe, provisioningRe *regexp.Regexp) []map[string]string { - objects := objectRe.FindAllString(body, -1) - keys := make([]map[string]string, 0, len(objects)) - - for _, obj := range objects { - hashMatch := hashRe.FindStringSubmatch(obj) - if len(hashMatch) < 2 || hashMatch[1] == "" { - continue - } - - nameMatch := nameRe.FindStringSubmatch(obj) - if len(nameMatch) < 2 || nameMatch[1] == "" { - continue - } - - provisioningMatch := provisioningRe.FindStringSubmatch(obj) - if len(provisioningMatch) >= 2 && provisioningMatch[1] != "true" { - continue - } - - keys = append(keys, map[string]string{ - "name": nameMatch[1], - "hash": hashMatch[1], - }) +// FetchActivityData fetches account identity and privacy toggles from the +// current-user JSON endpoint. It does not depend on Next.js bundle internals. +func FetchActivityData(auth *Auth) (map[string]any, error) { + body, err := doFrontendJSON( + auth, + "fetch_activity_data", + http.MethodGet, + currentUserAPIPath, + privacySettingsPagePath, + nil, + ) + if err != nil { + return nil, err } - - return keys + return decodeDataObject(body, "fetch_activity_data") } -// DeleteProvisioningKey deletes a provisioning key by hash. -// -// Prefers the private REST API; the server-action path below is retained as a -// fallback in case OpenRouter reverts the 2026-08 migration. -func DeleteProvisioningKey(auth *Auth, keyHash string) error { - if err := deleteProvisioningKeyREST(auth, keyHash); err == nil { - return nil - } else { - slog.Warn("delete provisioning key via REST failed, trying legacy server action", "error", err) +// FetchWorkspaceData fetches the station account's default workspace settings. +func FetchWorkspaceData(auth *Auth) (map[string]any, error) { + query := url.Values{"scope": []string{"member"}} + body, err := doFrontendJSON( + auth, + "fetch_workspace_data", + http.MethodGet, + userWorkspacesAPIPath+"?"+query.Encode(), + workspaceSettingsPagePath, + nil, + ) + if err != nil { + return nil, err } - actionHash := auth.GetActionHash("provisioning_keys_delete") - if actionHash == "" { - return fmt.Errorf("could not get delete action hash") + var envelope struct { + Data []map[string]any `json:"data"` + ActiveWorkspaceID string `json:"active_workspace_id"` + DefaultWorkspaceID string `json:"default_workspace_id"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("fetch_workspace_data: decode response: %w", err) + } + if envelope.DefaultWorkspaceID == "" { + return nil, fmt.Errorf("fetch_workspace_data: response missing default_workspace_id") } - cookies := auth.GetCookies() - payload := fmt.Sprintf(`[%q,{"deleted":true},{"isProvisioningKey":true}]`, keyHash) - - for attempt := 1; attempt <= maxRetries; attempt++ { - req, _ := http.NewRequest("POST", config.BaseURL+managementKeysPagePath, strings.NewReader(payload)) - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - req.Header.Set("Accept", "text/x-component") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Next-Action", actionHash) - req.Header.Set("Next-Router-State-Tree", managementKeysRouterState) - req.Header.Set("Origin", config.BaseURL) - req.Header.Set("Referer", config.BaseURL+managementKeysPagePath) - - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("delete_provisioning_key error", "attempt", attempt, "error", err) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return &RequestResponseError{ - Operation: "delete_provisioning_key", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - Err: err, - } - } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - slog.Warn("delete_provisioning_key failed", "attempt", attempt, "status", resp.StatusCode) - if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return &RequestResponseError{ - Operation: "delete_provisioning_key", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("delete_provisioning_key failed: status %d", resp.StatusCode), - } - } - - if strings.Contains(string(body), `"deleted":true`) || strings.Contains(string(body), `"__kind":"OK"`) { - slog.Info("deleted provisioning key", "hash", keyHash[:min(16, len(keyHash))]) - return nil - } - - slog.Warn("delete_provisioning_key unexpected response", "attempt", attempt) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return &RequestResponseError{ - Operation: "delete_provisioning_key_parse", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("delete_provisioning_key unexpected response"), + for _, workspace := range envelope.Data { + if id, _ := workspace["id"].(string); id == envelope.DefaultWorkspaceID { + return workspace, nil } } - return fmt.Errorf("delete_provisioning_key failed after %d attempts", maxRetries) + return nil, fmt.Errorf( + "fetch_workspace_data: default workspace %q absent from %d memberships", + envelope.DefaultWorkspaceID, + len(envelope.Data), + ) } -// Private frontend REST endpoints for management (provisioning) keys. These -// replace the createManagementKeySA / updateManagementKeySA server actions -// OpenRouter removed in 2026-08. -const ( - managementKeysListPath = "/api/frontend/v1/private/management-keys" - workspaceAPIKeysPath = "/api/frontend/v1/private/workspace-api-keys" - - // maxManagementKeyPages bounds pagination so a bad total_count can't spin - // forever. At 20 keys/page this covers 4000 keys. - maxManagementKeyPages = 200 -) - -// doJSONRequest performs an authenticated JSON request against the private -// frontend API with the shared retry policy, returning the raw body. -func doJSONRequest(auth *Auth, method, path, reqBody, operation string) ([]byte, error) { - cookies := auth.GetCookies() - url := config.BaseURL + path - - for attempt := 1; attempt <= maxRetries; attempt++ { - var bodyReader io.Reader - if reqBody != "" { - bodyReader = strings.NewReader(reqBody) - } - req, _ := http.NewRequest(method, url, bodyReader) - req.Header.Set("Accept", "application/json") - if reqBody != "" { - req.Header.Set("Content-Type", "application/json") - } - req.Header.Set("Origin", config.BaseURL) - req.Header.Set("Referer", config.BaseURL+managementKeysPagePath) - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn(operation+" error", "attempt", attempt, "error", err) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return nil, &RequestResponseError{ - Operation: operation, - Method: method, - URL: url, - RequestHeaders: flattenHeaders(req.Header), - RequestBody: reqBody, - Err: err, - } - } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - slog.Warn(operation+" 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: operation, - Method: method, - URL: url, - RequestHeaders: flattenHeaders(req.Header), - RequestBody: reqBody, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("%s failed: status %d", operation, resp.StatusCode), - } - } - - return body, nil - } - - return nil, fmt.Errorf("%s failed after %d attempts", operation, maxRetries) +type managementKeyMetadata struct { + Hash string `json:"hash"` + Name string `json:"name"` + Deleted bool `json:"deleted"` } -// parseManagementKeysPage extracts one page of management keys. Exposed as a -// pure function so the response contract is testable without a live session. -func parseManagementKeysPage(body []byte) (keys []map[string]string, totalCount int, err error) { +func parseManagementKeysPage(body []byte) ([]managementKeyMetadata, int, error) { var envelope struct { - Data struct { - Keys []struct { - Hash string `json:"hash"` - Name string `json:"name"` - } `json:"keys"` - TotalCount int `json:"total_count"` + Data *struct { + Keys *[]managementKeyMetadata `json:"keys"` + TotalCount *int `json:"total_count"` } `json:"data"` - Error any `json:"error"` } if err := json.Unmarshal(body, &envelope); err != nil { - return nil, 0, fmt.Errorf("management_keys response is not json: %w", err) + return nil, 0, fmt.Errorf("fetch_provisioning_keys: decode response: %w", err) } - if envelope.Error != nil { - return nil, 0, fmt.Errorf("management_keys returned error: %v", envelope.Error) + if envelope.Data == nil || envelope.Data.Keys == nil || envelope.Data.TotalCount == nil { + return nil, 0, fmt.Errorf("fetch_provisioning_keys: response missing data.keys or data.total_count") } - for _, k := range envelope.Data.Keys { - if k.Hash == "" { - continue - } - keys = append(keys, map[string]string{"hash": k.Hash, "name": k.Name}) + if *envelope.Data.TotalCount < 0 { + return nil, 0, fmt.Errorf("fetch_provisioning_keys: response has negative data.total_count") } - return keys, envelope.Data.TotalCount, nil + return *envelope.Data.Keys, *envelope.Data.TotalCount, nil } -// fetchProvisioningKeysREST lists every management key, following pagination. -// -// Paging matters for correctness, not just completeness: CleanupProvisioningKeys -// deletes keys whose name matches a station label, so a partial listing would -// silently leave that station's keys alive on the operator's account. -func fetchProvisioningKeysREST(auth *Auth) ([]map[string]string, error) { - var all []map[string]string +// FetchProvisioningKeys fetches every account management/provisioning key. +// OpenRouter paginates this endpoint, so cleanup must follow all pages or it can +// silently leave an older same-label verifier key behind. +func FetchProvisioningKeys(auth *Auth) ([]map[string]string, error) { + keys := make([]map[string]string, 0) seen := make(map[string]struct{}) for page := 1; page <= maxManagementKeyPages; page++ { - body, err := doJSONRequest(auth, "GET", - fmt.Sprintf("%s?page=%d", managementKeysListPath, page), "", - "fetch_provisioning_keys_rest") + query := url.Values{"page": []string{strconv.Itoa(page)}} + body, err := doFrontendJSON( + auth, + "fetch_provisioning_keys", + http.MethodGet, + managementKeysAPIPath+"?"+query.Encode(), + managementKeysPagePath, + nil, + ) if err != nil { return nil, err } - keys, totalCount, err := parseManagementKeysPage(body) + pageKeys, totalCount, err := parseManagementKeysPage(body) if err != nil { return nil, err } - if len(keys) == 0 { - break + if len(pageKeys) == 0 { + if len(seen) >= totalCount { + return keys, nil + } + return nil, fmt.Errorf( + "fetch_provisioning_keys: pagination ended after %d of %d keys", + len(seen), + totalCount, + ) } - for _, k := range keys { - if _, dup := seen[k["hash"]]; dup { + + added := 0 + for _, key := range pageKeys { + if key.Hash == "" { continue } - seen[k["hash"]] = struct{}{} - all = append(all, k) + if _, exists := seen[key.Hash]; exists { + continue + } + seen[key.Hash] = struct{}{} + added++ + if key.Deleted || key.Name == "" { + continue + } + keys = append(keys, map[string]string{ + "name": key.Name, + "hash": key.Hash, + }) + } + if len(seen) >= totalCount { + return keys, nil } - if totalCount > 0 && len(all) >= totalCount { - break + if added == 0 { + return nil, fmt.Errorf( + "fetch_provisioning_keys: page %d made no progress after %d of %d keys", + page, + len(seen), + totalCount, + ) } } - return all, nil + return nil, fmt.Errorf("fetch_provisioning_keys: exceeded %d pages", maxManagementKeyPages) } -// createProvisioningKeyREST creates a management key and returns its secret. -func createProvisioningKeyREST(auth *Auth, label string) (string, error) { - reqBody, err := json.Marshal(map[string]string{"name": label}) - if err != nil { - return "", err - } - - body, err := doJSONRequest(auth, "POST", workspaceAPIKeysPath+"/management", - string(reqBody), "create_provisioning_key_rest") - if err != nil { - return "", err - } - - var envelope struct { - Data struct { - Key string `json:"key"` - } `json:"data"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return "", fmt.Errorf("create_provisioning_key response is not json: %w", err) - } - if !strings.HasPrefix(envelope.Data.Key, "sk-or-") { - return "", fmt.Errorf("create_provisioning_key response had no sk-or- key") - } - - slog.Info("created provisioning key", "key", envelope.Data.Key[:20]) - return envelope.Data.Key, nil -} +var managementKeyHashRe = regexp.MustCompile(`^[0-9a-f]{64}$`) -// deleteProvisioningKeyREST soft-deletes a management key. -// -// The opts flag is required and must be snake_case: the endpoint answers 403 -// for opts:{} or opts:{"isProvisioningKey":true}, and only accepts -// {"is_provisioning_key":true} for a management key. -func deleteProvisioningKeyREST(auth *Auth, keyHash string) error { - reqBody := `{"payload":{"deleted":true},"opts":{"is_provisioning_key":true}}` - - body, err := doJSONRequest(auth, "PATCH", - workspaceAPIKeysPath+"/"+url.PathEscape(keyHash), reqBody, - "delete_provisioning_key_rest") +// DeleteProvisioningKey deletes a management/provisioning key by hash. +func DeleteProvisioningKey(auth *Auth, keyHash string) error { + if !managementKeyHashRe.MatchString(keyHash) { + return fmt.Errorf("delete_provisioning_key: invalid key hash") + } + + payload := map[string]any{ + "payload": map[string]bool{"deleted": true}, + "opts": map[string]bool{"is_provisioning_key": true}, + } + body, err := doFrontendJSON( + auth, + "delete_provisioning_key", + http.MethodPatch, + workspaceAPIKeysAPIPath+"/"+url.PathEscape(keyHash), + managementKeysPagePath, + payload, + ) if err != nil { return err } var envelope struct { - Data struct { - Deleted bool `json:"deleted"` + Data *struct { + Deleted *bool `json:"deleted"` } `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil { - return fmt.Errorf("delete_provisioning_key response is not json: %w", err) + return fmt.Errorf("delete_provisioning_key: decode response: %w", err) } - if !envelope.Data.Deleted { - return fmt.Errorf("delete_provisioning_key did not report deleted") + if envelope.Data == nil || envelope.Data.Deleted == nil || !*envelope.Data.Deleted { + return fmt.Errorf("delete_provisioning_key: response did not confirm deletion") } + slog.Info("deleted provisioning key") return nil } @@ -1008,115 +581,73 @@ func CleanupProvisioningKeys(auth *Auth, label string) (int, error) { return deleted, nil } -// CreateProvisioningKey creates a new provisioning key and returns it. -// -// Prefers the private REST API; the server-action path below is retained as a -// fallback in case OpenRouter reverts the 2026-08 migration. +// CreateProvisioningKey creates a new management/provisioning key and returns +// its plaintext value. OpenRouter only returns the plaintext once. func CreateProvisioningKey(auth *Auth, label string) (string, error) { - if key, err := createProvisioningKeyREST(auth, label); err == nil { - return key, nil - } else { - slog.Warn("create provisioning key via REST failed, trying legacy server action", "error", err) + if strings.TrimSpace(label) == "" { + return "", fmt.Errorf("create_provisioning_key: label is required") + } + + body, err := doFrontendJSON( + auth, + "create_provisioning_key", + http.MethodPost, + workspaceAPIKeysAPIPath+"/management", + managementKeysPagePath, + map[string]string{"name": label}, + ) + if err != nil { + var requestErr *RequestResponseError + if errors.As(err, &requestErr) && + requestErr.ResponseStatus >= http.StatusBadRequest && + requestErr.ResponseStatus < http.StatusInternalServerError { + return "", err + } + return "", reconcileAmbiguousCreate(auth, label, err) } - actionHash := auth.GetActionHash("provisioning_keys_create") - if actionHash == "" { - return "", fmt.Errorf("could not get create action hash, available: %v", auth.GetAllActionHashes()) + var envelope struct { + Data *struct { + Key string `json:"key"` + } `json:"data"` } - - cookies := auth.GetCookies() - payload := fmt.Sprintf(`[{"name":%q}]`, label) - - for attempt := 1; attempt <= maxRetries; attempt++ { - req, _ := http.NewRequest("POST", config.BaseURL+managementKeysPagePath, strings.NewReader(payload)) - req.Header.Set("Content-Type", "text/plain;charset=UTF-8") - req.Header.Set("Accept", "text/x-component") - req.Header.Set("Accept-Encoding", "identity") - req.Header.Set("Next-Action", actionHash) - req.Header.Set("Next-Router-State-Tree", managementKeysRouterState) - req.Header.Set("Origin", config.BaseURL) - req.Header.Set("Referer", config.BaseURL+managementKeysPagePath) - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36") - - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := httpClient.Do(req) - if err != nil { - slog.Warn("create_provisioning_key error", "attempt", attempt, "error", err) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return "", &RequestResponseError{ - Operation: "create_provisioning_key", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - Err: err, - } - } - - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - slog.Warn("create_provisioning_key failed", "attempt", attempt, "status", resp.StatusCode) - if netretry.ShouldRetry(resp.StatusCode, nil) && attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return "", &RequestResponseError{ - Operation: "create_provisioning_key", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("create_provisioning_key failed: status %d", resp.StatusCode), - } - } - - for _, line := range strings.Split(string(body), "\n") { - idx := strings.Index(line, "{") - if idx >= 0 { - var obj map[string]any - if err := json.Unmarshal([]byte(line[idx:]), &obj); err == nil { - if obj["__kind"] == "OK" { - if data, ok := obj["data"].(map[string]any); ok { - if key, ok := data["key"].(string); ok && strings.HasPrefix(key, "sk-or-") { - slog.Info("created provisioning key", "key", key[:20]) - return key, nil - } - } - } - } - } - } - - slog.Warn("create_provisioning_key could not parse key from response", "attempt", attempt) - if attempt < maxRetries { - _ = netretry.Sleep(context.Background(), attempt, retryCfg) - continue - } - return "", &RequestResponseError{ - Operation: "create_provisioning_key_parse", - Method: req.Method, - URL: req.URL.String(), - RequestHeaders: flattenHeaders(req.Header), - RequestBody: payload, - ResponseStatus: resp.StatusCode, - ResponseHeaders: flattenHeaders(resp.Header), - ResponseBody: string(body), - Err: fmt.Errorf("create_provisioning_key parse failed"), - } + if err := json.Unmarshal(body, &envelope); err != nil { + return "", reconcileAmbiguousCreate( + auth, + label, + fmt.Errorf("create_provisioning_key: decode response: %w", err), + ) + } + if envelope.Data == nil || !strings.HasPrefix(envelope.Data.Key, "sk-or-") { + return "", reconcileAmbiguousCreate( + auth, + label, + fmt.Errorf("create_provisioning_key: response missing a valid key"), + ) } - return "", fmt.Errorf("create_provisioning_key failed after %d attempts", maxRetries) + return envelope.Data.Key, nil +} + +// reconcileAmbiguousCreate removes a same-label key that may have been created +// when OpenRouter accepted the POST but the response was lost or malformed. +func reconcileAmbiguousCreate(auth *Auth, label string, createErr error) error { + deleted, cleanupErr := CleanupProvisioningKeys(auth, label) + if cleanupErr != nil { + return fmt.Errorf( + "create_provisioning_key was ambiguous and orphan cleanup failed: %v: %w", + cleanupErr, + createErr, + ) + } + if deleted > 0 { + return fmt.Errorf( + "create_provisioning_key response was ambiguous; cleaned up %d possible orphan(s): %w", + deleted, + createErr, + ) + } + return createErr } // OwnershipCheckResult describes the ownership check outcome. diff --git a/internal/openrouter/api_test.go b/internal/openrouter/api_test.go index 8eb35db..7a33ade 100644 --- a/internal/openrouter/api_test.go +++ b/internal/openrouter/api_test.go @@ -1,150 +1,490 @@ 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)) +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +const ( + testKeyHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testDeletedHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + testManagementKey = "sk-or-v1-test-management-key" +) + +func newFrontendTestAuth(t *testing.T, handler http.Handler) (*Auth, *httptest.Server) { + t.Helper() + + server := httptest.NewServer(handler) + previousBaseURL := frontendBaseURL + frontendBaseURL = server.URL + t.Cleanup(func() { + frontendBaseURL = previousBaseURL + server.Close() + }) + + return &Auth{ + state: map[string]string{ + "client_uat": "123", + "clerk_active_context": "sess_test:org_test", + }, + sessionJWT: "fresh-session-jwt", + client: server.Client(), + }, server +} + +func writeJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + t.Errorf("encode response: %v", err) + } +} + +func requireFrontendSession(t *testing.T, r *http.Request) { + t.Helper() + cookie, err := r.Cookie("__session") + if err != nil || cookie.Value != "fresh-session-jwt" { + t.Errorf("request missing refreshed __session cookie") + } + if got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("Accept = %q, want application/json", got) + } +} + +func TestFrontendJSONAPIFlow(t *testing.T) { + requestCounts := make(map[string]int) + var managementPages []string + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requireFrontendSession(t, r) + requestCounts[r.Method+" "+r.URL.Path]++ + + switch { + case r.Method == http.MethodGet && r.URL.Path == currentUserAPIPath: + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "email": "station@example.invalid", + "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, + }, + }) + + case r.Method == http.MethodGet && r.URL.Path == userWorkspacesAPIPath: + if got := r.URL.Query().Get("scope"); got != "member" { + t.Errorf("scope = %q, want member", got) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "active_workspace_id": "ws-active", + "default_workspace_id": "ws-default", + "data": []any{ + map[string]any{ + "id": "ws-active", + "slug": "active", + "is_data_discount_logging_enabled": true, + }, + map[string]any{ + "id": "ws-default", + "slug": "custom-default-slug", + "is_data_discount_logging_enabled": false, + }, + }, + }) + + case r.Method == http.MethodGet && r.URL.Path == managementKeysAPIPath: + page := r.URL.Query().Get("page") + managementPages = append(managementPages, page) + var pageKeys []any + switch page { + case "1": + pageKeys = []any{ + map[string]any{"hash": testKeyHash, "name": "oa-verifier"}, + } + case "2": + pageKeys = []any{ + map[string]any{"hash": testDeletedHash, "name": "other"}, + } + default: + t.Errorf("management page = %q, want 1 or 2", page) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "keys": pageKeys, + "total_count": 2, + }, + }) + + case r.Method == http.MethodPost && r.URL.Path == workspaceAPIKeysAPIPath+"/management": + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode create payload: %v", err) + } + if len(payload) != 1 || payload["name"] != "oa-verifier" { + t.Errorf("create payload = %#v, want name only", payload) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "api_key": map[string]any{"hash": testKeyHash}, + "key": testManagementKey, + }, + }) + + case r.Method == http.MethodPatch && r.URL.Path == workspaceAPIKeysAPIPath+"/"+testKeyHash: + var payload struct { + Payload map[string]bool `json:"payload"` + Opts map[string]bool `json:"opts"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Errorf("decode delete payload: %v", err) + } + if !payload.Payload["deleted"] || !payload.Opts["is_provisioning_key"] { + t.Errorf("delete payload = %#v", payload) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{"hash": testKeyHash, "deleted": true}, + }) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "unexpected request"}) + } + })) + + activity, err := FetchActivityData(auth) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("FetchActivityData: %v", err) } - if data["email"] != "test@example.com" { - t.Errorf("email = %v, want test@example.com", data["email"]) + if activity["email"] != "station@example.invalid" { + t.Errorf("activity email = %#v", activity["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 + + workspace, err := FetchWorkspaceData(auth) + if err != nil { + t.Fatalf("FetchWorkspaceData: %v", err) + } + if workspace["id"] != "ws-default" || workspace["is_data_discount_logging_enabled"] != false { + t.Errorf("selected wrong workspace: %#v", workspace) + } + + keys, err := FetchProvisioningKeys(auth) + if err != nil { + t.Fatalf("FetchProvisioningKeys: %v", err) + } + if len(keys) != 2 || keys[0]["hash"] != testKeyHash || keys[0]["name"] != "oa-verifier" || keys[1]["hash"] != testDeletedHash { + t.Errorf("keys = %#v", keys) + } + if strings.Join(managementPages, ",") != "1,2" { + t.Errorf("management pages = %v, want [1 2]", managementPages) + } + + key, err := CreateProvisioningKey(auth, "oa-verifier") + if err != nil { + t.Fatalf("CreateProvisioningKey: %v", err) + } + if key != testManagementKey { + t.Errorf("created key = %q", key) + } + + if err := DeleteProvisioningKey(auth, testKeyHash); err != nil { + t.Fatalf("DeleteProvisioningKey: %v", err) + } + + wantCounts := map[string]int{ + http.MethodGet + " " + currentUserAPIPath: 1, + http.MethodGet + " " + userWorkspacesAPIPath: 1, + http.MethodGet + " " + managementKeysAPIPath: 2, + http.MethodPost + " " + workspaceAPIKeysAPIPath + "/management": 1, + http.MethodPatch + " " + workspaceAPIKeysAPIPath + "/" + testKeyHash: 1, + } + for request, want := range wantCounts { + if got := requestCounts[request]; got != want { + t.Errorf("%s count = %d, want %d", request, got, want) } - if _, isBool := v.(bool); !isBool { - t.Errorf("toggle %q = %T(%v), want bool", k, v, v) + } +} + +func TestCreateProvisioningKeyCleansAmbiguousOrphanWithoutRetry(t *testing.T) { + var createCalls atomic.Int32 + var deleteCalls atomic.Int32 + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == workspaceAPIKeysAPIPath+"/management": + createCalls.Add(1) + writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{}}) + case r.Method == http.MethodGet && r.URL.Path == managementKeysAPIPath: + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "keys": []any{ + map[string]any{"hash": testKeyHash, "name": "ambiguous"}, + }, + "total_count": 1, + }, + }) + case r.Method == http.MethodPatch && r.URL.Path == workspaceAPIKeysAPIPath+"/"+testKeyHash: + deleteCalls.Add(1) + writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) + default: + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "unexpected request"}) } + })) + + _, err := CreateProvisioningKey(auth, "ambiguous") + if err == nil || !strings.Contains(err.Error(), "cleaned up 1 possible orphan") { + t.Fatalf("error = %v, want orphan-cleanup error", err) + } + if got := createCalls.Load(); got != 1 { + t.Errorf("create calls = %d, want exactly 1", got) + } + if got := deleteCalls.Load(); got != 1 { + t.Errorf("delete calls = %d, want 1", got) } } -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 TestCleanupProvisioningKeysFindsMatchOnLaterPage(t *testing.T) { + var pages []string + var deletedHash string + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == managementKeysAPIPath: + page := r.URL.Query().Get("page") + pages = append(pages, page) + var keys []any + if page == "1" { + keys = []any{map[string]any{"hash": testKeyHash, "name": "other"}} + } else if page == "2" { + keys = []any{map[string]any{"hash": testDeletedHash, "name": "target"}} + } else { + t.Errorf("unexpected management page %q", page) + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{"keys": keys, "total_count": 2}, + }) + case r.Method == http.MethodPatch && r.URL.Path == workspaceAPIKeysAPIPath+"/"+testDeletedHash: + deletedHash = testDeletedHash + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{"hash": testDeletedHash, "deleted": true}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "unexpected request"}) + } + })) + + deleted, err := CleanupProvisioningKeys(auth, "target") + if err != nil { + t.Fatalf("CleanupProvisioningKeys: %v", err) + } + if deleted != 1 || deletedHash != testDeletedHash { + t.Errorf("deleted=%d hash=%q, want one page-two key", deleted, deletedHash) + } + if strings.Join(pages, ",") != "1,2" { + t.Errorf("pages = %v, want [1 2]", pages) } } -func TestParseCurrentUserResponse_Rejects(t *testing.T) { - cases := []struct { - name string - body string +func TestFetchProvisioningKeysRejectsIncompletePagination(t *testing.T) { + tests := []struct { + name string + pageKeys func(page string) []any + want 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", `sign in`}, - } - for _, tc := range cases { + { + name: "empty page before total", + pageKeys: func(page string) []any { + if page == "1" { + return []any{map[string]any{"hash": testKeyHash, "name": "first"}} + } + return []any{} + }, + want: "pagination ended after 1 of 2 keys", + }, + { + name: "repeated page", + pageKeys: func(string) []any { + return []any{map[string]any{"hash": testKeyHash, "name": "first"}} + }, + want: "page 2 made no progress after 1 of 2 keys", + }, + } + + for _, tc := range tests { 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) + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "keys": tc.pageKeys(r.URL.Query().Get("page")), + "total_count": 2, + }, + }) + })) + + _, err := FetchProvisioningKeys(auth) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want %q", err, tc.want) } }) } } -// --------------------------------------------------------------------------- -// Management-key REST contract -// -// Regression guard for the 2026-08-25 migration: createManagementKeySA / -// updateManagementKeySA were removed. Shapes below are real responses from -// /api/frontend/v1/private/management-keys, with hashes redacted. -// --------------------------------------------------------------------------- - -func TestParseManagementKeysPage_OK(t *testing.T) { - body := `{"data":{"keys":[ - {"hash":"aaa","name":"key-watchdog-v2-station-x","label":"sk-or-v1-2f1...628","expires_at":null,"disabled":false}, - {"hash":"bbb","name":"other","label":"sk-or-v1-a9a...e37","expires_at":null,"disabled":false} - ],"total_count":164}}` - - keys, total, err := parseManagementKeysPage([]byte(body)) - if err != nil { - t.Fatalf("unexpected error: %v", err) +func TestParseManagementKeysPageRequiresCompleteEnvelope(t *testing.T) { + for _, body := range []string{ + `{}`, + `{"data":{}}`, + `{"data":{"total_count":0}}`, + `{"data":{"keys":[]}}`, + `{"data":{"keys":[],"total_count":-1}}`, + } { + if _, _, err := parseManagementKeysPage([]byte(body)); err == nil { + t.Errorf("parseManagementKeysPage(%s) unexpectedly succeeded", body) + } + } +} + +func TestFrontendAPIErrorContextRedactsCredentialsAndBodies(t *testing.T) { + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "__session=response-secret") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"body-secret"}`) + })) + + _, err := FetchActivityData(auth) + if err == nil { + t.Fatal("expected unauthorized error") + } + if !strings.Contains(err.Error(), "session rejected") { + t.Errorf("error does not distinguish auth failure: %v", err) } - if total != 164 { - t.Errorf("total_count = %d, want 164", total) + + context := ErrorContext(err) + request := context["openrouter_request"].(map[string]any) + requestHeaders := request["headers"].(map[string]string) + if requestHeaders["Cookie"] != "[REDACTED]" { + t.Errorf("Cookie header was not redacted: %#v", requestHeaders) } - if len(keys) != 2 { - t.Fatalf("got %d keys, want 2", len(keys)) + response := context["openrouter_response"].(map[string]any) + responseHeaders := response["headers"].(map[string]string) + if responseHeaders["Set-Cookie"] != "[REDACTED]" { + t.Errorf("Set-Cookie header was not redacted: %#v", responseHeaders) } - // CleanupProvisioningKeys matches on "name" and deletes by "hash"; both must - // survive the parse or cleanup silently no-ops. - if keys[0]["hash"] != "aaa" || keys[0]["name"] != "key-watchdog-v2-station-x" { - t.Errorf("first key = %v", keys[0]) + contextText := fmt.Sprint(context) + for _, secret := range []string{"fresh-session-jwt", "response-secret", "body-secret"} { + if strings.Contains(contextText, secret) { + t.Errorf("error context leaked %q: %s", secret, contextText) + } } } -func TestParseManagementKeysPage_SkipsHashlessEntries(t *testing.T) { - // A key with no hash cannot be deleted, so it must not enter the list and - // give cleanup a target it will fail on. - body := `{"data":{"keys":[{"hash":"","name":"broken"},{"hash":"ccc","name":"ok"}],"total_count":2}}` - keys, _, err := parseManagementKeysPage([]byte(body)) - if err != nil { - t.Fatalf("unexpected error: %v", err) +func TestFrontendAPIRejectsSignedOutHTML(t *testing.T) { + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "sign in") + })) + + _, err := FetchActivityData(auth) + if err == nil || !strings.Contains(err.Error(), "unexpected content type") { + t.Fatalf("error = %v, want signed-out HTML rejection", err) + } +} + +func TestFetchWorkspaceDataRequiresDefaultMembership(t *testing.T) { + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "default_workspace_id": "ws-missing", + "data": []any{ + map[string]any{"id": "ws-active", "is_data_discount_logging_enabled": false}, + }, + }) + })) + + _, err := FetchWorkspaceData(auth) + if err == nil || !strings.Contains(err.Error(), "absent from") { + t.Fatalf("error = %v, want missing-default error", err) + } +} + +func TestDeleteProvisioningKeyRejectsMalformedHash(t *testing.T) { + var requests atomic.Int32 + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + writeJSON(t, w, http.StatusOK, map[string]any{"data": map[string]any{}}) + })) + + if err := DeleteProvisioningKey(auth, "not-a-key-hash"); err == nil { + t.Fatal("expected invalid hash error") } - if len(keys) != 1 || keys[0]["hash"] != "ccc" { - t.Errorf("got %v, want only the hashed entry", keys) + if requests.Load() != 0 { + t.Errorf("made %d requests for malformed hash", requests.Load()) } } -func TestParseManagementKeysPage_Rejects(t *testing.T) { - cases := []struct{ name, body string }{ - {"error envelope", `{"error":{"message":"Forbidden","code":403}}`}, - {"html", ``}, +func TestDeleteProvisioningKeyRequiresDeletionConfirmation(t *testing.T) { + responses := map[string]any{ + "false": map[string]any{"data": map[string]any{"hash": testKeyHash, "deleted": false}}, + "missing": map[string]any{"data": map[string]any{"hash": testKeyHash}}, } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, _, err := parseManagementKeysPage([]byte(tc.body)); err == nil { - t.Errorf("expected error for %s", tc.name) + + for name, response := range responses { + t.Run(name, func(t *testing.T) { + auth, _ := newFrontendTestAuth(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, response) + })) + + err := DeleteProvisioningKey(auth, testKeyHash) + if err == nil || !strings.Contains(err.Error(), "did not confirm deletion") { + t.Fatalf("error = %v, want deletion-confirmation error", err) } }) } } -func TestParseManagementKeysPage_EmptyPageTerminatesPaging(t *testing.T) { - // fetchProvisioningKeysREST stops on an empty page; that must parse cleanly - // rather than erroring, or pagination would abort mid-listing. - keys, total, err := parseManagementKeysPage([]byte(`{"data":{"keys":[],"total_count":40}}`)) +func TestNewAuthDoesNotFetchOpenRouterBundles(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch { + case r.URL.Path == "/clerk.js": + fmt.Fprint(w, `window.clerkVersion="5.111.0";window.apiVersion="2025-11-10";`) + case strings.Contains(r.URL.Path, "/v1/client/sessions/sess_test/tokens"): + writeJSON(t, w, http.StatusOK, map[string]string{"jwt": "refreshed-jwt"}) + default: + t.Errorf("unexpected auth-time request: %s", r.URL.Path) + writeJSON(t, w, http.StatusNotFound, map[string]string{"error": "unexpected"}) + } + })) + defer server.Close() + + previousJSURL := clerkJSURL + previousAPI := clerkAPI + clerkJSURL = server.URL + "/clerk.js" + clerkAPI = server.URL + "/v1/client/sessions/%s/tokens" + defer func() { + clerkJSURL = previousJSURL + clerkAPI = previousAPI + }() + + _, err := NewAuthFromCookieData(map[string]any{ + "cookies": []any{ + map[string]any{"name": "__client", "value": "client-token", "domain": "clerk.openrouter.ai"}, + map[string]any{"name": "__client_uat", "value": "123", "domain": "openrouter.ai"}, + map[string]any{"name": "clerk_active_context", "value": "sess_test:", "domain": "openrouter.ai"}, + }, + }) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("NewAuthFromCookieData: %v", err) } - if len(keys) != 0 || total != 40 { - t.Errorf("keys=%v total=%d", keys, total) + if len(paths) != 2 { + t.Fatalf("auth made %d requests, want Clerk JS + token only: %v", len(paths), paths) } } diff --git a/internal/openrouter/auth.go b/internal/openrouter/auth.go index 05175a4..f3aaeef 100644 --- a/internal/openrouter/auth.go +++ b/internal/openrouter/auth.go @@ -9,21 +9,21 @@ // job. Every piece of verification evidence originates from OpenRouter's own // systems; the verifier adds zero proprietary truth to the chain. // -// - Toggle state: read from OpenRouter's /activity page using the station -// operator's own authenticated session (cookies). These are OpenRouter's own +// - Toggle state: read from OpenRouter's authenticated private JSON APIs using +// the station operator's own session cookies. These are OpenRouter's own // account settings, not station self-reported claims. Checks occur at // cryptographically random intervals, making it impossible for stations to // predict when checks happen and cheat by temporarily toggling settings. // - Management key: issued by OpenRouter on the station operator's account -// when the verifier calls POST /settings/management-keys with the operator's -// cookies. The key lives on OpenRouter; the verifier merely holds a +// through OpenRouter's authenticated management-key API. The key lives on +// OpenRouter; the verifier merely holds a // reference to use for subsequent ownership checks. // - Key ownership: checked by calling OpenRouter's GET /api/v1/keys/{hash} // authenticated with the management key. OpenRouter's own API answers // whether a submitted key belongs to the same account. // Ref: https://openrouter.ai/docs/api/api-reference/api-keys/get-key -// - Account identity (email): extracted server-side from the OpenRouter -// activity response, not from station-supplied text. +// - Account identity (email): read from OpenRouter's current-user response, +// not from station-supplied text. // // Shadow-account attack prevention: // @@ -67,74 +67,29 @@ import ( "sync" "time" - "github.com/openanonymity/oa-verifier/internal/config" "github.com/openanonymity/oa-verifier/internal/netretry" ) -const ( - clerkJSURL = "https://clerk.openrouter.ai/npm/@clerk/clerk-js@5/dist/clerk.browser.js" - managementKeysPagePath = "/settings/management-keys" +var ( + // These are variables so contract tests can use a local Clerk server. + clerkJSURL = "https://clerk.openrouter.ai/npm/@clerk/clerk-js@5/dist/clerk.browser.js" + clerkAPI = "https://clerk.openrouter.ai/v1/client/sessions/%s/tokens" ) -// clerkAPI is the URL template for Clerk's session-token endpoint. Defined as -// a var (not const) so tests can substitute an httptest server. -var clerkAPI = "https://clerk.openrouter.ai/v1/client/sessions/%s/tokens" - -var pages = map[string]string{ - "activity": "/activity", - "management_keys": managementKeysPagePath, -} - -// chunkPathRe matches the client-bundle chunk URLs referenced by a page's HTML. -// OpenRouter serves them from a build-specific prefix that has moved before: -// /_next/static/chunks/ until 2026-08, then /_next/static/immutable/chunks/. -// The whole path is captured and reused verbatim for the fetch, so relocating -// the intermediate segment can't silently strand discovery with zero hashes. -var chunkPathRe = regexp.MustCompile(`/_next/static/(?:[A-Za-z0-9._-]+/)*chunks/[A-Za-z0-9._-]+\.js`) - -var actionNameMap = map[string]string{ - "getCurrentUserSA": "activity", - "createProvisioningAPIKeySA": "provisioning_keys_create", - "createManagementAPIKeySA": "provisioning_keys_create", - "createManagementKeySA": "provisioning_keys_create", - "updateAPIKeySA": "provisioning_keys_delete", - "updateManagementAPIKeySA": "provisioning_keys_delete", - "updateManagementKeySA": "provisioning_keys_delete", -} - -// hashDiscovery records what an action-hash sweep actually saw. Without it an -// empty result surfaces as a bare "map[]", which reads identically whether the -// session is dead, the bundle moved, or the action names were renamed -- three -// failures with three different fixes. -type hashDiscovery struct { - pagesOK int - pagesSignedOut int - chunksSeen int - chunksFetched int -} - -func (d hashDiscovery) String() string { - return fmt.Sprintf("pages_ok=%d pages_signed_out=%d chunks_seen=%d chunks_fetched=%d", - d.pagesOK, d.pagesSignedOut, d.chunksSeen, d.chunksFetched) -} - // Auth manages OpenRouter authentication via Clerk cookies. type Auth struct { - mu sync.RWMutex - clerkParams map[string]string - state map[string]string - sessionJWT string - actionHashes map[string]string - discovery hashDiscovery - client *http.Client + mu sync.RWMutex + clerkParams map[string]string + state map[string]string + sessionJWT string + client *http.Client } // NewAuthFromCookieData creates an Auth instance from cookie dict. func NewAuthFromCookieData(cookieData map[string]any) (*Auth, error) { a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), client: &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ @@ -214,8 +169,6 @@ func NewAuthFromCookieData(cookieData map[string]any) (*Auth, error) { if err := a.refreshToken(); err != nil { return nil, fmt.Errorf("failed to refresh token: %w", err) } - a.fetchActionHashes() - return a, nil } @@ -445,127 +398,6 @@ func (a *Auth) refreshToken() error { return fmt.Errorf("token refresh failed after retries") } -var ( - actionHashRe = regexp.MustCompile(`"([0-9a-f]{40,42})"`) - actionNameRe = regexp.MustCompile(`"([a-zA-Z0-9_]+)"[)\]]`) -) - -// actionNameLookahead is how far past a candidate hash to search for the action -// name. The registration puts the name last, after the callServer/sourcemap -// arguments; ~60 chars in the current bundle, so this leaves headroom. -const actionNameLookahead = 100 - -// extractActionHashes scans one client-bundle chunk for Next.js server-action -// registrations and records the ones the verifier calls. The live shape is: -// -// createServerReference("<40-42 hex>",callServer,void 0,findSourceMapURL,"getCurrentUserSA") -// -// Matching is deliberately anchored on the hash rather than on -// createServerReference, since the minified helper name changes between builds. -func extractActionHashes(jsText string, into map[string]string) { - for _, m := range actionHashRe.FindAllStringSubmatchIndex(jsText, -1) { - hashVal := jsText[m[2]:m[3]] - afterEnd := min(m[1]+actionNameLookahead, len(jsText)) - after := jsText[m[1]:afterEnd] - - if nameMatch := actionNameRe.FindStringSubmatch(after); len(nameMatch) > 1 { - if key, ok := actionNameMap[nameMatch[1]]; ok { - into[key] = hashVal - } - } - } -} - -func (a *Auth) fetchActionHashes() { - cookies := a.GetCookies() - fetchedChunks := make(map[string]bool) - actionHashes := make(map[string]string) - requiredHashes := requiredActionHashCount() - - var diag hashDiscovery - - for _, pagePath := range pages { - req, _ := http.NewRequest("GET", config.BaseURL+pagePath, nil) - for _, c := range cookies { - req.AddCookie(c) - } - - resp, err := a.client.Do(req) - if err != nil { - continue - } - if resp.StatusCode != 200 { - resp.Body.Close() - continue - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - // An expired session doesn't fail loudly: OpenRouter redirects the - // auth-gated page to /sign-in, which still returns 200 but carries none - // of the app's server actions. Record it so a dead cookie is - // distinguishable from a bundle-layout change. - if resp.Request != nil && strings.Contains(resp.Request.URL.Path, "/sign-in") { - diag.pagesSignedOut++ - continue - } - diag.pagesOK++ - - jsChunks := chunkPathRe.FindAllString(string(body), -1) - diag.chunksSeen += len(jsChunks) - for _, chunkPath := range jsChunks { - if fetchedChunks[chunkPath] { - continue - } - fetchedChunks[chunkPath] = true - diag.chunksFetched++ - - chunkReq, _ := http.NewRequest("GET", config.BaseURL+chunkPath, nil) - for _, c := range cookies { - chunkReq.AddCookie(c) - } - - chunkResp, err := a.client.Do(chunkReq) - if err != nil { - continue - } - if chunkResp.StatusCode != 200 { - chunkResp.Body.Close() - continue - } - js, _ := io.ReadAll(chunkResp.Body) - chunkResp.Body.Close() - - extractActionHashes(string(js), actionHashes) - } - - if len(actionHashes) >= requiredHashes { - break - } - } - - a.mu.Lock() - a.actionHashes = actionHashes - a.discovery = diag - a.mu.Unlock() -} - -// DiscoveryDiagnostics summarizes the last action-hash sweep. Callers include it -// when reporting a missing hash so the failure names its own cause. -func (a *Auth) DiscoveryDiagnostics() string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.discovery.String() -} - -func requiredActionHashCount() int { - unique := make(map[string]struct{}) - for _, key := range actionNameMap { - unique[key] = struct{}{} - } - return len(unique) -} - // GetCookies returns cookies for HTTP requests. // Includes both standard and Clerk v5 suffixed cookie names to ensure // compatibility with OpenRouter's server-side cookie parsing. @@ -590,21 +422,3 @@ func (a *Auth) GetCookies() []*http.Cookie { return cookies } - -// GetActionHash returns the next-action hash for a specific page. -func (a *Auth) GetActionHash(page string) string { - a.mu.RLock() - defer a.mu.RUnlock() - return a.actionHashes[page] -} - -// GetAllActionHashes returns all available next-action hashes. -func (a *Auth) GetAllActionHashes() map[string]string { - a.mu.RLock() - defer a.mu.RUnlock() - result := make(map[string]string) - for k, v := range a.actionHashes { - result[k] = v - } - return result -} diff --git a/internal/openrouter/auth_test.go b/internal/openrouter/auth_test.go index e538c4d..3a24e19 100644 --- a/internal/openrouter/auth_test.go +++ b/internal/openrouter/auth_test.go @@ -22,9 +22,8 @@ func TestParseCookieData_Standard(t *testing.T) { } a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), } cookies, ok := cookieData["cookies"].([]any) @@ -55,9 +54,8 @@ func TestParseCookieData_ClerkV5Suffixed(t *testing.T) { } a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), } cookies, _ := cookieData["cookies"].([]any) @@ -85,9 +83,8 @@ func TestParseCookieData_RefreshFallback(t *testing.T) { } a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), } cookies, _ := cookieData["cookies"].([]any) @@ -111,9 +108,8 @@ func TestParseCookieData_ClientUATNotMatchedAsClient(t *testing.T) { } a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), } cookies, _ := cookieData["cookies"].([]any) @@ -134,9 +130,8 @@ func TestParseCookieData_OrgID(t *testing.T) { } a := &Auth{ - clerkParams: make(map[string]string), - state: make(map[string]string), - actionHashes: make(map[string]string), + clerkParams: make(map[string]string), + state: make(map[string]string), } cookies, _ := cookieData["cookies"].([]any) @@ -228,10 +223,9 @@ func TestRefreshToken_HappyPath(t *testing.T) { defer withFakeClerkAPI(srv)() a := &Auth{ - state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok", "client_uat": "1"}, - clerkParams: map[string]string{}, - actionHashes: map[string]string{}, - client: &http.Client{Timeout: 5 * time.Second}, + state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok", "client_uat": "1"}, + clerkParams: map[string]string{}, + client: &http.Client{Timeout: 5 * time.Second}, } if err := a.refreshToken(); err != nil { t.Fatalf("refreshToken: %v", err) @@ -279,11 +273,10 @@ func TestRefreshToken_RetryOnMissingExpiredToken(t *testing.T) { defer withFakeClerkAPI(srv)() a := &Auth{ - state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok", "client_uat": "1"}, - clerkParams: map[string]string{}, - actionHashes: map[string]string{}, - client: &http.Client{Timeout: 5 * time.Second}, - sessionJWT: "prior_jwt_from_cookie", // seeded from __session cookie at registration + state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok", "client_uat": "1"}, + clerkParams: map[string]string{}, + client: &http.Client{Timeout: 5 * time.Second}, + sessionJWT: "prior_jwt_from_cookie", // seeded from __session cookie at registration } if err := a.refreshToken(); err != nil { t.Fatalf("refreshToken: %v", err) @@ -324,11 +317,10 @@ func TestRefreshToken_RetryNotInfiniteLoop(t *testing.T) { defer withFakeClerkAPI(srv)() a := &Auth{ - state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok"}, - clerkParams: map[string]string{}, - actionHashes: map[string]string{}, - client: &http.Client{Timeout: 5 * time.Second}, - sessionJWT: "prior_jwt", + state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok"}, + clerkParams: map[string]string{}, + client: &http.Client{Timeout: 5 * time.Second}, + sessionJWT: "prior_jwt", } err := a.refreshToken() if err == nil { @@ -354,10 +346,9 @@ func TestRefreshToken_NoExpiredTokenWhenSessionJWTEmpty(t *testing.T) { defer withFakeClerkAPI(srv)() a := &Auth{ - state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok"}, - clerkParams: map[string]string{}, - actionHashes: map[string]string{}, - client: &http.Client{Timeout: 5 * time.Second}, + state: map[string]string{"session_id": "sess_TEST", "client_token": "ctok"}, + clerkParams: map[string]string{}, + client: &http.Client{Timeout: 5 * time.Second}, // sessionJWT empty — no JWT to submit as expired_token } _ = a.refreshToken() @@ -383,9 +374,8 @@ func TestParseCookieData_SeedsSessionJWT(t *testing.T) { // Manually replay the parsing logic without triggering refreshToken (which // requires HTTP). We only verify state seeding. a := &Auth{ - state: map[string]string{}, - clerkParams: map[string]string{}, - actionHashes: map[string]string{}, + state: map[string]string{}, + clerkParams: map[string]string{}, } cookies, _ := cookieData["cookies"].([]any) for _, c := range cookies { @@ -403,116 +393,3 @@ func TestParseCookieData_SeedsSessionJWT(t *testing.T) { t.Errorf("sessionJWT = %q, want the_initial_jwt", a.sessionJWT) } } - -// --------------------------------------------------------------------------- -// Action-hash discovery -// -// Regression guard for the 2026-08 outage: OpenRouter relocated the client -// bundle from /_next/static/chunks/ to /_next/static/immutable/chunks/. The -// old pattern matched nothing, so every station registered with zero action -// hashes and failed with "no activity hash found, available: map[]". -// --------------------------------------------------------------------------- - -func TestChunkPathRe_MatchesCurrentAndLegacyLayouts(t *testing.T) { - cases := []struct { - name string - html string - want string - }{ - { - name: "current immutable layout", - html: ``, - want: "/_next/static/immutable/chunks/1yoblnpeamuzi.js", - }, - { - name: "legacy layout still supported", - html: ``, - want: "/_next/static/chunks/main-app-1a2b3c.js", - }, - { - name: "chunk name with underscores and dashes", - html: ``, - want: "/_next/static/immutable/chunks/0_er77wa20lmt.js", - }, - { - name: "hypothetical deeper prefix", - html: ``, - want: "/_next/static/v2/immutable/chunks/abc.js", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := chunkPathRe.FindAllString(tc.html, -1) - if len(got) != 1 { - t.Fatalf("matched %d paths, want 1: %v", len(got), got) - } - if got[0] != tc.want { - t.Errorf("got %q, want %q", got[0], tc.want) - } - }) - } -} - -func TestChunkPathRe_ReturnsFullFetchablePath(t *testing.T) { - // The matched value is concatenated onto config.BaseURL directly, so it must - // be the complete path. Reassembling it from a hardcoded prefix is exactly - // what broke in 2026-08. - html := `` - got := chunkPathRe.FindString(html) - if !strings.HasPrefix(got, "/_next/static/") || !strings.HasSuffix(got, ".js") { - t.Fatalf("not a fetchable path: %q", got) - } - if !strings.Contains(got, "immutable") { - t.Errorf("dropped the intermediate segment: %q", got) - } -} - -// liveChunkSnippet is a verbatim excerpt of OpenRouter's minified bundle, -// captured 2026-08-10 from /_next/static/immutable/chunks/1yoblnpeamuzi.js. -const liveChunkSnippet = `ModelChanged="model_changed",s.Error="error",s),y=e.i(165701);let L=(0,y.createServerReference)("003b43c49a1bfb8a3e6f7f6e74736da5a4e5bfc663",y.callServer,void 0,y.findSourceMapURL,"getCurrentUserSA")` - -func TestExtractActionHashes_LiveBundleShape(t *testing.T) { - got := map[string]string{} - extractActionHashes(liveChunkSnippet, got) - - want := "003b43c49a1bfb8a3e6f7f6e74736da5a4e5bfc663" - if got["activity"] != want { - t.Errorf("activity hash = %q, want %q (all hashes: %v)", got["activity"], want, got) - } -} - -func TestExtractActionHashes_IgnoresUnmappedActions(t *testing.T) { - js := `(0,y.createServerReference)("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",y.callServer,void 0,y.findSourceMapURL,"someUnrelatedSA")` - got := map[string]string{} - extractActionHashes(js, got) - - if len(got) != 0 { - t.Errorf("recorded unmapped action: %v", got) - } -} - -func TestExtractActionHashes_NameBeyondLookaheadIsIgnored(t *testing.T) { - // Guards the lookahead window: if a future build inserts enough arguments - // between the hash and the name, we want a visible miss rather than a hash - // silently bound to the wrong action. - js := `("003b43c49a1bfb8a3e6f7f6e74736da5a4e5bfc663",` + strings.Repeat("x", actionNameLookahead) + `,"getCurrentUserSA")` - got := map[string]string{} - extractActionHashes(js, got) - - if _, ok := got["activity"]; ok { - t.Errorf("matched a name past the lookahead window: %v", got) - } -} - -func TestHashDiscovery_StringDistinguishesFailureModes(t *testing.T) { - signedOut := hashDiscovery{pagesSignedOut: 2}.String() - if !strings.Contains(signedOut, "pages_signed_out=2") || !strings.Contains(signedOut, "pages_ok=0") { - t.Errorf("dead-session diagnostic unclear: %q", signedOut) - } - - bundleMoved := hashDiscovery{pagesOK: 2, chunksSeen: 0}.String() - if !strings.Contains(bundleMoved, "pages_ok=2") || !strings.Contains(bundleMoved, "chunks_seen=0") { - t.Errorf("bundle-layout diagnostic unclear: %q", bundleMoved) - } -} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 3b20166..682234c 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -192,6 +192,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { data, err := openrouter.FetchActivityData(auth) if err != nil || data == nil { errDetail := "empty_activity_payload" + statusCode, clientMessage := classifyOpenRouterReadFailure(err) if err != nil { errDetail = err.Error() } @@ -206,7 +207,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { event: "register_activity_fetch_failed", publicKey: req.PublicKey, reason: "failed_to_fetch_activity", - statusCode: http.StatusUnauthorized, + statusCode: statusCode, errorDetail: errDetail, operation: "activity_fetch", consecutiveFailureCount: count, @@ -215,7 +216,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { slog.Warn("registration rejected: failed to fetch activity data", "pk", req.PublicKey[:16], "display_name", req.DisplayName) - writeError(w, http.StatusUnauthorized, "Failed to verify cookie") + writeError(w, statusCode, clientMessage) return } s.clearOpFailure(registerIdentity, "activity_fetch") @@ -309,9 +310,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { // Fetch workspace data for workspace-level toggles wsData, wsErr := openrouter.FetchWorkspaceData(auth) if wsErr != nil { - slog.Warn("registration: workspace data fetch failed, checking with user data only", + statusCode, clientMessage := classifyOpenRouterReadFailure(wsErr) + count := s.incOpFailure(registerIdentity, "workspace_fetch") + s.notifyOrgEvent(orgEvent{ + event: "register_workspace_fetch_failed", + stationID: stationID, + publicKey: req.PublicKey, + email: email, + reason: "failed_to_fetch_workspace", + statusCode: statusCode, + errorDetail: wsErr.Error(), + operation: "workspace_fetch", + consecutiveFailureCount: count, + details: openrouterErrorDetails(wsErr), + }) + slog.Warn("registration rejected: workspace data fetch failed", "email", email, "error", wsErr) + writeError(w, statusCode, clientMessage) + return } + s.clearOpFailure(registerIdentity, "workspace_fetch") // Verify privacy toggles immediately (merged user + workspace data) toggleResult, toggleDetails := challenge.CheckPrivacyToggles(mergeToggleData(data, wsData)) @@ -435,8 +453,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { provisioningKey = key slog.Info("created provisioning key", "station_id", stationID, - "label", label, - "key_prefix", provisioningKey[:min(20, len(provisioningKey))]) + "label", label) } else { slog.Info("using existing provisioning key", "station_id", stationID) } diff --git a/internal/server/openrouter_context.go b/internal/server/openrouter_context.go index 184f9a3..e2e1ff8 100644 --- a/internal/server/openrouter_context.go +++ b/internal/server/openrouter_context.go @@ -1,6 +1,10 @@ package server -import "github.com/openanonymity/oa-verifier/internal/openrouter" +import ( + "net/http" + + "github.com/openanonymity/oa-verifier/internal/openrouter" +) func openrouterErrorDetails(err error) map[string]any { ctx := openrouter.ErrorContext(err) @@ -10,6 +14,13 @@ func openrouterErrorDetails(err error) map[string]any { return ctx } +func classifyOpenRouterReadFailure(err error) (int, string) { + if openrouter.IsSessionAuthError(err) { + return http.StatusUnauthorized, "Failed to verify cookie" + } + return http.StatusBadGateway, "Unable to fetch OpenRouter verification data" +} + func openrouterOwnershipDetails(result openrouter.OwnershipCheckResult) map[string]any { return map[string]any{ "openrouter_operation": "ownership_check", diff --git a/internal/server/openrouter_context_test.go b/internal/server/openrouter_context_test.go new file mode 100644 index 0000000..85a26b2 --- /dev/null +++ b/internal/server/openrouter_context_test.go @@ -0,0 +1,58 @@ +package server + +import ( + "errors" + "net/http" + "testing" + + "github.com/openanonymity/oa-verifier/internal/openrouter" +) + +func TestClassifyOpenRouterReadFailure(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + wantMessage string + }{ + { + name: "session rejected", + err: &openrouter.RequestResponseError{ + Operation: "fetch_activity_data", + ResponseStatus: http.StatusUnauthorized, + }, + wantStatus: http.StatusUnauthorized, + wantMessage: "Failed to verify cookie", + }, + { + name: "frontend contract missing", + err: &openrouter.RequestResponseError{ + Operation: "fetch_activity_data", + ResponseStatus: http.StatusNotFound, + }, + wantStatus: http.StatusBadGateway, + wantMessage: "Unable to fetch OpenRouter verification data", + }, + { + name: "schema drift", + err: errors.New("response missing data object"), + wantStatus: http.StatusBadGateway, + wantMessage: "Unable to fetch OpenRouter verification data", + }, + { + name: "empty response", + err: nil, + wantStatus: http.StatusBadGateway, + wantMessage: "Unable to fetch OpenRouter verification data", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + status, message := classifyOpenRouterReadFailure(test.err) + if status != test.wantStatus || message != test.wantMessage { + t.Errorf("got (%d, %q), want (%d, %q)", status, message, test.wantStatus, test.wantMessage) + } + }) + } +} diff --git a/internal/server/verification.go b/internal/server/verification.go index 0e80667..3783628 100644 --- a/internal/server/verification.go +++ b/internal/server/verification.go @@ -166,6 +166,7 @@ func (s *Server) challengeOneStation(_ context.Context, pk string, station model unregisterOperation = "activity_fetch" transientEvent = "verification_activity_fetch_failed" transientOperation = "activity_fetch" + transientStatusCode, _ = classifyOpenRouterReadFailure(err) if err != nil { unregisterDetail = err.Error() transientDetails = openrouterErrorDetails(err) @@ -176,33 +177,43 @@ func (s *Server) challengeOneStation(_ context.Context, pk string, station model // are only available from the workspace settings endpoint. wsData, wsErr := openrouter.FetchWorkspaceData(auth) if wsErr != nil { - slog.Warn("workspace data fetch failed, checking with user data only", "station_id", stationID, "error", wsErr) - } - mergedData := mergeToggleData(activityData, wsData) - - toggleResult, toggleDetails := challenge.CheckPrivacyToggles(mergedData) - switch toggleResult { - case challenge.ToggleOK: - passed = true - case challenge.ToggleInvalid: - reason = fmt.Sprintf("privacy_toggles_invalid:[%s]", strings.Join(toggleDetails, ",")) - slog.Error("station failed privacy toggle check", "station_id", stationID, "toggles", toggleDetails) - case challenge.ToggleMissing: - reason = fmt.Sprintf("privacy_toggles_missing:[%s]", strings.Join(toggleDetails, ",")) + reason = "workspace_fetch_failed" transientFailure = true - unregisterReason = "privacy_toggles_missing" - unregisterDetail = strings.Join(toggleDetails, ",") - unregisterOperation = "privacy_toggle_check" - transientEvent = "verification_privacy_toggles_missing" - transientOperation = "privacy_toggle_check" - case challenge.ToggleUnparseable: - reason = fmt.Sprintf("privacy_toggles_unparseable:[%s]", strings.Join(toggleDetails, ",")) - transientFailure = true - unregisterReason = "privacy_toggles_unparseable" - unregisterDetail = strings.Join(toggleDetails, ",") - unregisterOperation = "privacy_toggle_check" - transientEvent = "verification_privacy_toggles_unparseable" - transientOperation = "privacy_toggle_check" + unregisterReason = "workspace_fetch_failed" + unregisterDetail = wsErr.Error() + unregisterOperation = "workspace_fetch" + transientEvent = "verification_workspace_fetch_failed" + transientOperation = "workspace_fetch" + transientStatusCode, _ = classifyOpenRouterReadFailure(wsErr) + transientDetails = openrouterErrorDetails(wsErr) + slog.Warn("workspace data fetch failed", "station_id", stationID, "error", wsErr) + } else { + mergedData := mergeToggleData(activityData, wsData) + + toggleResult, toggleDetails := challenge.CheckPrivacyToggles(mergedData) + switch toggleResult { + case challenge.ToggleOK: + passed = true + case challenge.ToggleInvalid: + reason = fmt.Sprintf("privacy_toggles_invalid:[%s]", strings.Join(toggleDetails, ",")) + slog.Error("station failed privacy toggle check", "station_id", stationID, "toggles", toggleDetails) + case challenge.ToggleMissing: + reason = fmt.Sprintf("privacy_toggles_missing:[%s]", strings.Join(toggleDetails, ",")) + transientFailure = true + unregisterReason = "privacy_toggles_missing" + unregisterDetail = strings.Join(toggleDetails, ",") + unregisterOperation = "privacy_toggle_check" + transientEvent = "verification_privacy_toggles_missing" + transientOperation = "privacy_toggle_check" + case challenge.ToggleUnparseable: + reason = fmt.Sprintf("privacy_toggles_unparseable:[%s]", strings.Join(toggleDetails, ",")) + transientFailure = true + unregisterReason = "privacy_toggles_unparseable" + unregisterDetail = strings.Join(toggleDetails, ",") + unregisterOperation = "privacy_toggle_check" + transientEvent = "verification_privacy_toggles_unparseable" + transientOperation = "privacy_toggle_check" + } } } }