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

Configure Feed

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

fix: show 404 overlay when opening deleted or missing document (#672)

Problem: opening a URL for a deleted/missing document either fell back to
a (possibly stale) local backup or, if no local backup existed, showed
the generic "link has expired" copy. Users had no clean signal that the
doc was truly gone.

Changes:
- server/routes/documents.ts: GET /api/documents/:id/snapshot now
differentiates `code: 'not-found'` (doc row missing) from
`code: 'no-snapshot'` (doc exists but never saved). Existing callers
keep working — the HTTP status stays 404.
- src/lib/provider.ts: on a 404, parse the `code` field. If `not-found`,
emit `doc-gone` with `reason: 'not-found'` instead of silently falling
back to a local backup.
- src/lib/doc-gone-handler.ts: split the handler into a pure
`buildGoneOverlayCopy` + DOM renderer. Handles both `expired` and
`not-found` reasons with tailored copy and a `data-gone-reason`
attribute for testing. Exposes `_resetDocGoneOverlayGuard` for tests.
- tests/doc-gone-handler.test.ts: unit tests covering the copy branches
and the install/render path via a stub provider.
- tests/server/routes.test.ts: server tests for the new 404 variants.

Closes #672.

+218 -21
+1
CHANGELOG.md
··· 12 12 13 13 ### Fixed 14 14 - 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) 15 + - Deleted/missing documents: GET `/api/documents/:id/snapshot` now distinguishes `not-found` (doc row missing) from `no-snapshot` (exists but never saved). Client renders a dedicated 404 overlay for `not-found` instead of silently falling back to local backup or showing the generic expired copy. (#672) 15 16 16 17 ## [0.48.0] — 2026-04-15 17 18
+5 -1
server/routes/documents.ts
··· 263 263 264 264 router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 265 265 const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined; 266 - if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 266 + // #672 — differentiate "doc does not exist" from "doc exists but no snapshot yet": 267 + // - `not-found` — row missing entirely. Nothing to unlock; client shows 404 overlay. 268 + // - `no-snapshot` — the doc exists but was never saved (brand-new doc). Harmless. 269 + if (!row) { res.status(404).json({ error: 'Not found', code: 'not-found' }); return; } 270 + if (!row.snapshot) { res.status(404).json({ error: 'No snapshot', code: 'no-snapshot' }); return; } 267 271 268 272 if (isExpiredForUser(row.expires_at, row.owner, req.tsUser)) { 269 273 res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: row.expires_at });
+61 -15
src/lib/doc-gone-handler.ts
··· 1 1 /** 2 2 * Shared handler for the provider's `doc-gone` event. 3 3 * 4 - * Issue #673 — when the server returns 410 Gone (share link expired), we 5 - * surface a blocking overlay so the user isn't stuck staring at an empty 6 - * editor. Owners should never hit this path because the server bypasses 7 - * expiry for them. 4 + * Surfaces a blocking overlay when the server tells us the document is no 5 + * longer reachable. 6 + * - `expired` (#673) — share link expiry passed. Owners never hit this. 7 + * - `not-found` (#672) — document was deleted, purged from trash, or never 8 + * existed. The key in the URL has nothing to unlock. 9 + * 10 + * Rendering is split from decision logic so we can unit-test the copy. 8 11 */ 9 12 10 - import type { EncryptedProvider, DocGonePayload } from './provider.js'; 13 + import type { EncryptedProvider, DocGonePayload, DocGoneReason } from './provider.js'; 11 14 12 15 let overlayShown = false; 13 16 ··· 23 26 } 24 27 } 25 28 26 - function showExpiredOverlay(payload: DocGonePayload): void { 29 + export interface GoneOverlayCopy { 30 + title: string; 31 + body: string; 32 + primary: { label: string; href: string }; 33 + } 34 + 35 + /** 36 + * Pure function: decide overlay copy from a DocGonePayload. 37 + * Exported for unit tests — the overlay itself needs a live DOM. 38 + */ 39 + export function buildGoneOverlayCopy(payload: DocGonePayload): GoneOverlayCopy { 40 + switch (payload.reason) { 41 + case 'expired': 42 + return { 43 + title: 'This share link has expired', 44 + body: 45 + `The document owner set this link to expire${formatExpiry(payload.expiresAt)}. ` + 46 + 'Ask them to extend or regenerate the share link.', 47 + primary: { label: 'Back to home', href: '/' }, 48 + }; 49 + case 'not-found': 50 + return { 51 + title: 'This document no longer exists', 52 + body: 53 + 'The document may have been deleted, or the link may be wrong. ' + 54 + 'If it was in your trash, it has been permanently purged. ' + 55 + 'Check the URL, or head back to your documents to find something else.', 56 + primary: { label: 'Back to home', href: '/' }, 57 + }; 58 + } 59 + } 60 + 61 + function renderOverlay(payload: DocGonePayload): void { 62 + if (typeof document === 'undefined') return; 27 63 if (overlayShown) return; 28 64 overlayShown = true; 29 65 66 + const copy = buildGoneOverlayCopy(payload); 67 + 30 68 const overlay = document.createElement('div'); 31 69 overlay.setAttribute('role', 'alertdialog'); 32 70 overlay.setAttribute('aria-labelledby', 'doc-gone-title'); 71 + overlay.setAttribute('data-gone-reason', payload.reason); 33 72 overlay.style.cssText = [ 34 73 'position:fixed', 35 74 'inset:0', ··· 56 95 57 96 const title = document.createElement('h2'); 58 97 title.id = 'doc-gone-title'; 59 - title.textContent = 'This share link has expired'; 98 + title.textContent = copy.title; 60 99 title.style.cssText = 'margin:0 0 12px;font-size:18px;font-weight:600;'; 61 100 62 101 const body = document.createElement('p'); 63 - body.textContent = 64 - `The document owner set this link to expire${formatExpiry(payload.expiresAt)}. ` + 65 - 'Ask them to extend or regenerate the share link.'; 102 + body.textContent = copy.body; 66 103 body.style.cssText = 'margin:0 0 20px;line-height:1.5;opacity:0.85;'; 67 104 68 105 const btn = document.createElement('button'); 69 106 btn.type = 'button'; 70 - btn.textContent = 'Back to home'; 107 + btn.textContent = copy.primary.label; 71 108 btn.style.cssText = [ 72 109 'appearance:none', 73 110 'border:none', ··· 80 117 'font-weight:500', 81 118 ].join(';'); 82 119 btn.addEventListener('click', () => { 83 - window.location.href = '/'; 120 + window.location.href = copy.primary.href; 84 121 }); 85 122 86 123 card.append(title, body, btn); ··· 95 132 */ 96 133 export function installDocGoneHandler(provider: EncryptedProvider): void { 97 134 provider.on('doc-gone', (payload: DocGonePayload) => { 98 - if (payload.reason === 'expired') { 99 - showExpiredOverlay(payload); 100 - } 135 + renderOverlay(payload); 101 136 }); 102 137 } 138 + 139 + // Exported for tests only — resets the single-shot overlay guard. 140 + export function _resetDocGoneOverlayGuard(): void { 141 + overlayShown = false; 142 + if (typeof document !== 'undefined') { 143 + document.querySelectorAll('[data-gone-reason]').forEach(el => el.remove()); 144 + } 145 + } 146 + 147 + // Re-export for callers that want the union type without importing provider. 148 + export type { DocGoneReason };
+20 -4
src/lib/provider.ts
··· 433 433 this._emit('doc-gone', { reason: 'expired', expiresAt }); 434 434 return; 435 435 } 436 - // 404 = no snapshot exists (new doc) — not a failure 437 - // Other errors = server had data we couldn't get 438 - if (res.status !== 404) { 439 - this._snapshotLoadFailed = true; 436 + // 404: differentiate "doc does not exist" (#672) from "doc exists but has no snapshot yet". 437 + // - `not-found` — the row is gone. Emit doc-gone with reason 'not-found'. 438 + // - `no-snapshot` (or missing code) — treat as new doc and fall back to local backup. 439 + if (res.status === 404) { 440 + let code: string | null = null; 441 + try { 442 + const body = await res.clone().json(); 443 + code = typeof body?.code === 'string' ? body.code : null; 444 + } catch { 445 + // Non-JSON body — treat as legacy "no snapshot" (safe default). 446 + } 447 + if (code === 'not-found') { 448 + this._snapshotLoadFailed = true; 449 + this._emit('doc-gone', { reason: 'not-found' }); 450 + return; 451 + } 452 + await this._loadFromLocalBackup(); 453 + return; 440 454 } 455 + // Other errors = server had data we couldn't get 456 + this._snapshotLoadFailed = true; 441 457 await this._loadFromLocalBackup(); 442 458 return; 443 459 }
+103
tests/doc-gone-handler.test.ts
··· 1 + // @vitest-environment jsdom 2 + /** 3 + * #672 — 404 overlay for deleted/missing documents. 4 + * 5 + * The handler is split into a pure copy-builder and a DOM-renderer. We test 6 + * the copy branches directly, and the install/render path by driving a 7 + * lightweight stub provider and asserting on the live DOM. 8 + */ 9 + 10 + import { describe, it, expect, beforeEach } from 'vitest'; 11 + import type { DocGonePayload } from '../src/lib/provider.js'; 12 + import { 13 + buildGoneOverlayCopy, 14 + installDocGoneHandler, 15 + _resetDocGoneOverlayGuard, 16 + } from '../src/lib/doc-gone-handler.js'; 17 + 18 + describe('buildGoneOverlayCopy', () => { 19 + it('produces the expired-link copy for reason=expired', () => { 20 + const copy = buildGoneOverlayCopy({ reason: 'expired', expiresAt: null }); 21 + expect(copy.title).toMatch(/expired/i); 22 + expect(copy.body).toMatch(/owner set this link to expire/i); 23 + expect(copy.primary.label).toBe('Back to home'); 24 + expect(copy.primary.href).toBe('/'); 25 + }); 26 + 27 + it('includes a formatted expiry date when one is provided', () => { 28 + const copy = buildGoneOverlayCopy({ 29 + reason: 'expired', 30 + expiresAt: '2026-04-15T12:00:00Z', 31 + }); 32 + // toLocaleString output varies by locale, so just check the parens tag shows up. 33 + expect(copy.body).toMatch(/\(expired .+\)/); 34 + }); 35 + 36 + it('produces the 404 copy for reason=not-found', () => { 37 + const copy = buildGoneOverlayCopy({ reason: 'not-found' }); 38 + expect(copy.title).toMatch(/no longer exists/i); 39 + expect(copy.body).toMatch(/deleted|purged/i); 40 + expect(copy.primary.label).toBe('Back to home'); 41 + expect(copy.primary.href).toBe('/'); 42 + }); 43 + 44 + it('does not leak expiry wording into the 404 copy', () => { 45 + // Regression guard: the old handler hardcoded expiry copy and ignored reason. 46 + const copy = buildGoneOverlayCopy({ reason: 'not-found' }); 47 + expect(copy.title).not.toMatch(/expired/i); 48 + expect(copy.body).not.toMatch(/expire/i); 49 + }); 50 + }); 51 + 52 + // Minimal stub — the real provider has a huge surface; we only need `on('doc-gone', ...)`. 53 + function makeStubProvider() { 54 + const listeners: Array<(p: DocGonePayload) => void> = []; 55 + return { 56 + on(_evt: string, fn: (p: DocGonePayload) => void) { 57 + listeners.push(fn); 58 + }, 59 + fire(payload: DocGonePayload) { 60 + for (const fn of listeners) fn(payload); 61 + }, 62 + }; 63 + } 64 + 65 + describe('installDocGoneHandler', () => { 66 + beforeEach(() => { 67 + _resetDocGoneOverlayGuard(); 68 + }); 69 + 70 + it('renders the not-found overlay when the provider fires reason=not-found', () => { 71 + const stub = makeStubProvider(); 72 + // The stub's shape satisfies the one method the handler uses. 73 + installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]); 74 + 75 + stub.fire({ reason: 'not-found' }); 76 + 77 + const overlay = document.querySelector<HTMLDivElement>('[data-gone-reason="not-found"]'); 78 + expect(overlay).not.toBeNull(); 79 + expect(overlay!.textContent).toMatch(/no longer exists/i); 80 + }); 81 + 82 + it('renders the expired overlay when the provider fires reason=expired', () => { 83 + const stub = makeStubProvider(); 84 + installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]); 85 + 86 + stub.fire({ reason: 'expired', expiresAt: null }); 87 + 88 + const overlay = document.querySelector<HTMLDivElement>('[data-gone-reason="expired"]'); 89 + expect(overlay).not.toBeNull(); 90 + expect(overlay!.textContent).toMatch(/share link has expired/i); 91 + }); 92 + 93 + it('only renders one overlay even if the event fires multiple times', () => { 94 + const stub = makeStubProvider(); 95 + installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]); 96 + 97 + stub.fire({ reason: 'not-found' }); 98 + stub.fire({ reason: 'not-found' }); 99 + stub.fire({ reason: 'expired', expiresAt: null }); 100 + 101 + expect(document.querySelectorAll('[data-gone-reason]').length).toBe(1); 102 + }); 103 + });
+28 -1
tests/server/routes.test.ts
··· 303 303 304 304 app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => { 305 305 const row = stmts.getSnapshot.get(req.params.id) as { snapshot: Buffer | null; expires_at: string | null } | undefined; 306 - if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 306 + // #672 — distinguish row-missing from snapshot-missing so the client can show a 404 overlay. 307 + if (!row) { res.status(404).json({ error: 'Not found', code: 'not-found' }); return; } 308 + if (!row.snapshot) { res.status(404).json({ error: 'No snapshot', code: 'no-snapshot' }); return; } 307 309 if (row.expires_at) { 308 310 const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z'); 309 311 if (expiresAt <= new Date()) { ··· 736 738 body: new Uint8Array(0), 737 739 }); 738 740 expect(res.status).toBe(400); 741 + }); 742 + }); 743 + 744 + describe('GET /snapshot 404 variants (#672)', () => { 745 + it('returns code=not-found when the document row does not exist', async () => { 746 + const missing = randomUUID(); 747 + const res = await fetch(`${baseUrl}/api/documents/${missing}/snapshot`); 748 + expect(res.status).toBe(404); 749 + const body = await res.json() as { code?: string }; 750 + expect(body.code).toBe('not-found'); 751 + }); 752 + 753 + it('returns code=no-snapshot when the document exists but has not been saved', async () => { 754 + // Create the doc via the metadata endpoint so it has a row but no snapshot. 755 + const create = await fetch(`${baseUrl}/api/documents`, { 756 + method: 'POST', 757 + headers: { 'Content-Type': 'application/json' }, 758 + body: JSON.stringify({ type: 'doc' }), 759 + }); 760 + const { id } = await create.json() as { id: string }; 761 + 762 + const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`); 763 + expect(res.status).toBe(404); 764 + const body = await res.json() as { code?: string }; 765 + expect(body.code).toBe('no-snapshot'); 739 766 }); 740 767 }); 741 768