Files
Molecule AI Dev Engineer A (Kimi) a87384b479 fix(cli): cpURL defaults to CP host, MintWorkspaceToken uses admin route
- cpURL() no longer falls back to apiURL (tenant host), preventing
  CP_ADMIN_API_TOKEN from being sent to the wrong service.
- MintWorkspaceToken now calls /admin/workspaces/:id/tokens (AdminAuth)
  instead of /workspaces/:id/tokens (WorkspaceAuth), matching the
  MCP implementation and the server router.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:21:55 +00:00

388 lines
14 KiB
Go

// Package client — management surface (orgs, secrets, tokens, templates,
// bundles, budget, billing-mode, events, approvals).
//
// Request/response shapes are aligned to the live workspace-server handlers
// (tenant) and controlplane handlers (CP). Auth tiers per
// PLATFORM-MANAGEMENT-API.md §1: tenant calls use the Org API Key as a bearer
// (+ X-Molecule-Org-Id); CP org calls use the same key/CP-admin bearer.
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
// ---------------------------------------------------------------------------
// Control-plane: orgs (ADMIN bearer surface — /api/v1/admin/orgs)
//
// The customer-facing /api/v1/orgs* routes are gated by a WorkOS browser
// session (controlplane auth.RequireSession), which a bearer-token CLI cannot
// satisfy — every call 401s in production, and the tenant Org API Key has no
// standing on the CP at all. So org-lifecycle verbs go to the CP ADMIN routes
// (controlplane router AdminGate), authenticated with a DISTINCT CP-admin
// bearer (MOLECULE_CP_ADMIN_TOKEN). The org key is never sent to the CP.
//
// Note: only create + list exist on the admin surface. There is NO admin
// GET /orgs/:slug and NO admin /orgs/:slug/export in controlplane — those are
// session-only — so the CLI fails those verbs fast rather than 401'ing.
// ---------------------------------------------------------------------------
// Org mirrors controlplane handlers.adminOrgSummary, the per-row shape of
// GET /api/v1/admin/orgs. Intentionally narrower than the session-facing
// customer org view (no credits/overage — those aren't on the admin summary).
type Org struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Plan string `json:"plan"`
Status string `json:"status,omitempty"`
StripeCustomerID string `json:"stripe_customer_id,omitempty"`
MemberCount int `json:"member_count,omitempty"`
InstanceStatus string `json:"instance_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// adminListOrgsResponse is the envelope returned by GET /api/v1/admin/orgs:
// {"limit":N,"offset":N,"orgs":[...]}.
type adminListOrgsResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Orgs []Org `json:"orgs"`
}
// ListOrgs returns all orgs from the CP admin surface
// (GET /api/v1/admin/orgs). Requires a CP-admin bearer.
func (p *Platform) ListOrgs() ([]Org, error) {
var out adminListOrgsResponse
if err := p.getInto("/api/v1/admin/orgs", &out); err != nil {
return nil, err
}
return out.Orgs, nil
}
// CreateOrgRequest is the body for POST /api/v1/admin/orgs. owner_user_id is
// REQUIRED by the admin route (controlplane adminCreateOrgRequest): the
// server-to-server path has no implicit WorkOS session to hang the owner
// membership off of, so the caller passes it explicitly.
type CreateOrgRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
OwnerUserID string `json:"owner_user_id"`
}
// CreateOrg creates an org on the CP admin surface (POST /api/v1/admin/orgs).
// Requires a CP-admin bearer. Returns the full created org.
func (p *Platform) CreateOrg(req CreateOrgRequest) (*Org, error) {
var out Org
if err := p.postInto("/api/v1/admin/orgs", req, &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------------
// Tenant: org-from-template (POST /org/import) + allowlist + org tokens
// ---------------------------------------------------------------------------
// ImportOrgRequest is the body for POST /org/import.
type ImportOrgRequest struct {
Dir string `json:"dir,omitempty"` // org template directory name
Mode string `json:"mode,omitempty"` // "merge" (default) | "reconcile"
}
// CreateOrgFromTemplate provisions workspaces from an org template directory
// (POST /org/import). Returns the raw per-workspace result list.
func (p *Platform) CreateOrgFromTemplate(req ImportOrgRequest) (json.RawMessage, error) {
return p.postRaw("/org/import", req)
}
// GetAllowlist returns the org plugin allowlist (GET /orgs/:id/plugins/allowlist).
func (p *Platform) GetAllowlist(orgID string) (json.RawMessage, error) {
return p.getRaw("/orgs/" + url.PathEscape(orgID) + "/plugins/allowlist")
}
// OrgToken is a row from GET /org/tokens.
type OrgToken struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Name string `json:"name,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
LastUsed string `json:"last_used_at,omitempty"`
}
// ListOrgTokens lists the org's Org API Keys (GET /org/tokens).
func (p *Platform) ListOrgTokens() ([]OrgToken, error) {
var out []OrgToken
if err := p.getInto("/org/tokens", &out); err != nil {
return nil, err
}
return out, nil
}
// CreateOrgTokenResponse is the POST /org/tokens response. The plaintext
// token is shown exactly once.
type CreateOrgTokenResponse struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Name string `json:"name,omitempty"`
Token string `json:"auth_token"`
Warning string `json:"warning,omitempty"`
}
// CreateOrgToken mints a new Org API Key (POST /org/tokens).
func (p *Platform) CreateOrgToken(name string) (*CreateOrgTokenResponse, error) {
var out CreateOrgTokenResponse
if err := p.postInto("/org/tokens", map[string]string{"name": name}, &out); err != nil {
return nil, err
}
return &out, nil
}
// RevokeOrgToken revokes an Org API Key by id (DELETE /org/tokens/:id).
func (p *Platform) RevokeOrgToken(id string) error {
_, err := p.delete("/org/tokens/" + url.PathEscape(id))
return err
}
// ---------------------------------------------------------------------------
// Tenant: workspace lifecycle (pause/resume) + budget + billing-mode + token
// ---------------------------------------------------------------------------
// PauseWorkspace pauses a workspace (POST /workspaces/:id/pause).
func (p *Platform) PauseWorkspace(id string) error {
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/pause")
return err
}
// ResumeWorkspace resumes a workspace (POST /workspaces/:id/resume).
func (p *Platform) ResumeWorkspace(id string) error {
_, err := p.postEmpty("/workspaces/" + url.PathEscape(id) + "/resume")
return err
}
// GetBudget returns a workspace's budget (GET /workspaces/:id/budget).
func (p *Platform) GetBudget(id string) (json.RawMessage, error) {
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/budget")
}
// SetBudget sets per-period budget limits in USD cents
// (PATCH /workspaces/:id/budget, body {"budget_limits": {...}}). A nil value
// for a period clears that period's limit.
func (p *Platform) SetBudget(id string, limits map[string]*int64) (json.RawMessage, error) {
body := map[string]interface{}{"budget_limits": limits}
return p.patchRaw("/workspaces/"+url.PathEscape(id)+"/budget", body)
}
// SetBillingMode sets a workspace's LLM billing-mode override
// (PUT /admin/workspaces/:id/llm-billing-mode, body {"mode": ...}).
// Pass mode="" to clear the override (sends null).
func (p *Platform) SetBillingMode(id, mode string) (json.RawMessage, error) {
var modeVal interface{}
if mode == "" {
modeVal = nil
} else {
modeVal = mode
}
body := map[string]interface{}{"mode": modeVal}
return p.putRaw("/admin/workspaces/"+url.PathEscape(id)+"/llm-billing-mode", body)
}
// MintWorkspaceTokenResponse is the POST /workspaces/:id/tokens response.
type MintWorkspaceTokenResponse struct {
Token string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
Message string `json:"message,omitempty"`
}
// MintWorkspaceToken mints a new per-workspace auth token
// (POST /workspaces/:id/tokens). Plaintext is returned exactly once.
func (p *Platform) MintWorkspaceToken(id string) (*MintWorkspaceTokenResponse, error) {
var out MintWorkspaceTokenResponse
// Pass empty object rather than nil so the body is {} not null.
if err := p.postInto("/admin/workspaces/"+url.PathEscape(id)+"/tokens", map[string]interface{}{}, &out); err != nil {
return nil, err
}
return &out, nil
}
// ---------------------------------------------------------------------------
// Tenant: secrets (workspace + org)
// ---------------------------------------------------------------------------
// Secret is a key entry from a secrets list. Values are not returned by the
// list endpoints (only keys + metadata).
type Secret struct {
Key string `json:"key"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// ListWorkspaceSecrets lists a workspace's secret keys
// (GET /workspaces/:id/secrets).
func (p *Platform) ListWorkspaceSecrets(id string) (json.RawMessage, error) {
return p.getRaw("/workspaces/" + url.PathEscape(id) + "/secrets")
}
// SetWorkspaceSecret upserts a workspace secret (POST /workspaces/:id/secrets,
// body {"key","value"}). The tenant auto-restarts the workspace.
func (p *Platform) SetWorkspaceSecret(id, key, value string) error {
body := map[string]string{"key": key, "value": value}
return p.postInto("/workspaces/"+url.PathEscape(id)+"/secrets", body, nil)
}
// DeleteWorkspaceSecret deletes a workspace secret by key
// (DELETE /workspaces/:id/secrets/:key).
func (p *Platform) DeleteWorkspaceSecret(id, key string) error {
_, err := p.delete("/workspaces/" + url.PathEscape(id) + "/secrets/" + url.PathEscape(key))
return err
}
// ListOrgSecrets lists org-wide secret keys (GET /settings/secrets).
func (p *Platform) ListOrgSecrets() (json.RawMessage, error) {
return p.getRaw("/settings/secrets")
}
// SetOrgSecret upserts an org-wide secret (POST /settings/secrets).
func (p *Platform) SetOrgSecret(key, value string) error {
body := map[string]string{"key": key, "value": value}
return p.postInto("/settings/secrets", body, nil)
}
// DeleteOrgSecret deletes an org-wide secret by key
// (DELETE /settings/secrets/:key).
func (p *Platform) DeleteOrgSecret(key string) error {
_, err := p.delete("/settings/secrets/" + url.PathEscape(key))
return err
}
// ---------------------------------------------------------------------------
// Tenant: templates + bundles
// ---------------------------------------------------------------------------
// ListTemplates lists workspace templates (GET /templates).
func (p *Platform) ListTemplates() (json.RawMessage, error) {
return p.getRaw("/templates")
}
// ImportTemplate imports a template (POST /templates/import,
// body {"name","files"}).
func (p *Platform) ImportTemplate(name string, files map[string]string) (json.RawMessage, error) {
body := map[string]interface{}{"name": name, "files": files}
return p.postRaw("/templates/import", body)
}
// RefreshTemplates refreshes the template cache (POST /admin/templates/refresh).
func (p *Platform) RefreshTemplates() (json.RawMessage, error) {
return p.postRaw("/admin/templates/refresh", nil)
}
// ExportBundle exports a workspace bundle (GET /bundles/export/:id).
func (p *Platform) ExportBundle(id string) (json.RawMessage, error) {
return p.getRaw("/bundles/export/" + url.PathEscape(id))
}
// ImportBundle imports a bundle (POST /bundles/import). bundle is the raw
// bundle JSON as exported by ExportBundle.
func (p *Platform) ImportBundle(bundle json.RawMessage) (json.RawMessage, error) {
return p.postRaw("/bundles/import", bundle)
}
// ---------------------------------------------------------------------------
// Tenant: events + approvals
// ---------------------------------------------------------------------------
// ListEvents lists recent structure_events (GET /events, AdminAuth).
func (p *Platform) ListEvents() (json.RawMessage, error) {
return p.getRaw("/events")
}
// ListPendingApprovals lists pending approvals (GET /approvals/pending).
func (p *Platform) ListPendingApprovals() (json.RawMessage, error) {
return p.getRaw("/approvals/pending")
}
// ---------------------------------------------------------------------------
// Raw helpers — return the response body verbatim for endpoints whose shape
// is loose / pass-through (events, secrets lists, budget, exports, …).
// ---------------------------------------------------------------------------
func (p *Platform) getRaw(path string) (json.RawMessage, error) {
req, err := http.NewRequest("GET", p.BaseURL+path, nil)
if err != nil {
return nil, fmt.Errorf("new GET request: %w", err)
}
p.setAuth(req)
return p.doRaw(req, "GET", path)
}
func (p *Platform) postRaw(path string, body interface{}) (json.RawMessage, error) {
var encoded []byte
if body != nil {
// Allow callers to pass a pre-encoded json.RawMessage straight through.
if rm, ok := body.(json.RawMessage); ok {
encoded = rm
} else {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal POST body: %w", err)
}
encoded = b
}
}
var req *http.Request
var err error
if encoded != nil {
req, err = http.NewRequest("POST", p.BaseURL+path, bytes.NewReader(encoded))
} else {
req, err = http.NewRequest("POST", p.BaseURL+path, nil)
}
if err != nil {
return nil, fmt.Errorf("new POST request: %w", err)
}
if encoded != nil {
req.Header.Set("Content-Type", "application/json")
}
p.setAuth(req)
return p.doRaw(req, "POST", path)
}
func (p *Platform) putRaw(path string, body interface{}) (json.RawMessage, error) {
return p.bodyRaw("PUT", path, body)
}
func (p *Platform) patchRaw(path string, body interface{}) (json.RawMessage, error) {
return p.bodyRaw("PATCH", path, body)
}
func (p *Platform) bodyRaw(method, path string, body interface{}) (json.RawMessage, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal %s body: %w", method, err)
}
req, err := http.NewRequest(method, p.BaseURL+path, bytes.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("new %s request: %w", method, err)
}
req.Header.Set("Content-Type", "application/json")
p.setAuth(req)
return p.doRaw(req, method, path)
}
func (p *Platform) doRaw(req *http.Request, method, path string) (json.RawMessage, error) {
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("%s %s: HTTP %d — %s", method, path, resp.StatusCode, string(body))
}
return json.RawMessage(body), nil
}