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>
6 files changed,
+100
−11
changelog/2026-07-08-last-admin-guard.md+5 −0home/main.go+7 −2home/test/serverlist.test.mjs+10 −0home/web/pages/index.html+6 −2home/web/static/home.js+61 −7home/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; |
| +} |