carlos / platform Public

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>
Paul Campbell 623819be2812a86b9f07afaca41b48e9fb7590a7 parent 7e77c53
10 files changed, +637 −57
  • apimode.go +36 −0
  • apimode_test.go +60 −0
  • apps.go +86 −0
  • docs/superpowers/specs/2026-08-03-cli-console-api-design.md +4 −3
  • docs/superpowers/specs/2026-08-04-cli-app-create-design.md +104 −0
  • internal/console/apicli.go +78 −12
  • internal/console/apicli_test.go +156 −0
  • internal/console/authcli.go +26 −1
  • internal/console/server.go +82 −41
  • main.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>)