server+home: creation gates — fleet-wide budget + fresh passkeys are ineligible (#679)
Two holes closed. (1) createPasskey seeded an EMPTY vault blob, and blob-existence is home's creation-eligibility gate — so a fresh account with zero spaces passed the API check (the + stayed hidden, but curl didn't care). No blob is written now until the first space enrolls (enroll.js already handles blob-less passkeys: 'start one under it'). (2) LCHAT_CLOUD_MAX_CREATES_PER_DAY: a fleet-wide trailing-24h creation circuit breaker on the cloud service, counted against an append-only creates_log so create-and-delete can't reset it. Docs record the whole posture: passkeys are the bot check; no fingerprinting, ever.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+92
−3
cloud.go+17 −0cloud_billing_test.go+38 −0cloud_db.go+17 −1docs/cloud-provisioner.md+15 −1home-linux-amd64binaryhome/web/static/home.js+5 −1
diff --git a/cloud.go b/cloud.go| index eb89b2c..828c356 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -83,6 +83,7 @@ type cloudServer struct { |
| reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited) |
| billing *stripeBilling // optional space upgrades (billing.go); nil = not offered |
| storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| + createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped) |
| } |
| func (s *cloudServer) handler() http.Handler { |
| @@ -148,6 +149,17 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request) |
| return |
| } |
| + // The fleet-wide circuit breaker: a trailing-24h creation budget, counted |
| + // against an append-only log so create-and-delete can't reset it. Insurance |
| + // for the pre-production era — the per-account gates do the fine-grained work. |
| + if s.createBudget > 0 { |
| + if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget { |
| + log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing create", n, s.createBudget) |
| + writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| + return |
| + } |
| + } |
| + |
| // Reserve the id (fails closed if over quota). The row is pending (NULL host) |
| // until the instance is up; on any failure below we delete it so it doesn't |
| // hold a quota slot. |
| @@ -382,6 +394,7 @@ func runCloud(args []string) { |
| reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything") |
| reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)") |
| meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)") |
| + createBudget := fs.Int("max-creates-per-day", envInt("LCHAT_CLOUD_MAX_CREATES_PER_DAY", 0), "fleet-wide creation circuit breaker: max spaces per trailing 24h (0 = uncapped)") |
| _ = fs.Parse(args) |
| if *baseDomain == "" || !validHost("x."+*baseDomain) { |
| @@ -425,6 +438,10 @@ func runCloud(args []string) { |
| reapMax: *reapMax, |
| billing: billing, |
| storageCaps: storageCaps, |
| + createBudget: *createBudget, |
| + } |
| + if *createBudget > 0 { |
| + log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget) |
| } |
| if len(storageCaps) > 0 { |
| log.Printf("cloud: storage caps on (%d entries)", len(storageCaps)) |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index 50ab085..f1ab232 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -434,3 +434,41 @@ func TestQuotaExemptsPaidSpaces(t *testing.T) { |
| t.Fatalf("refilled quota: status = %d, want 409", resp.StatusCode) |
| } |
| } |
| + |
| +// TestCreateBudgetCircuitBreaker pins the fleet-wide throttle: past the |
| +// trailing-24h budget, creation answers the capacity message — and deleting |
| +// spaces doesn't reset it (the log is append-only). |
| +func TestCreateBudgetCircuitBreaker(t *testing.T) { |
| + srv, s, _ := billingTestCloud(t) |
| + s.createBudget = 2 |
| + tok := mintCaller(t, srv, "home") |
| + |
| + var firstHost string |
| + for i := 0; i < 2; i++ { |
| + resp := createSpace(t, srv, tok, "user-1") |
| + if resp.StatusCode != http.StatusCreated { |
| + t.Fatalf("create %d: %d", i, resp.StatusCode) |
| + } |
| + if i == 0 { |
| + firstHost, _ = decodeBody(t, resp)["host"].(string) |
| + } |
| + } |
| + resp := createSpace(t, srv, tok, "user-2") // a DIFFERENT account — budget is global |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("over budget: %d, want 503", resp.StatusCode) |
| + } |
| + if m := decodeBody(t, resp); m["error"] != "at_capacity" || m["message"] != spaceUnavailableMsg { |
| + t.Errorf("over budget body = %v", m) |
| + } |
| + |
| + // Deleting a space frees its quota slot but NOT the creation budget: |
| + // the log is append-only, so create-and-delete can't reset the throttle. |
| + label, _, _ := strings.Cut(firstHost, ".") |
| + id, _ := spaceID(label) |
| + if err := s.db.deleteSpaceByID(id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusServiceUnavailable { |
| + t.Errorf("post-delete create: %d, want 503 (budget must not reset)", r.StatusCode) |
| + } |
| +} |
diff --git a/cloud_db.go b/cloud_db.go| index fb55964..59c7aa0 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -59,6 +59,11 @@ CREATE TABLE IF NOT EXISTS spaces ( |
| caller_id TEXT NOT NULL, |
| requester TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +); |
| +-- Every allocation, forever (rows here survive space deletion): the global |
| +-- creation budget counts THIS, so create-and-delete can't reset the throttle. |
| +CREATE TABLE IF NOT EXISTS creates_log ( |
| + created_at INTEGER NOT NULL |
| );`); err != nil { |
| return err |
| } |
| @@ -277,19 +282,30 @@ func (db *cloudDB) allocateSpace(callerID, requester string, quota int) (id int6 |
| if n >= quota { |
| return 0, true, nil |
| } |
| + now := time.Now().Unix() |
| res, err := tx.Exec( |
| `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL, ?, ?, ?)`, |
| - callerID, requester, time.Now().Unix(), |
| + callerID, requester, now, |
| ) |
| if err != nil { |
| return 0, false, err |
| } |
| + if _, err := tx.Exec(`INSERT INTO creates_log(created_at) VALUES(?)`, now); err != nil { |
| + return 0, false, err |
| + } |
| if id, err = res.LastInsertId(); err != nil { |
| return 0, false, err |
| } |
| return id, false, tx.Commit() |
| } |
| +// createsSince counts allocations at/after cutoff — the global budget's meter. |
| +func (db *cloudDB) createsSince(cutoff int64) (int, error) { |
| + var n int |
| + err := db.sql.QueryRow(`SELECT COUNT(*) FROM creates_log WHERE created_at >= ?`, cutoff).Scan(&n) |
| + return n, err |
| +} |
| + |
| // finalizeSpace records the hostname once the instance is created. |
| func (db *cloudDB) finalizeSpace(id int64, host string) error { |
| _, err := db.sql.Exec(`UPDATE spaces SET host = ? WHERE id = ?`, host, id) |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index dc50c24..903ed71 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -204,7 +204,21 @@ bound to the account; the client grinds SHA-256(challenge:nonce) to that many |
| leading zero bits during the fader's build phase and submits it with the |
| create. Verification is one hash; a consumed challenge never buys a second |
| space. This prices the Sybil loop (invite yourself → fresh account → 3 more |
| -free spaces) in CPU. **Creation |
| +free spaces) in CPU. |
| + |
| +Above all of that sits the **fleet-wide creation budget** |
| +(`LCHAT_CLOUD_MAX_CREATES_PER_DAY`, 0/unset = uncapped): a trailing-24h |
| +circuit breaker counted against an append-only log, so create-and-delete |
| +can't reset it. Past the budget, creation answers the same member-facing |
| +at-capacity message. This is the pre-production insurance dial — the |
| +per-account gates do the fine-grained work; this bounds a bad day. |
| + |
| +On bot detection generally, the position is deliberate: **passkeys are the |
| +bot check**. Creating an account requires a platform-authenticator WebAuthn |
| +ceremony; there is no fingerprinting, UA sniffing, or behavioral scoring |
| +anywhere, and there won't be — the layers are cryptographic (passkeys), |
| +economic (hashcash, storage caps), and quantitative (quotas, rate limits, |
| +the budget, the reaper). **Creation |
| is also gated to existing members:** `POST /api/spaces` returns `403 not_eligible` |
| unless the caller already has a backup blob. A blob only exists once you've |
| enrolled ≥1 space, and enrolling needs a space you were *invited* into — so this |
diff --git a/home-linux-amd64 b/home-linux-amd64| new file mode 100755 |
| index 0000000..53a9981 |
| Binary files /dev/null and b/home-linux-amd64 differ |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 2e170f9..d2e7db5 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -205,12 +205,16 @@ async function createPasskey() { |
| const kek = await kekFromPRF(reg.prf); |
| dekKey = await generateDEK(); |
| state.dek = await exportDEK(dekKey); |
| + // Deliberately NOT persisted: no blob is written until the first space is |
| + // enrolled (enroll.js starts one under this passkey). Blob-existence is the |
| + // server's creation-eligibility gate — "has at least one space" — and an |
| + // empty vault saved here would quietly turn every fresh passkey into an |
| + // eligible account (the + stays hidden, but the API wouldn't 403). |
| vault = { |
| obj: { v: 1, servers: [] }, |
| keys: { [reg.credId]: await wrapDEK(dekKey, kek) }, |
| version: "0", |
| }; |
| - await saveVault(); |
| btn.disabled = false; |
| setStatus("Your sign-in is ready — next time it's one tap.", "ok"); |
| showVault(); |