eleven / ocho Public

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>
Paul Campbell 4a633f2ff71809d7765193ec53cb8fe337ea4c6e parent 9dbc74b
4 files changed, +84 −7
  • docs/cloud-provisioner.md +10 −2
  • home/provision.go +17 −0
  • home/provision_test.go +47 −3
  • home/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 */ });