eleven / messenger Public

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>
Paul Campbell 4fff7431ecc99d80e3cd9a6dc37156daa2dd156e parent c96872a
6 files changed, +92 −3
  • cloud.go +17 −0
  • cloud_billing_test.go +38 −0
  • cloud_db.go +17 −1
  • docs/cloud-provisioner.md +15 −1
  • home-linux-amd64 binary
  • home/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();