diff --git a/CHANGELOG.md b/CHANGELOG.md index 1396783..68403f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ > **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** / **3.12.x** / **3.13.x** / **3.14.x** / **3.15.x** / **3.16.x** / **3.17.x** unless noted below. +## [3.17.3] - 2026-06-01 + +### Changed + +- **Temporary board lifetime** - Link-expiring boards now use a **90-day** rolling `expires_at` window (`TemporaryBoardLifetimeDays`), applied at creation, on import paths, and when **`UpdateBoardActivity`** refreshes activity (throttled to once every 5 minutes). Previously the window was 14 days. + +### Fixed + +- **Expired temporary boards** - Once `expires_at` is in the past, board reads and mutations return **404** until the project row is removed, including todo/tag routes and import-into-board targets. Rename and claim already refused expired boards; other paths now match. + +### Improvements + +- **Expiration cleanup scope** - Comments and operator docs clarify that **`DeleteExpiredProjects`** removes every expired temporary board (anonymous and authenticated), not only unowned paste boards. + +### Tests + +- **Temporary board expiration** - Store and HTTP coverage for 90-day initial expiry, rolling **`UpdateBoardActivity`** refresh, blocked anonymous project delete, expired-board **404**s, import replace forbidden in anonymous mode, anonymous-mode PATCH rename rules, authenticated temp cleanup, todo cascade on expiry, and append-only **`audit_events`** rows surviving project deletion. + +### Documentation + +- **`FAQ.md`** - New **“What is a temporary board?”** entry (sharing, 90-day rolling expiry, activity refresh). +- **`docs/roles_and_permissions.md`** and **`docs/audit_trail.md`** - Expiration wording and **`audit_events`** retention vs project cleanup. + ## [3.17.2] - 2026-05-29 ### Fixed diff --git a/FAQ.md b/FAQ.md index 267b69a..42befa1 100644 --- a/FAQ.md +++ b/FAQ.md @@ -5,6 +5,7 @@ - [How do I enable Markdown in my notes?](#how-do-i-enable-markdown-in-my-notes) - [How do I enable Mermaid diagrams in my notes?](#how-do-i-enable-mermaid-diagrams-in-my-notes) - [How do I edit several todos at once?](#how-do-i-edit-several-todos-at-once) +- [What is a temporary board?](#what-is-a-temporary-board) - [What does the done lane mean for dashboard stats?](#what-does-the-done-lane-mean-for-dashboard-stats) - [Are tag colors personal, or shared with the team?](#are-tag-colors-personal-or-shared-with-the-team) - [How do I use Scrumboy with Claude or other MCP clients?](#how-do-i-use-scrumboy-with-claude-or-other-mcp-clients) @@ -65,6 +66,34 @@ In that dialog, turn on only the changes you want (each field has its own checkb A normal click on a card (without Ctrl/⌘) opens the usual single-todo editor and clears the selection. Viewers cannot use multi-select; Ctrl/⌘+click still opens one todo for them. +## What is a temporary board? + +A **temporary board** is a Scrumboy project with an **`expires_at`** timestamp. It is meant to be shared by URL (pastebin-style) rather than kept as a long-lived team project. Durable projects have **`expires_at` unset** and use normal sign-in, members, and roles. + +**How you get one** + +- Open **`/anon`** (or **`/temp`**, which redirects there). Scrumboy creates a new board and sends you to **`/{slug}`** - that link is how you share it. +- In **full mode**, if you are signed in when the board is created, it is still temporary but recorded as yours (**“Temporary Board”**, with a `creator_user_id`). You can later **claim** it to turn it into a durable project (`POST /api/board/{slug}/claim` while logged in). +- In **anonymous server mode** (`SCRUMBOY_MODE=anonymous`), the instance is built for temporary boards only; new boards from `/anon` have **no owner** (**“Anonymous Board”**, `creator_user_id` is null). + +**Anonymous temporary boards** (no owner: `expires_at` is set and `creator_user_id` is null) can be used **without signing in**. Anyone with the link can create, edit, move, and delete todos and rename the board. They cannot assign todos to users, change the project image, or delete the whole project from the UI. Tag colors on that board are shared for everyone on the link. + +**When it expires** + +- New temporary boards start with **`expires_at` about 90 days ahead** (`TemporaryBoardLifetimeDays` in the server code). +- **Yes, activity resets the expiry window** - but not as a separate “inactivity counter.” When the board is used, the server runs **`UpdateBoardActivity`**, which sets **`expires_at` to about 90 days from that moment** (rolling lifetime). Qualifying activity includes loading the board (for example a full board read) and todo changes (create, update, move, delete). Updates are **throttled to at most once every 5 minutes** per board so rapid refreshes do not hammer the database. +- After **`expires_at` has passed**, the board URL returns **404** for reads and edits until the server removes the expired row. There is no “grace period” in the API after expiry. + +**Compared to a normal project** + +| | Temporary board | Durable project | +|--|-----------------|-----------------| +| Lifetime | `expires_at` (90-day rolling window with activity) | No expiry | +| Sharing | Link-based; anonymous temps need no login | Members and roles | +| Delete project | Not offered for anonymous temps | Maintainers can delete | + +For permissions detail, see [`docs/roles_and_permissions.md`](docs/roles_and_permissions.md). + # Dashboard ## What does the done lane mean for dashboard stats? diff --git a/README.md b/README.md index d287492..f20d20e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

scrumboy logo
- version + version license diff --git a/cmd/scrumboy/main.go b/cmd/scrumboy/main.go index 70655c9..c593c6b 100644 --- a/cmd/scrumboy/main.go +++ b/cmd/scrumboy/main.go @@ -177,7 +177,7 @@ func main() { } }() - // Start background cleanup process for expired anonymous boards + // Start background cleanup for expired temporary boards (any project with expires_at in the past). stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) @@ -190,7 +190,6 @@ func main() { case <-ticker.C: ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - // Cleanup expired anonymous boards deleted, err := st.DeleteExpiredProjects(ctx) if err != nil { logger.Printf("cleanup expired projects: %v", err) diff --git a/docs/audit_trail.md b/docs/audit_trail.md index bbd5302..a120886 100644 --- a/docs/audit_trail.md +++ b/docs/audit_trail.md @@ -103,6 +103,12 @@ Store methods that write audit events: --- +## Retention vs project cleanup + +`audit_events` references `project_id` but has **no foreign key** to `projects` and is **append-only** (migration 047 blocks updates and deletes on audit rows). When an expired temporary board is removed, dependent todos/tags cascade via `ON DELETE CASCADE`, but audit rows for that `project_id` may remain on purpose. That is expected, not a missing cascade. + +--- + ## Security - **Immutability:** Append-only triggers prevent tampering diff --git a/docs/roles_and_permissions.md b/docs/roles_and_permissions.md index 885b5a7..1c1fdce 100644 --- a/docs/roles_and_permissions.md +++ b/docs/roles_and_permissions.md @@ -60,10 +60,12 @@ Permissions flow: `permissions.go` → store enforcement → API error mapping **Bypass rules:** - Create, delete, move todos: allowed without auth -- Rename project: allowed without auth +- Rename project: allowed without auth (active boards only; past `expires_at` → 404) - Assignment: not allowed (validation error) - Project image, delete project: immutable (404) +**Expiration:** Temporary boards use `expires_at` (initially 90 days from creation; board activity can roll the expiry forward). Once `expires_at` is in the past, board reads and mutations return **404** until the project row is removed. This applies to authenticated temporary boards in full mode as well as unowned anonymous boards. + **UI:** New Todo and drag-and-drop are enabled for anonymous boards (same as Maintainer on durable boards). --- diff --git a/internal/httpapi/anonymous_temp_expiration_test.go b/internal/httpapi/anonymous_temp_expiration_test.go new file mode 100644 index 0000000..e0510ef --- /dev/null +++ b/internal/httpapi/anonymous_temp_expiration_test.go @@ -0,0 +1,152 @@ +package httpapi + +import ( + "context" + "net/http" + "strconv" + "testing" + "time" + + "scrumboy/internal/store" + "scrumboy/internal/version" +) + +func TestAnonymousMode_DeleteProjectRoute_Returns404(t *testing.T) { + ts, _, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + client := ts.Client() + resp, _ := doJSON(t, client, http.MethodDelete, ts.URL+"/api/projects/1", nil, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for DELETE /api/projects/{id} in anonymous mode, got %d", resp.StatusCode) + } +} + +func TestExpiredAnonymousTempBoard_BoardGET_Returns404(t *testing.T) { + ts, sqlDB, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + st := store.New(sqlDB, nil) + p, err := st.CreateAnonymousBoard(context.Background()) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + pastMs := time.Now().UTC().Add(-24 * time.Hour).UnixMilli() + if _, err := sqlDB.Exec(`UPDATE projects SET expires_at = ? WHERE id = ?`, pastMs, p.ID); err != nil { + t.Fatalf("expire project: %v", err) + } + + client := ts.Client() + resp, body := doJSON(t, client, http.MethodGet, ts.URL+"/api/board/"+p.Slug, nil, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for expired board GET, got %d body=%s", resp.StatusCode, string(body)) + } +} + +func TestExpiredAnonymousTempBoard_TodoCreate_Returns404(t *testing.T) { + ts, sqlDB, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + st := store.New(sqlDB, nil) + p, err := st.CreateAnonymousBoard(context.Background()) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + pastMs := time.Now().UTC().Add(-24 * time.Hour).UnixMilli() + if _, err := sqlDB.Exec(`UPDATE projects SET expires_at = ? WHERE id = ?`, pastMs, p.ID); err != nil { + t.Fatalf("expire project: %v", err) + } + + client := ts.Client() + resp, body := doJSON(t, client, http.MethodPost, ts.URL+"/api/board/"+p.Slug+"/todos", map[string]any{ + "title": "late", + "columnKey": "backlog", + }, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for todo create on expired board, got %d body=%s", resp.StatusCode, string(body)) + } +} + +func TestAnonymousMode_ImportReplace_Forbidden(t *testing.T) { + ts, _, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + client := ts.Client() + data := store.ExportData{ + Version: version.ExportFormatVersion, + Scope: "single", + Projects: []store.ProjectExport{ + {Slug: "imp", Name: "Import"}, + }, + } + resp, body := doJSON(t, client, http.MethodPost, ts.URL+"/api/backup/import", map[string]any{ + "data": data, + "importMode": "replace", + "confirmation": "REPLACE", + }, nil) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for replace import in anonymous mode, got %d body=%s", resp.StatusCode, string(body)) + } +} + +func TestAnonymousMode_PatchRename_ExpiredBoard_Returns404(t *testing.T) { + ts, sqlDB, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + st := store.New(sqlDB, nil) + p, err := st.CreateAnonymousBoard(context.Background()) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + pastMs := time.Now().UTC().Add(-24 * time.Hour).UnixMilli() + if _, err := sqlDB.Exec(`UPDATE projects SET expires_at = ? WHERE id = ?`, pastMs, p.ID); err != nil { + t.Fatalf("expire project: %v", err) + } + + client := ts.Client() + resp, _ := doJSON(t, client, http.MethodPatch, ts.URL+"/api/projects/"+strconv.FormatInt(p.ID, 10), map[string]any{ + "name": "Too Late", + }, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for PATCH rename on expired board, got %d", resp.StatusCode) + } +} + +func TestAnonymousMode_PatchRename_DurableProject_Returns404(t *testing.T) { + ts, sqlDB, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + st := store.New(sqlDB, nil) + p, err := st.CreateProject(context.Background(), "Durable") + if err != nil { + t.Fatalf("CreateProject: %v", err) + } + + client := ts.Client() + resp, _ := doJSON(t, client, http.MethodPatch, ts.URL+"/api/projects/"+strconv.FormatInt(p.ID, 10), map[string]any{ + "name": "Nope", + }, nil) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 for PATCH on durable project in anonymous mode, got %d", resp.StatusCode) + } +} + +func TestAnonymousMode_PatchRename_ImageRejected(t *testing.T) { + ts, sqlDB, cleanup := newTestHTTPServer(t, "anonymous") + defer cleanup() + + st := store.New(sqlDB, nil) + p, err := st.CreateAnonymousBoard(context.Background()) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + + client := ts.Client() + resp, _ := doJSON(t, client, http.MethodPatch, ts.URL+"/api/projects/"+strconv.FormatInt(p.ID, 10), map[string]any{ + "image": "data:image/png;base64,aaaa", + }, nil) + // Anonymous mode has no session; image PATCH requires auth (route is open only for rename on active anon temps). + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 for image PATCH in anonymous mode, got %d", resp.StatusCode) + } +} diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index b7ad3fd..d2311b3 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -2606,14 +2606,14 @@ func TestAnonymousMode_BoardRouteServesSPA(t *testing.T) { } func TestExpiredProjectCleanup(t *testing.T) { + // DeleteExpiredProjects removes every project with expires_at in the past (anonymous and authenticated temps). _, sqlDB, cleanup := newTestHTTPServer(t, "full") defer cleanup() st := store.New(sqlDB, nil) - // Create a project with expires_at in the past nowMs := time.Now().UTC().UnixMilli() - pastMs := nowMs - (15 * 24 * 60 * 60 * 1000) // 15 days ago + pastMs := nowMs - int64((91 * 24 * time.Hour).Milliseconds()) // clearly past any 90-day lifetime _, err := sqlDB.Exec(`INSERT INTO projects(name, image, slug, last_activity_at, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, "expired", "/scrumboy.png", "expired123", nowMs, pastMs, nowMs, nowMs) if err != nil { @@ -2942,7 +2942,7 @@ func TestAnonymousMode_RenameProjectAuthorization(t *testing.T) { // Create temp board with creator_user_id set (authenticated temp board) nowMs := time.Now().UTC().UnixMilli() - expiresAtMs := nowMs + (14 * 24 * 60 * 60 * 1000) + expiresAtMs := nowMs + int64((store.TemporaryBoardLifetimeDays * 24 * time.Hour).Milliseconds()) var authenticatedTempProjectID int64 if err := sqlDB.QueryRow(`INSERT INTO projects (name, image, slug, creator_user_id, last_activity_at, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, diff --git a/internal/store/backup.go b/internal/store/backup.go index 00a8a0d..ba21096 100644 --- a/internal/store/backup.go +++ b/internal/store/backup.go @@ -942,6 +942,9 @@ func (s *Store) importIntoBoard(ctx context.Context, data *ExportData, mode Mode if targetProject.ExpiresAt == nil { return nil, fmt.Errorf("%w: target board is not an anonymous board", ErrValidation) } + if err := rejectIfExpiredTemporaryProject(targetProject); err != nil { + return nil, err + } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { @@ -1429,8 +1432,7 @@ func (s *Store) importMergeUpdate(ctx context.Context, data *ExportData, mode Mo // For temporary boards, give a fresh expiration date instead of preserving the old one expiresAtMs.Valid = true if mode == ModeAnonymous { - // Anonymous boards: 14 days from now - expiresAtMs.Int64 = time.Now().UTC().Add(14 * 24 * time.Hour).UnixMilli() + expiresAtMs.Int64 = temporaryBoardExpiresAtMs(time.Now().UTC().UnixMilli()) } else { // Full mode temp boards: preserve original expiration (user's choice) expiresAtMs.Int64 = pExport.ExpiresAt.UnixMilli() @@ -1750,8 +1752,7 @@ func (s *Store) importCreateCopy(ctx context.Context, data *ExportData, mode Mod // This prevents importing boards that are already expired or about to expire expiresAtMs.Valid = true if mode == ModeAnonymous { - // Anonymous boards: 14 days from now - expiresAtMs.Int64 = time.Now().UTC().Add(14 * 24 * time.Hour).UnixMilli() + expiresAtMs.Int64 = temporaryBoardExpiresAtMs(time.Now().UTC().UnixMilli()) } else { // Full mode temp boards: preserve original expiration (user's choice) expiresAtMs.Int64 = pExport.ExpiresAt.UnixMilli() diff --git a/internal/store/board_activity_test.go b/internal/store/board_activity_test.go index 1d3dcf4..1686215 100644 --- a/internal/store/board_activity_test.go +++ b/internal/store/board_activity_test.go @@ -58,6 +58,40 @@ func TestDurableBoardRead_DoesNotRefreshLastActivityAt(t *testing.T) { } } +func TestUpdateBoardActivity_ExtendsExpiresAt90DaysFromNow(t *testing.T) { + st, sqlDB, cleanup := newTestStoreWithSQL(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + + // Near expiry and stale activity so UpdateBoardActivity applies (not throttled). + nearExpiryMs := time.Now().UTC().Add(24 * time.Hour).UnixMilli() + staleActivityMs := time.Now().UTC().Add(-6 * time.Minute).UnixMilli() + if _, err := sqlDB.Exec(`UPDATE projects SET expires_at = ?, last_activity_at = ? WHERE id = ?`, + nearExpiryMs, staleActivityMs, p.ID); err != nil { + t.Fatalf("prime expires_at/last_activity_at: %v", err) + } + + if err := st.UpdateBoardActivity(ctx, p.ID); err != nil { + t.Fatalf("UpdateBoardActivity: %v", err) + } + + var expiresAtMs int64 + if err := sqlDB.QueryRow(`SELECT expires_at FROM projects WHERE id = ?`, p.ID).Scan(&expiresAtMs); err != nil { + t.Fatalf("read expires_at: %v", err) + } + got := time.UnixMilli(expiresAtMs).UTC() + want := time.Now().UTC().AddDate(0, 0, TemporaryBoardLifetimeDays) + slack := 2 * time.Minute + if got.Before(want.Add(-slack)) || got.After(want.Add(slack)) { + t.Fatalf("expires_at %v, want about %v (±%v) after activity refresh", got, want, slack) + } +} + func TestExpiringBoardRead_RefreshesLastActivityWhenStale(t *testing.T) { st, sqlDB, cleanup := newTestStoreWithSQL(t) defer cleanup() diff --git a/internal/store/projects.go b/internal/store/projects.go index 30c13c2..f7302c5 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -101,6 +101,9 @@ func (s *Store) getProjectForRead(ctx context.Context, projectID int64, mode Mod // Temporary boards are share-links (pastebin-style): bypass ownership checks because the project is temporary/unowned, // not because of request mode. Request mode remains orthogonal and request-scoped. if p.ExpiresAt != nil { + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return Project{}, err + } return p, nil } @@ -160,6 +163,9 @@ func (s *Store) buildProjectContext(ctx context.Context, p Project, mode Mode) ( // Temporary boards bypass ownership checks if p.ExpiresAt != nil { + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return ProjectContext{}, err + } pc.AuthEnabled = true // arbitrary; role unused for temp boards return pc, nil } @@ -244,6 +250,23 @@ func isTemporaryProject(p Project) bool { return p.ExpiresAt != nil } +// TemporaryBoardLifetimeDays is the rolling lifetime applied to link-expiring boards at creation +// and when UpdateBoardActivity extends expires_at. +const TemporaryBoardLifetimeDays = 90 + +func temporaryBoardExpiresAtMs(fromMs int64) int64 { + return fromMs + int64(TemporaryBoardLifetimeDays)*24*60*60*1000 +} + +// rejectIfExpiredTemporaryProject returns ErrNotFound when a link-expiring board is past expires_at. +// Expired rows remain until DeleteExpiredProjects runs; until then all board access and mutations refuse. +func rejectIfExpiredTemporaryProject(p Project) error { + if p.ExpiresAt != nil && !p.ExpiresAt.After(time.Now().UTC()) { + return ErrNotFound + } + return nil +} + // effectiveTagModeForProject determines tag scoping based on project state. // Request mode stays request-scoped and is not rewritten. Tag scoping is orthogonal: unowned temporary boards // should have project-scoped tags to avoid cross-board leakage. @@ -817,6 +840,9 @@ func (s *Store) getProjectForReadTx(ctx context.Context, tx *sql.Tx, projectID i } // Temporary boards bypass ownership because of project state, not because of request mode. if p.ExpiresAt != nil { + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return Project{}, err + } return p, nil } enabled, err := authEnabledTx(ctx, tx) @@ -850,6 +876,9 @@ func (s *Store) getProjectForWriteTx(ctx context.Context, tx *sql.Tx, projectID // Temporary boards bypass role checks if p.ExpiresAt != nil { + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return Project{}, err + } return p, nil } @@ -1013,10 +1042,8 @@ func (s *Store) UpdateProjectName(ctx context.Context, projectID int64, userID i // Do not allow unauthenticated rename for any other case. isAnonymousTempBoard := p.ExpiresAt != nil && p.CreatorUserID == nil if isAnonymousTempBoard { - // Check if board is expired - expired boards cannot be renamed - now := time.Now().UTC() - if p.ExpiresAt.Before(now) { - return ErrNotFound + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return err } // Anonymous temp boards can be renamed by anyone (no auth required) } else { @@ -1060,10 +1087,10 @@ func (s *Store) UpdateProjectName(ctx context.Context, projectID int64, userID i } // CreateAnonymousBoard creates a new project for anonymous board mode. -// Sets expires_at = now + 14 days and last_activity_at = now. +// Sets expires_at = now + TemporaryBoardLifetimeDays and last_activity_at = now. func (s *Store) CreateAnonymousBoard(ctx context.Context) (Project, error) { nowMs := time.Now().UTC().UnixMilli() - expiresAtMs := nowMs + (14 * 24 * 60 * 60 * 1000) // 14 days in milliseconds + expiresAtMs := temporaryBoardExpiresAtMs(nowMs) defaultImage := "/scrumboy.png" var ( @@ -1152,7 +1179,7 @@ func (s *Store) CreateAnonymousBoard(ctx context.Context) (Project, error) { func (s *Store) UpdateBoardActivity(ctx context.Context, projectID int64) error { nowMs := time.Now().UTC().UnixMilli() throttleMs := nowMs - (5 * 60 * 1000) // 5 minutes ago - expiresAtMs := nowMs + (14 * 24 * 60 * 60 * 1000) + expiresAtMs := temporaryBoardExpiresAtMs(nowMs) res, err := s.db.ExecContext(ctx, ` UPDATE projects @@ -1236,8 +1263,12 @@ func (s *Store) BackfillDominantColors(ctx context.Context, extractor func(strin return updated, nil } -// DeleteExpiredProjects deletes projects where expires_at IS NOT NULL AND expires_at < now(). -// Returns the count of deleted projects. +// DeleteExpiredProjects deletes all link-expiring boards (expires_at IS NOT NULL) past their expiry time. +// This includes unowned anonymous paste boards and authenticated temporary boards in full mode. +// Returns the count of deleted project rows. +// +// audit_events has no FK to projects and is append-only (migration 047), so rows for a deleted +// project_id may remain intentionally; that is not a failed CASCADE. func (s *Store) DeleteExpiredProjects(ctx context.Context) (int64, error) { nowMs := time.Now().UTC().UnixMilli() res, err := s.db.ExecContext(ctx, `DELETE FROM projects WHERE expires_at IS NOT NULL AND expires_at < ?`, nowMs) diff --git a/internal/store/projects_expiration_test.go b/internal/store/projects_expiration_test.go new file mode 100644 index 0000000..826e43b --- /dev/null +++ b/internal/store/projects_expiration_test.go @@ -0,0 +1,222 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "scrumboy/internal/version" +) + +func expireProject(t *testing.T, st *Store, projectID int64, past time.Duration) { + t.Helper() + pastMs := time.Now().UTC().Add(-past).UnixMilli() + if _, err := st.db.ExecContext(context.Background(), `UPDATE projects SET expires_at = ? WHERE id = ?`, pastMs, projectID); err != nil { + t.Fatalf("expire project: %v", err) + } +} + +func TestCreateAnonymousBoard_InitialExpiresAt90Days(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + if p.ExpiresAt == nil { + t.Fatal("expected expires_at on anonymous board") + } + + want := time.Now().UTC().AddDate(0, 0, TemporaryBoardLifetimeDays) + slack := 2 * time.Minute + if p.ExpiresAt.Before(want.Add(-slack)) || p.ExpiresAt.After(want.Add(slack)) { + t.Fatalf("expires_at %v, want about %v (±%v)", p.ExpiresAt, want, slack) + } +} + +func TestDeleteProject_AnonymousTempBoard_Blocked(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + + err = st.DeleteProject(ctx, p.ID, 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestExpiredTemporaryProject_BoardReadDenied(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + expireProject(t, st, p.ID, 24*time.Hour) + + _, err = st.GetProjectContextBySlug(ctx, p.Slug, ModeAnonymous) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for expired board read, got %v", err) + } +} + +func TestExpiredTemporaryProject_TodoCreateDenied(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + expireProject(t, st, p.ID, 24*time.Hour) + + _, err = st.CreateTodo(ctx, p.ID, CreateTodoInput{ + Title: "late", + ColumnKey: DefaultColumnBacklog, + }, ModeAnonymous) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for todo create on expired board, got %v", err) + } +} + +func TestDeleteExpiredProjects_AuthenticatedTempBoard(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + u, err := st.BootstrapUser(ctx, "owner@example.com", "password123", "Owner") + if err != nil { + t.Fatalf("BootstrapUser: %v", err) + } + if _, err := st.CreateAnonymousBoard(WithUserID(ctx, u.ID)); err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + var projectID int64 + if err := st.db.QueryRowContext(ctx, `SELECT id FROM projects WHERE creator_user_id = ? AND expires_at IS NOT NULL ORDER BY id DESC LIMIT 1`, u.ID).Scan(&projectID); err != nil { + t.Fatalf("find authenticated temp board: %v", err) + } + p, err := st.GetProject(ctx, projectID) + if err != nil { + t.Fatalf("GetProject: %v", err) + } + if p.CreatorUserID == nil { + t.Fatal("expected authenticated temporary board with creator_user_id") + } + expireProject(t, st, p.ID, 15*24*time.Hour) + + deleted, err := st.DeleteExpiredProjects(ctx) + if err != nil { + t.Fatalf("DeleteExpiredProjects: %v", err) + } + if deleted < 1 { + t.Fatalf("expected at least 1 deleted project, got %d", deleted) + } + + var count int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects WHERE id = ?`, p.ID).Scan(&count); err != nil { + t.Fatalf("count project: %v", err) + } + if count != 0 { + t.Fatal("expected authenticated expired temp board to be removed") + } +} + +func TestDeleteExpiredProjects_AuditEventsMayRemain(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + expireProject(t, st, p.ID, 91*24*time.Hour) + + nowMs := time.Now().UTC().UnixMilli() + if _, err := st.db.ExecContext(ctx, ` +INSERT INTO audit_events (project_id, actor_user_id, action, target_type, target_id, metadata, created_at) +VALUES (?, NULL, 'project_created', 'project', ?, '{}', ?)`, p.ID, p.ID, nowMs); err != nil { + t.Fatalf("insert audit event: %v", err) + } + + if _, err := st.DeleteExpiredProjects(ctx); err != nil { + t.Fatalf("DeleteExpiredProjects: %v", err) + } + + var auditCount int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_events WHERE project_id = ?`, p.ID).Scan(&auditCount); err != nil { + t.Fatalf("count audit_events: %v", err) + } + if auditCount < 1 { + t.Fatalf("expected append-only audit row(s) to remain after project delete, got count %d", auditCount) + } + var projectCount int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects WHERE id = ?`, p.ID).Scan(&projectCount); err != nil { + t.Fatalf("count project: %v", err) + } + if projectCount != 0 { + t.Fatal("expected project row removed by DeleteExpiredProjects") + } +} + +func TestDeleteExpiredProjects_CascadesTodos(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + p, err := st.CreateAnonymousBoard(ctx) + if err != nil { + t.Fatalf("CreateAnonymousBoard: %v", err) + } + todo, err := st.CreateTodo(ctx, p.ID, CreateTodoInput{ + Title: "gone", + ColumnKey: DefaultColumnBacklog, + }, ModeAnonymous) + if err != nil { + t.Fatalf("CreateTodo: %v", err) + } + expireProject(t, st, p.ID, 15*24*time.Hour) + + if _, err := st.DeleteExpiredProjects(ctx); err != nil { + t.Fatalf("DeleteExpiredProjects: %v", err) + } + + var todoCount int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM todos WHERE id = ?`, todo.ID).Scan(&todoCount); err != nil { + t.Fatalf("count todo: %v", err) + } + if todoCount != 0 { + t.Fatal("expected todo cascade delete when expired project is removed") + } +} + +func TestImportReplace_ForbiddenInAnonymousMode(t *testing.T) { + st, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + data := &ExportData{ + Version: version.ExportFormatVersion, + Scope: "single", + Projects: []ProjectExport{{Slug: "x", Name: "X"}}, + } + + _, err := st.ImportProjects(ctx, data, ModeAnonymous, "replace") + if err == nil { + t.Fatal("expected error for replace import in anonymous mode") + } + if !errors.Is(err, ErrValidation) { + t.Fatalf("expected ErrValidation, got %v", err) + } +} diff --git a/internal/store/tags.go b/internal/store/tags.go index ea74097..57b1402 100644 --- a/internal/store/tags.go +++ b/internal/store/tags.go @@ -771,6 +771,9 @@ func (s *Store) DeleteTag(ctx context.Context, userID int64, tagID int64, isAnon if err != nil { return fmt.Errorf("get project: %w", err) } + if err := rejectIfExpiredTemporaryProject(p); err != nil { + return err + } if p.ExpiresAt == nil || p.CreatorUserID != nil { return fmt.Errorf("%w: project is not anonymous", ErrUnauthorized) } diff --git a/internal/store/trello_import.go b/internal/store/trello_import.go index d0508e7..73b6459 100644 --- a/internal/store/trello_import.go +++ b/internal/store/trello_import.go @@ -235,7 +235,7 @@ func (s *Store) insertTrelloDurableProjectRow(ctx context.Context, tx *sql.Tx, n func (s *Store) insertTrelloTemporaryProjectRow(ctx context.Context, tx *sql.Tx, name string, projectImportMetadata string, nowMs int64) (int64, Project, error) { defaultImage := "/scrumboy.png" - expiresAtMs := nowMs + (14 * 24 * 60 * 60 * 1000) + expiresAtMs := temporaryBoardExpiresAtMs(nowMs) var creatorUserID *int64 if userID, ok := UserIDFromContext(ctx); ok { creatorUserID = &userID diff --git a/internal/version/version.go b/internal/version/version.go index 2e531b0..4ee04cb 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,6 +1,6 @@ package version -const Version = "3.17.2" +const Version = "3.17.3" // ExportFormatVersion is the version of the backup/export data format. // Only increment this when the ExportData structure changes in a breaking way. diff --git a/win_run_anonymous.bat b/win_run_anonymous.bat index 94fdf0c..1d49865 100644 --- a/win_run_anonymous.bat +++ b/win_run_anonymous.bat @@ -9,7 +9,7 @@ echo ======================================== echo. echo Data will be stored in ./data/app.db echo Mode: Anonymous (single-board, auto-creates on /^) -echo Boards expire after 14 days of inactivity +echo Boards expire after 90 days without activity echo. REM ---- Free port 8080 ----