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>
12 files changed,
+650
−10
cloud.go+64 −0cloud_db.go+18 −0cloud_test.go+76 −0control.go+52 −0controld.go+25 −0controld_test.go+47 −0docs/cloud-provisioner.md+20 −8home/main.go+4 −0home/provision.go+72 −2home/provision_test.go+72 −0home/web/pages/index.html+62 −0home/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(); }); |