spec: CLI through the console API — ship without seeing the infrastructure
carlos ship/promote/rollback/channels consume the auth-login credential and talk only to the console; the console does bucket writes server-side. Paul's calls recorded: CLI refuses stable (passkey stays browser-only), AWS-direct survives as the operator/CI path, credential-first resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+172
−0
docs/superpowers/specs/2026-08-03-cli-console-api-design.md+172 −0
diff --git a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md| new file mode 100644 |
| index 000000000..1cc476154 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md |
| @@ -0,0 +1,172 @@ |
| +# CLI through the console API — ship without seeing the infrastructure |
| + |
| +**Date:** 2026-08-03 · **Status:** approved (Paul, in-session) · **Slice:** 1 |
| + |
| +## Problem |
| + |
| +`carlos auth login` (device flow, `cli_tokens`) shipped, but nothing consumes |
| +the credential. The verbs an app developer actually needs still reach around |
| +the platform's own control plane: |
| + |
| +- `carlos ship` opens the S3 store directly — needs AWS credentials, the |
| + bucket name, and a region in env. |
| +- `carlos promote` signs pointers locally — needs the release signing key. |
| +- `carlos channels` reads the bucket directly. |
| + |
| +So "give Vicky deploy access" today means giving her AWS credentials, which |
| +is exactly the infrastructure the platform exists to hide. carlos commands |
| +should talk to the carlos API; the deployment's console already holds bucket |
| +write access and the signing key, gates every mutation on membership, and |
| +audits it. |
| + |
| +## Decisions (made with Paul, 2026-08-03) |
| + |
| +1. **CLI refuses `stable`.** Stable promotion is passkey-step-up-gated in the |
| + browser (spec §7a) and a bearer token cannot do a passkey ceremony. The |
| + API returns the refusal with the console URL to finish in the browser. |
| + A pending-action browser-confirm handoff is future work, not this slice. |
| +2. **AWS-direct stays, for operators and CI.** Resolution is |
| + credential-first: a stored `auth login` credential and no explicit |
| + `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_DEPLOYMENT_DIR` in env → API mode. |
| + Explicit store env → direct mode, unchanged. Box-side scripts, pinfra, |
| + and the console shipping itself keep working; the two modes never mix in |
| + one invocation. |
| + |
| +## §1 Mode resolution (client) |
| + |
| +In `ship`, `promote`, `rollback`, `channels` (and nothing else this slice): |
| + |
| +- `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` set → **direct |
| + mode**, byte-identical to today. |
| +- Otherwise, if the credential file has exactly one console (or `-console |
| + <origin>` picks one, same `pick` semantics as `auth whoami`) → **API |
| + mode** against that origin. |
| +- Neither → the current "no deployment store configured" error, extended to |
| + mention `carlos auth login`. |
| + |
| +No silent fallback from API mode to direct mode or vice versa: a failed API |
| +call is a failed command. The `-account` flag keeps its meaning in both |
| +modes; in API mode the server additionally verifies membership. |
| + |
| +## §2 API surface (server) |
| + |
| +All under the existing CLI route family (`registerCLIRoutes`), all bearer |
| +(`cliUser`), all JSON except the upload, all sharing the browser doors' |
| +semantics — a hand-written request meets exactly the checks a button does: |
| + |
| +- `GET /api/cli/apps` — accounts the address belongs to, each with its |
| + claimed apps and the caller's role. (Powers `carlos apps`, and lets ship |
| + fail fast on a bad account/app pair.) |
| +- `GET /api/cli/apps/{account}/{app}/channels` — channel pointers: |
| + channel, version, promoted_at, rung. Powers `carlos channels`. |
| +- `POST /api/cli/apps/{account}/{app}/releases` — ship. Multipart: |
| + `meta` part (JSON: version, kind binary|static, target per file) then one |
| + or more artifact parts. Server computes sha256 and sizes from the bytes it |
| + received — the client never states a hash it could lie about — builds the |
| + manifest exactly as `runShip` does (target-keyed artifacts; `BuiltBy: |
| + "console-api <console version>"`), and calls `release.Ship` against its |
| + own store. Idempotent per (account, app, version) the same way a re-run |
| + `carlos ship` is. |
| +- `POST /api/cli/apps/{account}/{app}/promote` — body {version, channel}. |
| + Mirrors `handlePromote` minus CSRF (bearer replaces the session): valid |
| + channel, valid version, manifest exists, rung gate, then |
| + `release.PromoteOnLadder` with `Actor: addr`. No `-hotfix` from the API |
| + this slice — a recorded ladder bypass is an operator act, and operators |
| + have direct mode. |
| +- `POST /api/cli/apps/{account}/{app}/rollback` — body {channel}. Mirrors |
| + the browser rollback door (rollback never bakes). |
| + |
| +## §3 Authorization and refusal order |
| + |
| +Every endpoint: bearer → address; membership in `{account}`; app claimed in |
| +that account (`HasAppIn`). Any failure in that chain is the byte-identical |
| +404 the console's gate ordering rule demands — a token holder probing app |
| +names learns nothing a stranger wouldn't. Then, and only then, the cheap |
| +refusals (bad channel, bad version, unknown release), then the rung gate: |
| + |
| +- non-stable rung: membership already proved — allowed. |
| +- stable: `403` with body |
| + `{"error":"stable needs a passkey — finish in the console","console_url": |
| + "<origin>/a/<sqid>/apps/<app>"}`. Role is NOT consulted first — the 403 is |
| + identical for member and owner, because distinguishing them would leak |
| + role to a stolen token. (The browser's owner-404 ordering protects a |
| + different secret — account membership — which the 404 above already |
| + covers.) |
| + |
| +Rate limits: reuse the `cliLimits` family. Uploads get their own bucket-of- |
| +tokens (small: ships are rare) keyed by token hash, not IP — a shared NAT |
| +must not let one developer starve another, and a token is the natural |
| +principal here. |
| + |
| +## §4 Upload handling |
| + |
| +- `http.MaxBytesReader` at 256 MiB per request (largest current artifact is |
| + ~30 MiB; 256 leaves room for multi-target ships without inviting abuse). |
| +- Artifacts stream to a temp file, hashed while streaming; nothing is |
| + buffered whole in memory. |
| +- `version` and `target` validated against the same charsets ship enforces |
| + (`release.ValidVersion`; targets `GOOS-GOARCH` shaped) — they become |
| + bucket key segments, so the validation is path-traversal defense, stated |
| + as such in code. |
| +- The console's existing store credentials cover `releases/*` writes: it |
| + writes `apps/<sqid>/<app>/channels/*` today and the box role grants the |
| + whole bucket; §9 verifies this live on tito before merge. |
| + |
| +## §5 What the CLI prints |
| + |
| +API-mode output stays verb-for-verb compatible with direct mode where it |
| +can (`shipped <app> <version>`, `promoted <version> to <channel>`), plus the |
| +console origin on first use of a credential (`using https://carlos.tito.io |
| +as vicky@keymail.dev`) so a developer with two deployments always knows |
| +which one they just shipped to. Stable refusal prints the console URL from |
| +the 403 body. |
| + |
| +## §6 Audit |
| + |
| +API mutations audit through the same `s.audit` rows as the browser, actor = |
| +the token's address, detail suffixed `" (cli)"`. The issuance notification |
| +path (Keymail message on token creation) already exists; no new |
| +notification this slice. |
| + |
| +## §7 Out of scope (recorded, deliberate) |
| + |
| +- Claim/create-app via API — claim-proofing semantics stay in the browser |
| + and box-side (migrated names need operator judgment, as tito's titogo |
| + just demonstrated). |
| +- `-hotfix`, `carlos route/add/remove/instances`, env/secrets verbs — |
| + operator/box territory or future slices. |
| +- Stable via pending-action handoff. |
| +- `carlos canary` — uses ship+promote internals; follows automatically if |
| + those go through the client seam, but is not a named requirement and is |
| + not tested this slice. |
| + |
| +## §8 Security notes |
| + |
| +- Blast radius of a stolen token: non-stable promotes and ships on apps the |
| + address can already deploy. No key material, no stable, no AWS. |
| +- The signing key never moves: promotion signing stays in the console |
| + process. Ship needs no signature (pointers, not artifacts, are signed — |
| + the signed pointer names the version and the manifest carries the |
| + server-computed hashes). |
| +- Server-side hashing means a compromised client cannot make the manifest |
| + disagree with the artifact bytes. |
| +- Registry stays read-only to the console; nothing here touches routes. |
| + |
| +## §9 Testing |
| + |
| +- Unit: mode resolution matrix (env × credential file × -console flag); |
| + upload validation refusals; the 404/403 refusal order (a non-member and a |
| + member probing stable must be byte-distinguishable only where designed). |
| +- e2e (`auth_e2e_test.go` pattern): device flow → token → `GET apps` → |
| + multipart ship → promote to a canary channel → channels shows it → |
| + stable refused with console_url → rollback. Against `httptest` console |
| + with a dir-backed store. |
| +- Live before merge: on tito, confirm the box role accepts a |
| + `releases/*` PutObject from the console process (a 1-byte probe object, |
| + deleted after). |
| + |
| +## §10 Rollout |
| + |
| +Server first (console is backward-compatible: new routes only), then CLI. |
| +One PR, both halves; deploy console via the normal ship/promote/adopt; |
| +nothing changes for existing users until they run `carlos auth login`. |