eleven / messenger Public

home,cloud,controld: remove spaces from the list; stern delete for yours (#372)

Two distinct actions on the home spaces list, per feedback after tearing
down test spaces left dead entries in the vault:

REMOVE FROM LIST (light, vault-only): each row gains an × that reveals
on hover (same pattern as the drag grip) and opens a manage screen —
name, address, and "Remove from list" with copy making clear the space
itself keeps running and the way back in is an invite/add-device link.

DELETE THE SPACE (stern, permanent, only for spaces you created): the
manage screen shows "Delete this space permanently…" only when the
provisioner's ledger says this account created that host (GET
/api/spaces proxies the ledger). The stern step is a separate pane in
red: it names what's destroyed and for whom, requires TYPING the space's
address to arm the button, then a FRESH passkey ceremony (Face ID)
authorises it — a link token can never destroy a space, matching home's
existing destructive-op rule. Cancel path included.

Pipeline: home DELETE /api/spaces/{host} (ceremony-gated) → cloud DELETE
/api/instances/{host} (ownership: exact caller+requester+host ledger
match — spaces merely saved by URL are never deletable here; row deleted
frees the quota slot) → controld DELETE /instances/{host} (stops units,
drops the route, reloads the router, and ARCHIVES the DB/env to
/data/trash/<host>-<unix>/ rather than destroying them — the operator
safety net; cert + live link removed). unknown_host from controld still
clears the ledger row, so an operator-removed space can be tidied away.

Tests: controld (401/404/200 + archive-to-trash asserted), cloud
(ownership 404, owner 200 + quota freed, repeat 404), home (link token
refused for DELETE, fresh proof accepted, requester forwarded; ledger
list proxied). docs/cloud-provisioner.md updated.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4ad5e0a76d4376022d12f93ded7ac48aec025fb6 parent cec9db4
12 files changed, +650 −10
  • cloud.go +64 −0
  • cloud_db.go +18 −0
  • cloud_test.go +76 −0
  • control.go +52 −0
  • controld.go +25 −0
  • controld_test.go +47 −0
  • docs/cloud-provisioner.md +20 −8
  • home/main.go +4 −0
  • home/provision.go +72 −2
  • home/provision_test.go +72 −0
  • home/web/pages/index.html +62 −0
  • home/web/static/home.js +138 −0
diff --git a/cloud.go b/cloud.go
index 9552d49..b158358 100644
--- a/cloud.go
+++ b/cloud.go
@@ -85,6 +85,7 @@ func (s *cloudServer) handler() http.Handler {
// The provisioning surface: bearer caller token (a home server).
mux.HandleFunc("POST /api/instances", s.callerAuth(s.createSpaceHandler))
mux.HandleFunc("GET /api/instances", s.callerAuth(s.listSpacesHandler))
+ mux.HandleFunc("DELETE /api/instances/{host}", s.callerAuth(s.deleteSpaceHandler))
// Admin surface: mint / list / revoke caller tokens at runtime (mirrors the
// push relay's admin API).
mux.HandleFunc("POST /api/admin/tokens", s.admin(s.mintTokenHandler))
@@ -213,6 +214,69 @@ func (s *cloudServer) controlCreate(host string) (int, map[string]any, error) {
return resp.StatusCode, body, nil
}
+// deleteSpaceHandler permanently deprovisions a space — gated on ownership: the
+// (caller, requester) pair must be the one that provisioned this exact host, so
+// nothing outside the ledger is deletable through this surface. The heavy human
+// confirmation (typed name + fresh passkey ceremony) lives upstream in the home
+// server; here the ledger is the authority. controld archives the data to
+// /data/trash rather than destroying it.
+func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ requester := strings.TrimSpace(r.URL.Query().Get("requester"))
+ if requester == "" {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester query param required")
+ return
+ }
+ id, owned, err := s.db.spaceOwned(callerID, requester, host)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error")
+ return
+ }
+ if !owned {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account")
+ return
+ }
+ status, body, err := s.controlDelete(host)
+ if err != nil {
+ log.Printf("cloud: control delete for %s: %v", host, err)
+ writeControlErr(w, http.StatusBadGateway, "delete_failed", "the box's control daemon is unreachable")
+ return
+ }
+ // unknown_host means the instance is already gone (e.g. removed by an
+ // operator) — still clear the ledger row so the account's quota frees.
+ if status != http.StatusOK && body["error"] != "unknown_host" {
+ msg, _ := body["message"].(string)
+ log.Printf("cloud: control delete %s: HTTP %d %s", host, status, msg)
+ writeControlErr(w, http.StatusBadGateway, "delete_failed", "space deletion failed")
+ return
+ }
+ if err := s.db.deleteSpaceByID(id); err != nil {
+ log.Printf("cloud: ledger delete for %s (id %d) failed: %v", host, id, err)
+ }
+ log.Printf("cloud: deleted %s (id %d, caller %.8s…)", host, id, callerID)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// controlDelete sends one deletion to the control daemon.
+func (s *cloudServer) controlDelete(host string) (int, map[string]any, error) {
+ req, err := http.NewRequest("DELETE", "http://control/instances/"+host, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer resp.Body.Close()
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return resp.StatusCode, map[string]any{}, nil
+ }
+ return resp.StatusCode, body, nil
+}
+
func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
callerID := r.Context().Value(callerKey{}).(string)
requester := strings.TrimSpace(r.URL.Query().Get("requester"))
diff --git a/cloud_db.go b/cloud_db.go
index a164cce..ffaa9fa 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -220,6 +220,24 @@ func (db *cloudDB) deleteSpaceByID(id int64) error {
return err
}
+// spaceOwned reports whether this exact (caller, requester) provisioned host —
+// the ownership check in front of deletion. Nobody can delete a space through
+// the provisioner that the provisioner didn't create for them.
+func (db *cloudDB) spaceOwned(callerID, requester, host string) (int64, bool, error) {
+ var id int64
+ err := db.sql.QueryRow(
+ `SELECT id FROM spaces WHERE caller_id = ? AND requester = ? AND host = ?`,
+ callerID, requester, host,
+ ).Scan(&id)
+ if err == sql.ErrNoRows {
+ return 0, false, nil
+ }
+ if err != nil {
+ return 0, false, err
+ }
+ return id, true, nil
+}
+
// Space is one provisioned instance in a requester's listing.
type Space struct {
Host string `json:"host"`
diff --git a/cloud_test.go b/cloud_test.go
index 877a1d1..9b15201 100644
--- a/cloud_test.go
+++ b/cloud_test.go
@@ -242,6 +242,82 @@ func TestCloudRequesterRequired(t *testing.T) {
}
}
+func TestCloudDeleteSpace(t *testing.T) {
+ // Control answers create with 201 and delete with 200.
+ ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "DELETE" {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ return
+ }
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#S",
+ })
+ })
+ srv, s := newTestCloud(t, ctl)
+ tok := mintCaller(t, srv, "home")
+
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create: %d", resp.StatusCode)
+ }
+ host, _ := decodeBody(t, resp)["host"].(string)
+
+ del := func(token, h, requester string) *http.Response {
+ req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+h+"?requester="+requester, nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ r, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { r.Body.Close() })
+ return r
+ }
+
+ // Ownership is the gate: a different requester → 404, nothing deleted.
+ if r := del(tok, host, "someone-else"); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("wrong requester: %d, want 404", r.StatusCode)
+ }
+ // No token → 401.
+ if r := del("", host, "user-1"); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no token: %d, want 401", r.StatusCode)
+ }
+ // The owner deletes → 200 and the quota slot frees.
+ if r := del(tok, host, "user-1"); r.StatusCode != http.StatusOK {
+ t.Fatalf("owner delete: %d, want 200", r.StatusCode)
+ }
+ spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(spaces) != 0 {
+ t.Errorf("spaces after delete = %+v, want none (quota freed)", spaces)
+ }
+ // Deleting again → 404 (no longer in the ledger).
+ if r := del(tok, host, "user-1"); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("second delete: %d, want 404", r.StatusCode)
+ }
+}
+
+// mustCallerID digs a caller token's id out of the DB by label.
+func mustCallerID(t *testing.T, s *cloudServer, label string) string {
+ t.Helper()
+ tokens, err := s.db.listCallerTokens()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tk := range tokens {
+ if tk.Label == label {
+ return tk.ID
+ }
+ }
+ t.Fatalf("no caller token labelled %q", label)
+ return ""
+}
+
func TestCloudAdminTokenLifecycle(t *testing.T) {
srv, _ := newTestCloud(t, okControl(t))
diff --git a/control.go b/control.go
index dcd8fb6..180dfb7 100644
--- a/control.go
+++ b/control.go
@@ -328,6 +328,58 @@ func runAddService(args []string) {
fmt.Printf("added service %s -> %s\n", host, *socket)
}
+// errUnknownHost lets callers (controld) map "no such instance" to a 404.
+var errUnknownHost = fmt.Errorf("unknown host")
+
+// deleteInstance is the shared deprovisioning core behind the control daemon's
+// DELETE (self-serve space deletion): stop the units, drop the route, reload the
+// router, and ARCHIVE the instance's data — the DB/env move to
+// /data/trash/<host>-<unix>/ rather than being destroyed, so an operator can
+// still recover a space deleted in error until the trash is purged. The cert and
+// live symlink are removed (both are re-creatable).
+func deleteInstance(reg *Registry, host string) error {
+ host = strings.ToLower(strings.TrimSpace(host))
+ if !validHost(host) {
+ return fmt.Errorf("%w: %q", errInvalidHost, host)
+ }
+ rt, err := reg.get(host)
+ if err != nil {
+ return err
+ }
+ if rt == nil {
+ return fmt.Errorf("%w: %q", errUnknownHost, host)
+ }
+ if rt.Kind != "instance" {
+ return fmt.Errorf("%q is a %s route, not an instance", host, rt.Kind)
+ }
+ _ = systemctl("disable", "--now", "lchat-bot@"+host) // best-effort: a sidecar may not exist
+ if err := systemctl("disable", "--now", "lchat@"+host); err != nil {
+ return fmt.Errorf("stop unit: %v", err)
+ }
+ if err := reg.delete(host); err != nil {
+ return fmt.Errorf("deregister: %v", err)
+ }
+ if err := systemctl("reload", "lchat-router"); err != nil {
+ return fmt.Errorf("reload router: %v", err)
+ }
+ // Archive the data (never destroy it here — the trash dir is the safety net).
+ trash := filepath.Join(instanceDataDir, "trash", fmt.Sprintf("%s-%d", host, time.Now().Unix()))
+ if err := os.MkdirAll(trash, 0o700); err != nil {
+ return fmt.Errorf("trash dir: %v", err)
+ }
+ for _, name := range []string{host + ".db", host + ".db-wal", host + ".db-shm", host + ".env"} {
+ src := filepath.Join(instanceDataDir, name)
+ if _, err := os.Stat(src); err == nil {
+ if err := os.Rename(src, filepath.Join(trash, name)); err != nil {
+ return fmt.Errorf("archive %s: %v", name, err)
+ }
+ }
+ }
+ _ = os.Remove(filepath.Join(instanceDataDir, "acme", host)) // cert re-issues if ever needed
+ _ = os.Remove(liveLink(host))
+ return nil
+}
+
// runRemove: `lchat remove <host>` — stop routing a host. Data-safe by default:
// it deletes the route and reloads the router but leaves the instance running and
// its DB/env in place; --stop also stops+disables an instance's unit.
diff --git a/controld.go b/controld.go
index 59173b9..82e5ee4 100644
--- a/controld.go
+++ b/controld.go
@@ -83,9 +83,34 @@ func writeControlErr(w http.ResponseWriter, status int, code, msg string) {
func (c *controld) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /instances", c.auth(c.createHandler))
+ mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
return mux
}
+// deleteHandler deprovisions one instance (self-serve space deletion, reached
+// via the cloud provisioner which enforces ownership). The instance's data is
+// archived to /data/trash, not destroyed — see deleteInstance.
+func (c *controld) deleteHandler(w http.ResponseWriter, r *http.Request) {
+ host := r.PathValue("host")
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ err := deleteInstance(c.reg, host)
+ switch {
+ case errors.Is(err, errInvalidHost):
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", err.Error())
+ return
+ case errors.Is(err, errUnknownHost):
+ writeControlErr(w, http.StatusNotFound, "unknown_host", err.Error())
+ return
+ case err != nil:
+ log.Printf("controld: delete %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "delete_failed", err.Error())
+ return
+ }
+ log.Printf("controld: deleted %s (data archived to trash)", host)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// auth gates a handler behind the control token (constant-time hash compare,
// same pattern as the push relay's admin gate).
func (c *controld) auth(next http.HandlerFunc) http.HandlerFunc {
diff --git a/controld_test.go b/controld_test.go
index d2b2713..e83d41e 100644
--- a/controld_test.go
+++ b/controld_test.go
@@ -241,3 +241,50 @@ func TestControldInvalidHost(t *testing.T) {
t.Errorf("error = %v, want invalid_host", m["error"])
}
}
+
+func TestControldDeleteInstance(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+
+ // Create a real (fake-systemd) instance first.
+ if resp := controlCreate(t, srv, token, "gone.example.com"); resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create: %d", resp.StatusCode)
+ }
+
+ del := func(tok, host string) *http.Response {
+ req, _ := http.NewRequest("DELETE", srv.URL+"/instances/"+host, nil)
+ if tok != "" {
+ req.Header.Set("Authorization", "Bearer "+tok)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+ }
+
+ if r := del("", "gone.example.com"); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no token: %d, want 401", r.StatusCode)
+ }
+ if r := del(token, "never-was.example.com"); r.StatusCode != http.StatusNotFound {
+ t.Fatalf("unknown host: %d, want 404", r.StatusCode)
+ }
+ if r := del(token, "gone.example.com"); r.StatusCode != http.StatusOK {
+ t.Fatalf("delete: %d, want 200", r.StatusCode)
+ }
+
+ // The route is gone…
+ if rt, _ := reg.get("gone.example.com"); rt != nil {
+ t.Errorf("route still present after delete: %+v", rt)
+ }
+ // …the live data files are gone from /data…
+ if _, err := os.Stat(filepath.Join(instanceDataDir, "gone.example.com.env")); !os.IsNotExist(err) {
+ t.Error("env file still in /data — should be archived")
+ }
+ // …but ARCHIVED into the trash, not destroyed (the operator safety net).
+ matches, err := filepath.Glob(filepath.Join(instanceDataDir, "trash", "gone.example.com-*", "gone.example.com.env"))
+ if err != nil || len(matches) != 1 {
+ t.Errorf("archived env not found in trash (matches=%v err=%v)", matches, err)
+ }
+}
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index a4c9108..cb12ac7 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -153,14 +153,27 @@ POST /instances bearer: control token {host, admin_token?, …env fields}
### home
```
-POST /api/spaces bearer: device link token
+POST /api/spaces bearer: device link token
→ relays the cloud response (201 / 409 / 503 shapes above)
- 501 {error: unavailable} this home server has no provisioner configured
+ 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)
+DELETE /api/spaces/{host} X-Ceremony-Proof: fresh passkey ceremony ONLY
+ → relays cloud's 200 / 404
```
-Home's endpoint is link-token-authed (creating a space is additive, not
-destructive, so no fresh passkey ceremony) and rate-limited per IP; the real
-abuse bound is the provisioner's quota.
+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
+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
+exact (caller, requester) that provisioned a host can delete it, so spaces
+added to a list by URL are never deletable through this surface. "Remove from
+list" (vault-only, the space keeps running) is a separate, lighter client-side
+action. On the box, deletion **archives** the instance's DB/env to
+`/data/trash/<host>-<unix>/` rather than destroying them — the operator safety
+net until the trash is purged.
## What this changes about who-sees-what
@@ -197,6 +210,5 @@ curl -sX POST https://cloud.<domain>/api/admin/tokens \
(`lchat bot`) lands; controld grows an attach-bot step.
- **Reaper for never-claimed spaces** — setup invites already expire after 7
days; a zero-member space older than N days could be reclaimed so abandoned
- creates don't eat the cap.
-- **Delete/deactivate via the API; multi-box placement** — a cloud front
- choosing among several router boxes.
+ creates don't eat the cap. (A periodic purge of `/data/trash` belongs with it.)
+- **Multi-box placement** — a cloud front choosing among several router boxes.
diff --git a/home/main.go b/home/main.go
index cafb43f..0bcc3ce 100644
--- a/home/main.go
+++ b/home/main.go
@@ -183,6 +183,10 @@ func (s *Server) handler() http.Handler {
// server doesn't offer it (public — it reveals only whether the feature is on).
mux.HandleFunc("GET /api/features", s.featuresHandler)
mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler))
+ // Which spaces this user provisioned (read; drives the delete affordance)…
+ mux.HandleFunc("GET /api/spaces", s.listSpacesHandler)
+ // …and permanent deletion of one — ceremony-gated like every destructive op.
+ mux.HandleFunc("DELETE /api/spaces/{host}", s.deleteSpaceHandler)
// Devices: list with a link token; revoke only with a fresh ceremony. The
// asymmetry is deliberate — a stolen link token must never reduce access.
diff --git a/home/provision.go b/home/provision.go
index d5bb6ec..e4942d7 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -18,6 +18,7 @@ import (
"encoding/json"
"io"
"net/http"
+ "net/url"
"strings"
"time"
)
@@ -86,16 +87,85 @@ func (s *Server) createSpaceHandler(w http.ResponseWriter, r *http.Request) {
}
}
+// listSpacesHandler: GET /api/spaces — the hosts this user provisioned (from
+// the provisioner's ledger). Link-token auth: it's a read, and the client uses
+// it only to decide which rows may offer "Delete space".
+func (s *Server) listSpacesHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if s.cloud == nil {
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": []any{}})
+ return
+ }
+ status, body, err := s.cloud.do("GET", "/api/instances?requester="+url.QueryEscape(uid), nil)
+ if err != nil || status != http.StatusOK {
+ writeJSON(w, http.StatusBadGateway, map[string]any{"error": "list_failed"})
+ return
+ }
+ writeJSON(w, http.StatusOK, body)
+}
+
+// deleteSpaceHandler: DELETE /api/spaces/{host} — permanently deprovision a
+// space this user created. DESTRUCTIVE, so it demands a FRESH passkey ceremony
+// (X-Ceremony-Proof), never a link token — same rule as revoking devices or
+// deleting the backup: a stolen link token must never be able to destroy a
+// space. Ownership is enforced downstream by the provisioner's ledger.
+func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.ceremonyUser(r)
+ if !ok {
+ http.Error(w, "fresh passkey ceremony required", http.StatusUnauthorized)
+ return
+ }
+ if s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer space management",
+ })
+ return
+ }
+ host := r.PathValue("host")
+ status, body, err := s.cloud.do("DELETE",
+ "/api/instances/"+url.PathEscape(host)+"?requester="+url.QueryEscape(uid), nil)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "delete_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ switch status {
+ case http.StatusOK, http.StatusNotFound:
+ writeJSON(w, status, body)
+ default:
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "delete_failed", "message": "space deletion failed",
+ })
+ }
+}
+
// createSpace forwards one provisioning request; the user id is the quota key
// (opaque to the provisioner — random, no email or name anywhere).
func (c *cloudProvisioner) createSpace(requester string) (int, map[string]any, error) {
payload, _ := json.Marshal(map[string]string{"requester": requester})
- req, err := http.NewRequest("POST", c.url+"/api/instances", bytes.NewReader(payload))
+ return c.do("POST", "/api/instances", payload)
+}
+
+// do sends one request to the provisioner with the caller token, returning the
+// status + decoded JSON body.
+func (c *cloudProvisioner) do(method, path string, payload []byte) (int, map[string]any, error) {
+ var rd io.Reader
+ if payload != nil {
+ rd = bytes.NewReader(payload)
+ }
+ req, err := http.NewRequest(method, c.url+path, rd)
if err != nil {
return 0, nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
- req.Header.Set("Content-Type", "application/json")
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
resp, err := c.client.Do(req)
if err != nil {
return 0, nil, err
diff --git a/home/provision_test.go b/home/provision_test.go
index 169e27a..b5d53d1 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
)
@@ -171,3 +172,74 @@ func TestCreateSpaceFlattensUnexpectedStatus(t *testing.T) {
t.Errorf("error = %v, want create_failed", m["error"])
}
}
+
+func TestDeleteSpaceRequiresFreshCeremony(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, linkToken := seedUser(t, s)
+
+ cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{"ok": true})
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ del := func(headers map[string]string) *http.Response {
+ req, _ := http.NewRequest("DELETE", ts.URL+"/api/spaces/a4f.example.com", nil)
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+ }
+
+ // No auth → 401. A LINK TOKEN → still 401: destroying a space demands a
+ // fresh passkey ceremony, never the bearer a device holds all day.
+ if r := del(nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no auth: %d, want 401", r.StatusCode)
+ }
+ if r := del(map[string]string{"Authorization": "Bearer " + linkToken}); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("link token: %d, want 401", r.StatusCode)
+ }
+
+ // A fresh ceremony proof authorises it, and home forwards the requester uid.
+ proof, err := s.db.createCeremonyProof(uid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if r := del(map[string]string{"X-Ceremony-Proof": proof}); r.StatusCode != http.StatusOK {
+ t.Fatalf("with ceremony: %d, want 200", r.StatusCode)
+ }
+ if captured.Method != "DELETE" || !strings.Contains(captured.URL.RawQuery, "requester="+uid) {
+ t.Errorf("cloud saw %s %s?%s, want DELETE with requester=%s", captured.Method, captured.URL.Path, captured.URL.RawQuery, uid)
+ }
+}
+
+func TestListSpacesProxiesLedger(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, linkToken := seedUser(t, s)
+
+ cloud, captured, _ := fakeCloud(t, http.StatusOK, map[string]any{
+ "spaces": []map[string]any{{"host": "a4f.example.com", "url": "https://a4f.example.com"}},
+ })
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ req, _ := http.NewRequest("GET", ts.URL+"/api/spaces", nil)
+ req.Header.Set("Authorization", "Bearer "+linkToken)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status %d, want 200", resp.StatusCode)
+ }
+ var m struct{ Spaces []struct{ Host string } }
+ json.NewDecoder(resp.Body).Decode(&m)
+ if len(m.Spaces) != 1 || m.Spaces[0].Host != "a4f.example.com" {
+ t.Errorf("spaces = %+v", m.Spaces)
+ }
+ if !strings.Contains(captured.URL.RawQuery, "requester="+uid) {
+ t.Errorf("cloud saw ?%s, want requester=%s", captured.URL.RawQuery, uid)
+ }
+}
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index cb4c36e..de179d8 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -175,6 +175,40 @@
.led, .num.eleven { transition: none; }
.build-spin { animation: none; opacity: .5; }
}
+ /* Manage-a-space screen. Remove is quiet; delete is deliberately loud. */
+ #remove { text-align: center; }
+ .remove-note { font-size: .8rem; color: var(--muted); margin: 10px 0 0; }
+ #danger-zone { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--border); }
+ .danger-link { color: var(--danger); font-size: .9rem; }
+ .danger-ink { color: var(--danger); }
+ .stern-copy b { color: var(--ink); overflow-wrap: anywhere; }
+ #delete-arm {
+ width: 100%; margin: 6px 0 12px; padding: 10px 12px; text-align: center;
+ font: inherit; font-size: .95rem;
+ color: var(--ink); background: var(--quiet);
+ border: 1px solid var(--border); border-radius: 10px;
+ }
+ #delete-arm:focus-visible { outline: none; border-color: var(--danger); background: var(--panel); }
+ .btn-danger {
+ width: 100%; font: inherit; font-weight: 700; cursor: pointer;
+ background: var(--danger); color: #fff; border: none; border-radius: 999px; padding: 11px 18px;
+ }
+ .btn-danger:disabled { opacity: .4; cursor: default; }
+ .btn-danger:focus-visible { outline: 2px solid var(--danger); outline-offset: 2px; }
+ #delete-stern .wide-quiet { margin-top: 10px; }
+ /* The per-row remove: an × that reveals on hover, like the drag grip. */
+ .row-remove {
+ flex: none; display: grid; place-items: center; width: 22px; height: 30px;
+ padding: 0; border: none; background: none; color: var(--muted);
+ border-radius: 7px; cursor: pointer;
+ opacity: 0; transition: opacity .12s ease, background .12s ease, color .12s ease;
+ }
+ .row-remove svg { display: block; }
+ .row-remove:hover { background: var(--quiet); color: var(--danger); }
+ .row-remove:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; }
+ @media (hover: hover) { ul.servers li:hover .row-remove { opacity: .5; } }
+ @media (hover: none) { .row-remove { opacity: .4; } }
+ @media (prefers-reduced-motion: reduce) { .row-remove { transition: none; } }
/* Section headings: "Starred" and "More" — only shown once some are starred. */
.spaces-head {
font-size: .7rem; text-transform: uppercase; letter-spacing: .05em;
@@ -315,6 +349,34 @@
<button id="create-done" type="button" class="wide-quiet">Done</button>
</div>
</div>
+
+ <!-- Manage one saved space: remove it from the list (light — the space keeps
+ running), or — only for spaces this account created — delete it forever
+ behind the stern typed-confirmation + fresh-passkey step. -->
+ <div id="remove" class="hidden screen">
+ <button id="remove-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="remove-main">
+ <h2 class="create-title" id="remove-title"></h2>
+ <p class="create-sub" id="remove-host"></p>
+ <button id="remove-from-list" type="button" class="wide-quiet">Remove from list</button>
+ <p class="sub remove-note">This only takes the space off your list (on every device you sign into).
+ The space itself keeps running — you'd need an invite or add-device link to get back in.</p>
+ <div id="danger-zone" class="hidden">
+ <button id="open-delete" type="button" class="linkish danger-link">Delete this space permanently…</button>
+ </div>
+ </div>
+ <div id="delete-stern" class="hidden">
+ <h2 class="create-title danger-ink">Delete this space?</h2>
+ <p class="create-sub stern-copy">This permanently destroys <b id="stern-host"></b> —
+ every message, photo, and member in it. For everyone, not just you.
+ <b>It cannot be undone.</b></p>
+ <input id="delete-arm" type="text" autocomplete="off" autocapitalize="off"
+ spellcheck="false" aria-label="Type the space address to confirm">
+ <button id="delete-forever" type="button" class="btn-danger" disabled>Delete forever</button>
+ <button id="delete-cancel" type="button" class="wide-quiet">Cancel</button>
+ </div>
+ <p class="status" id="remove-status"></p>
+ </div>
</main>
<!-- Reassurance lives outside the card, under it; hidden once signed in. -->
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 636b8a1..188ebd3 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -188,6 +188,8 @@ function signOut() {
dekKey = null;
$("vault").classList.add("hidden");
$("create").classList.add("hidden");
+ $("remove").classList.add("hidden");
+ removing = null;
$("spaces").textContent = "";
$("signed-out").classList.remove("hidden");
$("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel
@@ -261,6 +263,10 @@ function serverList(group, sortable) {
// A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it
// lives on the right so the star stays the left anchor; it surfaces on hover.
+const X_SVG =
+ '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' +
+ '<path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
+
const GRIP_SVG =
'<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' +
'<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' +
@@ -291,6 +297,16 @@ function serverRow(s, sortable) {
li.append(star, main);
+ // Manage/remove: an × that reveals on hover (like the grip) and opens the
+ // manage screen — where "remove from list" and the stern delete live.
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.className = "row-remove";
+ rm.setAttribute("aria-label", "Remove " + (s.name || s.url) + "…");
+ rm.innerHTML = X_SVG;
+ rm.addEventListener("click", () => openRemove(s));
+ li.append(rm);
+
// The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) —
// the a11y reorder fallback. Omitted while a search filter is active (you can't
// meaningfully reorder a filtered subset).
@@ -562,6 +578,115 @@ function closeCreate() {
showVault(); // reveals the list again, now including any newly created space
}
+// ---- manage / remove / delete a space -------------------------------------------
+
+// removing is the entry being managed on the #remove screen.
+let removing = null;
+
+function removeStatus(msg, kind) {
+ const el = $("remove-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// openRemove shows the manage screen for one saved space: "remove from list"
+// always; the stern delete only if this account created the space (checked
+// against the provisioner's ledger via GET /api/spaces — the server enforces
+// the same ownership on the actual delete, this just decides what to offer).
+function openRemove(s) {
+ removing = s;
+ const host = (() => { try { return new URL(s.url).host; } catch { return s.url; } })();
+ $("remove-title").textContent = s.name || host;
+ $("remove-host").textContent = s.url;
+ $("remove-main").classList.remove("hidden");
+ $("delete-stern").classList.add("hidden");
+ $("danger-zone").classList.add("hidden");
+ removeStatus("");
+ $("vault").classList.add("hidden");
+ $("remove").classList.remove("hidden");
+ if (spacesEnabled) {
+ apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
+ .then((r) => {
+ const mine = (r.spaces || []).some((sp) => sp.host === host);
+ if (mine && removing === s) $("danger-zone").classList.remove("hidden");
+ })
+ .catch(() => { /* can't confirm ownership — don't offer deletion */ });
+ }
+}
+
+function closeRemove() {
+ removing = null;
+ $("remove").classList.add("hidden");
+ showVault();
+}
+
+// removeFromList drops the entry from the vault — the light action. The space
+// itself keeps running; an invite/add-device link is the way back in.
+async function removeFromList() {
+ if (!removing) return;
+ vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url);
+ removeStatus("Removing…", "busy");
+ try {
+ await saveVault();
+ closeRemove();
+ vaultStatus("Removed from your list.", "ok");
+ } catch (e) {
+ removeStatus(e.message || "Couldn't save the change.", "error");
+ }
+}
+
+// The stern step: type the space's address to arm the button, then a FRESH
+// passkey ceremony (Face ID) authorises the deletion — a link token alone can
+// never destroy a space. The server re-checks ownership against the ledger.
+function openDelete() {
+ const host = (() => { try { return new URL(removing.url).host; } catch { return removing.url; } })();
+ $("stern-host").textContent = host;
+ $("delete-arm").value = "";
+ $("delete-arm").placeholder = host;
+ $("delete-forever").disabled = true;
+ $("remove-main").classList.add("hidden");
+ $("delete-stern").classList.remove("hidden");
+ removeStatus("");
+ prefetchLogin(); // the ceremony ahead needs fresh options (Safari gesture rule)
+}
+
+async function deleteForever() {
+ if (!removing) return;
+ const host = $("stern-host").textContent;
+ const btn = $("delete-forever");
+ btn.disabled = true;
+ removeStatus("Confirm with your passkey…", "busy");
+ let proof;
+ try {
+ ({ proof } = await runLogin(loginBegin));
+ } catch (e) {
+ prefetchLogin();
+ removeStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error");
+ btn.disabled = false;
+ return;
+ }
+ removeStatus("Deleting the space…", "busy");
+ try {
+ const res = await fetch("/api/spaces/" + encodeURIComponent(host), {
+ method: "DELETE",
+ headers: { "X-Ceremony-Proof": proof },
+ });
+ const body = await res.json().catch(() => ({}));
+ // 404 = not in the ledger (already gone / not yours) — treat "already gone"
+ // as done; the list entry goes either way.
+ if (!res.ok && res.status !== 404) {
+ throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`);
+ }
+ vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url);
+ try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ }
+ closeRemove();
+ vaultStatus("Space deleted.", "ok");
+ } catch (e) {
+ removeStatus(e.message || String(e), "error");
+ btn.disabled = false;
+ }
+}
+
// ---- wiring -------------------------------------------------------------------
const onSignInError = (err) => {
@@ -599,6 +724,19 @@ $("open-create").addEventListener("click", openCreate);
$("create-back").addEventListener("click", closeCreate);
$("create-done").addEventListener("click", closeCreate);
// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
+$("remove-back").addEventListener("click", closeRemove);
+$("remove-from-list").addEventListener("click", removeFromList);
+$("open-delete").addEventListener("click", openDelete);
+$("delete-cancel").addEventListener("click", () => {
+ $("delete-stern").classList.add("hidden");
+ $("remove-main").classList.remove("hidden");
+ removeStatus("");
+});
+// The typed address arms the delete — exact match only.
+$("delete-arm").addEventListener("input", () => {
+ $("delete-forever").disabled = $("delete-arm").value.trim() !== $("stern-host").textContent;
+});
+$("delete-forever").addEventListener("click", deleteForever);
// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input
// lives outside #spaces, so re-rendering the lists never steals its focus.
$("space-search").addEventListener("input", () => { if (vault) showVault(); });