home: gate space creation to existing members (blob-existence check) (#437)
Keeps self-serve space creation invite-gated — not open to the world — while it's early, without the server ever reading the E2E-encrypted saved-spaces list. The home server can't see your list (it's sealed in the vault blob), so the enforceable proxy is "has a backup blob": a blob only exists once you've enrolled at least one space, and enrolling needs a space you were invited into. So: no invite → no joined space → no blob → can't create. - Server (the boundary): POST /api/spaces returns 403 not_eligible unless the caller already has a blob (s.db.getBlob existence, contents never read). - Client (the UX): home.js hides the "+" unless the list is non-empty (canCreateSpace); requestSpace already surfaces the 403 message inline if raced. Lift it later by removing the check — the invite-only funnel becomes self-serve. Tests: TestCreateSpaceRequiresExistingSpace (no blob → 403, provisioner never called; then blob → 201). Existing create tests seed a blob via a new seedMember helper (a realistic signed-in user has one).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
4 files changed,
+84
−7
docs/cloud-provisioner.md+10 −2home/provision.go+17 −0home/provision_test.go+47 −3home/web/static/home.js+10 −2
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 89204f8..a028d1f 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -156,6 +156,7 @@ POST /instances bearer: control token {host, admin_token?, …env fields} |
| ``` |
| POST /api/spaces bearer: device link token |
| → relays the cloud response (201 / 409 / 503 shapes above) |
| + 403 {error: not_eligible} caller has no saved space yet (see gate below) |
| 501 {error: unavailable} this home server has no provisioner configured |
| GET /api/spaces bearer: device link token |
| → the hosts this user provisioned (drives the "Delete space" affordance) |
| @@ -164,8 +165,15 @@ DELETE /api/spaces/{host} X-Ceremony-Proof: fresh passkey ceremony ONLY |
| ``` |
| Creation and listing are link-token-authed (additive/reads) and creation is |
| -rate-limited per IP; the real abuse bound is the provisioner's quota. **Deletion |
| -is ceremony-gated** — same rule as revoking devices or deleting the backup: a |
| +rate-limited per IP; the real abuse bound is the provisioner's quota. **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 |
| +keeps creation invite-gated (not open to the world) while it's early, without the |
| +server ever reading the E2E-encrypted server list. The client hides the "+" when |
| +the list is empty; the 403 is the boundary behind that. Lift it later by removing |
| +the check (the invite-only funnel becomes self-serve). **Deletion is |
| +ceremony-gated** — same rule as revoking devices or deleting the backup: a |
| stolen link token must never be able to destroy a space. The UI adds its own |
| stern step (type the space's address to arm the button) before the ceremony, |
| and the cloud layer re-checks ownership against its ledger regardless: only the |
diff --git a/home/provision.go b/home/provision.go| index e4942d7..10fc4df 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -67,6 +67,23 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| + // Gated to existing members: you can only create a space once you already |
| + // have one. A backup blob exists only after enrolling ≥1 space, and enrolling |
| + // needs a space you were invited into — so this keeps creation invite-gated |
| + // (not open to the world) without the server ever reading the E2E-encrypted |
| + // list. The client hides the button too; this is the boundary behind it. |
| + if _, _, hasBlob, err := s.db.getBlob(uid); err != nil { |
| + writeJSON(w, http.StatusInternalServerError, map[string]any{ |
| + "error": "create_failed", "message": "could not check your account", |
| + }) |
| + return |
| + } else if !hasBlob { |
| + writeJSON(w, http.StatusForbidden, map[string]any{ |
| + "error": "not_eligible", |
| + "message": "You need at least one Eleven space before you can create a new one.", |
| + }) |
| + return |
| + } |
| status, body, err := s.cloud.createSpace(uid) |
| if err != nil { |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
diff --git a/home/provision_test.go b/home/provision_test.go| index b5d53d1..b9b342e 100644 |
| --- a/home/provision_test.go |
| +++ b/home/provision_test.go |
| @@ -39,6 +39,17 @@ func postSpaces(t *testing.T, ts *httptest.Server, token string) *http.Response |
| return resp |
| } |
| +// seedMember is a seedUser who already has a saved space (a backup blob) — the |
| +// state the create gate requires, and the realistic state of any signed-in user. |
| +func seedMember(t *testing.T, s *Server) (userID, token string) { |
| + t.Helper() |
| + userID, token = seedUser(t, s) |
| + if _, _, err := s.db.putBlob(userID, []byte("vault"), 0); err != nil { |
| + t.Fatalf("putBlob: %v", err) |
| + } |
| + return userID, token |
| +} |
| + |
| func TestFeaturesReflectsProvisioner(t *testing.T) { |
| ts, s := newTestServer(t) |
| @@ -92,7 +103,7 @@ func TestCreateSpaceUnconfigured(t *testing.T) { |
| func TestCreateSpaceRelaysSuccess(t *testing.T) { |
| ts, s := newTestServer(t) |
| - uid, token := seedUser(t, s) |
| + uid, token := seedMember(t, s) |
| cloud, captured, capturedBody := fakeCloud(t, http.StatusCreated, map[string]any{ |
| "host": "amber-fox-42.example.com", "url": "https://amber-fox-42.example.com", |
| @@ -122,9 +133,42 @@ func TestCreateSpaceRelaysSuccess(t *testing.T) { |
| } |
| } |
| +func TestCreateSpaceRequiresExistingSpace(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + uid, token := seedUser(t, s) // a signed-in user with NO saved space (no blob) |
| + |
| + cloud, captured, _ := fakeCloud(t, http.StatusCreated, map[string]any{ |
| + "host": "h.example.com", "url": "https://h.example.com", |
| + "setup_link": "https://h.example.com/join/setup#S", |
| + }) |
| + s.cloud = newCloudProvisioner(cloud.URL, "caller-token") |
| + |
| + // No space yet → gated with 403 not_eligible, and the provisioner is never called. |
| + resp := postSpaces(t, ts, token) |
| + if resp.StatusCode != http.StatusForbidden { |
| + t.Fatalf("no-space create = %d, want 403", resp.StatusCode) |
| + } |
| + var m map[string]any |
| + json.NewDecoder(resp.Body).Decode(&m) |
| + if m["error"] != "not_eligible" { |
| + t.Errorf("error = %v, want not_eligible", m["error"]) |
| + } |
| + if captured.Method != "" { |
| + t.Error("provisioner must not be called when the caller is ineligible") |
| + } |
| + |
| + // Once they have a saved space (a blob), creation goes through. |
| + if _, _, err := s.db.putBlob(uid, []byte("vault"), 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if resp2 := postSpaces(t, ts, token); resp2.StatusCode != http.StatusCreated { |
| + t.Fatalf("with-space create = %d, want 201", resp2.StatusCode) |
| + } |
| +} |
| + |
| func TestCreateSpaceRelaysQuotaAndCapacity(t *testing.T) { |
| ts, s := newTestServer(t) |
| - _, token := seedUser(t, s) |
| + _, token := seedMember(t, s) |
| quota, _, _ := fakeCloud(t, http.StatusConflict, map[string]any{ |
| "error": "quota_exceeded", "message": "this account already has 3 spaces (the limit)", |
| @@ -157,7 +201,7 @@ func TestCreateSpaceRelaysQuotaAndCapacity(t *testing.T) { |
| func TestCreateSpaceFlattensUnexpectedStatus(t *testing.T) { |
| ts, s := newTestServer(t) |
| - _, token := seedUser(t, s) |
| + _, token := seedMember(t, s) |
| weird, _, _ := fakeCloud(t, http.StatusTeapot, map[string]any{"error": "internal-detail"}) |
| s.cloud = newCloudProvisioner(weird.URL, "caller-token") |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index b373e91..dd10f11 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -201,12 +201,20 @@ function signOut() { |
| // ---- spaces list -------------------------------------------------------------- |
| +// Creating a space is gated to existing members: only offered once you already |
| +// have at least one saved space. Mirrors (and stays ahead of) the home server's |
| +// blob-existence gate on POST /api/spaces, so creation stays invite-gated — not |
| +// open to the world — rather than surfacing a button that 403s. |
| +function canCreateSpace() { |
| + return spacesEnabled && !!(vault && (vault.obj.servers || []).length); |
| +} |
| + |
| function showVault() { |
| $("signed-out").classList.add("hidden"); |
| $("reassure").classList.add("hidden"); |
| $("create").classList.add("hidden"); |
| $("vault").classList.remove("hidden"); |
| - $("open-create").classList.toggle("hidden", !spacesEnabled); // the + to create |
| + $("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create |
| const box = $("spaces"); |
| box.textContent = ""; |
| @@ -749,7 +757,7 @@ $("space-search").addEventListener("input", () => { if (vault) showVault(); }); |
| apiJSON("/api/features") |
| .then((f) => { |
| spacesEnabled = !!f.spaces; |
| - if (vault) $("open-create").classList.toggle("hidden", !spacesEnabled); |
| + if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace()); |
| }) |
| .catch(() => { /* older home server without the endpoint: leave the + hidden */ }); |