cli: carlos apps create — claim an app through the console API, rate limited
Lifts the parent spec's §7 exclusion (Paul's ruling 2026-08-04): app
creation was browser-only; now `POST /api/cli/apps/{account}/new` claims a
name over a bearer token too. No new authority — any member could already
claim in the browser — and no divergence risk: the claim checks moved out
of handleNewApp into ONE shared claimApp both doors call, ops-account
legacy claim-proofing included.
The stolen-token ceiling is the point: create gets its OWN limiter (ten
attempts per ADDRESS per day — attempts, not successes, so refused probes
aren't free; address-keyed so re-minting tokens isn't a reset button). The
browser form stays uncapped as the honest escape hatch. Account resolution
is the shared apiAccount (split from apiAccountApp), so unknown and
someone-else's accounts stay byte-identical 404s.
Spec: docs/superpowers/specs/2026-08-04-cli-app-create-design.md.
Reviewed by Opus (no security findings; its test/doc nits applied).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+637
−57
apimode.go+36 −0apimode_test.go+60 −0apps.go+86 −0docs/superpowers/specs/2026-08-03-cli-console-api-design.md+4 −3docs/superpowers/specs/2026-08-04-cli-app-create-design.md+104 −0internal/console/apicli.go+78 −12internal/console/apicli_test.go+156 −0internal/console/authcli.go+26 −1internal/console/server.go+82 −41main.go+5 −0
diff --git a/apimode.go b/apimode.go| index f50e08cd7..5d93f6642 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -341,6 +341,42 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann |
| return apiPromoteResult{}, apiErrorFromBody(status, body) |
| } |
| +// apiCreateAppResult carries what `carlos apps create` prints: the console's |
| +// canonical account sqid (the segment the caller typed may have been a |
| +// name), the normalized app name, and the app's console page. |
| +type apiCreateAppResult struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + URL string `json:"url"` |
| +} |
| + |
| +// createApp claims one app name through the console — the API door onto the |
| +// same claim the browser's New app form makes. The path is the literal |
| +// `new` segment (mirroring /a/{sqid}/apps/new), which apiPath builds when |
| +// handed "new" as the app. |
| +func (s *apiSession) createApp(ctx context.Context, a apiAcct, app string) (apiCreateAppResult, error) { |
| + in := struct { |
| + Name string `json:"name"` |
| + }{app} |
| + status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in) |
| + if err != nil { |
| + return apiCreateAppResult{}, err |
| + } |
| + if status == http.StatusNotFound { |
| + // Unlike every other verb's 404 there is no -app to second-guess: |
| + // the create door only 404s on the account. |
| + return apiCreateAppResult{}, errors.New("not found: check -account, and that your token's address is a member") |
| + } |
| + if status != http.StatusOK { |
| + return apiCreateAppResult{}, apiErrorFromBody(status, body) |
| + } |
| + var res apiCreateAppResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiCreateAppResult{}, fmt.Errorf("apps create: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| // rollback asks the console to roll channel back to its previous version. |
| // There is no noop case for a rollback — it either has a previous version to |
| // land on (409 if not) or it doesn't — so this stays error-only. |
diff --git a/apimode_test.go b/apimode_test.go| index 6365f8fe8..e8e6778ed 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1886,3 +1886,63 @@ func TestLedgerVerifyVerb(t *testing.T) { |
| t.Fatalf("stdout = %q, want the verified line", buf.String()) |
| } |
| } |
| + |
| +// TestAPISessionCreateApp: the createApp client sends the claim to the |
| +// literal `new` door with the name in the body, honours -account-id's |
| +// exact=sqid transport, surfaces the server's own refusal sentence, and |
| +// rewrites the bare 404 into create's account-only wording (there is no |
| +// -app to second-guess on this verb). |
| +func TestAPISessionCreateApp(t *testing.T) { |
| + var gotPath, gotQuery, gotName string |
| + status := http.StatusOK |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotQuery = r.URL.Path, r.URL.RawQuery |
| + var req struct { |
| + Name string `json:"name"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + gotName = req.Name |
| + if status != http.StatusOK { |
| + w.WriteHeader(status) |
| + if status == http.StatusConflict { |
| + json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken"}) |
| + } |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name, |
| + }) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + |
| + res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul") |
| + if err != nil { |
| + t.Fatalf("createApp: %v", err) |
| + } |
| + if gotPath != "/api/cli/apps/keymail/new" || gotQuery != "" || gotName != "paul" { |
| + t.Fatalf("request = %s?%s name=%q", gotPath, gotQuery, gotName) |
| + } |
| + if res.Account != "bae" || res.App != "paul" || res.URL != "https://console.example/a/bae/apps/paul" { |
| + t.Fatalf("result = %+v", res) |
| + } |
| + |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul"); err != nil { |
| + t.Fatalf("createApp exact: %v", err) |
| + } |
| + if gotQuery != "exact=sqid" { |
| + t.Fatalf("exact query = %q, want exact=sqid", gotQuery) |
| + } |
| + |
| + status = http.StatusConflict |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul"); err == nil || |
| + err.Error() != "that name is taken" { |
| + t.Fatalf("409 err = %v, want the server's sentence", err) |
| + } |
| + |
| + status = http.StatusNotFound |
| + if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul"); err == nil || |
| + !strings.Contains(err.Error(), "check -account,") { |
| + t.Fatalf("404 err = %v, want create's account-only wording", err) |
| + } |
| +} |
diff --git a/apps.go b/apps.go| new file mode 100644 |
| index 000000000..211c24bf3 |
| --- /dev/null |
| +++ b/apps.go |
| @@ -0,0 +1,86 @@ |
| +package main |
| + |
| +// `carlos apps` — the app-lifecycle verbs. One sub-verb today: create, the |
| +// CLI door onto the same app-name claim the console's New app form makes |
| +// (docs/superpowers/specs/2026-08-04-cli-app-create-design.md). |
| +// |
| +// API mode ONLY, deliberately: the claims table lives in the console's |
| +// database, which direct bucket mode never opens. In direct mode an app |
| +// comes into existence at its first ship (the reconciler derives the |
| +// app→account linkage from the key path), or box-side via `console grant` — |
| +// so a bucket-credentialed operator is refused here with those answers, not |
| +// given a competing third path. |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +const appsUsage = `usage: carlos apps <sub> |
| + |
| + create -app <name> [-account <sqid|name>] [-account-id <sqid>] |
| + [-console <origin>] |
| + claim a new app name in an account, through the |
| + console API — the same claim the console's New app |
| + form makes, so a name the form would refuse is |
| + refused here too. Rate limited per address per day; |
| + the console form is not, and is the fallback. |
| +` |
| + |
| +// RunApps dispatches the apps sub-verbs. |
| +func RunApps(args []string) { |
| + if len(args) == 0 { |
| + fmt.Fprint(os.Stderr, appsUsage) |
| + os.Exit(1) |
| + } |
| + switch args[0] { |
| + case "create": |
| + runAppsCreate(args[1:]) |
| + default: |
| + fmt.Fprint(os.Stderr, appsUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +func runAppsCreate(args []string) { |
| + fs := flag.NewFlagSet("apps create", flag.ExitOnError) |
| + app := fs.String("app", "", "app name to claim (lowercase letters, digits, dashes)") |
| + // Not accountFlags: that helper also registers -registry for direct-mode |
| + // resolution, and this verb never resolves locally — the console resolves |
| + // the account against the caller's own memberships. |
| + acctRaw := fs.String("account", "ops", "account (sqid or name) to claim the app in") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use") |
| + consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)") |
| + fs.Parse(args) |
| + if strings.TrimSpace(*app) == "" { |
| + log.Fatal("usage: carlos apps create -app <name> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]") |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + // Direct mode. The claim lives in the console's database — there is |
| + // nothing in the bucket to write it to, and pretending otherwise |
| + // re-creates the ownerless-resource failure accounts create solved. |
| + log.Fatal("apps create works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Direct mode needs no claim:\n" + |
| + "the first `carlos ship` brings the app into existence, and `console grant` is the\n" + |
| + "box-side way to hand a name to an address. To claim through a console instead,\n" + |
| + "unset the store env and run: carlos auth login -console <url>") |
| + } |
| + acct := apiAccount(acctRaw, acctIDRaw) |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app)) |
| + if err != nil { |
| + log.Fatalf("apps create: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "created app %s in account %s — it's yours\n", res.App, res.Account) |
| + fmt.Fprintf(stdout, " open: %s\n", res.URL) |
| + fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App) |
| +} |
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| index 3e98633e2..a39879448 100644 |
| --- a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md |
| @@ -140,9 +140,10 @@ 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). |
| +- ~~Claim/create-app via API~~ — LIFTED 2026-08-04, see |
| + `2026-08-04-cli-app-create-design.md`: the claim-proofing checks moved |
| + into one shared method both doors call, and the API door got its own |
| + 10-per-address-per-day budget. |
| - `-hotfix`, `carlos route/add/remove/instances`, env/secrets verbs — |
| operator/box territory or future slices. |
| - Stable via pending-action handoff. |
diff --git a/docs/superpowers/specs/2026-08-04-cli-app-create-design.md b/docs/superpowers/specs/2026-08-04-cli-app-create-design.md| new file mode 100644 |
| index 000000000..1e9d37f36 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-cli-app-create-design.md |
| @@ -0,0 +1,104 @@ |
| +# CLI app creation — `carlos apps create` |
| + |
| +2026-08-04. Lifts the §7 exclusion in |
| +`2026-08-03-cli-console-api-design.md` ("Claim/create-app via API") — Paul's |
| +ruling 2026-08-04: "seems foolish not to allow new apps from the command |
| +line", rate-limited so a stolen token doesn't go berserk. |
| + |
| +## §1 What changes and what doesn't |
| + |
| +One new console endpoint and one new CLI verb. No new authority: any |
| +member of an account can already claim an app in the browser |
| +(`POST /a/{sqid}/apps/new`, membership + CSRF, no step-up). This adds a |
| +second door to the same claim, with a budget the browser door doesn't |
| +need — not a wider grant. |
| + |
| +The original exclusion worried about claim-proofing semantics (migrated |
| +names need operator judgment). That judgment lives in `handleNewApp`'s |
| +checks, and this slice moves those checks into ONE shared method both |
| +doors call, so they cannot diverge. The ops-account legacy rules |
| +(`HasOwners`, legacy bucket prefix) ride along unchanged. |
| + |
| +## §2 Endpoint |
| + |
| +`POST /api/cli/apps/{account}/new` — body `{"name":"<app>"}` (JSON, like |
| +`POST /auth/cli/accounts`). The literal `new` segment mirrors the browser |
| +door; no bare `POST .../{app}` route exists to collide with, and the mux |
| +prefers literals if one ever does. |
| + |
| +Refusal order (§3 doctrine of the parent spec): |
| + |
| +1. bearer → address (`cliUser`) — 401. |
| +2. account resolved against the caller's OWN memberships — SYMMETRIC |
| + sqid/name matching with no precedence, exactly the doctrine |
| + `apiAccountApp` documents (`?exact=sqid` for `-account-id`) — so |
| + unknown, someone else's, and ambiguous behave exactly as every other |
| + route (404 / 404 / 409). The resolution half of `apiAccountApp` is |
| + extracted (`apiAccount`) and shared; `HasAppIn` is NOT part of this |
| + endpoint's gate — the app is what's being created. |
| +3. rate limit — 429 (see §3). |
| +4. name validation (`release.ValidApp`, after ToLower+TrimSpace, same |
| + normalization as the browser) — 400. |
| +5. claim checks via the shared claim method — 409 `{"error":"that name is |
| + taken"}`. For a non-ops account every check is scoped to the caller's |
| + own account (apps table row, own bucket prefix), so the 409 reveals |
| + nothing a member couldn't see in their own console page. The ops |
| + account keeps its legacy claim-proofing (owners rows, legacy prefix) |
| + behind the same 409. |
| +6. create: `CreateAppIn` (the ON CONFLICT no-op means a lost race is the |
| + same 409), `ensureAccountManifest`, audit action `create`, detail |
| + `"created app <name> (cli)"` per the parent spec's audit rule. |
| + |
| +Success: 200 `{"account":"<sqid>","app":"<name>","url":"<origin>/a/<sqid>/apps/<name>"}`. |
| + |
| +## §3 Rate limit |
| + |
| +A dedicated limiter in `cliLimits`, like `ship`'s own budget: |
| +`create = 10 per address per 24h` (`cliCreateBurst = 10`, |
| +`cliCreateWindow = 24 * time.Hour`). |
| + |
| +- Keyed by ADDRESS, not token — the repo's own doctrine (authcli.go): a |
| + token-keyed counter is one the attacker re-mints; an address-keyed one |
| + isn't. |
| +- Counted per ATTEMPT that reaches the limiter (before validation), not |
| + per success — otherwise refused probes are free and the limiter bounds |
| + nothing an attacker cares about. |
| +- In-memory fixed window, per the limiter's stated design: one process on |
| + one box; a restart forgetting counters is the recorded, accepted |
| + failure mode. |
| +- The browser door stays uncapped — it is the legit escape hatch when the |
| + cap is hit honestly, and it sits behind session auth + CSRF. |
| + |
| +Stolen-token blast radius (amends parent §8): plus at most 10 claimed |
| +app names per day, inert until shipped to — and ship has its own |
| +10-per-hour budget. Still no key material, no stable, no AWS. |
| + |
| +## §4 CLI verb |
| + |
| +`carlos apps create -account <sqid|name> -app <name> [-console <url>]` — |
| +plural `apps`, matching `carlos accounts`. New top-level `apps` dispatch |
| +(main.go) with `create` as its first sub-verb. |
| + |
| +API mode only. In direct mode (either store env set) the verb refuses |
| +with the box-side answer: apps come into existence there by the first |
| +ship (bucket-first) or `console grant` — the claims table lives in the |
| +console's database, which direct mode deliberately never opens. |
| + |
| +Client method `createApp` on `apiSession`; 404 surfaces the same |
| +"check -account…" wording the other verbs use, 409/429 surface the |
| +server's error sentence. |
| + |
| +## §5 Testing |
| + |
| +- Happy path: 200, apps row exists (`HasAppIn`), audit row with `(cli)` |
| + detail, then the created app is immediately shippable through |
| + `apiAccountApp`'s gate. |
| +- Refusal matrix: no token 401; unknown account and someone-else's |
| + account byte-identical 404; ambiguous name 409; invalid name 400; |
| + duplicate in-account 409; ops legacy grant/prefix 409. |
| +- Rate limit: 11th attempt within the window → 429; attempts count even |
| + when refused; a second token for the same address inherits the spent |
| + budget (address-keyed, the point of the design) while another address's |
| + budget is its own. |
| +- Browser door unchanged: existing `handleNewApp` tests keep passing |
| + against the extracted claim method. |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 9d5a4d479..0c8d09332 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -46,16 +46,37 @@ import ( |
| // settle an ambiguity can never itself be ambiguous. It never widens access |
| // — a non-member's probe meets the same 404 either way. |
| func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { |
| - addr, ok := s.cliUser(w, r) |
| + addr, acct, ok := s.apiAccount(w, r) |
| if !ok { |
| return "", nil, "", false |
| } |
| - seg := r.PathValue("account") |
| app := r.PathValue("app") |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err) |
| + } |
| + if err != nil || !has { |
| + http.NotFound(w, r) |
| + return "", nil, "", false |
| + } |
| + return addr, acct, app, true |
| +} |
| + |
| +// apiAccount is the account half of apiAccountApp: bearer → address, then |
| +// the {account} segment resolved against the caller's OWN memberships. |
| +// Split out for the one route that has no app to check — create, where the |
| +// app is what's being made — so its unknown-account and someone-else's- |
| +// account refusals stay byte-identical with every other route's. |
| +func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Account, bool) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return "", nil, false |
| + } |
| + seg := r.PathValue("account") |
| accts, err := s.store.AccountsFor(addr) |
| if err != nil { |
| jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| - return "", nil, "", false |
| + return "", nil, false |
| } |
| var matches []*Account |
| if r.URL.Query().Get("exact") == "sqid" { |
| @@ -84,21 +105,66 @@ func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, |
| } |
| jsonError(w, http.StatusConflict, |
| fmt.Sprintf("account %q is ambiguous — pass -account-id with one of: %s", seg, strings.Join(alts, ", "))) |
| - return "", nil, "", false |
| + return "", nil, false |
| } |
| if acct == nil { |
| http.NotFound(w, r) |
| - return "", nil, "", false |
| + return "", nil, false |
| } |
| - has, err := s.store.HasAppIn(acct.ID, app) |
| - if err != nil { |
| - log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err) |
| + return addr, acct, true |
| +} |
| + |
| +// handleAPICreateApp answers `carlos apps create`: the CLI door onto the |
| +// same claim the browser's New app form posts to. claimApp holds the checks |
| +// for both doors — including the ops account's legacy claim-proofing — so |
| +// lifting the parent spec's §7 exclusion changes where a claim can come |
| +// from, never what a claim will refuse |
| +// (docs/superpowers/specs/2026-08-04-cli-app-create-design.md). |
| +// |
| +// The rate limit is create's OWN budget, not the shared `write` one, and it |
| +// is spent per ATTEMPT, before the name is even read — a refused probe that |
| +// cost nothing would leave the limiter bounding nothing a stolen token's |
| +// holder cares about. For a non-ops account every refusal below reveals |
| +// only the caller's own account's state, so the 409 is not the cross-tenant |
| +// oracle the parent spec's §3 guards against. |
| +func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, ok := s.apiAccount(w, r) |
| + if !ok { |
| + return |
| } |
| - if err != nil || !has { |
| - http.NotFound(w, r) |
| - return "", nil, "", false |
| + // Keyed on the ADDRESS, like every write budget here: a token-keyed |
| + // counter is one the holder of several tokens resets at will. |
| + if !s.cli.create.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, |
| + "app creation limit reached for today — use the console's New app form") |
| + return |
| + } |
| + var req struct { |
| + Name string `json:"name"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + name := normalizeAppName(req.Name) |
| + switch s.claimApp(r.Context(), acct, name, addr) { |
| + case claimInvalid: |
| + jsonError(w, http.StatusBadRequest, |
| + "invalid app name — lowercase letters, digits, and dashes, starting with a letter") |
| + case claimTaken: |
| + jsonError(w, http.StatusConflict, "that name is taken") |
| + case claimCheckFailed, claimCreateFailed: |
| + jsonError(w, http.StatusInternalServerError, "could not create the app — try again") |
| + case claimStoreFailed: |
| + // A bucket error, like handleCLINewAccount's: the detail stays on |
| + // the box (key names and endpoints don't belong in API bodies). |
| + jsonError(w, http.StatusBadGateway, "could not check the release store — try again") |
| + case claimOK: |
| + s.audit(acct, name, addr, "create", "created app "+name+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "account": acct.Sqid, "app": name, |
| + "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| + }) |
| } |
| - return addr, acct, app, true |
| } |
| // handleAPIApps answers `carlos apps`: every account the token's holder |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index 464f0c472..fc86073a7 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "io" |
| "mime/multipart" |
| "net/http" |
| + "net/url" |
| "strings" |
| "sync" |
| "testing" |
| @@ -1204,3 +1205,158 @@ func TestAPIShipConcurrencyLimit(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestAPICreateAppHappy: `carlos apps create` claims a name through the same |
| +// claimApp the browser form uses — the row exists afterwards (so every other |
| +// API route's HasAppIn gate opens), the audit row carries the parent spec's |
| +// "(cli)" suffix, and the name is normalized exactly as the browser door |
| +// normalizes it. |
| +func TestAPICreateAppHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-create-alice") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": " Paul "}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("create = %d %v", status, out) |
| + } |
| + if str(t, out, "account") != "bab" || str(t, out, "app") != "paul" { |
| + t.Fatalf("create body = %v", out) |
| + } |
| + if got, want := str(t, out, "url"), fx.ts.URL+"/a/bab/apps/paul"; got != want { |
| + t.Fatalf("url = %q, want %q", got, want) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "paul"); err != nil || !has { |
| + t.Fatalf("HasAppIn(paul) = %v %v, want true", has, err) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "paul", 10) |
| + if err != nil || len(events) != 1 { |
| + t.Fatalf("events = %v %v, want exactly one", events, err) |
| + } |
| + if e := events[0]; e.Action != "create" || e.Address != "alice@keymail.dev" || |
| + e.Detail != "created app paul (cli)" { |
| + t.Fatalf("audit row = %+v", e) |
| + } |
| + // A second claim of the same name is the ordinary duplicate refusal. |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "paul"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("duplicate create = %d %v, want 409", status, out) |
| + } |
| +} |
| + |
| +// TestAPICreateAppRefusalMatrix: the doors that must NOT open, and the shapes |
| +// of their refusals. The one that matters most is byte-identical 404s — an |
| +// account that doesn't exist and an account the token's holder doesn't belong |
| +// to must be indistinguishable, exactly as they are on every other API route. |
| +func TestAPICreateAppRefusalMatrix(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + // bac: somebody else's account. |
| + if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-create-refusals") |
| + |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "x"}, ""); status != http.StatusUnauthorized { |
| + t.Fatalf("no token = %d %v, want 401", status, out) |
| + } |
| + stTheirs, bodyTheirs := postRaw(t, fx.ts.URL+"/api/cli/apps/bac/new", map[string]string{"name": "fresh"}, token) |
| + stUnknown, bodyUnknown := postRaw(t, fx.ts.URL+"/api/cli/apps/zzz/new", map[string]string{"name": "fresh"}, token) |
| + if stTheirs != http.StatusNotFound || stUnknown != http.StatusNotFound { |
| + t.Fatalf("someone-else's = %d, unknown = %d, want 404 and 404", stTheirs, stUnknown) |
| + } |
| + if bodyTheirs != bodyUnknown { |
| + t.Fatalf("404 bodies differ:\n%q\n%q", bodyTheirs, bodyUnknown) |
| + } |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest { |
| + t.Fatalf("invalid name = %d %v, want 400", status, out) |
| + } |
| + // kass is the fixture's already-claimed app. |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict { |
| + t.Fatalf("claimed name = %d %v, want 409", status, out) |
| + } |
| + // A CLI-granted app stays claim-proof in the ops account. |
| + if err := fx.st.Grant("bob@keymail.dev", "granted"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "granted"}, token); status != http.StatusConflict { |
| + t.Fatalf("granted name = %d %v, want 409", status, out) |
| + } |
| + // So does a name whose objects already sit in the release store. |
| + if _, err := fx.rel.Put(context.Background(), |
| + release.AppPrefix("bab", "shipped")+"releases/0.0.1/manifest.json", []byte("{}"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "shipped"}, token); status != http.StatusConflict { |
| + t.Fatalf("bucket-held name = %d %v, want 409", status, out) |
| + } |
| + // An ambiguous account NAME is refused, not guessed at — the same 409 |
| + // every other route gives, via the shared apiAccount. |
| + if err := fx.st.CreateAccount(3, "bad", "ops", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/ops/new", map[string]string{"name": "fresh"}, token) |
| + if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "ambiguous") { |
| + t.Fatalf("ambiguous account = %d %v, want 409 naming the ambiguity", status, out) |
| + } |
| +} |
| + |
| +// TestAPICreateAppRateLimit: the create budget is its own (not `write`'s), |
| +// keyed by address, and spent per ATTEMPT — refused probes count, or the |
| +// limiter would bound nothing an attacker cares about. |
| +func TestAPICreateAppRateLimit(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-create-limit") |
| + |
| + for i := 0; i < 3; i++ { // refused probes spend budget too |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9bad"}, token); status != http.StatusBadRequest { |
| + t.Fatalf("probe %d = %d %v, want 400", i, status, out) |
| + } |
| + } |
| + for i := 0; i < cliCreateBurst-3; i++ { |
| + name := fmt.Sprintf("app-%d", i) |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": name}, token); status != http.StatusOK { |
| + t.Fatalf("create %s = %d %v", name, status, out) |
| + } |
| + } |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "one-too-many"}, token) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("over budget = %d %v, want 429", status, out) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "one-too-many"); err != nil || has { |
| + t.Fatalf("over-budget name claimed anyway: %v %v", has, err) |
| + } |
| + // The budget belongs to the ADDRESS, not the token: a second token for |
| + // the same address inherits the spent budget — re-minting tokens must |
| + // not be a reset button. |
| + token2 := cliToken(t, fx, "alice@keymail.dev", "code-create-limit-2") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "second-token"}, token2); status != http.StatusTooManyRequests { |
| + t.Fatalf("second token, same address = %d %v, want 429", status, out) |
| + } |
| + // ...while a different address's budget is its own. |
| + if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + bobToken := cliToken(t, fx, "bob@keymail.dev", "code-create-limit-bob") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "bob-fresh"}, bobToken); status != http.StatusOK { |
| + t.Fatalf("fresh address = %d %v, want 200", status, out) |
| + } |
| + // The browser door is NOT behind this budget: the same address, its |
| + // budget spent, still claims through the console form. |
| + browser := newBrowser(t) |
| + fx.setUser("alice@keymail.dev") |
| + signUp(t, fx, browser, "code-create-limit-browser") |
| + postForm(t, browser, fx.u("/apps/new"), |
| + url.Values{"name": {"via-browser"}, "csrf": {sessionToken(t, fx, browser)}}) |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "via-browser"); err != nil || !has { |
| + t.Fatalf("browser claim after CLI budget spent = %v %v, want true", has, err) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 60db52c59..72187c9c5 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -76,6 +76,24 @@ const ( |
| // legitimate use while making a stolen token's usefulness for flooding |
| // storage negligible. |
| cliShipBurst = 10 |
| + |
| + // cliCreateWindow is app creation's own budget window — a DAY, the |
| + // longest here, because claiming an app name is the rarest deliberate |
| + // act on this surface and the one whose spam is permanent: a claimed |
| + // name is a row an operator has to clean up, not a pointer the next |
| + // promote overwrites. The number is a ceiling on a stolen token (ten |
| + // inert names a day, per address), not a policing of humans — the |
| + // browser's New app form has no limit and is the escape hatch |
| + // (2026-08-04-cli-app-create-design.md §3). |
| + cliCreateWindow = 24 * time.Hour |
| + // cliCreateBurst bounds app-claim ATTEMPTS per address within that day |
| + // — attempts, not successes, or refused probes would be free. One caveat |
| + // the limiter's maxRateKeys note doesn't cover: create's keys are real |
| + // member addresses accumulating for a whole day, not attacker-minted |
| + // strings, so a deployment with >8192 members claiming apps in one day |
| + // would trip the wholesale map drop and reset every budget. Orders of |
| + // magnitude beyond this fleet; recorded, not defended. |
| + cliCreateBurst = 10 |
| ) |
| // rateLimiter is a fixed-window counter, keyed by whatever the caller thinks |
| @@ -140,7 +158,7 @@ func (l *rateLimiter) allow(key string, now time.Time) bool { |
| // cliLimits is the set of limiters the CLI endpoints share, hung off the |
| // Server so a test gets a fresh set per fixture. |
| type cliLimits struct { |
| - start, poll, pollIP, guessAddr, guessIP, write, ship *rateLimiter |
| + start, poll, pollIP, guessAddr, guessIP, write, ship, create *rateLimiter |
| } |
| func newCLILimits() *cliLimits { |
| @@ -158,6 +176,9 @@ func newCLILimits() *cliLimits { |
| // than address (see handleAPIShip) — an upload is a different shape |
| // of cost (bandwidth, storage) from the JSON mutations write covers. |
| ship: newRateLimiter(cliShipBurst, cliShipWindow), |
| + // Create gets its own budget on its own (day-long) window, keyed by |
| + // address — the stolen-token ceiling handleAPICreateApp explains. |
| + create: newRateLimiter(cliCreateBurst, cliCreateWindow), |
| } |
| } |
| @@ -176,6 +197,10 @@ func (s *Server) registerCLIRoutes() { |
| // (/api/cli/) from the identity routes above (/auth/cli/), which must not |
| // move now that CLIs depend on them. |
| s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps) |
| + // The literal `new` mirrors the browser door (/a/{sqid}/apps/new); no |
| + // bare POST .../{app} route exists to collide with, and the mux prefers |
| + // the literal if one ever does. |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/new", s.handleAPICreateApp) |
| s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/channels", s.handleAPIChannels) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote) |
| s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback) |
diff --git a/internal/console/server.go b/internal/console/server.go| index e0031150a..d75bfc8e6 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1257,52 +1257,56 @@ func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) { |
| log.Printf("console: backfilled account manifest for %s (%s)", acct.Sqid, acct.Name) |
| } |
| -// handleNewApp claims an app name — once, ever. A name is refused when the |
| -// apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| +// claimOutcome is how one claim attempt ended: the shared vocabulary of the |
| +// two claim doors (the browser form's handleNewApp, the CLI API's |
| +// handleAPICreateApp), each of which maps every outcome onto its own wire |
| +// shape. The distinct failure outcomes exist because the browser door has |
| +// always named which step failed ("that name" / "the release store" / "the |
| +// app") and collapsing them here would change its copy. |
| +type claimOutcome int |
| + |
| +const ( |
| + claimOK claimOutcome = iota |
| + claimInvalid // not a ValidApp name |
| + claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race |
| + claimCheckFailed // a database lookup failed |
| + claimStoreFailed // the release-store listing failed |
| + claimCreateFailed // the insert itself failed |
| +) |
| + |
| +// normalizeAppName is the one normalization both claim doors apply before |
| +// claimApp sees the name. |
| +func normalizeAppName(name string) string { |
| + return strings.ToLower(strings.TrimSpace(name)) |
| +} |
| + |
| +// claimApp claims an app name for acct — once, ever. A name is refused when |
| +// the apps table has it, when ANY owner exists for it (CLI-granted apps stay |
| // claim-proof), or when the release store already holds anything under |
| -// apps/<name>/. |
| -func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, acct, _, ok := s.account(w, r) |
| - if !ok { |
| - return |
| - } |
| - if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| - s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| - return |
| - } |
| - home := "/a/" + url.PathEscape(acct.Sqid) + "/" |
| - fail := func(msg string) { |
| - s.setFlash(w, "err", msg) |
| - http.Redirect(w, r, home, http.StatusSeeOther) |
| - } |
| - name := strings.ToLower(strings.TrimSpace(r.FormValue("name"))) |
| +// apps/<name>/. This is the ONLY place the claim checks live: both doors |
| +// call it, so they cannot drift apart. Authentication, rate limits, the |
| +// audit row and the response are the callers' business. |
| +func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string) claimOutcome { |
| if !release.ValidApp(name) { |
| - fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |
| - return |
| + return claimInvalid |
| } |
| // Taken means taken IN THIS ACCOUNT — app names are unique per account, |
| // never globally, so another account's "blog" is not a conflict. |
| if has, err := s.store.HasAppIn(acct.ID, name); err != nil { |
| - fail("Could not check that name. Please try again.") |
| - return |
| + return claimCheckFailed |
| } else if has { |
| - fail("That name is taken.") |
| - return |
| + return claimTaken |
| } |
| // The pre-accounts checks still apply to the OPS account, where every |
| // CLI-granted and pre-accounts app lives: a name with grants or bucket |
| // objects there stays claim-proof. |
| if acct.ID == OpsAccountID { |
| if has, err := s.store.HasOwners(name); err != nil { |
| - fail("Could not check that name. Please try again.") |
| - return |
| + return claimCheckFailed |
| } else if has { |
| - fail("That name is taken.") |
| - return |
| + return claimTaken |
| } |
| } |
| - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| - defer cancel() |
| // The account's own prefix, plus — for ops only — the legacy unqualified |
| // one an unmigrated app's objects are still at. This one legacy read |
| // OUTLIVES the retired read-fallback, and deliberately: it is explicit |
| @@ -1315,34 +1319,71 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| if acct.ID == OpsAccountID { |
| prefixes = append(prefixes, release.LegacyAppPrefix(name)) |
| } |
| + // The release-store deadline lives HERE, not in the doors, so both doors |
| + // give the bucket the same ten seconds the browser form always has — |
| + // and the database checks above stay outside it, as they always were. |
| + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| + defer cancel() |
| for _, prefix := range prefixes { |
| keys, err := s.cfg.Releases.List(ctx, prefix) |
| if err != nil { |
| - fail("Could not check the release store. Please try again.") |
| - return |
| + return claimStoreFailed |
| } |
| if len(keys) > 0 { |
| - fail("That name is taken.") |
| - return |
| + return claimTaken |
| } |
| } |
| created, err := s.store.CreateAppIn(acct.ID, name, addr, time.Now()) |
| if err != nil { |
| - fail("Could not create the app. Please try again.") |
| - return |
| + return claimCreateFailed |
| } |
| if !created { // lost a claim race after the checks |
| - fail("That name is taken.") |
| - return |
| + return claimTaken |
| } |
| // The app→account linkage needs no manifest of its own any more: it IS the |
| // bucket key path (apps/<sqid>/<app>/…), written by the first ship. The |
| // account's own manifest is what the agent materializes, and minting |
| // already wrote it. |
| s.ensureAccountManifest(ctx, acct) |
| - s.audit(acct, name, addr, "create", "created app "+name) |
| - s.setFlash(w, "ok", "Created app "+name+" — it's yours.") |
| - http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther) |
| + return claimOK |
| +} |
| + |
| +// handleNewApp is the browser door onto claimApp: the account page's New app |
| +// form. Any member may claim — creation grants nothing promotion doesn't |
| +// already gate — and unlike the CLI door it carries no rate limit: a session |
| +// behind CSRF is not the stolen-credential shape the API budget exists for, |
| +// and this form is the escape hatch when that budget is honestly spent. |
| +func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + home := "/a/" + url.PathEscape(acct.Sqid) + "/" |
| + fail := func(msg string) { |
| + s.setFlash(w, "err", msg) |
| + http.Redirect(w, r, home, http.StatusSeeOther) |
| + } |
| + name := normalizeAppName(r.FormValue("name")) |
| + switch s.claimApp(r.Context(), acct, name, addr) { |
| + case claimInvalid: |
| + fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |
| + case claimTaken: |
| + fail("That name is taken.") |
| + case claimCheckFailed: |
| + fail("Could not check that name. Please try again.") |
| + case claimStoreFailed: |
| + fail("Could not check the release store. Please try again.") |
| + case claimCreateFailed: |
| + fail("Could not create the app. Please try again.") |
| + case claimOK: |
| + s.audit(acct, name, addr, "create", "created app "+name) |
| + s.setFlash(w, "ok", "Created app "+name+" — it's yours.") |
| + http.Redirect(w, r, home+"apps/"+url.PathEscape(name), http.StatusSeeOther) |
| + } |
| } |
| // handlePromote points a channel at a shipped version. It is the app's ONE |
diff --git a/main.go b/main.go| index 1ad7f2335..d390f1f79 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -47,6 +47,8 @@ func main() { |
| RunAuth(args) |
| case "accounts": |
| RunAccounts(args) |
| + case "apps": |
| + RunApps(args) |
| case "env": |
| RunEnv(args) |
| case "secrets": |
| @@ -95,6 +97,9 @@ commands: |
| auth login | whoami | logout — who this terminal is |
| (headless? don't log in: pass -owner instead) |
| accounts create | list | migrate — the tenancy primitive |
| + apps create — claim a new app name in an account, |
| + through the console API (rate limited per |
| + address per day; the console form is not) |
| env set | unset | list — plain per-app config vars |
| secrets set | unset | list | genkey — sealed per-app secrets |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |