a87384b479
- 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>
388 lines
14 KiB
Go
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
|
|
}
|