server+home: shell detection — destroy promoted for never-set-up owned spaces (#709)
The meter sweep already learns setup_complete + members per space; now it records them on the ledger row, the listing carries them, and the stern screen uses that server truth exactly where the live sole-admin check can't reach: an owned entry with no credentials (never enrolled) whose space was never set up and never joined is an unusable shell — destroy is promoted to the only action, with shell-specific copy. Guarded on metered_at so an unswept fresh space never trips it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+58
−17
cloud_db.go+27 −7home/web/static/home.js+26 −5meter.go+1 −1meter_test.go+4 −4
diff --git a/cloud_db.go b/cloud_db.go| index 34dd35f..7b394be 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -192,10 +192,23 @@ ALTER TABLE spaces ADD COLUMN metered_at INTEGER NOT NULL DEFAULT 0;`); err != n |
| } |
| // quota_bytes landed one change later (enforcement): what the cloud last |
| // pushed into the instance's LCHAT_BLOB_QUOTA_BYTES. 0 = never pushed. |
| - if has, err := db.spacesHasColumn("quota_bytes"); err != nil || has { |
| + if has, err := db.spacesHasColumn("quota_bytes"); err != nil { |
| + return err |
| + } else if !has { |
| + if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 0`); err != nil { |
| + return err |
| + } |
| + } |
| + // The sweep also records each space's claimed-ness (setup_complete / |
| + // members), so clients can tell an unusable shell from a live space with |
| + // server truth instead of client-held credentials. Defaults match a fresh |
| + // create exactly: never set up, one pre-seeded admin. |
| + if has, err := db.spacesHasColumn("setup_complete"); err != nil || has { |
| return err |
| } |
| - _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN quota_bytes INTEGER NOT NULL DEFAULT 0`) |
| + _, err := db.sql.Exec(` |
| +ALTER TABLE spaces ADD COLUMN setup_complete INTEGER NOT NULL DEFAULT 0; |
| +ALTER TABLE spaces ADD COLUMN members INTEGER NOT NULL DEFAULT 1;`) |
| return err |
| } |
| @@ -395,13 +408,18 @@ type Space struct { |
| StorageBytes int64 `json:"storage_bytes,omitempty"` |
| MeteredAt int64 `json:"metered_at,omitempty"` |
| StorageCap int64 `json:"storage_cap,omitempty"` |
| + // Claimed-ness, as of the last sweep: whether the admin ever finished |
| + // setup, and the member count (1 = just the pre-seeded admin). Lets the |
| + // owner's UI recognize an unusable shell with server truth. |
| + SetupComplete bool `json:"setup_complete"` |
| + Members int `json:"members,omitempty"` |
| } |
| // listSpaces returns a requester's finalized spaces (a NULL host is a create |
| // still in flight or one that failed before rollback — not shown). |
| func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| rows, err := db.sql.Query( |
| - `SELECT host, created_at, entitlement, tier, paid_until, cancel_at_period_end, storage_bytes, metered_at, quota_bytes FROM spaces |
| + `SELECT host, created_at, entitlement, tier, paid_until, cancel_at_period_end, storage_bytes, metered_at, quota_bytes, setup_complete, members FROM spaces |
| WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`, |
| callerID, requester, |
| ) |
| @@ -412,7 +430,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| out := []Space{} |
| for rows.Next() { |
| var s Space |
| - if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.CancelAtPeriodEnd, &s.StorageBytes, &s.MeteredAt, &s.StorageCap); err != nil { |
| + if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.CancelAtPeriodEnd, &s.StorageBytes, &s.MeteredAt, &s.StorageCap, &s.SetupComplete, &s.Members); err != nil { |
| return nil, err |
| } |
| s.URL = "https://" + s.Host |
| @@ -531,9 +549,11 @@ func (db *cloudDB) setQuotaBytes(id, quota int64) error { |
| return err |
| } |
| -// setStorageBytes records one meter reading. |
| -func (db *cloudDB) setStorageBytes(id, bytes, at int64) error { |
| - _, err := db.sql.Exec(`UPDATE spaces SET storage_bytes = ?, metered_at = ? WHERE id = ?`, bytes, at, id) |
| +// setStorageBytes records one meter reading plus the space's claimed-ness. |
| +func (db *cloudDB) setStorageBytes(id, bytes int64, setupComplete bool, members int, at int64) error { |
| + _, err := db.sql.Exec( |
| + `UPDATE spaces SET storage_bytes = ?, setup_complete = ?, members = ?, metered_at = ? WHERE id = ?`, |
| + bytes, setupComplete, members, at, id) |
| return err |
| } |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 31de01c..2027a0f 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -935,6 +935,12 @@ let archiving = null; |
| // (provisioner ownership confirmed) picks which way the note points instead. |
| let sternSoleAdmin = false; |
| let sternDestroyable = false; |
| +// sternShell: the provisioner's ledger says this owned space was never set up |
| +// and holds only its pre-seeded admin — an unusable shell nobody can enter. |
| +// Deleting access to one strands a quota slot forever, so destroy is promoted |
| +// to the only action. Server truth (the meter sweep records claimed-ness), so |
| +// it works exactly where checkSoleAdmin can't: entries with no credentials. |
| +let sternShell = false; |
| // openArchive shows the archive screen (the archived-spaces list). |
| function openArchive() { |
| @@ -1038,6 +1044,7 @@ async function openArchiveDelete(s) { |
| archiving = s; |
| sternSoleAdmin = false; |
| sternDestroyable = false; |
| + sternShell = false; |
| const host = hostOf(s.url); |
| $("stern-name").textContent = labelOf(s); |
| $("stern-host").textContent = host; |
| @@ -1073,9 +1080,16 @@ function checkOwnership(s, host) { |
| if (!spacesEnabled) return Promise.resolve(); |
| return apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) |
| .then((r) => { |
| - const mine = (r.spaces || []).some((sp) => sp.host === host); |
| - if (mine && archiving === s) { |
| + const row = (r.spaces || []).find((sp) => sp.host === host); |
| + if (row && archiving === s) { |
| sternDestroyable = true; |
| + // An owned space the ledger has SEEN (a sweep ran) but that was never |
| + // set up and never joined is an unusable shell — and when the entry |
| + // carries no credentials (never enrolled), no live check could ever |
| + // say otherwise. Promote destroy; deleting access would only strand |
| + // the quota slot. |
| + const token = s.credentials && s.credentials.token; |
| + sternShell = !token && !row.setup_complete && (row.members || 1) <= 1 && !!row.metered_at; |
| prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule) |
| updateSternHold(); |
| } |
| @@ -1113,7 +1127,7 @@ function checkSoleAdmin(s) { |
| // title, copy, and the destroy button promoted to the primary slot. Rendering |
| // is declarative from the two flags, so straggling checks re-render cleanly. |
| function updateSternHold() { |
| - const destroyOnly = sternSoleAdmin && sternDestroyable; |
| + const destroyOnly = (sternSoleAdmin || sternShell) && sternDestroyable; |
| $("stern-title").textContent = destroyOnly ? "Destroy this space?" : "Delete your access?"; |
| $("stern-intro").classList.toggle("hidden", destroyOnly); |
| $("delete-access").classList.toggle("hidden", destroyOnly); |
| @@ -1124,8 +1138,15 @@ function updateSternHold() { |
| destroy.className = destroyOnly ? "btn-danger" : "linkish danger-link"; |
| destroy.textContent = destroyOnly ? "Destroy the space for everyone" : "Destroy the space for everyone…"; |
| const note = $("sole-admin-note"); |
| - if (!sternSoleAdmin) { note.classList.add("hidden"); return; } |
| - if (destroyOnly) { |
| + if (!sternSoleAdmin && !sternShell) { note.classList.add("hidden"); return; } |
| + if (sternShell) { |
| + const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| + const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); |
| + note.replaceChildren( |
| + host, " was never set up — it has no members and no way in, but it still ", |
| + "counts toward your space limit. Destroying it frees that slot. ", noUndo, |
| + ); |
| + } else if (destroyOnly) { |
| const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); |
| note.replaceChildren( |
diff --git a/meter.go b/meter.go| index 626b652..2b85d3c 100644 |
| --- a/meter.go |
| +++ b/meter.go |
| @@ -98,7 +98,7 @@ func (s *cloudServer) meterOnce() (metered int) { |
| log.Printf("cloud: meter: skip %s (stats unavailable): %v", sp.Host, err) |
| continue |
| } |
| - if err := s.db.setStorageBytes(sp.ID, st.StorageBytes, now); err != nil { |
| + if err := s.db.setStorageBytes(sp.ID, st.StorageBytes, st.SetupComplete, st.Members, now); err != nil { |
| log.Printf("cloud: meter: record %s: %v", sp.Host, err) |
| continue |
| } |
diff --git a/meter_test.go b/meter_test.go| index a245f80..923dbed 100644 |
| --- a/meter_test.go |
| +++ b/meter_test.go |
| @@ -54,7 +54,7 @@ func TestMeterOnceRecordsReadings(t *testing.T) { |
| w.WriteHeader(http.StatusInternalServerError) // stats down for b |
| return |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[h]}) |
| + writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[h], "setup_complete": true, "members": 3}) |
| }) |
| sock := fakeControl(t, mux.ServeHTTP) |
| s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"} |
| @@ -70,10 +70,10 @@ func TestMeterOnceRecordsReadings(t *testing.T) { |
| for _, sp := range spaces { |
| byHost[sp.Host] = sp |
| } |
| - if got := byHost["a.11ch.at"]; got.StorageBytes != 12345 || got.MeteredAt == 0 { |
| - t.Errorf("a = %+v, want 12345 bytes with a timestamp", got) |
| + if got := byHost["a.11ch.at"]; got.StorageBytes != 12345 || got.MeteredAt == 0 || !got.SetupComplete || got.Members != 3 { |
| + t.Errorf("a = %+v, want 12345 bytes, claimed-ness recorded, timestamp", got) |
| } |
| - if got := byHost["b.11ch.at"]; got.StorageBytes != 0 || got.MeteredAt != 0 { |
| + if got := byHost["b.11ch.at"]; got.StorageBytes != 0 || got.MeteredAt != 0 || got.SetupComplete { |
| t.Errorf("b = %+v, want untouched (stats error is a skip)", got) |
| } |
| _ = idA |