// Package tdclient is a minimal terdut-server API client for the operator's // own controllers. It mirrors the shape of terdut-tui's // internal/api/client.go (baseURL/httpClient fields, a shared do/statusError // helper, per terdut/CLAUDE.md's "any change to a server endpoint or JSON // shape must be mirrored" convention) but authorizes with a Bearer API key // rather than a session cookie — the operator never signs in as a human // (DESIGN.md §6). // // Deliberately minimal for Stage 1 (ROADMAP.md): only Version, the // reachability probe TerdutServer's controller needs. Bootstrap and the // service-account endpoints land here once self-registration does (Stage 5). package tdclient import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" ) // Client talks to one terdut-server install, optionally as a service // account. A zero-value token works for endpoints that don't need one // (Version). type Client struct { baseURL string httpClient *http.Client token string } // New creates a Client against baseURL, with no credential set. func New(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{Timeout: 10 * time.Second}, } } // WithToken returns a copy of c that authorizes every request as a Bearer // credential — a user's own API key or a service-account key // (SERVICE-ACCOUNTS.md), the server resolves either the same way. func (c *Client) WithToken(token string) *Client { cp := *c cp.token = token return &cp } // StatusError is a non-2xx response — the server's {"error": "..."} body // decoded into Message, same shape terdut-tui's client uses. type StatusError struct { Code int Message string } func (e *StatusError) Error() string { if e.Message != "" { return fmt.Sprintf("server returned %d: %s", e.Code, e.Message) } return fmt.Sprintf("server returned %d", e.Code) } func statusError(resp *http.Response) error { var e struct { Error string `json:"error"` } _ = json.NewDecoder(resp.Body).Decode(&e) return &StatusError{Code: resp.StatusCode, Message: e.Error} } func (c *Client) newRequest(ctx context.Context, method, path string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } return req, nil } func (c *Client) do(req *http.Request, out any) error { resp, err := c.httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { return statusError(resp) } if out != nil { return json.NewDecoder(resp.Body).Decode(out) } return nil } // Version calls GET /api/version — unauthenticated, per terdut-server's own // router.go comment ("a client deciding whether it can talk to this server — // terdut-tui, terdut-operator — needs to ask before it holds a credential // for it"). Used here purely as a reachability probe: a bad endpoint fails // here, clearly, rather than on whatever the controller tries first. func (c *Client) Version(ctx context.Context) (string, error) { req, err := c.newRequest(ctx, http.MethodGet, "/api/version") if err != nil { return "", err } var v struct { Version string `json:"version"` } if err := c.do(req, &v); err != nil { return "", err } return v.Version, nil }