Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add 30s undo window for permanent doc deletion (#674) (#389)

scott a6ccb762 79f05421

+651 -28
+1
CHANGELOG.md
··· 9 9 10 10 ### Added 11 11 - E2EE key-loss warning: one-time modal on first visit to an encrypted document, tailored to whether the user is anonymous, signed in without synced key, or fully backed up. Shield icon in the topbar re-opens the explanation. (#671) 12 + - Trash: 30-second undo window for permanent document deletion — `DELETE /api/documents/:id` now soft-marks the row via `pending_permanent_delete_at`, a background finalizer cascades real deletion of versions + blobs after the grace period, and `PUT /api/documents/:id/undo-delete` aborts within the window. Trash UI shows a "Permanently deleted. Undo" toast for 30s. (#674) 12 13 13 14 ### Fixed 14 15 - Share links: enforce expiry server-side on document, snapshot, and save endpoints. Client surfaces a blocking "link has expired" overlay; owners are never gated. (#673)
+70 -9
server/db.ts
··· 71 71 console.log('Migrated: added tags column'); 72 72 } 73 73 74 + // #674: pending_permanent_delete_at — marks a doc as in the 30s undo window 75 + // before hard deletion. Separate from expires_at (share link) and deleted_at (trash). 76 + try { 77 + db.prepare("SELECT pending_permanent_delete_at FROM documents LIMIT 1").get(); 78 + } catch { 79 + db.exec("ALTER TABLE documents ADD COLUMN pending_permanent_delete_at TEXT"); 80 + console.log('Migrated: added pending_permanent_delete_at column'); 81 + } 82 + 74 83 // Add owner column (must run before type CHECK migration) 75 84 try { 76 85 db.prepare("SELECT owner FROM documents LIMIT 1").get(); ··· 153 162 154 163 export const MAX_VERSIONS_PER_DOC = 50; 155 164 156 - // Auto-purge trash older than 30 days on startup and every 24 hours 157 - function purgeExpiredTrash(): void { 158 - const result = db.prepare("DELETE FROM documents WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days')").run(); 165 + // #674: Grace window (seconds) between DELETE and actual row removal. 166 + export const PERMANENT_DELETE_GRACE_SECONDS = 30; 167 + 168 + // #674: Finalize rows whose pending_permanent_delete_at has elapsed. 169 + // Cascades to versions + blobs in a single transaction (mirrors the old immediate-delete path). 170 + export function finalizePendingDeletes(): void { 171 + const due = db.prepare( 172 + "SELECT id FROM documents WHERE pending_permanent_delete_at IS NOT NULL AND pending_permanent_delete_at <= datetime('now')" 173 + ).all() as { id: string }[]; 174 + if (due.length === 0) return; 175 + const deleteVersions = db.prepare('DELETE FROM versions WHERE document_id = ?'); 176 + const deleteBlobs = db.prepare('DELETE FROM blobs WHERE document_id = ?'); 177 + const deleteDoc = db.prepare('DELETE FROM documents WHERE id = ?'); 178 + const tx = db.transaction((ids: string[]) => { 179 + for (const id of ids) { 180 + deleteVersions.run(id); 181 + deleteBlobs.run(id); 182 + deleteDoc.run(id); 183 + } 184 + }); 185 + tx(due.map(r => r.id)); 186 + console.log(`Finalized ${due.length} pending document deletion(s)`); 187 + } 188 + 189 + // Auto-purge: trash older than 30 days → mark as pending-delete. They'll be 190 + // hard-deleted by the finalizer after the 30-second grace window (acceptance 191 + // criterion 5). Runs on startup and every 24 hours. 192 + function expireOldTrash(): void { 193 + const result = db.prepare( 194 + "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+30 seconds') " + 195 + "WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days') " + 196 + "AND pending_permanent_delete_at IS NULL" 197 + ).run(); 159 198 if (result.changes > 0) { 160 - console.log(`Purged ${result.changes} expired trashed document(s)`); 199 + console.log(`Marked ${result.changes} expired trashed document(s) for finalization`); 161 200 } 162 201 } 163 - purgeExpiredTrash(); 164 - setInterval(purgeExpiredTrash, 24 * 60 * 60 * 1000); 202 + expireOldTrash(); 203 + // Don't block shutdown/tests on these intervals. 204 + setInterval(expireOldTrash, 24 * 60 * 60 * 1000).unref(); 205 + // Finalizer runs every 10s. .unref() so tests can exit cleanly. 206 + setInterval(finalizePendingDeletes, 10 * 1000).unref(); 207 + // Run once on startup too, in case the process was restarted mid-window. 208 + finalizePendingDeletes(); 165 209 166 210 export const stmts: PreparedStatements = { 167 211 insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'), 168 212 insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 169 - getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 170 - getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL ORDER BY updated_at DESC'), 171 - getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC'), 213 + // #674: getAll / getTrash exclude rows already pending permanent deletion. 214 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 215 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND pending_permanent_delete_at IS NULL ORDER BY updated_at DESC'), 216 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL AND pending_permanent_delete_at IS NULL ORDER BY deleted_at DESC'), 172 217 getSnapshot: db.prepare('SELECT snapshot, expires_at, owner FROM documents WHERE id = ?'), 173 218 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 174 219 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 175 220 trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 176 221 restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"), 222 + // #674: mark a doc as pending permanent deletion (30-second grace window). 223 + // Only applies when not already pending — idempotent, so a double-click on 224 + // "Delete forever" does not reset the undo window. 225 + markPendingDelete: db.prepare( 226 + "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+" + PERMANENT_DELETE_GRACE_SECONDS + " seconds') " + 227 + 'WHERE id = ? AND pending_permanent_delete_at IS NULL' 228 + ), 229 + // #674: undo: clears both pending AND trashed flags so the doc returns to the live list. 230 + undoPendingDelete: db.prepare('UPDATE documents SET pending_permanent_delete_at = NULL, deleted_at = NULL WHERE id = ? AND pending_permanent_delete_at IS NOT NULL'), 231 + // #674: rows whose grace window has elapsed — finalizer target set. 232 + selectDueForFinalize: db.prepare("SELECT id FROM documents WHERE pending_permanent_delete_at IS NOT NULL AND pending_permanent_delete_at <= datetime('now')"), 233 + // #674: used by the 30-day auto-purge: convert long-trashed docs into pending-delete. 234 + markTrashExpiredForFinalize: db.prepare( 235 + "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+" + PERMANENT_DELETE_GRACE_SECONDS + " seconds') " + 236 + "WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days') AND pending_permanent_delete_at IS NULL" 237 + ), 177 238 deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 178 239 insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'), 179 240 getVersions: db.prepare('SELECT id, document_id, created_at, metadata FROM versions WHERE document_id = ? ORDER BY rowid DESC LIMIT 50'),
+39 -8
server/routes/documents.ts
··· 137 137 router.get('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 138 138 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 139 139 if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 140 + // #674: treat pending-permanent-delete docs as already gone. 141 + if (doc.pending_permanent_delete_at) { 142 + res.status(404).json({ error: 'Not found' }); 143 + return; 144 + } 140 145 if (isExpiredForUser(doc.expires_at, doc.owner, req.tsUser)) { 141 146 res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: doc.expires_at }); 142 147 return; ··· 144 149 res.json(doc); 145 150 }); 146 151 152 + // #674: "Delete forever" no longer deletes immediately — it marks the row as 153 + // pending_permanent_delete_at = now + 30s. A background finalizer (see 154 + // server/db.ts::finalizePendingDeletes) hard-deletes the row + versions + blobs 155 + // once the grace window elapses. Clients can call PUT .../undo-delete to abort. 147 156 router.delete('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 148 157 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 149 158 if (!doc) { res.status(404).json({ error: 'Not found' }); return; } ··· 151 160 res.status(403).json({ error: 'Only the document owner can delete' }); 152 161 return; 153 162 } 154 - // Issue #502: cascade delete versions and blobs in a transaction 155 - db.transaction(() => { 156 - stmts.deleteVersionsForDoc.run(req.params.id); 157 - stmts.deleteBlobsForDoc.run(req.params.id); 158 - stmts.deleteDoc.run(req.params.id); 159 - })(); 163 + if (doc.pending_permanent_delete_at) { 164 + // Idempotent: a second click shouldn't extend the undo window. 165 + res.json({ ok: true, pending: true }); 166 + return; 167 + } 168 + stmts.markPendingDelete.run(req.params.id); 169 + res.json({ ok: true, pending: true }); 170 + }); 171 + 172 + // #674: Within the 30-second grace window, clear the pending flag AND the 173 + // trashed flag, returning the doc to the live list. Owner-only. 174 + router.put('/api/documents/:id/undo-delete', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 175 + const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 176 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 177 + if (doc.owner && req.tsUser?.login !== doc.owner) { 178 + res.status(403).json({ error: 'Only the document owner can undo deletion' }); 179 + return; 180 + } 181 + if (!doc.pending_permanent_delete_at) { 182 + res.status(410).json({ error: 'No pending deletion to undo' }); 183 + return; 184 + } 185 + const result = stmts.undoPendingDelete.run(req.params.id); 186 + if (result.changes === 0) { 187 + // Raced with the finalizer — the row might already be gone. 188 + res.status(410).json({ error: 'No pending deletion to undo' }); 189 + return; 190 + } 160 191 res.json({ ok: true }); 161 192 }); 162 193 163 194 router.put('/api/documents/:id/trash', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 164 195 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 165 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 196 + if (!doc || doc.pending_permanent_delete_at) { res.status(404).json({ error: 'Not found' }); return; } 166 197 if (doc.owner && req.tsUser?.login !== doc.owner) { 167 198 res.status(403).json({ error: 'Only the document owner can trash' }); 168 199 return; ··· 173 204 174 205 router.put('/api/documents/:id/restore', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 175 206 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 176 - if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 207 + if (!doc || doc.pending_permanent_delete_at) { res.status(404).json({ error: 'Not found' }); return; } 177 208 if (doc.owner && req.tsUser?.login !== doc.owner) { 178 209 res.status(403).json({ error: 'Only the document owner can restore' }); 179 210 return;
+6
server/types.ts
··· 14 14 share_mode: 'edit' | 'view' | null; 15 15 expires_at: string | null; 16 16 deleted_at: string | null; 17 + pending_permanent_delete_at: string | null; 17 18 created_at: string; 18 19 updated_at: string; 19 20 } ··· 25 26 share_mode: 'edit' | 'view' | null; 26 27 expires_at: string | null; 27 28 deleted_at: string | null; 29 + pending_permanent_delete_at: string | null; 28 30 tags: string | null; 29 31 owner: string | null; 30 32 created_at: string; ··· 97 99 putName: Statement; 98 100 trashDoc: Statement; 99 101 restoreDoc: Statement; 102 + markPendingDelete: Statement; 103 + undoPendingDelete: Statement; 104 + selectDueForFinalize: Statement; 105 + markTrashExpiredForFinalize: Statement; 100 106 deleteDoc: Statement; 101 107 insertVersion: Statement; 102 108 getVersions: Statement;
+72 -11
src/landing-events-trash.ts
··· 27 27 return; 28 28 } 29 29 30 - // Permanent delete 30 + // Permanent delete — #674: now goes through a 30s undo window on the server. 31 + // We defer the localStorage key purge until the undo window expires so that 32 + // an undo can still load the doc's encrypted data. 31 33 const permBtn = target.closest('.trash-permanent') as HTMLElement | null; 32 34 if (permBtn) { 33 35 const doc = deps.getTrashedDocs().find(d => d.id === permBtn.dataset.id); 34 36 const name = doc?._decryptedName || 'this document'; 35 - if (!confirm(`Permanently delete "${name}"? This cannot be undone.`)) return; 37 + if (!confirm(`Permanently delete "${name}"?`)) return; 36 38 const id = permBtn.dataset.id!; 37 39 await fetch(`/api/documents/${id}`, { method: 'DELETE' }); 38 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 39 - delete k[id]; 40 - localStorage.setItem('tools-keys', JSON.stringify(k)); 41 40 deps.setTrashedDocs(deps.getTrashedDocs().filter(d => d.id !== id)); 42 41 deps.renderDocuments(); 42 + 43 + // Snapshot the encryption key so we can restore it on undo; purge after grace. 44 + const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 45 + const savedKey = k[id]; 46 + const purgeKeyTimer = window.setTimeout(() => { 47 + const current = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 48 + delete current[id]; 49 + localStorage.setItem('tools-keys', JSON.stringify(current)); 50 + }, 30000); 51 + 52 + showToast('Permanently deleted.', 30000, false, async () => { 53 + const undoRes = await fetch(`/api/documents/${id}/undo-delete`, { method: 'PUT' }); 54 + if (!undoRes.ok) { 55 + showToast('Undo failed — the document may already be gone.', 4000, true); 56 + return; 57 + } 58 + window.clearTimeout(purgeKeyTimer); 59 + // Ensure the key is still present (timer might have fired in a race). 60 + if (savedKey) { 61 + const current = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 62 + if (!current[id]) { 63 + current[id] = savedKey; 64 + localStorage.setItem('tools-keys', JSON.stringify(current)); 65 + } 66 + } 67 + // Restored docs land back in the live list; refresh by reloading data. 68 + if (doc) { 69 + doc.deleted_at = null; 70 + deps.setAllDocs([...deps.getAllDocs(), doc]); 71 + } 72 + deps.renderDocuments(); 73 + showToast('Document restored.', 3000); 74 + }); 43 75 return; 44 76 } 45 77 46 - // Empty all trash 78 + // Empty all trash — each doc gets its own 30s undo window server-side, but 79 + // we don't show N toasts; instead one aggregated toast that calls undo on all. 47 80 const emptyBtn = target.closest('.trash-empty-all') as HTMLElement | null; 48 81 if (emptyBtn) { 49 82 const docs = deps.getTrashedDocs(); 50 - if (!confirm(`Permanently delete all ${docs.length} trashed documents? This cannot be undone.`)) return; 51 - const k = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 83 + if (!confirm(`Permanently delete all ${docs.length} trashed documents?`)) return; 84 + const deletedIds: string[] = []; 52 85 for (const doc of docs) { 53 - await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }).catch(() => showToast('Failed to delete document from server', 4000, true)); 54 - delete k[doc.id]; 86 + try { 87 + await fetch(`/api/documents/${doc.id}`, { method: 'DELETE' }); 88 + deletedIds.push(doc.id); 89 + } catch { 90 + showToast('Failed to delete document from server', 4000, true); 91 + } 55 92 } 56 - localStorage.setItem('tools-keys', JSON.stringify(k)); 57 93 deps.setTrashedDocs([]); 58 94 deps.renderDocuments(); 95 + 96 + const keysSnapshot = JSON.parse(localStorage.getItem('tools-keys') || '{}') as Record<string, string>; 97 + const purgeAllTimer = window.setTimeout(() => { 98 + const current = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 99 + for (const id of deletedIds) delete current[id]; 100 + localStorage.setItem('tools-keys', JSON.stringify(current)); 101 + }, 30000); 102 + 103 + if (deletedIds.length > 0) { 104 + showToast(`Permanently deleted ${deletedIds.length}.`, 30000, false, async () => { 105 + let restored = 0; 106 + for (const id of deletedIds) { 107 + const r = await fetch(`/api/documents/${id}/undo-delete`, { method: 'PUT' }); 108 + if (r.ok) restored++; 109 + } 110 + window.clearTimeout(purgeAllTimer); 111 + // Restore keys if needed. 112 + const current = JSON.parse(localStorage.getItem('tools-keys') || '{}'); 113 + for (const id of deletedIds) { 114 + if (!current[id] && keysSnapshot[id]) current[id] = keysSnapshot[id]; 115 + } 116 + localStorage.setItem('tools-keys', JSON.stringify(current)); 117 + showToast(`Restored ${restored} document(s).`, 3000); 118 + }); 119 + } 59 120 } 60 121 }); 61 122 }
+463
tests/permanent-delete-undo.test.ts
··· 1 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 2 + 3 + /** 4 + * #674 — 30-second undo window for permanent document deletion. 5 + * 6 + * Flow under test: 7 + * 1. DELETE /api/documents/:id does NOT immediately remove the row; it marks 8 + * pending_permanent_delete_at = now + 30 seconds, leaving the data intact. 9 + * 2. GET /api/documents/:id returns 404 while pending_permanent_delete_at is set. 10 + * 3. PUT /api/documents/:id/undo-delete clears pending_permanent_delete_at AND 11 + * clears deleted_at (restores the doc to the live list) — only if still 12 + * inside the grace window. 13 + * 4. A finalizer interval hard-deletes rows whose pending window has elapsed, 14 + * cascading to versions + blobs in a transaction. 15 + * 5. After finalization, undo returns 410 Gone (or 404 if the row is gone). 16 + * 6. Owner check: only the owner can permanent-delete AND only the owner can 17 + * undo. Anonymous (owner IS NULL) docs are public-delete. 18 + * 19 + * Implementation note: we replicate the exact server behavior in an in-memory 20 + * Express app, matching the existing pattern in server.test.ts / sharing.test.ts. 21 + * The finalizer uses setInterval with .unref() so tests don't hang. 22 + */ 23 + 24 + import type Database from 'better-sqlite3'; 25 + 26 + interface DocRow { 27 + id: string; 28 + type: string; 29 + name_encrypted: string | null; 30 + share_mode: string | null; 31 + expires_at: string | null; 32 + deleted_at: string | null; 33 + pending_permanent_delete_at: string | null; 34 + tags: string | null; 35 + owner: string | null; 36 + created_at: string; 37 + updated_at: string; 38 + } 39 + 40 + let baseUrl: string; 41 + let server: import('http').Server; 42 + let finalizerHandle: NodeJS.Timeout; 43 + let db: Database.Database; 44 + 45 + // Ownership simulator — tests can override this to test owner checks. 46 + let currentUser: string | null = null; 47 + 48 + beforeAll(async () => { 49 + const { createServer } = await import('http'); 50 + const express = (await import('express')).default; 51 + const Database = (await import('better-sqlite3')).default; 52 + const { randomUUID } = await import('crypto'); 53 + 54 + db = new Database(':memory:'); 55 + db.pragma('journal_mode = WAL'); 56 + db.exec(` 57 + CREATE TABLE IF NOT EXISTS documents ( 58 + id TEXT PRIMARY KEY, 59 + type TEXT NOT NULL, 60 + name_encrypted TEXT, 61 + snapshot BLOB, 62 + share_mode TEXT DEFAULT 'edit', 63 + expires_at TEXT, 64 + deleted_at TEXT, 65 + pending_permanent_delete_at TEXT, 66 + tags TEXT, 67 + owner TEXT, 68 + created_at TEXT DEFAULT (datetime('now')), 69 + updated_at TEXT DEFAULT (datetime('now')) 70 + ); 71 + CREATE TABLE IF NOT EXISTS versions ( 72 + id TEXT PRIMARY KEY, 73 + document_id TEXT NOT NULL, 74 + snapshot BLOB NOT NULL, 75 + created_at TEXT DEFAULT (datetime('now')), 76 + metadata TEXT 77 + ); 78 + CREATE TABLE IF NOT EXISTS blobs ( 79 + id TEXT PRIMARY KEY, 80 + document_id TEXT NOT NULL, 81 + file_name TEXT NOT NULL, 82 + mime_type TEXT NOT NULL, 83 + size INTEGER NOT NULL, 84 + data BLOB NOT NULL, 85 + created_at TEXT DEFAULT (datetime('now')) 86 + ); 87 + `); 88 + 89 + const stmts = { 90 + insert: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'), 91 + getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'), 92 + getAll: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND pending_permanent_delete_at IS NULL ORDER BY updated_at DESC'), 93 + getTrash: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, pending_permanent_delete_at, tags, owner, created_at, updated_at FROM documents WHERE deleted_at IS NOT NULL AND pending_permanent_delete_at IS NULL ORDER BY deleted_at DESC'), 94 + trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"), 95 + markPendingDelete: db.prepare("UPDATE documents SET pending_permanent_delete_at = datetime('now', ? || ' seconds') WHERE id = ? AND pending_permanent_delete_at IS NULL"), 96 + undoPendingDelete: db.prepare('UPDATE documents SET pending_permanent_delete_at = NULL, deleted_at = NULL WHERE id = ? AND pending_permanent_delete_at IS NOT NULL'), 97 + selectDueForFinalize: db.prepare("SELECT id FROM documents WHERE pending_permanent_delete_at IS NOT NULL AND pending_permanent_delete_at <= datetime('now')"), 98 + deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'), 99 + deleteVersionsForDoc: db.prepare('DELETE FROM versions WHERE document_id = ?'), 100 + deleteBlobsForDoc: db.prepare('DELETE FROM blobs WHERE document_id = ?'), 101 + insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot) VALUES (?, ?, ?)'), 102 + insertBlob: db.prepare('INSERT INTO blobs (id, document_id, file_name, mime_type, size, data) VALUES (?, ?, ?, ?, ?, ?)'), 103 + countVersions: db.prepare('SELECT COUNT(*) as c FROM versions WHERE document_id = ?'), 104 + countBlobs: db.prepare('SELECT COUNT(*) as c FROM blobs WHERE document_id = ?'), 105 + }; 106 + 107 + // Finalizer: deletes rows whose grace window has elapsed. 108 + function finalizePendingDeletes(graceSeconds = 30): void { 109 + // Stage uses the statement above; but in tests we may want a shorter 110 + // grace — callers override via the markPendingDelete timing. 111 + void graceSeconds; 112 + const due = stmts.selectDueForFinalize.all() as { id: string }[]; 113 + if (due.length === 0) return; 114 + const tx = db.transaction((ids: string[]) => { 115 + for (const id of ids) { 116 + stmts.deleteVersionsForDoc.run(id); 117 + stmts.deleteBlobsForDoc.run(id); 118 + stmts.deleteDoc.run(id); 119 + } 120 + }); 121 + tx(due.map(r => r.id)); 122 + } 123 + 124 + // Use a fast interval for tests — in prod this is 10s, tests use 50ms 125 + finalizerHandle = setInterval(finalizePendingDeletes, 50); 126 + finalizerHandle.unref(); 127 + 128 + const app = express(); 129 + app.use(express.json({ limit: '1mb' })); 130 + 131 + // Auth simulator: set X-Test-User header or leave it absent. 132 + app.use((req, _res, next) => { 133 + currentUser = (req.headers['x-test-user'] as string | undefined) || null; 134 + next(); 135 + }); 136 + 137 + type Req = import('express').Request; 138 + type Res = import('express').Response; 139 + 140 + app.post('/api/documents', (req: Req, res: Res) => { 141 + const id = randomUUID(); 142 + const { type, name_encrypted } = req.body as { type?: string; name_encrypted?: string }; 143 + stmts.insert.run(id, type || 'doc', name_encrypted || null, currentUser); 144 + res.json({ id }); 145 + }); 146 + 147 + // Helper test endpoint: add a version row 148 + app.post('/api/documents/:id/_test-version', (req: Req, res: Res) => { 149 + stmts.insertVersion.run(randomUUID(), req.params['id'], Buffer.from([1, 2, 3])); 150 + res.json({ ok: true }); 151 + }); 152 + 153 + // Helper test endpoint: add a blob row 154 + app.post('/api/documents/:id/_test-blob', (req: Req, res: Res) => { 155 + stmts.insertBlob.run(randomUUID(), req.params['id'], 'f.bin', 'application/octet-stream', 3, Buffer.from([1, 2, 3])); 156 + res.json({ ok: true }); 157 + }); 158 + 159 + // Helper test endpoint: force-finalize rows whose grace has elapsed 160 + app.post('/_test/finalize', (_req: Req, res: Res) => { 161 + finalizePendingDeletes(); 162 + res.json({ ok: true }); 163 + }); 164 + 165 + // Helper test endpoint: force the pending timestamp into the past 166 + app.post('/api/documents/:id/_test-expire-pending', (req: Req, res: Res) => { 167 + db.prepare("UPDATE documents SET pending_permanent_delete_at = datetime('now', '-1 seconds') WHERE id = ?").run(req.params['id']); 168 + res.json({ ok: true }); 169 + }); 170 + 171 + // Helper test endpoint: inspect raw row (bypassing 404) 172 + app.get('/_test/raw/:id', (req: Req, res: Res) => { 173 + const row = stmts.getOne.get(req.params['id']); 174 + res.json(row || null); 175 + }); 176 + 177 + // --- Real endpoints under test --- 178 + 179 + app.get('/api/documents/trash', (_req: Req, res: Res) => { 180 + res.json(stmts.getTrash.all()); 181 + }); 182 + 183 + app.get('/api/documents', (_req: Req, res: Res) => { 184 + res.json(stmts.getAll.all()); 185 + }); 186 + 187 + app.get('/api/documents/:id', (req: Req, res: Res) => { 188 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 189 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 190 + // Treat pending-delete as already gone. 191 + if (doc.pending_permanent_delete_at) { 192 + res.status(404).json({ error: 'Not found' }); 193 + return; 194 + } 195 + res.json(doc); 196 + }); 197 + 198 + app.put('/api/documents/:id/trash', (req: Req, res: Res) => { 199 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 200 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 201 + if (doc.owner && currentUser !== doc.owner) { 202 + res.status(403).json({ error: 'Only the document owner can trash' }); 203 + return; 204 + } 205 + stmts.trashDoc.run(req.params['id']); 206 + res.json({ ok: true }); 207 + }); 208 + 209 + app.delete('/api/documents/:id', (req: Req, res: Res) => { 210 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 211 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 212 + if (doc.owner && currentUser !== doc.owner) { 213 + res.status(403).json({ error: 'Only the document owner can delete' }); 214 + return; 215 + } 216 + if (doc.pending_permanent_delete_at) { 217 + // Already pending — return ok idempotently but don't reset the window 218 + res.json({ ok: true, pending: true }); 219 + return; 220 + } 221 + // Mark pending: 30-second grace. Tests may override via header for speed. 222 + const graceHeader = req.headers['x-test-grace-seconds']; 223 + const grace = typeof graceHeader === 'string' ? `+${graceHeader}` : '+30'; 224 + stmts.markPendingDelete.run(grace, req.params['id']); 225 + res.json({ ok: true, pending: true }); 226 + }); 227 + 228 + app.put('/api/documents/:id/undo-delete', (req: Req, res: Res) => { 229 + const doc = stmts.getOne.get(req.params['id']) as DocRow | undefined; 230 + if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 231 + if (doc.owner && currentUser !== doc.owner) { 232 + res.status(403).json({ error: 'Only the document owner can undo' }); 233 + return; 234 + } 235 + if (!doc.pending_permanent_delete_at) { 236 + res.status(410).json({ error: 'No pending deletion to undo' }); 237 + return; 238 + } 239 + // Inside grace window → clear both flags 240 + const result = stmts.undoPendingDelete.run(req.params['id']); 241 + if (result.changes === 0) { 242 + res.status(410).json({ error: 'No pending deletion to undo' }); 243 + return; 244 + } 245 + res.json({ ok: true }); 246 + }); 247 + 248 + server = createServer(app); 249 + await new Promise<void>((resolve) => { 250 + server.listen(0, () => { 251 + const addr = server.address(); 252 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 253 + resolve(); 254 + }); 255 + }); 256 + }); 257 + 258 + afterAll(() => { 259 + clearInterval(finalizerHandle); 260 + server?.close(); 261 + db?.close(); 262 + }); 263 + 264 + async function createDoc(opts: { owner?: string } = {}): Promise<string> { 265 + const headers: Record<string, string> = { 'Content-Type': 'application/json' }; 266 + if (opts.owner) headers['X-Test-User'] = opts.owner; 267 + const res = await fetch(`${baseUrl}/api/documents`, { 268 + method: 'POST', 269 + headers, 270 + body: JSON.stringify({ type: 'doc' }), 271 + }); 272 + const data = await res.json() as { id: string }; 273 + return data.id; 274 + } 275 + 276 + describe('#674 — DELETE marks pending instead of hard-deleting', () => { 277 + it('DELETE on a live doc sets pending_permanent_delete_at and leaves row intact', async () => { 278 + const id = await createDoc(); 279 + const delRes = await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 280 + expect(delRes.status).toBe(200); 281 + 282 + // Row still exists in raw DB 283 + const rawRes = await fetch(`${baseUrl}/_test/raw/${id}`); 284 + const raw = await rawRes.json() as DocRow | null; 285 + expect(raw).not.toBeNull(); 286 + expect(raw!.pending_permanent_delete_at).toBeTruthy(); 287 + }); 288 + 289 + it('GET /api/documents/:id returns 404 for pending-deleted doc', async () => { 290 + const id = await createDoc(); 291 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 292 + const getRes = await fetch(`${baseUrl}/api/documents/${id}`); 293 + expect(getRes.status).toBe(404); 294 + }); 295 + 296 + it('GET /api/documents list excludes pending-deleted docs', async () => { 297 + const id = await createDoc(); 298 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 299 + const listRes = await fetch(`${baseUrl}/api/documents`); 300 + const docs = await listRes.json() as Array<{ id: string }>; 301 + expect(docs.find(d => d.id === id)).toBeUndefined(); 302 + }); 303 + 304 + it('GET /api/documents/trash excludes pending-deleted docs', async () => { 305 + const id = await createDoc(); 306 + await fetch(`${baseUrl}/api/documents/${id}/trash`, { method: 'PUT' }); 307 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 308 + const trashRes = await fetch(`${baseUrl}/api/documents/trash`); 309 + const trash = await trashRes.json() as Array<{ id: string }>; 310 + expect(trash.find(d => d.id === id)).toBeUndefined(); 311 + }); 312 + }); 313 + 314 + describe('#674 — PUT /api/documents/:id/undo-delete', () => { 315 + it('clears pending flag within the grace window and restores the doc to the live list', async () => { 316 + const id = await createDoc(); 317 + // Trash then permanent-delete (the real UX flow) 318 + await fetch(`${baseUrl}/api/documents/${id}/trash`, { method: 'PUT' }); 319 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 320 + 321 + // 404 while pending 322 + expect((await fetch(`${baseUrl}/api/documents/${id}`)).status).toBe(404); 323 + 324 + // Undo 325 + const undoRes = await fetch(`${baseUrl}/api/documents/${id}/undo-delete`, { method: 'PUT' }); 326 + expect(undoRes.status).toBe(200); 327 + 328 + // Now reachable again 329 + const getRes = await fetch(`${baseUrl}/api/documents/${id}`); 330 + expect(getRes.status).toBe(200); 331 + 332 + // Shows up in live list (undo also clears deleted_at — acceptance criterion 3) 333 + const listRes = await fetch(`${baseUrl}/api/documents`); 334 + const docs = await listRes.json() as Array<{ id: string }>; 335 + expect(docs.find(d => d.id === id)).toBeDefined(); 336 + }); 337 + 338 + it('returns 410 when there is nothing pending to undo', async () => { 339 + const id = await createDoc(); 340 + const undoRes = await fetch(`${baseUrl}/api/documents/${id}/undo-delete`, { method: 'PUT' }); 341 + expect(undoRes.status).toBe(410); 342 + }); 343 + 344 + it('returns 404 for non-existent document', async () => { 345 + const undoRes = await fetch(`${baseUrl}/api/documents/nonexistent/undo-delete`, { method: 'PUT' }); 346 + expect(undoRes.status).toBe(404); 347 + }); 348 + }); 349 + 350 + describe('#674 — finalizer hard-deletes after the grace window', () => { 351 + it('after the grace elapses, the row + versions + blobs are all gone', async () => { 352 + const id = await createDoc(); 353 + // Attach a version + blob so we can verify cascade 354 + await fetch(`${baseUrl}/api/documents/${id}/_test-version`, { method: 'POST' }); 355 + await fetch(`${baseUrl}/api/documents/${id}/_test-blob`, { method: 'POST' }); 356 + 357 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 358 + 359 + // Force pending timestamp into the past and trigger finalize 360 + await fetch(`${baseUrl}/api/documents/${id}/_test-expire-pending`, { method: 'POST' }); 361 + await fetch(`${baseUrl}/_test/finalize`, { method: 'POST' }); 362 + 363 + // Row is gone 364 + const rawRes = await fetch(`${baseUrl}/_test/raw/${id}`); 365 + const raw = await rawRes.json() as DocRow | null; 366 + expect(raw).toBeNull(); 367 + 368 + // Versions + blobs cascaded 369 + const vCount = (db.prepare('SELECT COUNT(*) as c FROM versions WHERE document_id = ?').get(id) as { c: number }).c; 370 + const bCount = (db.prepare('SELECT COUNT(*) as c FROM blobs WHERE document_id = ?').get(id) as { c: number }).c; 371 + expect(vCount).toBe(0); 372 + expect(bCount).toBe(0); 373 + }); 374 + 375 + it('undo after finalization returns 404 (row is gone)', async () => { 376 + const id = await createDoc(); 377 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 378 + await fetch(`${baseUrl}/api/documents/${id}/_test-expire-pending`, { method: 'POST' }); 379 + await fetch(`${baseUrl}/_test/finalize`, { method: 'POST' }); 380 + 381 + const undoRes = await fetch(`${baseUrl}/api/documents/${id}/undo-delete`, { method: 'PUT' }); 382 + expect(undoRes.status).toBe(404); 383 + }); 384 + 385 + it('does NOT finalize rows still inside the grace window', async () => { 386 + const id = await createDoc(); 387 + await fetch(`${baseUrl}/api/documents/${id}`, { method: 'DELETE' }); 388 + // Do not expire — just trigger finalize 389 + await fetch(`${baseUrl}/_test/finalize`, { method: 'POST' }); 390 + 391 + const rawRes = await fetch(`${baseUrl}/_test/raw/${id}`); 392 + const raw = await rawRes.json() as DocRow | null; 393 + expect(raw).not.toBeNull(); 394 + expect(raw!.pending_permanent_delete_at).toBeTruthy(); 395 + }); 396 + }); 397 + 398 + describe('#674 — owner enforcement', () => { 399 + it('non-owner cannot permanent-delete another owner\'s doc', async () => { 400 + const id = await createDoc({ owner: 'alice' }); 401 + const delRes = await fetch(`${baseUrl}/api/documents/${id}`, { 402 + method: 'DELETE', 403 + headers: { 'X-Test-User': 'bob' }, 404 + }); 405 + expect(delRes.status).toBe(403); 406 + }); 407 + 408 + it('non-owner cannot undo another owner\'s pending deletion', async () => { 409 + const id = await createDoc({ owner: 'alice' }); 410 + await fetch(`${baseUrl}/api/documents/${id}`, { 411 + method: 'DELETE', 412 + headers: { 'X-Test-User': 'alice' }, 413 + }); 414 + const undoRes = await fetch(`${baseUrl}/api/documents/${id}/undo-delete`, { 415 + method: 'PUT', 416 + headers: { 'X-Test-User': 'bob' }, 417 + }); 418 + expect(undoRes.status).toBe(403); 419 + }); 420 + 421 + it('owner can permanent-delete and undo their own doc', async () => { 422 + const id = await createDoc({ owner: 'alice' }); 423 + const delRes = await fetch(`${baseUrl}/api/documents/${id}`, { 424 + method: 'DELETE', 425 + headers: { 'X-Test-User': 'alice' }, 426 + }); 427 + expect(delRes.status).toBe(200); 428 + const undoRes = await fetch(`${baseUrl}/api/documents/${id}/undo-delete`, { 429 + method: 'PUT', 430 + headers: { 'X-Test-User': 'alice' }, 431 + }); 432 + expect(undoRes.status).toBe(200); 433 + }); 434 + 435 + it('anonymous doc (owner IS NULL) is public-delete', async () => { 436 + const id = await createDoc(); 437 + const delRes = await fetch(`${baseUrl}/api/documents/${id}`, { 438 + method: 'DELETE', 439 + headers: { 'X-Test-User': 'random-user' }, 440 + }); 441 + expect(delRes.status).toBe(200); 442 + }); 443 + }); 444 + 445 + describe('#674 — idempotency', () => { 446 + it('DELETE on an already-pending doc does not reset the window', async () => { 447 + const id = await createDoc(); 448 + await fetch(`${baseUrl}/api/documents/${id}`, { 449 + method: 'DELETE', 450 + headers: { 'X-Test-Grace-Seconds': '30' }, 451 + }); 452 + const firstRaw = await (await fetch(`${baseUrl}/_test/raw/${id}`)).json() as DocRow; 453 + const firstPending = firstRaw.pending_permanent_delete_at; 454 + 455 + // 2nd delete — should NOT move the timestamp 456 + await fetch(`${baseUrl}/api/documents/${id}`, { 457 + method: 'DELETE', 458 + headers: { 'X-Test-Grace-Seconds': '999' }, 459 + }); 460 + const secondRaw = await (await fetch(`${baseUrl}/_test/raw/${id}`)).json() as DocRow; 461 + expect(secondRaw.pending_permanent_delete_at).toBe(firstPending); 462 + }); 463 + });