carlos / platform Public

merge: carlos apps create — app creation over the console API, rate limited (#52)

cli: carlos apps create — app creation over the console API, rate limited
Paul Campbell 1c32d739d875e6e91528b4a14d005e836911d649 parent 7302badparent 3bf7ac2
11 files changed, +633 −82
  • README.md +5 −3
  • apimode.go +36 −0
  • apimode_test.go +60 −0
  • apps.go +68 −10
  • 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 +81 −14
  • internal/console/apicli_test.go +156 −0
  • internal/console/authcli.go +26 −1
  • internal/console/server.go +82 −41
  • main.go +11 −10
diff --git a/README.md b/README.md
index 82ba29049..d02050170 100644
--- a/README.md
+++ b/README.md
@@ -76,9 +76,11 @@ Clicking it while signed in as that address creates the membership.
defaulting to `ops`:
carlos auth login -console https://platform.carlosframework.com
- carlos accounts create -name Kass # you own it
- carlos accounts create -name Kass -owner paul@keymail.dev # headless
+ carlos accounts create -name kass # you own it
+ carlos accounts create -name kass -owner paul@keymail.dev # headless
carlos accounts list # sqid, name, app count
+ carlos apps create -account kass -app blog # claim the name (rate limited;
+ # the console's New app form is not)
carlos ship -account kass -app blog ...
carlos add -account kass -app blog blog.example.com
@@ -135,7 +137,7 @@ long-lived token sitting on a box is a standing credential nobody is watching.
Name the owner outright instead — it needs no identity of its own and takes
precedence over any logged-in one:
- carlos accounts create -name Kass -owner paul@keymail.dev
+ carlos accounts create -name kass -owner paul@keymail.dev
Either way an owner is named. `carlos accounts create` **refuses** with no
login and no `-owner`: an account with no owner materializes into the console
diff --git a/apimode.go b/apimode.go
index faea3e263..09378fb82 100644
--- a/apimode.go
+++ b/apimode.go
@@ -343,6 +343,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 a7cb1552f..7fbff641b 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2468,3 +2468,63 @@ func TestAPISessionConfigSyncError(t *testing.T) {
t.Fatalf("configSync error = %v, want the server's message", err)
}
}
+
+// 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
index a2a1a37e4..d255eab49 100644
--- a/apps.go
+++ b/apps.go
@@ -1,11 +1,15 @@
-// The `carlos apps` verb: delete (trash) and restore an app from the command
-// line, in the same two modes every other write verb runs in — signed-in
-// through the console API, or headless straight against the deployment
-// bucket. See docs/superpowers/specs/2026-08-04-deletion-policy-design.md.
+// The `carlos apps` verbs: create an app, and delete (trash) / restore one.
//
-// There is deliberately no `create` here: draft PR #52 owns `apps create`.
-// Whichever of the two branches merges second rebases its own case onto this
-// file's shared `case "apps":` dispatch in main.go — see the comment there.
+// create is the CLI door onto the same app-name claim the console's New app
+// form makes, console API ONLY — 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, or box-side via `console grant`
+// (docs/superpowers/specs/2026-08-04-cli-app-create-design.md).
+//
+// delete/restore run in the same two modes every other write verb does —
+// signed-in through the console API, or headless straight against the
+// deployment bucket. See
+// docs/superpowers/specs/2026-08-04-deletion-policy-design.md.
//
// Deleting is reversible for 30 days (release.TrashCooloff) and restoring is
// the undo button — that asymmetry is why this file has two verbs and no
@@ -29,7 +33,15 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-const appsUsage = `usage: carlos apps <delete|restore> [flags] <app>
+const appsUsage = `usage: carlos apps <create|delete|restore> [flags] <app>
+
+ create -app <name> [flags] 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. Signed-in mode only:
+ in direct bucket mode the first ship brings an app into
+ existence, and "console grant" is the box-side path.
delete [flags] <app> move an app to the Trash: every route drops
(custom domains included), its name stays reserved, and it
@@ -67,14 +79,15 @@ There is no purge verb here: "Delete now" is console-only, behind its own
passkey step-up. Headless deployments wait out the 30 days.
`
-// RunApps dispatches the apps sub-verbs: delete and restore ONLY. See this
-// file's header comment for why "create" is not one of them.
+// 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:])
case "delete":
runAppsDelete(args[1:])
case "restore":
@@ -316,3 +329,48 @@ func runAppsRestore(args []string) {
fmt.Fprintf(stdout, "%s is restored with its releases and channels intact, and with NO routes — "+
"carlos add <host> to serve it again\n", app)
}
+
+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>]")
+ }
+ // Client-side gate before the server's: the create limiter charges
+ // ATTEMPTS, so a name the server is certain to 400 should cost a typo
+ // nothing.
+ if err := validAppName(strings.TrimSpace(*app)); err != nil {
+ log.Fatalf("apps create: %v", err)
+ }
+
+ 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 8f4fdb403..562649f35 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 (moved in-scope by
2026-08-04-config-console-api-design.md — issues #43/#44).
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 6dde1d5bc..7afd4bb13 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -62,19 +62,41 @@ func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string,
return s.apiAccountAppBy(w, r, s.store.HasTrashedAppIn)
}
-// apiAccountAppBy is the shared body of the two resolvers above. `has` is
-// the last gate and the only variable: whether THIS door's subject exists.
+// apiAccountAppBy is the shared body of the two resolvers above: apiAccount
+// resolves the account half, then `has` — the last gate and the only
+// variable — says whether THIS door's subject exists.
func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (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")
+ found, err := has(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err)
+ }
+ if err != nil || !found {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+
+// apiAccount is the account half of apiAccountAppBy: 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" {
@@ -103,21 +125,66 @@ func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has fun
}
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
}
- found, err := has(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 || !found {
- 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 6f95a49ac..6efab0315 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -20,6 +20,7 @@ import (
"io"
"mime/multipart"
"net/http"
+ "net/url"
"reflect"
"strings"
"sync"
@@ -1971,3 +1972,158 @@ func TestAPITrashNoToken(t *testing.T) {
t.Fatalf("an anonymous delete wrote a marker: %+v", tomb)
}
}
+
+// 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 cb1d18807..6fa89c19d 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -88,6 +88,24 @@ const (
// token's noise would starve the app's own signups, and a busy signup
// hour would refuse its minter's promotes.
cliInstanceBurst = 60
+
+ // 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
@@ -152,7 +170,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, instances *rateLimiter
+ start, poll, pollIP, guessAddr, guessIP, write, ship, instances, create *rateLimiter
}
func newCLILimits() *cliLimits {
@@ -175,6 +193,9 @@ func newCLILimits() *cliLimits {
// most of this work is not a person, so the address-keyed write
// budget is the wrong principal for it in both directions.
instances: newRateLimiter(cliInstanceBurst, cliRateWindow),
+ // Create gets its own budget on its own (day-long) window, keyed by
+ // address — the stolen-token ceiling handleAPICreateApp explains.
+ create: newRateLimiter(cliCreateBurst, cliCreateWindow),
}
}
@@ -193,6 +214,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 97a4c430f..8f62e5810 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1313,52 +1313,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
@@ -1371,34 +1375,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 840f15959..fb8b01722 100644
--- a/main.go
+++ b/main.go
@@ -47,9 +47,6 @@ func main() {
RunAuth(args)
case "accounts":
RunAccounts(args)
- // "apps" is shared: this branch adds delete/restore (apps.go); draft PR
- // #52 adds create. Whichever of the two merges second rebases its own
- // case onto this one rather than adding a second "case \"apps\":".
case "apps":
RunApps(args)
case "env":
@@ -112,13 +109,17 @@ 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 delete | restore — trash an app for 30 days
- (routes drop everywhere; refused while flagged
- production) or undo that within the window
- (no routes come back; carlos add <host> after);
- signed-in via the console or headless against
- the bucket (-account-id required headless); no
- purge here — "Delete now" is console-only
+ apps create | delete | restore — claim a new app
+ name (console API only, rate limited per
+ address per day; the console form is not),
+ trash an app for 30 days (routes drop
+ everywhere; refused while flagged production),
+ or undo that within the window (no routes come
+ back; carlos add <host> after); delete/restore
+ run signed-in via the console or headless
+ against the bucket (-account-id required
+ headless); no purge here — "Delete now" is
+ console-only
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>)