From dc7e660e908964a3e9c4eaaaf1a85437de39a0d9 Mon Sep 17 00:00:00 2001 From: Molecule AI core-be Date: Sun, 31 May 2026 20:40:36 -0700 Subject: [PATCH 1/2] docs(openapi): add OpenAPI 3.1 management spec (SSOT) + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author workspace-server/docs/openapi/management.yaml — the hand-authored, authoritative OpenAPI 3.1 contract for the Molecule platform MANAGEMENT surface, spanning both services in one spec: - CP (api.moleculesai.app, /api/v1/*): orgs create/get/list/delete/export/ provision-status, public instance lookup, billing (invoices/checkout/ portal/topup), admin (admin-create-org w/ dry_run, tenant delete + scrub w/ confirm guard, diagnostics, redeploy + fleet, workspace env w/ force guard, ListOrgWorkspaces, admin-token, thin-ami + runtime-image pins), provisioning (provision w/ 422 RUNTIME_PIN_MISSING, deprovision, status). - Tenant workspace-server: /workspaces[/:id] CRUD + restart/pause/resume, budget, llm-billing-mode, /workspaces/:id/secrets, /settings/secrets, /org/import, /org/templates, /org/tokens (Org API Key mint/revoke), /templates[/import], /bundles export/import. Defines the five security tiers as securitySchemes (workosSession cookie, cpAdminBearer, provisionSecret [+ tenantAdminToken on deprovision], orgApiKey + org routing header, workspaceToken) and applies the correct scheme(s) per-route. Dry-run / confirm / force guards modelled per-operation. Grounded in the router + handler sources (controlplane + workspace-server), not just the synthesis doc — notably llm-billing-mode is modelled on the real tenant route (/admin/workspaces/:id/llm-billing-mode, AdminAuth), with the divergence from the synthesis doc noted in the README. Adds README.md documenting the two-service split + the security-scheme→ surface tier matrix. This is the SSOT the management MCP + CLI + docs derive from (PLATFORM-MANAGEMENT-API.md §5c / RFC #1706). Supersedes the swaggo /schedules stub for the management surface; runtime surface stays out of scope. Per dev-sop Phase 1-4 + Five-Axis self-review (in PR body). Lints clean: npx @redocly/cli lint management.yaml (0 errors, 0 warnings). Co-Authored-By: Claude Opus 4.8 (1M context) --- workspace-server/docs/openapi/README.md | 114 + workspace-server/docs/openapi/management.yaml | 1851 +++++++++++++++++ 2 files changed, 1965 insertions(+) create mode 100644 workspace-server/docs/openapi/README.md create mode 100644 workspace-server/docs/openapi/management.yaml diff --git a/workspace-server/docs/openapi/README.md b/workspace-server/docs/openapi/README.md new file mode 100644 index 00000000..ead2baa5 --- /dev/null +++ b/workspace-server/docs/openapi/README.md @@ -0,0 +1,114 @@ +# Molecule Platform OpenAPI specs + +This directory holds the machine-readable API contracts for the Molecule +platform. + +| File | Spec | Scope | Status | +|------|------|-------|--------| +| `management.yaml` | OpenAPI **3.1** | The **management surface** across both services (orgs, billing, admin, provisioning, workspaces, secrets, templates, org-tokens, bundles). | **SSOT** — hand-authored. | +| `swagger.yaml` / `swagger.json` | OpenAPI 2.0 | swaggo-generated stub, `/schedules` only (the per-workspace **runtime** surface). | Legacy stub; superseded for management by `management.yaml`. | + +`management.yaml` is the **single source of truth** the management tooling +derives from — the management MCP server, the management CLI (`molecule-cli`), +and the human-facing API docs (RFC #1706, the gap closed by +`PLATFORM-MANAGEMENT-API.md` §5c). Do not hand-edit those clients' route maps; +change them here and regenerate/derive. + +## The two-service split + +One structural fact drives the whole spec: there are **two services with two +auth stacks**, and the management surface spans both. + +``` + ┌─────────────────────────────────────────┐ + browser / CLI / MCP │ Control plane (CP) │ + │ │ molecule-controlplane @ api.moleculesai │ + │ session │ /api/v1/* (stable) [+ /cp/* sunset] │ + ├───────────────▶│ orgs · members · billing · provisioning │ + │ admin bearer │ · fleet/admin ops · pins │ + │ provision sec │ │ + └────────────────┴──────────────┬───────────────────────────┘ + │ edge reverse-proxy + │ (subdomain / X-Molecule-Org-Slug) + ▼ + ┌─────────────────────────────────────────┐ + Org API Key / ws tok │ Tenant workspace-server │ + │ │ molecule-core/workspace-server │ + └───────────────▶│ ONE EC2 per org @ .moleculesai.app│ + │ workspaces · secrets · templates · │ + │ org-tokens · bundles │ + └─────────────────────────────────────────┘ +``` + +- **Control plane (CP)** — `api.moleculesai.app`, routes modelled under + `/api/v1/*` (the `/cp/*` mirror is identical but sunset-headed per RFC #61 and + is not duplicated in the spec). Owns **orgs, members, billing, provisioning, + fleet/admin ops**. +- **Tenant workspace-server** — one EC2 per org at `.moleculesai.app`. + Owns **workspaces, agents, secrets, templates, org-tokens, bundles**. Requests + may also be sent to the CP host with an `X-Molecule-Org-Slug` header; the CP + edge reverse-proxies them to the tenant host (the `Authorization`, + `X-Molecule-Org-*`, and cookie headers pass through unchanged and the tenant's + own middleware validates them). + +The key consequence, called out in `PLATFORM-MANAGEMENT-API.md`: **the Org API +Key is a TENANT credential, not a CP one.** It is full tenant-admin over its own +org's workspace-server surface and reaches **nothing** on the CP (org +create/delete, billing, members, provisioning all 401/403 it). That is why +member/billing tools belong in a separate CP-admin MCP, not the org-key-authed +management MCP. + +## Security scheme → surface map (the tier matrix) + +`management.yaml` defines these `securitySchemes`; each operation declares the +one(s) it accepts. Mirror of `PLATFORM-MANAGEMENT-API.md` §1: + +| Scheme | What it is | Where it applies | +|--------|-----------|------------------| +| `workosSession` | WorkOS AuthKit session cookie `mcp_session` (+ org membership/ownership checks) | CP `/api/v1/orgs/*`, `/api/v1/billing/*`. Also accepted on the tenant surface via the CP-session path. | +| `cpAdminBearer` | CP `CP_ADMIN_API_TOKEN` operator bearer (AdminGate, constant-time) | CP `/api/v1/admin/*` — admin-create-org, tenant teardown, workspace env, ListOrgWorkspaces, redeploy, pins. | +| `provisionSecret` | CP `PROVISION_SHARED_SECRET` bearer | CP `/api/v1/workspaces/provision`, `…/status`. Routes unmounted when the secret is unset. | +| `tenantAdminToken` | Per-tenant admin_token (+ `X-Molecule-Org-Id`) | CP `DELETE /api/v1/workspaces/:id` (deprovision) — **in addition to** `provisionSecret` (issue #118). | +| `orgApiKey` | Tenant Org API Key — `Authorization: Bearer ` + routing header; full tenant-admin, self-minting | **All** tenant routes: `/workspaces[/:id]`, `/workspaces/:id/secrets`, budget, billing-mode, `/settings/secrets`, `/org/import`, `/org/templates`, `/org/tokens`, `/templates`, `/bundles`. | +| `workspaceToken` | Per-workspace bearer, bound to one workspace id (+ routing header) | Read/lifecycle/secrets on a single `/workspaces/:id/*`. **Rejected** on admin list/create/delete when ADMIN_TOKEN is set — use `orgApiKey`. | +| `orgRoutingHeaderId` / `orgRoutingHeaderSlug` | `X-Molecule-Org-Id` / `X-Molecule-Org-Slug` | Required on every tenant-host request so the edge / TenantGuard route + authorize against the correct org. Send one of them alongside the bearer. | + +### Guards worth knowing (modelled per-operation) + +- **Dry-run:** `POST /api/v1/admin/orgs?dry_run=true` — validate + echo, no org + created. (The only dry-run on the whole management API.) +- **Confirm token:** `DELETE /api/v1/admin/tenants/:slug` and + `…/scrub-artifacts` — body `confirm` MUST equal the URL slug, else `400` + before any teardown. +- **Force flag:** `POST /api/v1/admin/workspaces/:id/env` — keys matching the + secret-keyword guard (`TOKEN`/`SECRET`/`KEY`/`PASSWORD`) require `force=true`. +- **Runtime-pin gate:** `POST /api/v1/workspaces/provision` returns `422 + RUNTIME_PIN_MISSING` when no runtime image pin exists. +- **Auto-restart side-effects:** writing a workspace or global secret + auto-restarts the affected workspace(s). + +## Security note (carried from the synthesis spec) + +The Org API Key is **full tenant-admin and self-minting** — a management MCP +holding one holds tenant root. There is no scope-down today (TODO in +`orgtoken`). Per-role / per-workspace scoping should ship alongside the +management MCP. + +## Validate + +```bash +cd workspace-server/docs/openapi +npx @redocly/cli lint management.yaml # must be clean (0 errors, 0 warnings) +``` + +## Scope notes / best-effort flags + +- The per-workspace **runtime** surface (schedules, agent, registry, a2a, + memory, approvals, channels, terminal, files) is intentionally **out of + scope** here — that's the runtime contract, not management. +- A handful of bodies are **best-effort** from the handlers (org-import inline + template, bundle import, list responses with open shapes) and are marked with + `additionalProperties: true` in the schema. Tighten as the handler structs + stabilise. +- `/cp/*` deprecated mirrors are omitted (identical shapes; RFC #61 + Deprecation/Sunset). Build against `/api/v1/*`. diff --git a/workspace-server/docs/openapi/management.yaml b/workspace-server/docs/openapi/management.yaml new file mode 100644 index 00000000..3305396c --- /dev/null +++ b/workspace-server/docs/openapi/management.yaml @@ -0,0 +1,1851 @@ +openapi: 3.1.0 + +# ============================================================================= +# Molecule Platform — Management API (SSOT) +# +# This is the hand-authored, authoritative OpenAPI 3.1 contract for the +# Molecule platform MANAGEMENT surface. It is the single source of truth from +# which all management tooling derives: the management MCP server, the +# management CLI (molecule-cli), and the human-facing API docs (RFC #1706 / +# the gap closed by PLATFORM-MANAGEMENT-API.md §5c). +# +# It deliberately covers TWO services behind ONE spec (see README.md in this +# directory for the full split): +# +# 1. Control plane (CP) — molecule-controlplane @ api.moleculesai.app. +# Owns orgs, members, billing, provisioning, fleet/admin ops. +# Routes: /api/v1/* (stable) — the /cp/* mirror is sunset-headed and is +# NOT modelled here (identical shapes, RFC #61 Deprecation/Sunset headers). +# +# 2. Tenant workspace-server — molecule-core/workspace-server, ONE EC2 per +# org @ .moleculesai.app. Owns workspaces, secrets, templates, +# org-tokens, bundles. Reached either directly at the tenant host or via +# the CP edge reverse-proxy (subdomain or X-Molecule-Org-Slug routing). +# +# The existing swaggo-generated swagger.{json,yaml} in this directory only +# covers /schedules and is OpenAPI 2.0; this file supersedes it for the +# management surface. Schedules/agent/registry/a2a (the per-workspace RUNTIME +# surface) are intentionally out of scope here. +# +# Grounding: every path, body and security scheme below was read from the +# router + handler sources (controlplane internal/router/router.go + +# internal/handlers/*, workspace-server internal/router/router.go + +# internal/handlers/*). Where a body field is best-effort it is flagged. +# ============================================================================= + +info: + title: Molecule Platform Management API + version: "1.0.0" + description: | + Authoritative management contract for the Molecule platform. Two services, + two auth stacks, one spec. See `docs/openapi/README.md` for the + service-split + per-tier security matrix. MCP and CLI tooling are + generated/derived from this document — treat it as SSOT. + + **Security tiers (summary — full matrix in README):** + - `workosSession` — WorkOS AuthKit session cookie (`mcp_session`), + org-membership/ownership checks. CP org/member/billing surface. + - `cpAdminBearer` — `CP_ADMIN_API_TOKEN` operator bearer. CP `/api/v1/admin/*` fleet/tenant/pin/env surface. + - `provisionSecret` — CP `PROVISION_SHARED_SECRET` bearer (+ tenant admin_token for deprovision). CP `/api/v1/workspaces/provision|deprovision`. + - `orgApiKey` — tenant Org API Key (`Authorization: Bearer ` + `X-Molecule-Org-Id`). Full tenant-admin; CANNOT reach CP. + - `workspaceToken` — per-workspace bearer, bound to one workspace id (+ tenant routing header). + + **Guards:** mutating CP admin ops carry confirm/dry-run guards — flagged + per-operation. The Org API Key is full-tenant-admin and self-minting + (treat as tenant root; no scope-down yet — TODO in `orgtoken`). + contact: + name: Molecule AI Core Platform + url: https://git.moleculesai.app/molecule-ai/molecule-core + license: + name: Proprietary + identifier: LicenseRef-Molecule-AI-Proprietary + +servers: + - url: https://api.moleculesai.app + description: Control plane (CP). Org/member/billing/provisioning/admin surface under /api/v1. + - url: https://{slug}.moleculesai.app + description: | + Tenant workspace-server, one per org. Workspace/secret/template/org-token/ + bundle surface. Requests may also be sent to the CP host with an + X-Molecule-Org-Slug header; the CP edge reverse-proxies to this host. + variables: + slug: + default: agents-team + description: Org slug (subdomain segment). + +tags: + - name: cp-orgs + description: "[CP] Organization lifecycle (session-owned)." + - name: cp-billing + description: "[CP] Billing — invoices, checkout, portal, top-up (session-owned)." + - name: cp-admin + description: "[CP] Operator admin — org create, tenant teardown, workspace env, ListOrgWorkspaces, pins (admin bearer)." + - name: cp-provision + description: "[CP] Workspace EC2 provisioning (provision shared-secret)." + - name: tenant-workspaces + description: "[Tenant] Workspace lifecycle + secrets + budget + billing-mode." + - name: tenant-org + description: "[Tenant] Org import, templates, Org API Key (org-token) management." + - name: tenant-secrets + description: "[Tenant] Org-wide (global) secrets." + - name: tenant-bundles + description: "[Tenant] Bundle export/import." + +# ----------------------------------------------------------------------------- +# Security schemes — the five tiers from PLATFORM-MANAGEMENT-API.md §1. +# Each operation declares the scheme(s) it accepts under its own `security`. +# ----------------------------------------------------------------------------- +components: + securitySchemes: + workosSession: + type: apiKey + in: cookie + name: mcp_session + description: | + WorkOS AuthKit session cookie. Set by the CP /api/v1/auth/callback flow. + Handlers additionally enforce org_members ownership/membership, so a + generic "logged in" cookie cannot read another org's data. CP org, + member, and billing surface only — never authorizes CP admin or the + tenant surface. + cpAdminBearer: + type: http + scheme: bearer + description: | + Operator bearer = the CP `CP_ADMIN_API_TOKEN`. Gates every + /api/v1/admin/* route via AdminGate (constant-time compare). Also + accepts an admin-allowlisted WorkOS session, but server-to-server + callers (CI, ops scripts, the CP-admin MCP) use this bearer. Disabled + (routes still mounted, all 401) when the token is unset in prod. + provisionSecret: + type: http + scheme: bearer + description: | + CP `PROVISION_SHARED_SECRET` bearer. Gates /api/v1/workspaces/provision + (constant-time compare). The routes are not mounted at all when the + secret is unset (an unauth'd provision endpoint is an unauth RCE). + DELETE /workspaces/:id (deprovision) additionally requires a per-tenant + admin_token + X-Molecule-Org-Id (issue #118) — see `tenantAdminToken`. + tenantAdminToken: + type: http + scheme: bearer + description: | + Per-tenant admin_token, presented to CP deprovision alongside the + provision shared secret so a leaked shared secret can't terminate + OTHER tenants' workspaces (issue #118). Sent with X-Molecule-Org-Id. + orgApiKey: + type: http + scheme: bearer + description: | + Tenant Org API Key (dashboard "Org API Keys"; `org_api_tokens` — + sha256-hashed, prefixed, revocable, FULL tenant-admin, self-minting). + Authorizes the entire tenant-admin surface of its own org via the + tenant AdminAuth/WorkspaceAuth gates. Present as `Authorization: + Bearer ` to the tenant host. MUST be paired with the + `orgRoutingHeader` (X-Molecule-Org-Id or X-Molecule-Org-Slug) so the + platform edge routes to the correct tenant. CANNOT reach any CP route + (org create/delete, billing, members, provisioning all 401/403 it). + workspaceToken: + type: http + scheme: bearer + description: | + Per-workspace bearer token (`workspace_auth_tokens`), bound to a single + workspace id by the tenant WorkspaceAuth middleware — workspace A's + token cannot hit workspace B's sub-routes. Sent with the + `orgRoutingHeader`. Rejected on admin-list/create/delete routes when an + ADMIN_TOKEN is configured (use orgApiKey there). + orgRoutingHeaderId: + type: apiKey + in: header + name: X-Molecule-Org-Id + description: | + Tenant routing + TenantGuard match (org UUID). Required on tenant-host + requests so the CP edge / TenantGuard route to and authorize against + the correct org. Either this or X-Molecule-Org-Slug must be present. + orgRoutingHeaderSlug: + type: apiKey + in: header + name: X-Molecule-Org-Slug + description: | + Tenant routing header (human-readable slug, e.g. "agents-team"). + Alternative to X-Molecule-Org-Id; slug is preferred for client code. + + parameters: + OrgSlug: + name: slug + in: path + required: true + description: Org slug (subdomain segment, ^[a-z][a-z0-9-]{2,31}$). + schema: + type: string + pattern: "^[a-z][a-z0-9-]{2,31}$" + WorkspaceId: + name: id + in: path + required: true + description: Workspace UUID. + schema: + type: string + format: uuid + OrgIdHeader: + name: X-Molecule-Org-Id + in: header + required: false + description: Tenant routing/TenantGuard header (org UUID). Send this or X-Molecule-Org-Slug. + schema: + type: string + OrgSlugHeader: + name: X-Molecule-Org-Slug + in: header + required: false + description: Tenant routing header (slug). Send this or X-Molecule-Org-Id. + schema: + type: string + + responses: + BadRequest: + description: Invalid request body or parameters. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Missing or invalid credential for the required tier. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Authenticated but not authorized (wrong tier, or not a member/owner). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Resource not found (also returned to avoid leaking existence). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Slug taken / resource already exists. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + Error: + type: object + properties: + error: + type: string + description: Human-readable error message. + required: [error] + + # ---- CP: orgs ---- + Organization: + type: object + description: One paying customer. Slug is immutable after creation. + properties: + id: { type: string, format: uuid } + slug: { type: string } + name: { type: string } + plan: { type: string } + status: { type: string, description: "e.g. active | provisioning | suspended | deleted" } + stripe_customer_id: { type: string } + credits_balance: { type: integer, format: int64 } + plan_monthly_credits: { type: integer, format: int64 } + overage_used_credits: { type: integer, format: int64 } + overage_cap_credits: { type: integer, format: int64 } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, slug, name, plan, status] + + CreateOrgRequest: + type: object + description: Session-authed org creation (POST /api/v1/orgs). + properties: + slug: { type: string, pattern: "^[a-z][a-z0-9-]{2,31}$" } + name: { type: string } + required: [slug, name] + + AdminCreateOrgRequest: + type: object + description: | + Admin server-to-server org creation. Strict-decoded — unknown fields + are a 400 (a stale `is_staging` key would otherwise silently provision + a prod org). Skips terms/quota/billing gates. + additionalProperties: false + properties: + slug: { type: string, pattern: "^[a-z][a-z0-9-]{2,31}$" } + name: { type: string } + owner_user_id: + type: string + description: Opaque membership owner. Convention "e2e-runner:" for CI orgs. + required: [slug, name, owner_user_id] + + AdminCreateOrgDryRun: + type: object + description: Response when POST /api/v1/admin/orgs?dry_run=true. + properties: + dry_run: { type: boolean, const: true } + slug: { type: string } + name: { type: string } + owner_user_id: { type: string } + + AdminTenantTokenResponse: + type: object + properties: + slug: { type: string } + admin_token: { type: string, description: "Per-tenant admin token (sensitive)." } + + ProvisionStatus: + type: object + description: Org provisioning progress (GET /api/v1/orgs/:slug/provision-status). + properties: + status: { type: string, enum: [provisioning, running, failed] } + stage: { type: string, enum: [launching, installing, starting, configuring_https, ready] } + progress: { type: integer, minimum: 0, maximum: 100 } + message: { type: string } + eta_seconds: { type: integer } + url: { type: string } + required: [status, stage, progress, message, eta_seconds] + + OrgExport: + type: object + description: GDPR data-portability export (best-effort field list). + properties: + schema_version: { type: string } + exported_at: { type: string, format: date-time } + exported_by: { type: string, description: "Requesting session user id." } + subject: + type: object + properties: + user_id: { type: string } + organization: + $ref: "#/components/schemas/Organization" + infrastructure: + type: object + description: Omitted for failed-provision orgs. + additionalProperties: true + members: + type: array + items: { type: object, additionalProperties: true } + notes: + type: object + additionalProperties: true + + DeleteTenantRequest: + type: object + description: Confirm-gated admin tenant teardown. `confirm` must equal the URL slug. + properties: + confirm: + type: string + description: Must exactly equal the path slug or the request 400s before any teardown. + required: [confirm] + + UpdateWorkspaceEnvRequest: + type: object + description: | + Admin env mutation for a running workspace (SSM + restart). Keys + matching the secret-keyword guard (TOKEN/SECRET/KEY/PASSWORD) require + force=true. Prefer Infisical per-persona paths for real secrets. + properties: + env: + type: object + additionalProperties: { type: string } + description: At least one key required. + merge: + type: boolean + default: true + description: true = merge into existing env; false = replace (destructive). + force: + type: boolean + default: false + description: Bypass the secret-keyword guard. + required: [env] + + UpdateWorkspaceEnvResponse: + type: object + properties: + ok: { type: boolean } + workspace_id: { type: string } + instance_id: { type: string } + ssm_status: { type: string } + ssm_exit_code: { type: integer, format: int32 } + container_restarted: { type: boolean } + applied_keys: { type: array, items: { type: string } } + env_keys_after: { type: array, items: { type: string } } + + PinPromoteRequest: + type: object + description: | + Generic pin-table promote body. Shape is per-resource (thin-ami: + region+ami_id; runtime-image: template+digest). `promoted_by` is filled + server-side from the admin actor. additionalProperties allowed because + the dispatcher validates per-resource. + additionalProperties: true + + # ---- CP: provisioning ---- + WorkspaceProvisionRequest: + type: object + description: Provision a workspace EC2 instance (called by tenant platforms). + properties: + org_id: { type: string, format: uuid } + workspace_id: { type: string, format: uuid } + runtime: { type: string, description: "claude-code | codex | langgraph | ..." } + tier: { type: integer } + instance_type: { type: string } + disk_gb: { type: integer, format: int32 } + platform_url: { type: string, format: uri } + env: + type: object + additionalProperties: { type: string } + config_files: + type: object + additionalProperties: { type: string } + display: + type: object + properties: + mode: { type: string } + width: { type: integer } + height: { type: integer } + protocol: { type: string } + required: [org_id, workspace_id, runtime, platform_url] + + WorkspaceProvisionResponse: + type: object + properties: + instance_id: { type: string } + public_ip: { type: string } + private_ip: { type: string } + state: { type: string } + runtime: { type: string } + domain: { type: string } + + WorkspaceProvisionStatus: + type: object + properties: + instance_id: { type: string } + state: { type: string } + public_ip: { type: string } + private_ip: { type: string } + + RuntimePinMissing: + type: object + description: 422 when no runtime image pin exists for (runtime). + properties: + error: { type: string, const: RUNTIME_PIN_MISSING } + required: [error] + + # ---- Tenant: workspaces ---- + CreateWorkspaceRequest: + type: object + description: | + Tenant workspace creation (POST /workspaces, AdminAuth). Best-effort — + full struct is models.CreateWorkspacePayload. Only `name` is required. + properties: + name: { type: string } + role: { type: string } + template: { type: string, description: "workspace-configs-templates folder name." } + tier: { type: integer } + model: { type: string } + llm_provider: { type: string } + runtime: { type: string, description: "Default claude-code; derived from template if empty." } + external: { type: boolean, description: "true = registered URL only, no Docker container." } + url: { type: string, description: "External A2A endpoint (push mode)." } + delivery_mode: { type: string, enum: [push, poll] } + workspace_dir: { type: string } + workspace_access: { type: string, enum: [none, read_only, read_write] } + parent_id: { type: [string, "null"], format: uuid } + budget_limit: + type: [integer, "null"] + format: int64 + description: Monthly spend ceiling in USD cents; null = no limit. + secrets: + type: object + additionalProperties: { type: string } + description: Optional key→value secrets persisted (encrypted) at creation. + max_concurrent_tasks: { type: integer } + required: [name] + + Workspace: + type: object + description: A workspace row (best-effort — see models.Workspace). + properties: + id: { type: string, format: uuid } + name: { type: string } + role: { type: [string, "null"] } + tier: { type: integer } + runtime: { type: string } + status: { type: string } + parent_id: { type: [string, "null"], format: uuid } + additionalProperties: true + + BudgetResponse: + type: object + properties: + budget_limit: { type: [integer, "null"], format: int64, description: "USD cents; null = no limit." } + monthly_spend: { type: integer, format: int64 } + budget_remaining: { type: [integer, "null"], format: int64 } + + PatchBudgetRequest: + type: object + description: budget_limit required; null clears the limit, integer (USD cents, >=0) sets it. + properties: + budget_limit: { type: [integer, "null"], format: int64 } + required: [budget_limit] + + BillingModeResponse: + type: object + properties: + mode: { type: string, enum: [platform_managed, byok, disabled] } + effective_mode: { type: string } + source: { type: string, description: "workspace | org | default" } + additionalProperties: true + + PutBillingModeRequest: + type: object + description: | + {"mode":"byok"} sets the override; {"mode":null} clears it; {} is a 400 + (caller must be explicit). + properties: + mode: + type: [string, "null"] + enum: [platform_managed, byok, disabled, null] + required: [mode] + + # ---- Tenant: secrets ---- + SecretSummary: + type: object + description: Secret metadata (keys only — values never returned by List). + properties: + key: { type: string } + created_at: { type: string } + updated_at: { type: string } + source: { type: string, description: "workspace | global override info." } + additionalProperties: true + + SetSecretRequest: + type: object + description: | + Upserts AES-256-GCM, emits secret.set audit, auto-restarts the + workspace. platform-managed billing strips vendor-LLM keys unless + billing-mode is byok; GIT_HTTP_* are never stripped. + properties: + key: { type: string } + value: { type: string } + required: [key, value] + + # ---- Tenant: org tokens (Org API Keys) ---- + CreateOrgTokenRequest: + type: object + description: Optional body — an empty POST mints an unnamed token. + properties: + name: { type: string, maxLength: 100 } + + CreateOrgTokenResponse: + type: object + description: Plaintext token returned exactly ONCE. + properties: + id: { type: string } + prefix: { type: string } + name: { type: string } + auth_token: { type: string, description: "Plaintext — shown once; copy now." } + warning: { type: string } + required: [id, prefix, auth_token] + + OrgTokenList: + type: object + properties: + tokens: + type: array + items: + type: object + properties: + id: { type: string } + prefix: { type: string } + name: { type: string } + created_at: { type: string } + additionalProperties: true + count: { type: integer } + + # ---- Tenant: org import / templates / bundles ---- + OrgImportRequest: + type: object + description: | + Create workspaces from an org template. Provide `dir` (server-side org + template dir, traversal-guarded) OR an inline `template`. `mode` + controls cleanup of pre-existing workspaces. + properties: + dir: { type: string, description: "Org template directory name." } + template: + type: object + description: Inline OrgTemplate (best-effort; see OrgTemplate YAML schema). + additionalProperties: true + mode: { type: string, enum: ["", merge, reconcile], default: merge } + + TemplateImportRequest: + type: object + description: Import a workspace template (writes config files into configsDir). + properties: + name: { type: string } + files: + type: object + additionalProperties: { type: string } + required: [name, files] + + BundleImportRequest: + type: object + description: Import a workspace bundle (CRITICAL surface — admin-gated). Best-effort body. + properties: + name: { type: string } + additionalProperties: true + required: [name] + +# ============================================================================= +# Paths +# ============================================================================= +paths: + + # ------------------------------------------------------------------ CP: ORGS + /api/v1/orgs: + post: + tags: [cp-orgs] + summary: Create an organization (session-owned) + operationId: createOrg + security: [{ workosSession: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateOrgRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "402": + description: Quota/credit gate — payment required. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "409": { $ref: "#/components/responses/Conflict" } + "412": + description: Terms of Service not yet accepted. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + get: + tags: [cp-orgs] + summary: List the caller's organizations + operationId: listOrgs + security: [{ workosSession: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Organization" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/orgs/{slug}: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-orgs] + summary: Get an organization (membership-checked) + operationId: getOrg + security: [{ workosSession: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [cp-orgs] + summary: Delete an organization (owner-only GDPR purge cascade) + description: | + Owner-only. Cascade purges Stripe + EC2 + Cloudflare + DB rows, writes + an org_purges audit row. Returns 204 on success. Irreversible. + operationId: deleteOrg + security: [{ workosSession: [] }] + responses: + "204": { description: Deleted. } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/orgs/{slug}/export: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-orgs] + summary: Export an organization (GDPR data portability) + operationId: exportOrg + security: [{ workosSession: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/OrgExport" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/orgs/{slug}/provision-status: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-orgs] + summary: Org provisioning progress + operationId: getProvisionStatus + security: [{ workosSession: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/ProvisionStatus" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/orgs/{slug}/instance: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-orgs] + summary: Public tenant-routing lookup (NO AUTH) + description: | + Public, unauthenticated routing lookup used by the Cloudflare Worker. + Returns {slug, status, ip, org_id}. Rate-limited to prevent + enumeration. (This is the "200 with no key" in the audit — not the Org + API Key.) + operationId: getOrgInstance + security: [] + responses: + "200": + description: OK. + content: + application/json: + schema: + type: object + properties: + slug: { type: string } + status: { type: string } + ip: { type: string } + org_id: { type: string } + additionalProperties: true + "404": { $ref: "#/components/responses/NotFound" } + + # ---------------------------------------------------------------- CP: BILLING + /api/v1/billing/invoices: + get: + tags: [cp-billing] + summary: List invoices + operationId: listInvoices + security: [{ workosSession: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/billing/checkout: + post: + tags: [cp-billing] + summary: Create a Stripe Checkout session + operationId: billingCheckout + security: [{ workosSession: [] }] + requestBody: + required: false + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": + description: OK (checkout url). + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/billing/portal: + post: + tags: [cp-billing] + summary: Create a Stripe billing-portal session + operationId: billingPortal + security: [{ workosSession: [] }] + responses: + "200": + description: OK (portal url). + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/billing/topup: + post: + tags: [cp-billing] + summary: Credit top-up + operationId: billingTopup + security: [{ workosSession: [] }] + requestBody: + required: false + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": + description: OK. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + + # ------------------------------------------------------------------ CP: ADMIN + /api/v1/admin/orgs: + get: + tags: [cp-admin] + summary: List all organizations (operator) + operationId: adminListOrgs + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/Organization" } } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [cp-admin] + summary: Admin-create an organization (server-to-server) + description: | + Skips terms/quota/billing gates. Strict-decoded (unknown fields → 400). + **Dry-run guard:** `?dry_run=true` validates + echoes without creating. + operationId: adminCreateOrg + security: [{ cpAdminBearer: [] }] + parameters: + - name: dry_run + in: query + required: false + description: When true, validate + echo only; no org created. + schema: { type: boolean } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AdminCreateOrgRequest" } + responses: + "200": + description: Dry-run echo (when ?dry_run=true). + content: + application/json: + schema: { $ref: "#/components/schemas/AdminCreateOrgDryRun" } + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + + /api/v1/admin/orgs/{slug}/admin-token: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-admin] + summary: Get the per-tenant admin token (operator) + operationId: adminGetTenantToken + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/AdminTenantTokenResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Tenant admin token not yet provisioned. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/admin/orgs/{slug}/workspaces: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-admin] + summary: List a tenant's workspaces + health (CP-side proxy) + description: | + CP proxies to the tenant's admin GET /workspaces so ops scripts get one + CP surface without holding per-tenant tokens or setting the WAF Origin + header. 502 on tenant-layer failure (carries http_status); 503 when the + WorkspaceLister is unwired. + operationId: adminListOrgWorkspaces + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "502": + description: Tenant-layer failure (auth/network/parse) — carries http_status. + content: + application/json: + schema: { type: object, additionalProperties: true } + "503": + description: WorkspaceLister not configured on this CP. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/admin/tenants/{slug}: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + delete: + tags: [cp-admin] + summary: Admin-delete a tenant (confirm-gated teardown) + description: | + **Confirm guard:** body `confirm` MUST equal the URL slug or the + request 400s before any teardown. Reuses the executeOrgPurge cascade + (Stripe + EC2 + CF + DB rows + audit). 503 when no provisioner is wired. + operationId: adminDeleteTenant + security: [{ cpAdminBearer: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeleteTenantRequest" } + responses: + "200": + description: Teardown completed. + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": + description: Confirm token missing or != slug. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + "503": + description: Provisioner not configured in this environment. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/admin/tenants/{slug}/scrub-artifacts: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + post: + tags: [cp-admin] + summary: Scrub tenant artifacts (destructive, confirm-gated) + description: "**Confirm guard:** confirm-token body must equal the URL slug." + operationId: adminScrubTenantArtifacts + security: [{ cpAdminBearer: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeleteTenantRequest" } + responses: + "200": + description: Scrubbed. + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/admin/tenants/{slug}/diagnostics: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + get: + tags: [cp-admin] + summary: Tenant diagnostics (read-only) + operationId: adminTenantDiagnostics + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/admin/tenants/{slug}/redeploy: + parameters: [{ $ref: "#/components/parameters/OrgSlug" }] + post: + tags: [cp-admin] + summary: Redeploy a single tenant container + operationId: adminRedeployTenant + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: Redeploy dispatched. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + "503": + description: Redeployer not wired (dev CP without AWS SSM). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/admin/tenants/redeploy-fleet: + post: + tags: [cp-admin] + summary: Redeploy the whole tenant fleet (post-merge CI) + operationId: adminRedeployFleet + security: [{ cpAdminBearer: [] }] + requestBody: + required: false + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": + description: Fleet rollout dispatched. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/admin/workspaces/{id}/env: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + post: + tags: [cp-admin] + summary: Mutate a running workspace's env (SSM + restart) + description: | + Merge (default) or replace env on a running workspace via SSM, then + restart. **Force guard:** keys matching TOKEN/SECRET/KEY/PASSWORD + require force=true. 503 when SSM/EC2 wiring is absent. + operationId: adminUpdateWorkspaceEnv + security: [{ cpAdminBearer: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/UpdateWorkspaceEnvRequest" } + responses: + "200": + description: Applied. + content: + application/json: + schema: { $ref: "#/components/schemas/UpdateWorkspaceEnvResponse" } + "400": + description: Bad body, or secret-keyword guard hit without force=true. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "503": + description: SSM/EC2 wiring not present. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/admin/thin-ami/promote: + post: + tags: [cp-admin] + summary: Promote a thin-AMI pin (host AMI per region) + operationId: adminPromoteThinAmi + security: [{ cpAdminBearer: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PinPromoteRequest" } + responses: + "200": + description: Promoted. + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + get: + tags: [cp-admin] + summary: List thin-AMI pins + operationId: adminListThinAmi + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/admin/runtime-image/promote: + post: + tags: [cp-admin] + summary: Promote a runtime-image pin (per-template GHCR/ECR digest) + operationId: adminPromoteRuntimeImage + security: [{ cpAdminBearer: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PinPromoteRequest" } + responses: + "200": + description: Promoted. + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + get: + tags: [cp-admin] + summary: List runtime-image pins + operationId: adminListRuntimeImage + security: [{ cpAdminBearer: [] }] + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Unauthorized" } + + # ------------------------------------------------------------- CP: PROVISION + /api/v1/workspaces/provision: + post: + tags: [cp-provision] + summary: Provision a workspace EC2 instance + description: | + Called by tenant platforms (server-to-server). SSRF + provider-env + + runtime-pin gated. **Returns 422 RUNTIME_PIN_MISSING** when no runtime + image pin exists. Mounted only when PROVISION_SHARED_SECRET is set. + operationId: provisionWorkspace + security: [{ provisionSecret: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceProvisionRequest" } + responses: + "201": + description: Provisioned. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceProvisionResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": + description: RUNTIME_PIN_MISSING — no runtime image pin for this runtime. + content: + application/json: + schema: { $ref: "#/components/schemas/RuntimePinMissing" } + + /api/v1/workspaces/{workspace_id}: + parameters: + - name: workspace_id + in: path + required: true + schema: { type: string, format: uuid } + delete: + tags: [cp-provision] + summary: Deprovision (terminate) a workspace EC2 + DNS + description: | + Requires the provision shared secret AND a per-tenant admin_token + + X-Molecule-Org-Id (issue #118) so a leaked shared secret can't kill + other tenants' workspaces. `?prune=` controls data-volume pruning. + operationId: deprovisionWorkspace + security: + - provisionSecret: [] + tenantAdminToken: [] + orgRoutingHeaderId: [] + parameters: + - name: prune + in: query + required: false + schema: { type: boolean } + responses: + "200": + description: Terminated. + content: + application/json: + schema: + type: object + properties: + status: { type: string, const: terminated } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/workspaces/{workspace_id}/status: + parameters: + - name: workspace_id + in: path + required: true + schema: { type: string, format: uuid } + get: + tags: [cp-provision] + summary: Workspace instance status + operationId: getProvisionWorkspaceStatus + security: [{ provisionSecret: [] }] + parameters: + - name: instance_id + in: query + required: true + schema: { type: string } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceProvisionStatus" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + # ----------------------------------------------------- TENANT: WORKSPACES + /workspaces: + get: + tags: [tenant-workspaces] + summary: List workspaces in the org (tenant-admin) + operationId: tenantListWorkspaces + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workosSession: [] + orgRoutingHeaderSlug: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/Workspace" } } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [tenant-workspaces] + summary: Create a workspace (tenant-admin) + description: | + AdminAuth. Rejected for a bare per-workspace token when ADMIN_TOKEN is + set — use the Org API Key. POST/secrets-at-create auto-restart applies. + operationId: tenantCreateWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workosSession: [] + orgRoutingHeaderSlug: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateWorkspaceRequest" } + responses: + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Workspace" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /workspaces/{id}: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + get: + tags: [tenant-workspaces] + summary: Get a workspace + operationId: tenantGetWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/Workspace" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [tenant-workspaces] + summary: Delete a workspace (cascade; tenant-admin) + operationId: tenantDeleteWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: Deleted. + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /workspaces/{id}/restart: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + post: + tags: [tenant-workspaces] + summary: Restart a workspace + operationId: tenantRestartWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": { description: Restart dispatched. } + "401": { $ref: "#/components/responses/Unauthorized" } + + /workspaces/{id}/pause: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + post: + tags: [tenant-workspaces] + summary: Pause a workspace (stops container) + operationId: tenantPauseWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": { description: Paused. } + "401": { $ref: "#/components/responses/Unauthorized" } + + /workspaces/{id}/resume: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + post: + tags: [tenant-workspaces] + summary: Resume a paused workspace + operationId: tenantResumeWorkspace + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": { description: Resumed. } + "401": { $ref: "#/components/responses/Unauthorized" } + + /workspaces/{id}/budget: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + get: + tags: [tenant-workspaces] + summary: Get workspace budget + spend + operationId: tenantGetBudget + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/BudgetResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [tenant-workspaces] + summary: Set workspace budget limit (tenant-admin) + operationId: tenantPatchBudget + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PatchBudgetRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/BudgetResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /admin/workspaces/{id}/llm-billing-mode: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + get: + tags: [tenant-workspaces] + summary: Get workspace LLM billing mode + operationId: tenantGetBillingMode + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/BillingModeResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + put: + tags: [tenant-workspaces] + summary: Set/clear workspace LLM billing mode (tenant-admin) + description: '{"mode":"byok"} sets override; {"mode":null} clears; {} → 400.' + operationId: tenantPutBillingMode + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PutBillingModeRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/BillingModeResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # ------------------------------------------------------ TENANT: WS SECRETS + /workspaces/{id}/secrets: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + get: + tags: [tenant-workspaces] + summary: List workspace secrets (keys only) + operationId: tenantListWorkspaceSecrets + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK (values masked). + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/SecretSummary" } } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [tenant-workspaces] + summary: Set a workspace secret (auto-restarts the workspace) + description: | + THE secret/env lever. Upserts AES-256-GCM, emits secret.set audit, + auto-restarts the workspace. platform-managed billing strips vendor-LLM + keys unless billing-mode=byok; GIT_HTTP_* never stripped. + operationId: tenantSetWorkspaceSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetSecretRequest" } + responses: + "200": { description: Set. } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + put: + tags: [tenant-workspaces] + summary: Upsert a workspace secret (alias of POST) + operationId: tenantPutWorkspaceSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetSecretRequest" } + responses: + "200": { description: Set. } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /workspaces/{id}/secrets/{key}: + parameters: + - { $ref: "#/components/parameters/WorkspaceId" } + - name: key + in: path + required: true + schema: { type: string } + delete: + tags: [tenant-workspaces] + summary: Delete a workspace secret (auto-restarts) + operationId: tenantDeleteWorkspaceSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + - workspaceToken: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": { description: Deleted. } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # -------------------------------------------------- TENANT: GLOBAL SECRETS + /settings/secrets: + get: + tags: [tenant-secrets] + summary: List org-wide (global) secrets (keys only) + operationId: tenantListGlobalSecrets + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK (values masked). + content: + application/json: + schema: { type: array, items: { $ref: "#/components/schemas/SecretSummary" } } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [tenant-secrets] + summary: Set an org-wide secret (fan-out auto-restart) + description: | + Sets a global secret; auto-restarts every non-paused/non-removed/ + non-external workspace that doesn't shadow the key (issue #15). + operationId: tenantSetGlobalSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetSecretRequest" } + responses: + "200": { description: Set. } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + put: + tags: [tenant-secrets] + summary: Upsert an org-wide secret (alias of POST) + operationId: tenantPutGlobalSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetSecretRequest" } + responses: + "200": { description: Set. } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /settings/secrets/{key}: + parameters: + - name: key + in: path + required: true + schema: { type: string } + delete: + tags: [tenant-secrets] + summary: Delete an org-wide secret (fan-out auto-restart) + operationId: tenantDeleteGlobalSecret + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": { description: Deleted. } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # ------------------------------------------------- TENANT: ORG TOKENS / KEYS + /org/tokens: + get: + tags: [tenant-org] + summary: List Org API Keys (prefix + metadata) + operationId: tenantListOrgTokens + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/OrgTokenList" } + "401": { $ref: "#/components/responses/Unauthorized" } + post: + tags: [tenant-org] + summary: Mint an Org API Key (full tenant-admin; plaintext shown once) + description: | + Mints a new Org API Key. Plaintext is returned exactly ONCE. The key is + full-tenant-admin and can itself mint/revoke more keys (treat as tenant + root). Empty body mints an unnamed token. + operationId: tenantCreateOrgToken + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/CreateOrgTokenRequest" } + responses: + "200": + description: Minted (plaintext shown once). + content: + application/json: + schema: { $ref: "#/components/schemas/CreateOrgTokenResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /org/tokens/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string } + delete: + tags: [tenant-org] + summary: Revoke an Org API Key + operationId: tenantRevokeOrgToken + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: Revoked. + content: + application/json: + schema: + type: object + properties: + revoked: { type: string } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + # ------------------------------------------------- TENANT: ORG IMPORT / TMPL + /org/import: + post: + tags: [tenant-org] + summary: Create workspaces from an org template (tenant-admin) + description: | + AdminAuth. Provide `dir` (server-side org template, traversal-guarded) + OR an inline `template`. `mode=reconcile` cascade-deletes zombie + workspaces left by a prior import. + operationId: tenantOrgImport + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OrgImportRequest" } + responses: + "200": + description: Imported (workspace result set). + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /org/templates: + get: + tags: [tenant-org] + summary: List available org templates (tenant-admin) + operationId: tenantListOrgTemplates + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Unauthorized" } + + /templates: + get: + tags: [tenant-org] + summary: List workspace templates (tenant-admin) + operationId: tenantListTemplates + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK. + content: + application/json: + schema: { type: array, items: { type: object, additionalProperties: true } } + "401": { $ref: "#/components/responses/Unauthorized" } + + /templates/import: + post: + tags: [tenant-org] + summary: Import a workspace template (writes config files; tenant-admin) + operationId: tenantImportTemplate + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TemplateImportRequest" } + responses: + "201": + description: Imported. + content: + application/json: + schema: + type: object + properties: + status: { type: string } + id: { type: string } + name: { type: string } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + + # ----------------------------------------------------------- TENANT: BUNDLES + /bundles/export/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string } + get: + tags: [tenant-bundles] + summary: Export a workspace bundle (tenant-admin) + description: HIGH-sensitivity — full system prompts + memory. Admin-gated (#165). + operationId: tenantExportBundle + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + responses: + "200": + description: OK (bundle). + content: + application/json: + schema: { type: object, additionalProperties: true } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + + /bundles/import: + post: + tags: [tenant-bundles] + summary: Import a workspace bundle (tenant-admin) + description: CRITICAL — anon creation of arbitrary workspaces if unguarded. Admin-gated (#164). + operationId: tenantImportBundle + security: + - orgApiKey: [] + orgRoutingHeaderId: [] + parameters: + - { $ref: "#/components/parameters/OrgIdHeader" } + - { $ref: "#/components/parameters/OrgSlugHeader" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BundleImportRequest" } + responses: + "201": + description: Imported. + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } -- 2.52.0 From 8cea4a30c4ce4ae7200433a4ea535cc598002c86 Mon Sep 17 00:00:00 2001 From: Molecule AI core-be Date: Sun, 31 May 2026 22:29:07 -0700 Subject: [PATCH 2/2] docs(openapi): apply Five-Axis review fixes to management spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each against the authoritative handler source (molecule-core workspace-server + molecule-controlplane) before editing: 1. tenantAdminToken: http/bearer -> apiKey header X-Molecule-Admin-Token. authenticateTenant (controlplane workspace_provision.go) reads that header, NOT Authorization, and derives org from the token (SELECT org_id ... WHERE admin_token=$1). Removed orgRoutingHeaderId from the DELETE /api/v1/workspaces/{workspace_id} security — no X-Molecule-Org-Id is read on deprovision. 2. ProvisionStatus.stage: added `failed` (emitted by orgs.go on failed/deprovisioning/deprovisioned). Existing launching/installing/ starting/configuring_https/ready all confirmed emitted by orgs_progress.go + estimateBootProgress — none trimmed. 3. GET /workspaces/{id}: set security: [] — router.go registers it outside every auth group (intentionally open for canvas-node self- polling). Dropped the now-inapplicable 401. 4. Multi-period budget shape: added `budget_limits` (canonical) + legacy `budget_limit` to PatchBudgetRequest, and `periods` (+ PeriodBudget) to BudgetResponse, matching budget.go budgetResponse/PatchBudget. 5. GET tenant llm-billing-mode already modeled (handler serves GET+PUT) — no change needed; verified. 6. Added prune=true destructive note (only literal "true" permanently deletes, internal#734) and the CP-admin /api/v1/admin/workspaces/{id}/llm-billing-mode GET+PUT pair (cpAdminBearer, requires ?org_slug=). redocly lint clean under both recommended and recommended-strict. Co-Authored-By: Claude Opus 4.8 (1M context) --- workspace-server/docs/openapi/management.yaml | 186 ++++++++++++++++-- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/workspace-server/docs/openapi/management.yaml b/workspace-server/docs/openapi/management.yaml index 3305396c..359e9289 100644 --- a/workspace-server/docs/openapi/management.yaml +++ b/workspace-server/docs/openapi/management.yaml @@ -125,12 +125,18 @@ components: DELETE /workspaces/:id (deprovision) additionally requires a per-tenant admin_token + X-Molecule-Org-Id (issue #118) — see `tenantAdminToken`. tenantAdminToken: - type: http - scheme: bearer + type: apiKey + in: header + name: X-Molecule-Admin-Token description: | Per-tenant admin_token, presented to CP deprovision alongside the provision shared secret so a leaked shared secret can't terminate - OTHER tenants' workspaces (issue #118). Sent with X-Molecule-Org-Id. + OTHER tenants' workspaces (issue #118). Carried in the + `X-Molecule-Admin-Token` header (NOT Authorization) — the handler + (controlplane internal/handlers/workspace_provision.go + authenticateTenant) reads this header and derives the org from the + token (SELECT org_id FROM org_instances WHERE admin_token = $1), so no + X-Molecule-Org-Id is needed on deprovision. orgApiKey: type: http scheme: bearer @@ -303,7 +309,14 @@ components: description: Org provisioning progress (GET /api/v1/orgs/:slug/provision-status). properties: status: { type: string, enum: [provisioning, running, failed] } - stage: { type: string, enum: [launching, installing, starting, configuring_https, ready] } + stage: + type: string + description: | + UI checklist step. `failed` is emitted on a failed/deprovisioning/ + deprovisioned instance; `ready` on a healthy instance; the rest are + the boot-progress stages (controlplane internal/handlers/orgs.go + + orgs_progress.go bootProgressOrder/estimateBootProgress). + enum: [launching, installing, starting, configuring_https, ready, failed] progress: { type: integer, minimum: 0, maximum: 100 } message: { type: string } eta_seconds: { type: integer } @@ -481,19 +494,60 @@ components: parent_id: { type: [string, "null"], format: uuid } additionalProperties: true + PeriodBudget: + type: object + description: | + Per-period budget view (workspace-server budget.go periodBudget): + configured ceiling (null = no limit), rolling-window spend, and + remaining headroom (null when no limit; may go negative). + properties: + limit: { type: [integer, "null"], format: int64, description: "USD cents; null = no limit." } + spend: { type: integer, format: int64, description: "Rolling-window spend, USD cents." } + remaining: { type: [integer, "null"], format: int64, description: "limit - spend; null when no limit; may be negative." } + required: [spend] + BudgetResponse: type: object + description: | + Canonical budget view (workspace-server budget.go budgetResponse), same + shape for GET and PATCH. `periods` is the multi-period SSOT; the + top-level `budget_limit`/`monthly_spend`/`budget_remaining` are + back-compat mirrors of the monthly period for pre-multi-period clients. properties: - budget_limit: { type: [integer, "null"], format: int64, description: "USD cents; null = no limit." } - monthly_spend: { type: integer, format: int64 } - budget_remaining: { type: [integer, "null"], format: int64 } + periods: + type: object + description: | + Per-period budgets keyed by period name. Canonical multi-period + view (internal#734 / budget_periods.go). + additionalProperties: { $ref: "#/components/schemas/PeriodBudget" } + propertyNames: { enum: [hourly, daily, weekly, monthly] } + budget_limit: { type: [integer, "null"], format: int64, description: "Back-compat: monthly limit (USD cents); null = no limit." } + monthly_spend: { type: integer, format: int64, description: "Back-compat: monthly rolling spend (USD cents)." } + budget_remaining: { type: [integer, "null"], format: int64, description: "Back-compat: monthly remaining (USD cents); null = no limit." } PatchBudgetRequest: type: object - description: budget_limit required; null clears the limit, integer (USD cents, >=0) sets it. + description: | + Set workspace budget limits. `budget_limits` is canonical (multi-period + map); legacy scalar `budget_limit` is kept for back-compat. Exactly one + of the two must be present (an empty body is a 400). A per-period value + of null/absent clears that period; a non-negative int (USD cents) sets + it. When `budget_limit` is used, it sets the monthly period and is kept + synced to the budget_limits monthly key server-side. + minProperties: 1 properties: - budget_limit: { type: [integer, "null"], format: int64 } - required: [budget_limit] + budget_limits: + type: object + description: | + CANONICAL. Period → USD-cents-or-null. Allowed keys: hourly, daily, + weekly, monthly (an unknown key is a 400). + additionalProperties: { type: [integer, "null"], format: int64, minimum: 0 } + propertyNames: { enum: [hourly, daily, weekly, monthly] } + budget_limit: + type: [integer, "null"] + format: int64 + minimum: 0 + description: "Legacy single-monthly limit (USD cents, >=0); null clears it." BillingModeResponse: type: object @@ -1119,6 +1173,91 @@ paths: schema: { type: array, items: { type: object, additionalProperties: true } } "401": { $ref: "#/components/responses/Unauthorized" } + /api/v1/admin/workspaces/{id}/llm-billing-mode: + parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] + get: + tags: [cp-admin] + summary: Get a workspace LLM billing mode (CP-admin proxy) + description: | + CP-side operator proxy to the per-tenant + /admin/workspaces/:id/llm-billing-mode. **Requires `?org_slug=`** — CP + has no workspace_id→org_id index, so the slug tells CP which tenant to + dispatch to (controlplane internal/handlers/admin_workspace_billing_mode.go). + operationId: adminGetWorkspaceBillingMode + security: [{ cpAdminBearer: [] }] + parameters: + - name: org_slug + in: query + required: true + description: Org slug — resolves which tenant CP proxies to. + schema: { type: string, pattern: "^[a-z][a-z0-9-]{2,31}$" } + responses: + "200": + description: OK. + content: + application/json: + schema: { $ref: "#/components/schemas/BillingModeResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Slug doesn't resolve to an org. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "502": + description: Per-tenant call failed (auth/network/parse). + content: + application/json: + schema: { type: object, additionalProperties: true } + "503": + description: Billing-mode client unwired on this CP (dev / no Secrets). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + put: + tags: [cp-admin] + summary: Set/clear a workspace LLM billing mode (CP-admin proxy) + description: | + CP-side operator proxy. **Requires `?org_slug=`.** Body + {"mode": "byok"|"platform_managed"|"disabled"|null}; mode is validated + against the credits enum BEFORE the per-tenant call. + operationId: adminPutWorkspaceBillingMode + security: [{ cpAdminBearer: [] }] + parameters: + - name: org_slug + in: query + required: true + description: Org slug — resolves which tenant CP proxies to. + schema: { type: string, pattern: "^[a-z][a-z0-9-]{2,31}$" } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PutBillingModeRequest" } + responses: + "200": + description: Updated. + content: + application/json: + schema: { $ref: "#/components/schemas/BillingModeResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": + description: Slug doesn't resolve to an org. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "502": + description: Per-tenant call failed (auth/network/parse). + content: + application/json: + schema: { type: object, additionalProperties: true } + "503": + description: Billing-mode client unwired on this CP (dev / no Secrets). + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + # ------------------------------------------------------------- CP: PROVISION /api/v1/workspaces/provision: post: @@ -1159,18 +1298,24 @@ paths: tags: [cp-provision] summary: Deprovision (terminate) a workspace EC2 + DNS description: | - Requires the provision shared secret AND a per-tenant admin_token + - X-Molecule-Org-Id (issue #118) so a leaked shared secret can't kill - other tenants' workspaces. `?prune=` controls data-volume pruning. + Requires the provision shared secret AND a per-tenant admin_token + (X-Molecule-Admin-Token, issue #118) so a leaked shared secret can't + kill other tenants' workspaces. The org is derived from the admin token + server-side, so no X-Molecule-Org-Id header is read here. `?prune=` + controls data-volume pruning. operationId: deprovisionWorkspace security: - provisionSecret: [] tenantAdminToken: [] - orgRoutingHeaderId: [] parameters: - name: prune in: query required: false + description: | + Only the literal string `true` triggers a PERMANENT data delete + (the data volume is tagged for immediate erase — destructive, + internal#734). Any other value (including absent) terminates the + instance but preserves the data volume for the grace-period sweep. schema: { type: boolean } responses: "200": @@ -1263,13 +1408,13 @@ paths: parameters: [{ $ref: "#/components/parameters/WorkspaceId" }] get: tags: [tenant-workspaces] - summary: Get a workspace + summary: Get a workspace (INTENTIONALLY UNAUTHENTICATED) + description: | + Intentionally open — registered outside every auth group + (workspace-server internal/router/router.go) so canvas nodes can fetch + their own state without a token (WorkspaceNode polling + health checks). operationId: tenantGetWorkspace - security: - - orgApiKey: [] - orgRoutingHeaderId: [] - - workspaceToken: [] - orgRoutingHeaderId: [] + security: [] parameters: - { $ref: "#/components/parameters/OrgIdHeader" } - { $ref: "#/components/parameters/OrgSlugHeader" } @@ -1279,7 +1424,6 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/Workspace" } - "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [tenant-workspaces] -- 2.52.0