carlos / platform Public

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>
Paul Campbell 4288805ee7c70f3fce0a1a82a6bec46a10e9d77a parent f1cc914
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`.