eleven / messenger Public

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>
Paul Campbell dbb115e3facf5230c7ce3a235c6fa438928cc4a1 parent 5dbed32
4 files changed, +58 −17
  • cloud_db.go +27 −7
  • home/web/static/home.js +26 −5
  • meter.go +1 −1
  • meter_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