Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<p align="center">
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
<br />
<img src="https://img.shields.io/badge/version-v3.17.2-blue" alt="version" />
<img src="https://img.shields.io/badge/version-v3.17.3-blue" alt="version" />
<a href="LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
</a>
Expand Down
3 changes: 1 addition & 2 deletions cmd/scrumboy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions docs/audit_trail.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/roles_and_permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---
Expand Down
152 changes: 152 additions & 0 deletions internal/httpapi/anonymous_temp_expiration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`,
Expand Down
9 changes: 5 additions & 4 deletions internal/store/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions internal/store/board_activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading