diff --git a/README.md b/README.md
index a3a589f..78b1efa 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,14 @@ losing either order. Account changes discard stale responses and retry work. Res
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
+Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
+review, Filed item, or update records its canonical detail route locally before navigation and marks
+the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
+merge the server list without duplicate routes, while each confirmed account remains bounded to its
+five most recent items. Titles, repositories, and routes are encrypted at rest with the shared
+private-state key; stale responses from a prior account are discarded. Set
+`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
+
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
@@ -310,7 +318,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
-likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
+likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 9959cce..c75af83 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -435,9 +435,11 @@
mobileRecentWork = createMobileRecentWork({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
+ fetchJson:fetchReviewJson,
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
+ status:qs('#mobile-recent-work-status'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
@@ -445,6 +447,7 @@
workRoute.sync();
},
});
+ mobileRecentWork.startLifecycle({window, document});
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
@@ -5665,6 +5668,7 @@
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
mobileQueuePriority.render();
renderMobileQueuePresentation();
+ void mobileRecentWork.load();
void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
diff --git a/frontend/index.html b/frontend/index.html
index f320300..9444ef8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2185,6 +2185,7 @@
diff --git a/frontend/mobile-recent-work.js b/frontend/mobile-recent-work.js
index dc5c7c1..ccab3c2 100644
--- a/frontend/mobile-recent-work.js
+++ b/frontend/mobile-recent-work.js
@@ -6,10 +6,17 @@
const storage = options.storage;
const getLogin = options.getLogin;
+ const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
+ const setTimer = options.setTimeout || setTimeout;
+ const clearTimer = options.clearTimeout || clearTimeout;
+ const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
+ let syncFlight = null;
+ let syncAccount = '';
+ let debounceTimer = null;
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
@@ -35,40 +42,157 @@
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
- return {
- kind,
- ...(repository ? { repository } : {}),
- number,
- title,
- route,
- };
+ return {kind, ...(repository ? {repository} : {}), number, title, route};
}
- function items() {
+ function normalizeList(value) {
+ if (!Array.isArray(value)) return [];
+ const unique = [];
+ for (const candidate of value) {
+ const item = normalize(candidate);
+ if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
+ if (unique.length === limit) break;
+ }
+ return unique;
+ }
+
+ function read() {
const storageKey = key();
- if (!storageKey) return [];
+ if (!storageKey) return {items:[], pending:[]};
try {
- const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
- if (!Array.isArray(parsed)) return [];
- return parsed.map(normalize).filter(Boolean).slice(0, limit);
+ const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
+ if (Array.isArray(parsed)) return {items:normalizeList(parsed), pending:[]};
+ return {
+ items:normalizeList(parsed?.items),
+ pending:normalizeList(parsed?.pending),
+ };
} catch (_) {
- return [];
+ return {items:[], pending:[]};
}
}
- function record(item) {
- const storageKey = key();
- const normalized = normalize(item);
- if (!storageKey || !normalized) return false;
- const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit);
+ function persist(value, accountKey = key()) {
+ if (!accountKey || accountKey !== key()) return false;
try {
- storage.setItem(storageKey, JSON.stringify(next));
+ storage.setItem(accountKey, JSON.stringify({
+ items:normalizeList(value.items), pending:normalizeList(value.pending),
+ }));
return true;
} catch (_) {
return false;
}
}
+ function announce(value = read(), status = null) {
+ if (!options.status) return;
+ options.status.textContent = status || (value.pending.length ? 'Sync pending.' : '');
+ }
+
+ function items() {
+ return read().items;
+ }
+
+ function state() {
+ const value = read();
+ return {pending:value.pending.length > 0, pendingCount:value.pending.length};
+ }
+
+ function scheduleSync() {
+ if (!fetchJson || !key()) return false;
+ if (debounceTimer) clearTimer(debounceTimer);
+ debounceTimer = setTimer(() => {
+ debounceTimer = null;
+ void sync();
+ }, debounceMs);
+ return true;
+ }
+
+ function record(item) {
+ const accountKey = key();
+ const normalized = normalize(item);
+ if (!accountKey || !normalized) return false;
+ const current = read();
+ current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
+ current.pending = [normalized, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
+ if (!persist(current, accountKey)) return false;
+ announce(current);
+ render();
+ scheduleSync();
+ return true;
+ }
+
+ function adopt(snapshot, accountKey, pending = []) {
+ if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
+ const remote = normalizeList(snapshot.items);
+ const unsent = normalizeList(pending);
+ const value = {
+ items:normalizeList([...unsent, ...remote]),
+ pending:unsent,
+ };
+ persist(value, accountKey);
+ announce(value);
+ render();
+ return true;
+ }
+
+ async function drain(accountKey) {
+ while (key() === accountKey) {
+ const current = read();
+ if (!current.pending.length) return current;
+ const sending = current.pending[current.pending.length - 1];
+ announce(current, 'Syncing recent work…');
+ try {
+ const snapshot = await fetchJson('api/v1/recent-work', {
+ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(sending),
+ });
+ if (key() !== accountKey) return read();
+ const latest = read();
+ const pending = latest.pending.filter(item => item.route !== sending.route);
+ if (!adopt(snapshot, accountKey, pending)) throw new Error('Recent work response is invalid.');
+ } catch (_error) {
+ if (key() === accountKey) announce(read());
+ return read();
+ }
+ }
+ return read();
+ }
+
+ function sync() {
+ if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
+ const accountKey = key();
+ if (!fetchJson || !accountKey || !read().pending.length) return Promise.resolve(read());
+ if (syncFlight && syncAccount === accountKey) return syncFlight;
+ syncAccount = accountKey;
+ syncFlight = drain(accountKey).finally(() => {
+ if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
+ });
+ return syncFlight;
+ }
+
+ async function load() {
+ const accountKey = key();
+ if (!fetchJson || !accountKey) return read();
+ try {
+ const snapshot = await fetchJson('api/v1/recent-work');
+ if (key() !== accountKey) return read();
+ const current = read();
+ adopt(snapshot, accountKey, current.pending);
+ return current.pending.length ? sync() : read();
+ } catch (_error) {
+ if (key() === accountKey) announce(read(), read().pending.length ? null : 'Recent work could not sync.');
+ return read();
+ }
+ }
+
+ function startLifecycle(lifecycle = {}) {
+ const reconcile = () => read().pending.length ? sync() : load();
+ lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
+ lifecycle.document?.addEventListener?.('visibilitychange', () => {
+ if (!lifecycle.document.hidden) void reconcile();
+ });
+ return reconcile;
+ }
+
function render() {
const recent = items();
const list = options.list;
@@ -98,5 +222,5 @@
return rows.length;
}
- return { items, record, render };
+ return {items, record, render, load, sync, startLifecycle, state};
});
diff --git a/scripts/rotate_private_state.py b/scripts/rotate_private_state.py
index 80d37ba..99a436b 100755
--- a/scripts/rotate_private_state.py
+++ b/scripts/rotate_private_state.py
@@ -81,6 +81,9 @@ STORES = (
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
)),
+ Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
+ Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
+ )),
Store(
"completed-filed-reviews",
"completed-filed-reviews",
diff --git a/src/main.py b/src/main.py
index 12bc6c9..42315ef 100644
--- a/src/main.py
+++ b/src/main.py
@@ -72,6 +72,7 @@ from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
+from src.recent_work_store import RecentWorkStore
from src.following_store import FollowingStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
@@ -971,6 +972,14 @@ class QueuePriorityCollection(BaseModel):
order: list[str] = Field(min_length=9, max_length=9)
+class RecentWorkItem(BaseModel):
+ kind: Literal["issue", "filed", "pull", "review", "update"]
+ repository: str = Field(default="", max_length=200)
+ number: PositiveInt
+ title: str = Field(min_length=1, max_length=180)
+ route: str = Field(min_length=1, max_length=300)
+
+
class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
min_length=3,
@@ -1790,7 +1799,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
- if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
+ if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -3114,6 +3123,12 @@ def _queue_priority_store() -> QueuePriorityStore:
)
+def _recent_work_store() -> RecentWorkStore:
+ return RecentWorkStore(
+ os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3"))
+ )
+
+
def _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
@@ -3421,6 +3436,38 @@ async def replace_queue_priority(payload: QueuePriorityCollection):
)
+@app.get("/api/v1/recent-work")
+async def get_recent_work(response: Response):
+ login = await _confirmed_login()
+ try:
+ snapshot = await asyncio.to_thread(_recent_work_store().get, login)
+ except (OSError, sqlite3.Error, PrivateStateEncryptionError):
+ raise HTTPException(
+ status_code=503,
+ detail="Recent work synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return snapshot
+
+
+@app.post("/api/v1/recent-work")
+async def record_recent_work(payload: RecentWorkItem):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _recent_work_store().record, login, payload.model_dump()
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc))
+ except (OSError, sqlite3.Error, PrivateStateEncryptionError):
+ raise HTTPException(
+ status_code=503,
+ detail="Recent work synchronization is unavailable",
+ headers={"Retry-After": "1"},
+ )
+
+
@app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
diff --git a/src/recent_work_store.py b/src/recent_work_store.py
new file mode 100644
index 0000000..36a1663
--- /dev/null
+++ b/src/recent_work_store.py
@@ -0,0 +1,131 @@
+"""Encrypted, account-scoped recent work shared by signed-in devices."""
+
+import sqlite3
+from pathlib import Path
+
+from src.private_state import connect_private_sqlite
+from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
+
+
+KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
+
+
+class RecentWorkStore:
+ def __init__(
+ self,
+ path: str | Path,
+ *,
+ timeout: float = 1.0,
+ encryption_key: bytes | None = None,
+ limit: int = 5,
+ ):
+ self.path = Path(path)
+ self.timeout = timeout
+ self.limit = max(1, int(limit))
+ self._cipher = PrivateStateCipher(
+ encryption_key if encryption_key is not None else private_state_encryption_config(),
+ store="recent-work",
+ )
+ with self._connect() as connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS recent_work ("
+ "login TEXT PRIMARY KEY, items TEXT NOT NULL)"
+ )
+
+ def _connect(self) -> sqlite3.Connection:
+ return connect_private_sqlite(self.path, timeout=self.timeout)
+
+ @staticmethod
+ def _login(login: str) -> str:
+ normalized = login.strip().lower()
+ if not normalized:
+ raise ValueError("login is required")
+ return normalized
+
+ @staticmethod
+ def _normalize(item: dict) -> dict:
+ if not isinstance(item, dict):
+ raise ValueError("recent work item is invalid")
+ kind = item.get("kind")
+ number = item.get("number")
+ title = item.get("title")
+ repository = item.get("repository", "")
+ if (
+ kind not in KINDS
+ or not isinstance(number, int)
+ or isinstance(number, bool)
+ or number < 1
+ or not isinstance(title, str)
+ or not title.strip()
+ ):
+ raise ValueError("recent work item is invalid")
+ title = title.strip()[:180]
+ if kind == "update":
+ if repository:
+ raise ValueError("recent work item is invalid")
+ route = f"#/my-work/update/{number}"
+ normalized = {"kind": kind, "number": number, "title": title, "route": route}
+ else:
+ if (
+ not isinstance(repository, str)
+ or repository.count("/") != 1
+ or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
+ for part in repository.split("/"))
+ ):
+ raise ValueError("recent work item is invalid")
+ route = f"#/my-work/{kind}/{repository}/{number}"
+ normalized = {
+ "kind": kind,
+ "repository": repository,
+ "number": number,
+ "title": title,
+ "route": route,
+ }
+ if item.get("route", route) != route:
+ raise ValueError("recent work item is invalid")
+ return normalized
+
+ def _items(self, row, login: str) -> tuple[list[dict], bool]:
+ if row is None:
+ return [], False
+ payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
+ if not isinstance(payload, list):
+ raise PrivateStateEncryptionError("private state could not be decrypted")
+ try:
+ return [self._normalize(item) for item in payload][: self.limit], legacy
+ except ValueError as error:
+ raise PrivateStateEncryptionError("private state could not be decrypted") from error
+
+ def get(self, login: str) -> dict:
+ login = self._login(login)
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT items FROM recent_work WHERE login = ?", (login,)
+ ).fetchone()
+ items, legacy = self._items(row, login)
+ if row is not None and legacy:
+ connection.execute(
+ "UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
+ (self._cipher.seal(items, binding=f"items:{login}"), login, row[0]),
+ )
+ return {"items": items}
+
+ def record(self, login: str, item: dict) -> dict:
+ login = self._login(login)
+ normalized = self._normalize(item)
+ with self._connect() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT items FROM recent_work WHERE login = ?", (login,)
+ ).fetchone()
+ current, _legacy = self._items(row, login)
+ items = [normalized, *(entry for entry in current if entry["route"] != normalized["route"])]
+ items = items[: self.limit]
+ sealed = self._cipher.seal(items, binding=f"items:{login}")
+ connection.execute(
+ "INSERT INTO recent_work(login, items) VALUES (?, ?) "
+ "ON CONFLICT(login) DO UPDATE SET items=excluded.items",
+ (login, sealed),
+ )
+ return {"items": items}
diff --git a/tests/test_mobile_recent_work.py b/tests/test_mobile_recent_work.py
index f33ac2c..6f57795 100644
--- a/tests/test_mobile_recent_work.py
+++ b/tests/test_mobile_recent_work.py
@@ -125,6 +125,37 @@ process.stdout.write(JSON.stringify({{
}
+def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
+ script = f"""
+const createRecentWork = require({json.dumps(str(RECENT_WORK))});
+(async()=>{{
+const values = new Map(); const calls=[]; const status={{textContent:''}};
+const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
+const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
+const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
+const recent=createRecentWork({{
+ storage,getLogin:()=>'alice',status,debounceMs:99999,
+ fetchJson:async (url, options={{}})=>{{
+ calls.push([url,options.method||'GET']);
+ return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
+ }},
+}});
+recent.record(local);
+const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
+await recent.sync();
+process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ payload = run_node(script)
+
+ assert payload["immediate"]["items"][0]["number"] == 7
+ assert payload["immediate"]["status"] == "Sync pending."
+ assert payload["immediate"]["state"]["pending"] is True
+ assert [item["number"] for item in payload["settled"]] == [7, 9]
+ assert payload["status"] == ""
+ assert payload["calls"] == [["api/v1/recent-work", "POST"]]
+
+
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
@@ -132,8 +163,13 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
assert 'id="mobile-recent-work"' in html
assert 'id="mobile-recent-work-list"' in html
+ assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
assert '' in html
assert "createMobileRecentWork({" in dashboard
+ assert "fetchJson:fetchReviewJson" in dashboard
+ assert "status:qs('#mobile-recent-work-status')" in dashboard
+ assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
+ assert "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard
diff --git a/tests/test_recent_work_api.py b/tests/test_recent_work_api.py
new file mode 100644
index 0000000..5241d14
--- /dev/null
+++ b/tests/test_recent_work_api.py
@@ -0,0 +1,53 @@
+import httpx
+import pytest
+
+from src import main
+
+
+@pytest.mark.anyio
+async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped(
+ monkeypatch, tmp_path
+):
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
+ monkeypatch.setenv(
+ "STACKCHAIN_DASHBOARD_SESSION_SECRET",
+ "a-separate-session-signing-secret-with-enough-entropy",
+ )
+ monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
+ active_login = "Timmy"
+
+ async def user():
+ return {"id": 1, "login": active_login}
+
+ monkeypatch.setattr(main, "current_user", user)
+ entry = {
+ "kind": "issue",
+ "repository": "stackchain/dashboard",
+ "number": 1475,
+ "title": "Sync recent work",
+ "route": "#/my-work/issue/stackchain/dashboard/1475",
+ }
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+ await client.post(
+ "/api/v1/session", json={"access_token": "correct horse battery staple"}
+ )
+ forbidden = await client.post("/api/v1/recent-work", json=entry)
+ headers = {
+ "Origin": "https://test",
+ "X-CSRF-Token": client.cookies["stackchain_csrf"],
+ }
+ saved = await client.post("/api/v1/recent-work", json=entry, headers=headers)
+ fetched = await client.get("/api/v1/recent-work")
+ active_login = "Alexander"
+ other_account = await client.get("/api/v1/recent-work")
+
+ assert forbidden.status_code == 403
+ assert saved.status_code == 200
+ assert saved.json() == {"items": [entry]}
+ assert fetched.json() == saved.json()
+ assert fetched.headers["cache-control"] == "no-store"
+ assert other_account.json() == {"items": []}
diff --git a/tests/test_recent_work_store.py b/tests/test_recent_work_store.py
new file mode 100644
index 0000000..bdac945
--- /dev/null
+++ b/tests/test_recent_work_store.py
@@ -0,0 +1,47 @@
+import sqlite3
+
+from src.recent_work_store import RecentWorkStore
+
+
+def item(number: int, *, title: str | None = None) -> dict:
+ return {
+ "kind": "issue",
+ "repository": "stackchain/dashboard",
+ "number": number,
+ "title": title or f"Issue {number}",
+ "route": f"#/my-work/issue/stackchain/dashboard/{number}",
+ }
+
+
+def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path):
+ database = tmp_path / "recent-work.sqlite3"
+ store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
+
+ for number in range(1, 7):
+ store.record(" Timmy ", item(number))
+ expected = store.record("timmy", item(3, title="Issue 3 updated"))
+
+ assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
+ assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
+ assert store.get("alexander") == {"items": []}
+ with sqlite3.connect(database) as connection:
+ payload = connection.execute(
+ "SELECT items FROM recent_work WHERE login = 'timmy'"
+ ).fetchone()[0]
+ assert payload.startswith("v1:")
+ assert "Issue 3 updated" not in payload
+ assert "#/my-work/issue" not in payload
+
+
+def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
+ store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
+
+ invalid = item(1)
+ invalid["route"] = "https://attacker.example/"
+
+ try:
+ store.record("timmy", invalid)
+ except ValueError as error:
+ assert str(error) == "recent work item is invalid"
+ else:
+ raise AssertionError("invalid route was accepted")
diff --git a/tests/test_rotate_private_state.py b/tests/test_rotate_private_state.py
index 60e9545..f3fca9e 100644
--- a/tests/test_rotate_private_state.py
+++ b/tests/test_rotate_private_state.py
@@ -8,6 +8,7 @@ from pathlib import Path
from src.completed_filed_review_store import CompletedFiledReviewStore
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
+from src.recent_work_store import RecentWorkStore
from src.saved_search_store import SavedSearchStore
@@ -138,3 +139,33 @@ def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
assert QueuePriorityStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == expected
+
+
+def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path):
+ state = tmp_path / "state"
+ path = state / "recent-work.sqlite3"
+ private_item = {
+ "kind": "issue",
+ "repository": "private/canary",
+ "number": 1475,
+ "title": "Secret release investigation",
+ "route": "#/my-work/issue/private/canary/1475",
+ }
+ expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item)
+
+ completed = run_rotation(state)
+
+ assert completed.returncode == 0, completed.stderr
+ assert json.loads(completed.stdout)["recent-work"] == {
+ "current": 0, "failed": 0, "migrated": 1, "total": 1
+ }
+ assert private_item["title"] not in completed.stdout
+ assert "timmy" not in completed.stdout
+ with sqlite3.connect(path) as connection:
+ payload = connection.execute(
+ "SELECT items FROM recent_work WHERE login = 'timmy'"
+ ).fetchone()[0]
+ assert payload.startswith("v2:next:")
+ assert RecentWorkStore(
+ path, encryption_key=({"next": b"n" * 32}, "next")
+ ).get("timmy") == expected