···991010### Added
1111- 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)
1212+- 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)
12131314### Fixed
1415- 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
···7171 console.log('Migrated: added tags column');
7272}
73737474+// #674: pending_permanent_delete_at — marks a doc as in the 30s undo window
7575+// before hard deletion. Separate from expires_at (share link) and deleted_at (trash).
7676+try {
7777+ db.prepare("SELECT pending_permanent_delete_at FROM documents LIMIT 1").get();
7878+} catch {
7979+ db.exec("ALTER TABLE documents ADD COLUMN pending_permanent_delete_at TEXT");
8080+ console.log('Migrated: added pending_permanent_delete_at column');
8181+}
8282+7483// Add owner column (must run before type CHECK migration)
7584try {
7685 db.prepare("SELECT owner FROM documents LIMIT 1").get();
···153162154163export const MAX_VERSIONS_PER_DOC = 50;
155164156156-// Auto-purge trash older than 30 days on startup and every 24 hours
157157-function purgeExpiredTrash(): void {
158158- const result = db.prepare("DELETE FROM documents WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days')").run();
165165+// #674: Grace window (seconds) between DELETE and actual row removal.
166166+export const PERMANENT_DELETE_GRACE_SECONDS = 30;
167167+168168+// #674: Finalize rows whose pending_permanent_delete_at has elapsed.
169169+// Cascades to versions + blobs in a single transaction (mirrors the old immediate-delete path).
170170+export function finalizePendingDeletes(): void {
171171+ const due = db.prepare(
172172+ "SELECT id FROM documents WHERE pending_permanent_delete_at IS NOT NULL AND pending_permanent_delete_at <= datetime('now')"
173173+ ).all() as { id: string }[];
174174+ if (due.length === 0) return;
175175+ const deleteVersions = db.prepare('DELETE FROM versions WHERE document_id = ?');
176176+ const deleteBlobs = db.prepare('DELETE FROM blobs WHERE document_id = ?');
177177+ const deleteDoc = db.prepare('DELETE FROM documents WHERE id = ?');
178178+ const tx = db.transaction((ids: string[]) => {
179179+ for (const id of ids) {
180180+ deleteVersions.run(id);
181181+ deleteBlobs.run(id);
182182+ deleteDoc.run(id);
183183+ }
184184+ });
185185+ tx(due.map(r => r.id));
186186+ console.log(`Finalized ${due.length} pending document deletion(s)`);
187187+}
188188+189189+// Auto-purge: trash older than 30 days → mark as pending-delete. They'll be
190190+// hard-deleted by the finalizer after the 30-second grace window (acceptance
191191+// criterion 5). Runs on startup and every 24 hours.
192192+function expireOldTrash(): void {
193193+ const result = db.prepare(
194194+ "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+30 seconds') " +
195195+ "WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days') " +
196196+ "AND pending_permanent_delete_at IS NULL"
197197+ ).run();
159198 if (result.changes > 0) {
160160- console.log(`Purged ${result.changes} expired trashed document(s)`);
199199+ console.log(`Marked ${result.changes} expired trashed document(s) for finalization`);
161200 }
162201}
163163-purgeExpiredTrash();
164164-setInterval(purgeExpiredTrash, 24 * 60 * 60 * 1000);
202202+expireOldTrash();
203203+// Don't block shutdown/tests on these intervals.
204204+setInterval(expireOldTrash, 24 * 60 * 60 * 1000).unref();
205205+// Finalizer runs every 10s. .unref() so tests can exit cleanly.
206206+setInterval(finalizePendingDeletes, 10 * 1000).unref();
207207+// Run once on startup too, in case the process was restarted mid-window.
208208+finalizePendingDeletes();
165209166210export const stmts: PreparedStatements = {
167211 insert: db.prepare('INSERT INTO documents (id, type, name_encrypted) VALUES (?, ?, ?)'),
168212 insertWithOwner: db.prepare('INSERT INTO documents (id, type, name_encrypted, owner) VALUES (?, ?, ?, ?)'),
169169- getOne: db.prepare('SELECT id, type, name_encrypted, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents WHERE id = ?'),
170170- 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'),
171171- 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'),
213213+ // #674: getAll / getTrash exclude rows already pending permanent deletion.
214214+ 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 = ?'),
215215+ 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'),
216216+ 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'),
172217 getSnapshot: db.prepare('SELECT snapshot, expires_at, owner FROM documents WHERE id = ?'),
173218 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"),
174219 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"),
175220 trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"),
176221 restoreDoc: db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"),
222222+ // #674: mark a doc as pending permanent deletion (30-second grace window).
223223+ // Only applies when not already pending — idempotent, so a double-click on
224224+ // "Delete forever" does not reset the undo window.
225225+ markPendingDelete: db.prepare(
226226+ "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+" + PERMANENT_DELETE_GRACE_SECONDS + " seconds') " +
227227+ 'WHERE id = ? AND pending_permanent_delete_at IS NULL'
228228+ ),
229229+ // #674: undo: clears both pending AND trashed flags so the doc returns to the live list.
230230+ undoPendingDelete: db.prepare('UPDATE documents SET pending_permanent_delete_at = NULL, deleted_at = NULL WHERE id = ? AND pending_permanent_delete_at IS NOT NULL'),
231231+ // #674: rows whose grace window has elapsed — finalizer target set.
232232+ selectDueForFinalize: db.prepare("SELECT id FROM documents WHERE pending_permanent_delete_at IS NOT NULL AND pending_permanent_delete_at <= datetime('now')"),
233233+ // #674: used by the 30-day auto-purge: convert long-trashed docs into pending-delete.
234234+ markTrashExpiredForFinalize: db.prepare(
235235+ "UPDATE documents SET pending_permanent_delete_at = datetime('now', '+" + PERMANENT_DELETE_GRACE_SECONDS + " seconds') " +
236236+ "WHERE deleted_at IS NOT NULL AND deleted_at < datetime('now', '-30 days') AND pending_permanent_delete_at IS NULL"
237237+ ),
177238 deleteDoc: db.prepare('DELETE FROM documents WHERE id = ?'),
178239 insertVersion: db.prepare('INSERT INTO versions (id, document_id, snapshot, metadata) VALUES (?, ?, ?, ?)'),
179240 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
···137137router.get('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
138138 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
139139 if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
140140+ // #674: treat pending-permanent-delete docs as already gone.
141141+ if (doc.pending_permanent_delete_at) {
142142+ res.status(404).json({ error: 'Not found' });
143143+ return;
144144+ }
140145 if (isExpiredForUser(doc.expires_at, doc.owner, req.tsUser)) {
141146 res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: doc.expires_at });
142147 return;
···144149 res.json(doc);
145150});
146151152152+// #674: "Delete forever" no longer deletes immediately — it marks the row as
153153+// pending_permanent_delete_at = now + 30s. A background finalizer (see
154154+// server/db.ts::finalizePendingDeletes) hard-deletes the row + versions + blobs
155155+// once the grace window elapses. Clients can call PUT .../undo-delete to abort.
147156router.delete('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
148157 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
149158 if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
···151160 res.status(403).json({ error: 'Only the document owner can delete' });
152161 return;
153162 }
154154- // Issue #502: cascade delete versions and blobs in a transaction
155155- db.transaction(() => {
156156- stmts.deleteVersionsForDoc.run(req.params.id);
157157- stmts.deleteBlobsForDoc.run(req.params.id);
158158- stmts.deleteDoc.run(req.params.id);
159159- })();
163163+ if (doc.pending_permanent_delete_at) {
164164+ // Idempotent: a second click shouldn't extend the undo window.
165165+ res.json({ ok: true, pending: true });
166166+ return;
167167+ }
168168+ stmts.markPendingDelete.run(req.params.id);
169169+ res.json({ ok: true, pending: true });
170170+});
171171+172172+// #674: Within the 30-second grace window, clear the pending flag AND the
173173+// trashed flag, returning the doc to the live list. Owner-only.
174174+router.put('/api/documents/:id/undo-delete', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
175175+ const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
176176+ if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
177177+ if (doc.owner && req.tsUser?.login !== doc.owner) {
178178+ res.status(403).json({ error: 'Only the document owner can undo deletion' });
179179+ return;
180180+ }
181181+ if (!doc.pending_permanent_delete_at) {
182182+ res.status(410).json({ error: 'No pending deletion to undo' });
183183+ return;
184184+ }
185185+ const result = stmts.undoPendingDelete.run(req.params.id);
186186+ if (result.changes === 0) {
187187+ // Raced with the finalizer — the row might already be gone.
188188+ res.status(410).json({ error: 'No pending deletion to undo' });
189189+ return;
190190+ }
160191 res.json({ ok: true });
161192});
162193163194router.put('/api/documents/:id/trash', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
164195 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
165165- if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
196196+ if (!doc || doc.pending_permanent_delete_at) { res.status(404).json({ error: 'Not found' }); return; }
166197 if (doc.owner && req.tsUser?.login !== doc.owner) {
167198 res.status(403).json({ error: 'Only the document owner can trash' });
168199 return;
···173204174205router.put('/api/documents/:id/restore', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
175206 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined;
176176- if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
207207+ if (!doc || doc.pending_permanent_delete_at) { res.status(404).json({ error: 'Not found' }); return; }
177208 if (doc.owner && req.tsUser?.login !== doc.owner) {
178209 res.status(403).json({ error: 'Only the document owner can restore' });
179210 return;