eleven / messenger Public

home: hold 'Delete your access' when it's the space's only admin key (#459)

Deleting the last saved copy of a space's ONLY admin key orphans the
space — it runs forever with no admin and the reaper (rightly) never
touches a set-up space. Now the stern delete step asks the space itself
via its /api/me (browser-side fetch with the saved session token, so
home stays blind to which spaces you hold; CSP widened to connect-src
https: on the main page only). On a positive sole-admin answer the
delete button is held and an inline note steers to Destroy-for-everyone
(when this account created the space) or to promoting another admin
first. Fails open — an unreachable space or an older build never makes
an entry undeletable. Requires the sole_admin field from PR #458.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0301a192cba3db58e22fcdea525646deca3b7977 parent b73eaef
6 files changed, +100 −11
  • changelog/2026-07-08-last-admin-guard.md +5 −0
  • home/main.go +7 −2
  • home/test/serverlist.test.mjs +10 −0
  • home/web/pages/index.html +6 −2
  • home/web/static/home.js +61 −7
  • home/web/static/serverlist.js +11 −0
diff --git a/changelog/2026-07-08-last-admin-guard.md b/changelog/2026-07-08-last-admin-guard.md
new file mode 100644
index 0000000..d452331
--- /dev/null
+++ b/changelog/2026-07-08-last-admin-guard.md
@@ -0,0 +1,5 @@
+🤖 A safety catch for space admins: if you hold the only admin key to a space,
+the passkey page will no longer let you delete your saved access to it — that
+would leave the space running forever with nobody able to manage it. Instead
+it points you the right way: destroy the space properly if it's yours to
+destroy, or make someone else an admin first.
diff --git a/home/main.go b/home/main.go
index 627ee94..8e2a93d 100644
--- a/home/main.go
+++ b/home/main.go
@@ -220,10 +220,15 @@ func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
return
}
// No inline scripts on this page, so no nonce needed — everything the CSP
- // allows is our own embedded modules and styles.
+ // allows is our own embedded modules and styles. connect-src admits https:
+ // beyond 'self' for exactly one client-side move: the last-admin check asks a
+ // saved space's own /api/me before allowing "Delete your access" (home.js
+ // checkSoleAdmin). Space hosts are dynamic, so they can't be allowlisted by
+ // name — and the fetch happening in the browser is the point: this server
+ // stays blind to which spaces a user holds.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
- "img-src 'self' data:; connect-src 'self'; "+
+ "img-src 'self' data:; connect-src 'self' https:; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs
index 1a5da4e..3a52562 100644
--- a/home/test/serverlist.test.mjs
+++ b/home/test/serverlist.test.mjs
@@ -11,6 +11,7 @@ import {
orderServers,
filterServers,
fuzzyMatch,
+ soleAdminHold,
} from "../web/static/serverlist.js";
let failures = 0;
@@ -215,5 +216,14 @@ await test("filterServers keeps fuzzy matches over name + host, order preserved"
assert.deepEqual(filterServers(servers, "zzzz"), [], "no matches");
});
+await test("soleAdminHold engages only on an explicit sole-admin answer (fail-open)", () => {
+ assert.equal(soleAdminHold({ role: "admin", sole_admin: true }), true, "the one positive case");
+ assert.equal(soleAdminHold({ role: "admin", sole_admin: false }), false, "other admins exist");
+ assert.equal(soleAdminHold({ role: "admin" }), false, "older build without the field");
+ assert.equal(soleAdminHold({ role: "member", sole_admin: true }), false, "field without the role");
+ assert.equal(soleAdminHold({ role: "admin", sole_admin: "true" }), false, "malformed answer");
+ assert.equal(soleAdminHold(null), false, "no answer at all");
+});
+
console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED");
process.exit(failures ? 1 : 0);
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 660653c..d912d65 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -388,8 +388,9 @@
<!-- The archive: spaces you've set aside, hidden from the main list. Bring one
back (unarchive), or delete it forever behind a stern typed-confirmation —
- "delete your access" always, and for spaces this account created, the
- deeper "destroy for everyone" (fresh-passkey ceremony). -->
+ "delete your access" (held while the space reports this is its ONLY admin
+ key), and for spaces this account created, the deeper "destroy for
+ everyone" (fresh-passkey ceremony). -->
<div id="archive" class="hidden screen">
<button id="archive-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<div id="archive-main">
@@ -403,6 +404,9 @@
saved way back into <b id="stern-host"></b>. If you're not signed in to it on
another device, you'll lose access <b>forever</b> — there will be no way back
in. <b>This cannot be undone.</b></p>
+ <!-- The last-admin hold: revealed when the space itself reports this is its
+ ONLY admin key — deleting the last saved copy would orphan the space. -->
+ <p class="create-sub stern-copy danger-ink hidden" id="sole-admin-note"></p>
<input id="delete-arm" type="text" autocomplete="off" autocapitalize="off"
spellcheck="false" aria-label="Type the space address to confirm">
<button id="delete-access" type="button" class="btn-danger" disabled>Delete forever</button>
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 4a4ea1e..908f4d4 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -21,7 +21,7 @@ import {
} from "/static/backup-core.js";
import {
partitionServers, partitionArchived, setArchived,
- toggleStar, moveInGroup, orderServers, filterServers,
+ toggleStar, moveInGroup, orderServers, filterServers, soleAdminHold,
} from "/static/serverlist.js";
import { mountFader } from "/static/fader.js";
@@ -688,6 +688,12 @@ function closeCreate() {
// archiving is the entry currently in the stern delete step of the archive.
let archiving = null;
+// The last-admin guard: before letting someone drop the only saved copy of a
+// space's ONLY admin key, ask the space itself (checkSoleAdmin). While
+// sternSoleAdmin is set, "Delete your access" stays off; sternDestroyable
+// (provisioner ownership confirmed) picks which way the note points instead.
+let sternSoleAdmin = false;
+let sternDestroyable = false;
// openArchive shows the archive screen (the archived-spaces list).
function openArchive() {
@@ -772,17 +778,21 @@ function unarchiveSpace(s) {
}
// openArchiveDelete opens the stern permanent-delete step for one archived space.
-// "Delete your access" (drop the saved entry) is always offered; for a space THIS
-// account created, the deeper "destroy for everyone" is revealed too (ownership
-// checked against the provisioner ledger; the server re-enforces it on delete).
+// "Delete your access" (drop the saved entry) is offered unless the space reports
+// this is its ONLY admin key (checkSoleAdmin); for a space THIS account created,
+// the deeper "destroy for everyone" is revealed too (ownership checked against
+// the provisioner ledger; the server re-enforces it on delete).
function openArchiveDelete(s) {
archiving = s;
+ sternSoleAdmin = false;
+ sternDestroyable = false;
const host = hostOf(s.url);
$("stern-name").textContent = labelOf(s);
$("stern-host").textContent = host;
$("delete-arm").value = "";
$("delete-arm").placeholder = host;
$("delete-access").disabled = true;
+ $("sole-admin-note").classList.add("hidden");
$("destroy-zone").classList.add("hidden");
$("destroy-forever").disabled = true;
$("archive-main").classList.add("hidden");
@@ -793,12 +803,54 @@ function openArchiveDelete(s) {
.then((r) => {
const mine = (r.spaces || []).some((sp) => sp.host === host);
if (mine && archiving === s) {
+ sternDestroyable = true;
$("destroy-zone").classList.remove("hidden");
prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule)
+ updateSternHold(); // the note steers to destroy once it's on the table
}
})
.catch(() => { /* can't confirm ownership — don't offer the destroy */ });
}
+ checkSoleAdmin(s);
+}
+
+// checkSoleAdmin asks the space itself — with the saved session token, straight
+// from the browser so this server stays blind to which spaces the user holds —
+// whether this key is its ONLY admin key (instances answer CORS for exactly this
+// origin on /api/me). Fails OPEN: an unreachable space or an older build without
+// the field must never make its entry undeletable, so the hold only ever engages
+// on a positive answer.
+function checkSoleAdmin(s) {
+ const token = s.credentials && s.credentials.token;
+ if (!token) return;
+ let url;
+ try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return; }
+ fetch(url, { signal: AbortSignal.timeout(8000) })
+ .then((r) => (r.ok ? r.json() : null))
+ .then((me) => {
+ if (soleAdminHold(me) && archiving === s) {
+ sternSoleAdmin = true;
+ updateSternHold();
+ }
+ })
+ .catch(() => { /* no answer — no hold */ });
+}
+
+// updateSternHold renders the last-admin hold: "Delete your access" goes (and
+// stays) off, and the note says why and what to do instead — destroy the space
+// below when this account created it, otherwise hand admin to someone else first.
+function updateSternHold() {
+ const note = $("sole-admin-note");
+ if (!sternSoleAdmin) { note.classList.add("hidden"); return; }
+ note.textContent = sternDestroyable
+ ? "This is the space's only admin key. Deleting just your access would leave " +
+ "the space running with no admin, forever — to actually shut it down, use " +
+ "“Destroy the space for everyone” below instead."
+ : "This is the space's only admin key. Deleting it would leave the space " +
+ "running with no admin, forever. Make another member an admin first, then " +
+ "come back to delete your access.";
+ note.classList.remove("hidden");
+ $("delete-access").disabled = true;
}
// After a delete/destroy empties the entry, return to wherever makes sense: the
@@ -819,7 +871,7 @@ function afterDelete(okMsg) {
// keeps running for everyone else; this only removes THIS account's saved way
// back in (the strongly-worded warning makes that consequence explicit).
async function deleteAccess() {
- if (!archiving) return;
+ if (!archiving || sternSoleAdmin) return; // the last-admin hold — see updateSternHold
const s = archiving;
vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url);
archiveStatus("Deleting…", "busy");
@@ -912,10 +964,12 @@ $("archive-back").addEventListener("click", closeArchive);
$("archive-cancel").addEventListener("click", closeArchiveStern);
$("delete-access").addEventListener("click", deleteAccess);
$("destroy-forever").addEventListener("click", destroyForEveryone);
-// The typed address arms both delete actions — exact match only.
+// The typed address arms both delete actions — exact match only. The last-admin
+// hold outranks arming for "Delete your access" (destroy is the sanctioned path,
+// so it still arms).
$("delete-arm").addEventListener("input", () => {
const armed = $("delete-arm").value.trim() === $("stern-host").textContent;
- $("delete-access").disabled = !armed;
+ $("delete-access").disabled = !armed || sternSoleAdmin;
$("destroy-forever").disabled = !armed;
});
// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js
index 02e57ac..c1cde52 100644
--- a/home/web/static/serverlist.js
+++ b/home/web/static/serverlist.js
@@ -129,3 +129,14 @@ export function moveInGroup(servers, url, dir) {
[arr[i], arr[j]] = [arr[j], arr[i]];
return arr;
}
+
+// soleAdminHold decides whether "Delete your access" must be held, given a
+// space's /api/me answer. The hold protects against orphaning: if this key is
+// the space's ONLY admin key, dropping its last saved copy leaves the space
+// running with no admin, forever. Fail-OPEN by design — only an explicit,
+// well-formed "yes, admin, and the only one" engages the hold, so an
+// unreachable space, an older build without the field, or a malformed answer
+// never makes an entry undeletable.
+export function soleAdminHold(me) {
+ return !!me && me.role === "admin" && me.sole_admin === true;
+}