// Package client provides the HTTP client wrapper for HarborForge API calls. package client import ( "encoding/json" "fmt" "io" "net" "net/http" neturl "net/url" "strings" "time" ) // Client is a simple HarborForge API client. type Client struct { BaseURL string Token string APIKey string HTTPClient *http.Client } // New creates a Client with the given base URL and optional auth token. // // The token is sent as Authorization: Bearer when it looks like a JWT // (eyJ-prefixed, three dot-separated segments). Anything else is treated as // an API key and sent via X-API-Key. This lets call sites that historically // passed an api-key as a "token" (e.g. the value returned by passmgr.GetToken // in padded-cell mode) authenticate correctly without per-callsite churn. func New(baseURL, token string) *Client { c := &Client{ BaseURL: strings.TrimRight(baseURL, "/"), HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, } if isLikelyJWT(token) { c.Token = token } else if token != "" { c.APIKey = token } return c } // isLikelyJWT returns true for tokens that look like a JSON Web Token: // "eyJ"-prefixed (base64-encoded JSON header opening with `{"`) and exactly // two dots separating header.payload.signature. API keys minted by the HF // backend are hex (`/users/{id}/apikey` returns a 64-hex-char `key`); fabric // keys use a `fak_` prefix. None of those match this shape. func isLikelyJWT(token string) bool { return strings.HasPrefix(token, "eyJ") && strings.Count(token, ".") == 2 } // NewWithAPIKey creates a Client that authenticates using X-API-Key. func NewWithAPIKey(baseURL, apiKey string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, } } // RequestError represents a non-2xx HTTP response. type RequestError struct { StatusCode int Body string } func (e *RequestError) Error() string { return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body) } // guardPlaintextCreds refuses to transmit a token / API key over a plaintext // http:// connection to a non-loopback host (prevents credential interception). func (c *Client) guardPlaintextCreds() error { if c.Token == "" && c.APIKey == "" { return nil } u, err := neturl.Parse(c.BaseURL) if err != nil || u.Scheme != "http" { return nil // parse errors and https:// are fine } if isLoopbackHost(u.Hostname()) { return nil } return fmt.Errorf("refusing to send credentials over plaintext http:// to non-loopback host %q — use an https:// base URL (hf config set-url ...)", u.Hostname()) } // isLoopbackHost reports whether h is a loopback address or localhost name. func isLoopbackHost(h string) bool { h = strings.ToLower(h) if h == "localhost" || strings.HasSuffix(h, ".localhost") { return true } if ip := net.ParseIP(h); ip != nil { return ip.IsLoopback() } return false } // Do executes an HTTP request and returns the response body bytes. func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) { if err := c.guardPlaintextCreds(); err != nil { return nil, err } url := c.BaseURL + path req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("cannot create request: %w", err) } if c.APIKey != "" { req.Header.Set("X-API-Key", c.APIKey) } else if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("cannot read response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, &RequestError{StatusCode: resp.StatusCode, Body: string(data)} } return data, nil } // Get performs a GET request. func (c *Client) Get(path string) ([]byte, error) { return c.Do("GET", path, nil) } // Post performs a POST request with a JSON body. func (c *Client) Post(path string, body io.Reader) ([]byte, error) { return c.Do("POST", path, body) } // Put performs a PUT request with a JSON body. func (c *Client) Put(path string, body io.Reader) ([]byte, error) { return c.Do("PUT", path, body) } // Patch performs a PATCH request with a JSON body. func (c *Client) Patch(path string, body io.Reader) ([]byte, error) { return c.Do("PATCH", path, body) } // Delete performs a DELETE request. func (c *Client) Delete(path string) ([]byte, error) { return c.Do("DELETE", path, nil) } // Health checks the API health endpoint and returns the response. func (c *Client) Health() (map[string]interface{}, error) { data, err := c.Get("/health") if err != nil { return nil, err } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("cannot parse health response: %w", err) } return result, nil }