From 64acb6a8271938a95c0781d9a1655bc9410f166a Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 6 Jul 2026 13:36:52 +0000 Subject: [PATCH] feat(server): workspace restore + deleted-list endpoints (TASK-1970) Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A workspace delete only stamps workspaces.deleted_at; items/collections/ members are untouched, hidden transitively. Restore clears deleted_at so everything re-surfaces intact. Store (internal/store/workspaces.go): - RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=? AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no soft-deleted row matched (already live or purged). - ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within the window, ordered deleted_at DESC. Account-deleted workspaces have no live owner, so they never leak. - GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404. - Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists). Handlers (internal/server/handlers_workspaces.go): - POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable, 403 non-owner, 200 + restored workspace; logs a "restored" activity. - GET /api/v1/workspaces/deleted: owner-scoped list with per-entry purge_at + days_left, both derived from workspacePurgeRetention so restore and the purge sweeper share ONE 30-day window (no drift). - Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which resolves only live workspaces); restore enforces owner authz inline. CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces. TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace. Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403, 404 live/unknown, 200 restore, owner-scoped deleted-list). Green on SQLite and Postgres (make test-pg); golangci-lint clean. Closes TASK-1970 Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST --- internal/cli/client.go | 24 ++ internal/server/handlers_workspaces.go | 162 +++++++++++ internal/server/handlers_workspaces_test.go | 306 ++++++++++++++++++++ internal/server/server.go | 13 + internal/server/workspace_purge.go | 16 + internal/store/workspaces.go | 108 +++++++ internal/store/workspaces_test.go | 190 ++++++++++++ web/src/lib/types/index.ts | 17 ++ 8 files changed, 836 insertions(+) create mode 100644 internal/store/workspaces_test.go diff --git a/internal/cli/client.go b/internal/cli/client.go index afde4e1e..55242dbe 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -119,6 +119,30 @@ func (c *Client) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*mo return &result, c.patch("/workspaces/"+slug, input, &result) } +// DeletedWorkspace is one entry from GET /api/v1/workspaces/deleted — a +// soft-deleted workspace still inside the restore window, plus the +// purge-window fields (both derived server-side from the shared purge +// retention constant) so callers can render "N days left". +type DeletedWorkspace struct { + models.Workspace + PurgeAt time.Time `json:"purge_at"` + DaysLeft int `json:"days_left"` +} + +// ListDeletedWorkspaces returns the soft-deleted workspaces the current +// user owns that are still restorable (not yet past the purge window). +func (c *Client) ListDeletedWorkspaces() ([]DeletedWorkspace, error) { + var result []DeletedWorkspace + return result, c.get("/workspaces/deleted", &result) +} + +// RestoreWorkspace un-soft-deletes a workspace by slug via the restore +// endpoint (owner-only server-side). Returns the now-live workspace. +func (c *Client) RestoreWorkspace(slug string) (*models.Workspace, error) { + var result models.Workspace + return &result, c.post("/workspaces/"+slug+"/restore", nil, &result) +} + // ClaimWorkspaceResponse is the shape of POST /api/v1/oauth/claim. // `AlreadyAdded` reports whether the workspace was already in the // calling connection's allow-list (idempotent re-claim returns true). diff --git a/internal/server/handlers_workspaces.go b/internal/server/handlers_workspaces.go index c682c4d6..2f036768 100644 --- a/internal/server/handlers_workspaces.go +++ b/internal/server/handlers_workspaces.go @@ -4,13 +4,16 @@ import ( "database/sql" "fmt" "log/slog" + "math" "net/http" "strings" + "time" "github.com/PerpetualSoftware/pad/internal/collections" "github.com/PerpetualSoftware/pad/internal/events" "github.com/PerpetualSoftware/pad/internal/models" "github.com/PerpetualSoftware/pad/internal/store" + "github.com/go-chi/chi/v5" ) func normalizeWorkspaceInput(input *models.WorkspaceCreate) error { @@ -396,6 +399,165 @@ func (s *Server) handleDeleteWorkspace(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// deletedWorkspaceResponse is one entry in the GET /workspaces/deleted +// payload: the soft-deleted workspace plus purge-window fields the UI +// renders as "N days left" before it's permanently removed. PurgeAt and +// DaysLeft are BOTH derived from workspacePurgeRetention so restore and +// the purge sweeper (workspace_purge.go) share exactly one window — no +// drift. +type deletedWorkspaceResponse struct { + models.Workspace + // PurgeAt is when the retention sweeper will hard-delete the + // workspace (deleted_at + workspacePurgeRetention). + PurgeAt time.Time `json:"purge_at"` + // DaysLeft is whole days remaining until PurgeAt, rounded up and + // clamped at 0. 0 means it's eligible for purge on the next sweep. + DaysLeft int `json:"days_left"` +} + +// handleListDeletedWorkspaces lists the soft-deleted workspaces the +// caller OWNS that are still restorable — i.e. not yet past the purge +// retention window. Owner-scoped inside the store query; the cutoff is +// derived from workspacePurgeRetention so this list and the purge +// sweeper agree on exactly which workspaces are recoverable. +func (s *Server) handleListDeletedWorkspaces(w http.ResponseWriter, r *http.Request) { + userID := currentUserID(r) + if userID == "" { + // No authenticated user (fresh install / pre-auth) has no owned + // workspaces to restore — return an empty list rather than 401 + // so the switcher's "recently deleted" section just renders + // nothing. + writeJSON(w, http.StatusOK, []deletedWorkspaceResponse{}) + return + } + + // Use the retention the purge sweeper actually enforces (which may be + // overridden via SetWorkspacePurgeConfig / PAD_WORKSPACE_PURGE_RETENTION) + // so the restore window + purge_at/days_left never drift from the + // horizon at which the workspace is really hard-deleted. + retention := s.effectivePurgeRetention() + cutoff := time.Now().UTC().Add(-retention) + workspaces, err := s.store.ListDeletedWorkspaces(userID, cutoff) + if err != nil { + writeInternalError(w, err) + return + } + + now := time.Now().UTC() + out := make([]deletedWorkspaceResponse, 0, len(workspaces)) + for _, ws := range workspaces { + entry := deletedWorkspaceResponse{Workspace: ws} + if ws.DeletedAt != nil { + entry.PurgeAt = ws.DeletedAt.Add(retention) + days := int(math.Ceil(entry.PurgeAt.Sub(now).Hours() / 24)) + if days < 0 { + days = 0 + } + entry.DaysLeft = days + } + out = append(out, entry) + } + writeJSON(w, http.StatusOK, out) +} + +// handleRestoreWorkspace un-soft-deletes a workspace, resurfacing it (and +// everything transitively hidden underneath) intact. Owner-only, mirroring +// the Danger-Zone gating on handleDeleteWorkspace — but it can't lean on +// RequireWorkspaceAccess/requireMinRole because those resolve only LIVE +// workspaces (`deleted_at IS NULL`), so a soft-deleted workspace 404s +// before any handler runs. Instead it resolves the soft-deleted row +// directly and checks ownership here. +// +// Status codes: +// - 404 — no restorable soft-deleted workspace with that slug (already +// live, unknown, or hard-purged). +// - 403 — the workspace exists but the caller doesn't own it. +// - 200 — restored; returns the now-live workspace. +func (s *Server) handleRestoreWorkspace(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + + ws, err := s.store.GetDeletedWorkspaceBySlug(slug) + if err != nil { + writeInternalError(w, err) + return + } + if ws == nil { + // Not soft-deleted (live), unknown, or already hard-purged — + // nothing to restore. + writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found") + return + } + + // Owner-only, with NO fresh-install / UserCount==0 bypass. A + // soft-deleted workspace existing while zero users remain is precisely + // the account-deletion case (DeleteAccountAtomic soft-deletes the + // owner's workspaces, then removes the owner) — a bypass there would + // let an unauthenticated caller restore an account-deleted workspace by + // guessing its slug and resurface data whose owner is gone. On a + // genuine fresh install there are no soft-deleted workspaces to restore + // anyway, so requiring the owner unconditionally loses nothing. + userID := currentUserID(r) + if userID == "" || ws.OwnerID != userID { + // Not the owner. If the owner user no longer exists — the + // account-deletion case, where DeleteAccountAtomic soft-deleted + // this workspace and then removed its owner — NO live user could + // ever restore it, so return the same 404 as "not restorable" + // instead of 403. That stops a guessed slug from confirming an + // account-deleted workspace row still exists. A genuine non-owner + // attempt on a live-owned workspace still gets 403. (GetUser only + // runs on this rejection path, never the owner's success path.) + if owner, _ := s.store.GetUser(ws.OwnerID); owner == nil { + writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found") + return + } + writeError(w, http.StatusForbidden, "forbidden", "Only the workspace owner can restore it") + return + } + + // Enforce the SAME purge horizon the deleted-list uses so restore and + // the list agree: a workspace older than the retention window is + // already eligible for hard-purge and is hidden from the list, so it + // must not be restorable by slug either (the sweeper may not have run + // yet — no attachments registry, deferred blob, or startup lag). Checked + // after the owner gate so a non-owner can't probe window state. + cutoff := time.Now().UTC().Add(-s.effectivePurgeRetention()) + if ws.DeletedAt == nil || !ws.DeletedAt.After(cutoff) { + writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found") + return + } + + if err := s.store.RestoreWorkspace(slug); err != nil { + if err == sql.ErrNoRows { + // Raced with a concurrent restore/purge between the lookup and + // the update — treat as nothing-to-restore. + writeError(w, http.StatusNotFound, "not_found", "No restorable workspace found") + return + } + writeInternalError(w, err) + return + } + + // Re-fetch the now-live row so the response carries the fully hydrated, + // un-deleted workspace. + restored, err := s.store.GetWorkspaceBySlug(slug) + if err != nil { + writeInternalError(w, err) + return + } + if restored == nil { + // Extremely unlikely (just restored), but don't lie about success. + writeError(w, http.StatusNotFound, "not_found", "Workspace not found") + return + } + + // Log the restore in the (now live) workspace's activity feed, mirroring + // how item restore logs a "restored" action. + s.logActivity(restored.ID, "", "restored", r) + s.publishEvent(events.WorkspaceUpdated, restored.ID, "", restored.Name, "", "", "") + + writeJSON(w, http.StatusOK, restored) +} + func (s *Server) handleExportWorkspace(w http.ResponseWriter, r *http.Request) { // `?format=tar` switches to the tar.gz bundle that includes // attachment blobs (TASK-884). Default stays JSON for backward diff --git a/internal/server/handlers_workspaces_test.go b/internal/server/handlers_workspaces_test.go index 69c81ad9..c8b8af0a 100644 --- a/internal/server/handlers_workspaces_test.go +++ b/internal/server/handlers_workspaces_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "testing" + "time" "github.com/PerpetualSoftware/pad/internal/models" ) @@ -138,3 +139,308 @@ func containsWorkspace(list []models.Workspace, id string) bool { } return false } + +// deletedWorkspaceEntry mirrors the GET /workspaces/deleted response +// shape (workspace fields + purge-window fields) for test assertions. +type deletedWorkspaceEntry struct { + models.Workspace + PurgeAt string `json:"purge_at"` + DaysLeft int `json:"days_left"` +} + +// setupOwnedDeletedWorkspace registers Alice + Bob, has Alice create and +// then soft-delete a workspace, and returns their tokens plus the +// workspace. Shared setup for the restore/deleted-list handler tests. +func setupOwnedDeletedWorkspace(t *testing.T, srv *Server) (aliceToken, bobToken string, ws models.Workspace) { + t.Helper() + adminToken := bootstrapFirstUser(t, srv, "admin@test.com", "Admin") + + for _, u := range []struct{ email, name string }{ + {"alice@test.com", "Alice"}, + {"bob@test.com", "Bob"}, + } { + rr := doRequestWithCookie(srv, "POST", "/api/v1/auth/register", map[string]string{ + "email": u.email, + "name": u.name, + "password": "correct-horse-battery-staple", + }, adminToken) + if rr.Code != http.StatusCreated { + t.Fatalf("register %s: expected 201, got %d: %s", u.email, rr.Code, rr.Body.String()) + } + } + + aliceToken = loginUser(t, srv, "alice@test.com", "correct-horse-battery-staple") + bobToken = loginUser(t, srv, "bob@test.com", "correct-horse-battery-staple") + + rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{ + "name": "Alice Recoverable", + }, aliceToken) + if rr.Code != http.StatusCreated { + t.Fatalf("create workspace: expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + parseJSON(t, rr, &ws) + + // Alice (owner) soft-deletes it. + rr = doRequestWithCookie(srv, "DELETE", "/api/v1/workspaces/"+ws.Slug, nil, aliceToken) + if rr.Code != http.StatusNoContent { + t.Fatalf("delete workspace: expected 204, got %d: %s", rr.Code, rr.Body.String()) + } + return aliceToken, bobToken, ws +} + +// TestRestoreWorkspace_OwnerFlow: the owner can restore their +// soft-deleted workspace (200 + restored, no deleted_at), and it +// re-appears in their workspace list. +func TestRestoreWorkspace_OwnerFlow(t *testing.T) { + srv := testServer(t) + aliceToken, _, ws := setupOwnedDeletedWorkspace(t, srv) + + // While deleted, it should not appear in the normal list. + rr := doRequestWithCookie(srv, "GET", "/api/v1/workspaces", nil, aliceToken) + var live []models.Workspace + parseJSON(t, rr, &live) + if containsWorkspace(live, ws.ID) { + t.Fatal("soft-deleted workspace should not appear in the normal workspace list") + } + + // Restore. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, aliceToken) + if rr.Code != http.StatusOK { + t.Fatalf("restore: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var restored models.Workspace + parseJSON(t, rr, &restored) + if restored.ID != ws.ID { + t.Errorf("restored workspace ID mismatch: got %s want %s", restored.ID, ws.ID) + } + if restored.DeletedAt != nil { + t.Errorf("restored workspace should have nil deleted_at, got %v", restored.DeletedAt) + } + + // It's back in the list. + rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces", nil, aliceToken) + parseJSON(t, rr, &live) + if !containsWorkspace(live, ws.ID) { + t.Fatal("restored workspace should re-appear in the workspace list") + } + + // Restoring again (now live) → 404. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, aliceToken) + if rr.Code != http.StatusNotFound { + t.Errorf("double restore: expected 404, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestRestoreWorkspace_NonOwnerForbidden: a non-owner who can see the +// endpoint gets 403, not a restore — owner-only authz. +func TestRestoreWorkspace_NonOwnerForbidden(t *testing.T) { + srv := testServer(t) + aliceToken, bobToken, ws := setupOwnedDeletedWorkspace(t, srv) + + rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, bobToken) + if rr.Code != http.StatusForbidden { + t.Fatalf("non-owner restore: expected 403, got %d: %s", rr.Code, rr.Body.String()) + } + + // The workspace is still soft-deleted (Bob's forbidden call didn't + // restore it): Alice can still restore it herself. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, aliceToken) + if rr.Code != http.StatusOK { + t.Fatalf("owner restore after forbidden attempt: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestRestoreWorkspace_LiveOrUnknown404: restoring a live or unknown +// workspace returns 404. +func TestRestoreWorkspace_LiveOrUnknown404(t *testing.T) { + srv := testServer(t) + adminToken := bootstrapFirstUser(t, srv, "admin@test.com", "Admin") + + rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{ + "name": "Still Live", + }, adminToken) + if rr.Code != http.StatusCreated { + t.Fatalf("create workspace: %d %s", rr.Code, rr.Body.String()) + } + var ws models.Workspace + parseJSON(t, rr, &ws) + + // Live workspace → nothing to restore → 404. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, adminToken) + if rr.Code != http.StatusNotFound { + t.Errorf("restore live workspace: expected 404, got %d: %s", rr.Code, rr.Body.String()) + } + + // Unknown slug → 404. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/nope-nope-nope/restore", nil, adminToken) + if rr.Code != http.StatusNotFound { + t.Errorf("restore unknown workspace: expected 404, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestRestoreWorkspace_NoZeroUsersBypass is the security regression for +// the Codex P1: when account deletion leaves zero live users, a +// soft-deleted (account-deleted) workspace must NOT be restorable by an +// unauthenticated caller who guesses its slug. There is no fresh-install +// UserCount==0 bypass on restore. +func TestRestoreWorkspace_NoZeroUsersBypass(t *testing.T) { + srv := testServer(t) + + // Simulate the post-account-deletion state: a workspace whose owner is + // gone (owner_id points at no live user) and zero users on the + // instance. CreateWorkspace with a ghost owner_id + no registered users + // reproduces it without driving the whole DeleteAccountAtomic path. + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{ + Name: "Orphaned", + OwnerID: "ghost-owner-id", + }) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := srv.store.DeleteWorkspace(ws.Slug); err != nil { + t.Fatalf("delete workspace: %v", err) + } + + // Unauthenticated restore on a zero-user instance (RequireAuth is + // bypassed on fresh install) must NOT be honored. The owner user is + // gone, so it takes the owner-gone path → 404 (not restorable), which + // also avoids leaking that the row exists. + rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil) + if rr.Code == http.StatusOK { + t.Fatalf("zero-user unauthenticated restore must be refused, got 200: %s", rr.Body.String()) + } + if rr.Code != http.StatusNotFound { + t.Fatalf("zero-user unauthenticated restore: expected 404, got %d: %s", rr.Code, rr.Body.String()) + } + + // And it stays soft-deleted. + if got, err := srv.store.GetDeletedWorkspaceBySlug(ws.Slug); err != nil { + t.Fatalf("GetDeletedWorkspaceBySlug: %v", err) + } else if got == nil { + t.Fatal("workspace must remain soft-deleted after a forbidden restore") + } +} + +// TestRestoreWorkspace_OwnerGone404NotProbeable is the Codex P2 fix for +// slug probing: when the owning user is gone (account deletion), a +// soft-deleted workspace must return 404 to a non-owner — not 403 — +// so a guessed slug can't confirm the account-deleted row still exists. +// A live-owned workspace still returns 403 to non-owners (see +// TestRestoreWorkspace_NonOwnerForbidden), preserving the owner-only +// contract. +func TestRestoreWorkspace_OwnerGone404NotProbeable(t *testing.T) { + srv := testServer(t) + // A real, authenticated caller exists (so RequireAuth is active and + // this isn't the zero-user path). + adminToken := bootstrapFirstUser(t, srv, "admin@test.com", "Admin") + + // Account-deletion shape: a soft-deleted workspace whose owner_id + // points at a user row that no longer exists. + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{ + Name: "Account Deleted", + OwnerID: "gone-owner-id", + }) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := srv.store.DeleteWorkspace(ws.Slug); err != nil { + t.Fatalf("delete workspace: %v", err) + } + + // The admin (authenticated non-owner) probing the slug gets 404, not + // 403 — no existence leak. + rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, adminToken) + if rr.Code != http.StatusNotFound { + t.Fatalf("owner-gone restore probe: expected 404 (no leak), got %d: %s", rr.Code, rr.Body.String()) + } + + // Still soft-deleted. + if got, err := srv.store.GetDeletedWorkspaceBySlug(ws.Slug); err != nil { + t.Fatalf("GetDeletedWorkspaceBySlug: %v", err) + } else if got == nil { + t.Fatal("workspace must remain soft-deleted") + } +} + +// TestRestoreWorkspace_PastHorizon404 pins the Codex P2 fix: a workspace +// soft-deleted longer ago than the purge retention window is eligible for +// hard-purge and hidden from the deleted-list, so restore must 404 on it +// too (even if the sweeper hasn't physically purged it yet) — restore and +// the list share one horizon. +func TestRestoreWorkspace_PastHorizon404(t *testing.T) { + srv := testServer(t) + aliceToken, _, ws := setupOwnedDeletedWorkspace(t, srv) + + // Backdate deleted_at to 31 days ago (past the 30-day horizon), + // dialect-safely so this also holds under Postgres (make test-pg). + past := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339) + if _, err := srv.store.DB().Exec( + srv.store.D().Rebind("UPDATE workspaces SET deleted_at = ? WHERE id = ?"), + past, ws.ID, + ); err != nil { + t.Fatalf("backdate deleted_at: %v", err) + } + + // It's hidden from the owner's deleted-list... + rr := doRequestWithCookie(srv, "GET", "/api/v1/workspaces/deleted", nil, aliceToken) + var deleted []deletedWorkspaceEntry + parseJSON(t, rr, &deleted) + for _, e := range deleted { + if e.ID == ws.ID { + t.Fatal("past-horizon workspace must not appear in the deleted-list") + } + } + + // ...and restore 404s rather than resurrecting a purge-eligible row. + rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/restore", nil, aliceToken) + if rr.Code != http.StatusNotFound { + t.Fatalf("past-horizon restore: expected 404, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestListDeletedWorkspaces_OwnerScoped: the deleted-list is scoped to +// the caller's owned workspaces and carries the purge-window fields. +func TestListDeletedWorkspaces_OwnerScoped(t *testing.T) { + srv := testServer(t) + aliceToken, bobToken, ws := setupOwnedDeletedWorkspace(t, srv) + + // Alice (owner) sees her deleted workspace with purge-window fields. + rr := doRequestWithCookie(srv, "GET", "/api/v1/workspaces/deleted", nil, aliceToken) + if rr.Code != http.StatusOK { + t.Fatalf("alice deleted list: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var aliceDeleted []deletedWorkspaceEntry + parseJSON(t, rr, &aliceDeleted) + var found *deletedWorkspaceEntry + for i := range aliceDeleted { + if aliceDeleted[i].ID == ws.ID { + found = &aliceDeleted[i] + } + } + if found == nil { + t.Fatalf("owner should see their deleted workspace, got %+v", aliceDeleted) + } + if found.DeletedAt == nil { + t.Error("deleted-list entry should carry deleted_at") + } + if found.PurgeAt == "" { + t.Error("deleted-list entry should carry purge_at") + } + // Just-deleted → close to the full 30-day window remaining. + if found.DaysLeft < 29 || found.DaysLeft > 30 { + t.Errorf("days_left for a just-deleted workspace should be ~30, got %d", found.DaysLeft) + } + + // Bob (not owner) sees nothing. + rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/deleted", nil, bobToken) + if rr.Code != http.StatusOK { + t.Fatalf("bob deleted list: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var bobDeleted []deletedWorkspaceEntry + parseJSON(t, rr, &bobDeleted) + for _, e := range bobDeleted { + if e.ID == ws.ID { + t.Fatal("non-owner must not see another user's deleted workspace") + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 38a49bd4..7be8ada7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1251,6 +1251,19 @@ func (s *Server) setupRouter() { r.Post("/import", s.handleImportWorkspace) r.Put("/reorder", s.handleReorderWorkspaces) + // Soft-delete recovery (PLAN-1969 / TASK-1970). Both live + // OUTSIDE the /{slug} RequireWorkspaceAccess subrouter + // because that middleware resolves only LIVE workspaces + // (deleted_at IS NULL) and would 404 a soft-deleted one + // before the handler ran. The static "/deleted" segment is + // registered before the /{slug} param route so chi matches + // it exactly (static beats param); it lists the caller's own + // deleted-but-restorable workspaces. "/{slug}/restore" + // resolves the soft-deleted row itself and enforces + // owner-only authz inside the handler. + r.Get("/deleted", s.handleListDeletedWorkspaces) + r.Post("/{slug}/restore", s.handleRestoreWorkspace) + r.Route("/{slug}", func(r chi.Router) { r.Use(s.RequireWorkspaceAccess) diff --git a/internal/server/workspace_purge.go b/internal/server/workspace_purge.go index 1751ecbc..a72554ef 100644 --- a/internal/server/workspace_purge.go +++ b/internal/server/workspace_purge.go @@ -77,6 +77,22 @@ func (s *Server) SetWorkspacePurgeConfig(interval, retention time.Duration) { } } +// effectivePurgeRetention returns the retention window the purge sweeper +// actually enforces: the SetWorkspacePurgeConfig override when set +// (PAD_WORKSPACE_PURGE_RETENTION in the real bootstrap), else the package +// default. The restore surfaces (deleted-list cutoff + purge_at/days_left) +// read this so the "N days left" they show never drifts from the horizon +// at which the workspace is really hard-purged. Safe to call whether or +// not the sweeper has been started. +func (s *Server) effectivePurgeRetention() time.Duration { + s.workspacePurge.mu.Lock() + defer s.workspacePurge.mu.Unlock() + if s.workspacePurge.retention > 0 { + return s.workspacePurge.retention + } + return workspacePurgeRetention +} + // StartWorkspacePurgeSweeper kicks off the periodic sweep loop. // Idempotent — calling twice is a no-op. Must be called AFTER // SetAttachments; the sweep no-ops (and logs) when the registry isn't diff --git a/internal/store/workspaces.go b/internal/store/workspaces.go index be409f8e..f0c7ec5e 100644 --- a/internal/store/workspaces.go +++ b/internal/store/workspaces.go @@ -267,3 +267,111 @@ func (s *Store) DeleteWorkspace(slug string) error { } return nil } + +// RestoreWorkspace un-soft-deletes a workspace: it clears deleted_at so +// the workspace (and every item/collection/member/attachment that was +// only transitively hidden by the `w.deleted_at IS NULL` filters) +// re-surfaces intact. Nothing below the workspace was ever soft-deleted +// by DeleteWorkspace, so restoring the parent row is all it takes. +// +// The inverse of DeleteWorkspace and modeled on its shape. Idempotent- +// ish: returns sql.ErrNoRows (→ 404 at the handler) when no soft-deleted +// row matched the slug — i.e. the workspace is already live, or it was +// hard-purged by the retention sweeper (workspace_purge.go) and is gone +// for good. +func (s *Store) RestoreWorkspace(slug string) error { + ts := now() + result, err := s.db.Exec(s.q(` + UPDATE workspaces SET deleted_at = NULL, updated_at = ? + WHERE slug = ? AND deleted_at IS NOT NULL + `), ts, slug) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +// GetDeletedWorkspaceBySlug returns a SOFT-DELETED workspace by slug +// (deleted_at IS NOT NULL), including its owner_id, or nil when no such +// row exists (live workspace, unknown slug, or already hard-purged). +// +// The normal resolvers (GetWorkspaceBySlug / resolveWorkspace) filter +// `deleted_at IS NULL`, so a soft-deleted workspace is invisible to +// them — the restore handler uses this to look up the row it's about to +// restore and check ownership BEFORE mutating (so it can tell a +// non-owner's 403 apart from a genuine 404). +func (s *Store) GetDeletedWorkspaceBySlug(slug string) (*models.Workspace, error) { + var w models.Workspace + var createdAt, updatedAt string + var deletedAt *string + + err := s.db.QueryRow(s.q(` + SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at + FROM workspaces w + LEFT JOIN users ou ON ou.id = w.owner_id + WHERE w.slug = ? AND w.deleted_at IS NOT NULL + `), slug).Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + w.CreatedAt = parseTime(createdAt) + w.UpdatedAt = parseTime(updatedAt) + w.DeletedAt = parseTimePtr(deletedAt) + w.HydrateDerivedFields() + return &w, nil +} + +// ListDeletedWorkspaces returns the soft-deleted workspaces the given +// user OWNS that are still inside the restore window — deleted_at set, +// newer than cutoff (which the caller derives from the purge retention +// constant so restore and purge share one horizon). Ordered +// most-recently-deleted first. +// +// Owner-scoped by owner_id, the canonical "workspaces you solely own" +// signal. Account-deleted workspaces have no live owner (the owner user +// row was removed with the account), so their owner_id can't match any +// live requester — they never leak into this list. That is the intended +// behavior: only manually-deleted workspaces owned by a live user are +// practically restorable. +// +// deleted_at is fixed-width RFC3339 UTC (store.now()), so the `> cutoff` +// string comparison is a valid chronological ordering — the same +// technique ListPurgeableWorkspaces uses for its `< cutoff` bound. +func (s *Store) ListDeletedWorkspaces(userID string, cutoff time.Time) ([]models.Workspace, error) { + cutoffStr := cutoff.UTC().Format(time.RFC3339) + rows, err := s.db.Query(s.q(` + SELECT w.id, w.name, w.slug, w.owner_id, COALESCE(ou.username, ''), w.description, w.settings, w.source, w.created_at, w.updated_at, w.deleted_at + FROM workspaces w + LEFT JOIN users ou ON ou.id = w.owner_id + WHERE w.owner_id = ? AND w.deleted_at IS NOT NULL AND w.deleted_at > ? + ORDER BY w.deleted_at DESC + `), userID, cutoffStr) + if err != nil { + return nil, fmt.Errorf("list deleted workspaces: %w", err) + } + defer rows.Close() + + var result []models.Workspace + for rows.Next() { + var w models.Workspace + var createdAt, updatedAt string + var deletedAt *string + if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.OwnerID, &w.OwnerUsername, &w.Description, &w.Settings, &w.Source, &createdAt, &updatedAt, &deletedAt); err != nil { + return nil, fmt.Errorf("scan deleted workspace: %w", err) + } + w.CreatedAt = parseTime(createdAt) + w.UpdatedAt = parseTime(updatedAt) + w.DeletedAt = parseTimePtr(deletedAt) + w.HydrateDerivedFields() + result = append(result, w) + } + return result, rows.Err() +} diff --git a/internal/store/workspaces_test.go b/internal/store/workspaces_test.go new file mode 100644 index 00000000..69fe33a3 --- /dev/null +++ b/internal/store/workspaces_test.go @@ -0,0 +1,190 @@ +package store + +import ( + "database/sql" + "testing" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TestRestoreWorkspace_ResurfacesIntact proves the core PLAN-1969 fact: +// DeleteWorkspace only soft-deletes the WORKSPACE row — items and +// collections underneath are never touched, just transitively hidden. +// RestoreWorkspace clears deleted_at and everything comes back intact. +func TestRestoreWorkspace_ResurfacesIntact(t *testing.T) { + s := testStore(t) + u, err := s.CreateUser(models.UserCreate{Email: "owner@test.com", Name: "Owner", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + + ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Recoverable", OwnerID: u.ID}) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + coll := createTestCollection(t, s, ws.ID, "Tasks") + item := createTestItem(t, s, ws.ID, coll.ID, "Keep Me", "body") + + // Soft-delete the workspace. + if err := s.DeleteWorkspace(ws.Slug); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + // The workspace is now hidden from the normal resolver... + if got, err := s.GetWorkspaceBySlug(ws.Slug); err != nil { + t.Fatalf("GetWorkspaceBySlug after delete: %v", err) + } else if got != nil { + t.Fatalf("expected soft-deleted workspace to be hidden from GetWorkspaceBySlug, got %+v", got) + } + + // ...but its collection and item were NEVER soft-deleted — only the + // workspace row carries deleted_at. + if n := s.countRows(t, `SELECT COUNT(*) FROM collections WHERE id = ? AND deleted_at IS NULL`, coll.ID); n != 1 { + t.Errorf("collection should remain live (deleted_at NULL) after workspace delete, got %d", n) + } + if n := s.countRows(t, `SELECT COUNT(*) FROM items WHERE id = ? AND deleted_at IS NULL`, item.ID); n != 1 { + t.Errorf("item should remain live (deleted_at NULL) after workspace delete, got %d", n) + } + + // Restore. + if err := s.RestoreWorkspace(ws.Slug); err != nil { + t.Fatalf("RestoreWorkspace: %v", err) + } + + restored, err := s.GetWorkspaceBySlug(ws.Slug) + if err != nil { + t.Fatalf("GetWorkspaceBySlug after restore: %v", err) + } + if restored == nil { + t.Fatal("expected workspace to be visible again after restore") + } + if restored.DeletedAt != nil { + t.Errorf("restored workspace deleted_at should be nil, got %v", restored.DeletedAt) + } + + // The item is retrievable through the normal getter again. + gotItem, err := s.GetItem(item.ID) + if err != nil { + t.Fatalf("GetItem after restore: %v", err) + } + if gotItem == nil || gotItem.Title != "Keep Me" { + t.Errorf("expected item to re-surface intact, got %+v", gotItem) + } + if n := s.countRows(t, `SELECT COUNT(*) FROM collections WHERE id = ? AND deleted_at IS NULL`, coll.ID); n != 1 { + t.Errorf("collection should still be live after restore, got %d", n) + } +} + +// TestRestoreWorkspace_ErrNoRowsWhenNothingToRestore covers the +// idempotent-ish 404 semantics: restoring a live workspace, restoring +// twice, and restoring an unknown slug all return sql.ErrNoRows. +func TestRestoreWorkspace_ErrNoRowsWhenNothingToRestore(t *testing.T) { + s := testStore(t) + u, err := s.CreateUser(models.UserCreate{Email: "owner@test.com", Name: "Owner", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + + ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Live One", OwnerID: u.ID}) + if err != nil { + t.Fatalf("create workspace: %v", err) + } + + // Restoring a live (never-deleted) workspace matches no soft-deleted row. + if err := s.RestoreWorkspace(ws.Slug); err != sql.ErrNoRows { + t.Errorf("restore of live workspace: expected sql.ErrNoRows, got %v", err) + } + + // Delete, then the first restore succeeds. + if err := s.DeleteWorkspace(ws.Slug); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + if err := s.RestoreWorkspace(ws.Slug); err != nil { + t.Fatalf("first RestoreWorkspace should succeed: %v", err) + } + + // A second restore (now live again) is a no-op → ErrNoRows. + if err := s.RestoreWorkspace(ws.Slug); err != sql.ErrNoRows { + t.Errorf("double restore: expected sql.ErrNoRows, got %v", err) + } + + // Unknown slug → ErrNoRows. + if err := s.RestoreWorkspace("does-not-exist"); err != sql.ErrNoRows { + t.Errorf("restore of unknown slug: expected sql.ErrNoRows, got %v", err) + } +} + +// TestListDeletedWorkspaces_WindowAndOwnerScope pins the 30-day restore +// window boundary (29d IN, 31d OUT — the inverse of the purge boundary), +// owner-scoping, live exclusion, and most-recently-deleted ordering. +func TestListDeletedWorkspaces_WindowAndOwnerScope(t *testing.T) { + s := testStore(t) + owner, err := s.CreateUser(models.UserCreate{Email: "owner@test.com", Name: "Owner", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := s.CreateUser(models.UserCreate{Email: "other@test.com", Name: "Other", Password: "correct-horse-battery-staple"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + + // Owner's workspaces. + inRecent := createWSWithDeletedAt(t, s, owner, "In Recent", time.Now().Add(-2*24*time.Hour)) + inOld := createWSWithDeletedAt(t, s, owner, "In Old", time.Now().Add(-29*24*time.Hour)) + out := createWSWithDeletedAt(t, s, owner, "Out", time.Now().Add(-31*24*time.Hour)) + liveWS, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Live", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("create live workspace: %v", err) + } + + // Another user's soft-deleted workspace — must never leak into the + // owner's list (owner-scoping; also the account-deleted-no-owner case). + otherWS := createWSWithDeletedAt(t, s, other, "Other Deleted", time.Now().Add(-1*24*time.Hour)) + + cutoff := time.Now().Add(-30 * 24 * time.Hour) + got, err := s.ListDeletedWorkspaces(owner.ID, cutoff) + if err != nil { + t.Fatalf("ListDeletedWorkspaces(owner): %v", err) + } + + ids := map[string]bool{} + for _, w := range got { + ids[w.ID] = true + if w.DeletedAt == nil { + t.Errorf("deleted workspace %s should carry deleted_at", w.Slug) + } + } + if !ids[inRecent] { + t.Errorf("workspace deleted 2d ago should be restorable") + } + if !ids[inOld] { + t.Errorf("workspace deleted 29d ago should be restorable (boundary IN)") + } + if ids[out] { + t.Errorf("workspace deleted 31d ago must NOT be restorable (boundary OUT)") + } + if ids[liveWS.ID] { + t.Errorf("live workspace must NEVER appear in the deleted list") + } + if ids[otherWS] { + t.Errorf("another user's deleted workspace must not leak (owner-scoping)") + } + if len(got) != 2 { + t.Fatalf("expected exactly 2 restorable workspaces for owner, got %d", len(got)) + } + + // Ordering: most-recently-deleted first. + if got[0].ID != inRecent || got[1].ID != inOld { + t.Errorf("expected DESC deleted_at ordering [inRecent, inOld], got [%s, %s]", got[0].ID, got[1].ID) + } + + // The other user sees only their own deleted workspace. + gotOther, err := s.ListDeletedWorkspaces(other.ID, cutoff) + if err != nil { + t.Fatalf("ListDeletedWorkspaces(other): %v", err) + } + if len(gotOther) != 1 || gotOther[0].ID != otherWS { + t.Errorf("other user should see exactly their own deleted workspace, got %+v", gotOther) + } +} diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index e09086ca..4e4d3802 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -259,6 +259,23 @@ export interface Workspace { context?: WorkspaceContext; created_at: string; updated_at: string; + // `deleted_at` marks a soft-deleted workspace. Present (non-null) only + // on soft-deleted rows — e.g. entries from GET /workspaces/deleted; the + // normal switcher list omits it. + deleted_at?: string | null; +} + +// DeletedWorkspace is one entry from GET /api/v1/workspaces/deleted — a +// soft-deleted workspace still inside the restore window, plus the +// purge-window fields (derived server-side from the shared purge +// retention constant) the UI renders as "N days left" before permanent +// deletion. (Web api.workspaces methods land in TASK-1971.) +export interface DeletedWorkspace extends Workspace { + // When the retention sweeper will hard-delete the workspace + // (deleted_at + purge retention), RFC3339. + purge_at: string; + // Whole days remaining until purge_at, rounded up and clamped at 0. + days_left: number; } export interface WorkspaceRepository {