···12121313### Fixed
1414- 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)
1515+- 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)
15161617## [0.48.0] — 2026-04-15
1718
+5-1
server/routes/documents.ts
···263263264264router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => {
265265 const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined;
266266- if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; }
266266+ // #672 — differentiate "doc does not exist" from "doc exists but no snapshot yet":
267267+ // - `not-found` — row missing entirely. Nothing to unlock; client shows 404 overlay.
268268+ // - `no-snapshot` — the doc exists but was never saved (brand-new doc). Harmless.
269269+ if (!row) { res.status(404).json({ error: 'Not found', code: 'not-found' }); return; }
270270+ if (!row.snapshot) { res.status(404).json({ error: 'No snapshot', code: 'no-snapshot' }); return; }
267271268272 if (isExpiredForUser(row.expires_at, row.owner, req.tsUser)) {
269273 res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: row.expires_at });
+61-15
src/lib/doc-gone-handler.ts
···11/**
22 * Shared handler for the provider's `doc-gone` event.
33 *
44- * Issue #673 — when the server returns 410 Gone (share link expired), we
55- * surface a blocking overlay so the user isn't stuck staring at an empty
66- * editor. Owners should never hit this path because the server bypasses
77- * expiry for them.
44+ * Surfaces a blocking overlay when the server tells us the document is no
55+ * longer reachable.
66+ * - `expired` (#673) — share link expiry passed. Owners never hit this.
77+ * - `not-found` (#672) — document was deleted, purged from trash, or never
88+ * existed. The key in the URL has nothing to unlock.
99+ *
1010+ * Rendering is split from decision logic so we can unit-test the copy.
811 */
9121010-import type { EncryptedProvider, DocGonePayload } from './provider.js';
1313+import type { EncryptedProvider, DocGonePayload, DocGoneReason } from './provider.js';
11141215let overlayShown = false;
1316···2326 }
2427}
25282626-function showExpiredOverlay(payload: DocGonePayload): void {
2929+export interface GoneOverlayCopy {
3030+ title: string;
3131+ body: string;
3232+ primary: { label: string; href: string };
3333+}
3434+3535+/**
3636+ * Pure function: decide overlay copy from a DocGonePayload.
3737+ * Exported for unit tests — the overlay itself needs a live DOM.
3838+ */
3939+export function buildGoneOverlayCopy(payload: DocGonePayload): GoneOverlayCopy {
4040+ switch (payload.reason) {
4141+ case 'expired':
4242+ return {
4343+ title: 'This share link has expired',
4444+ body:
4545+ `The document owner set this link to expire${formatExpiry(payload.expiresAt)}. ` +
4646+ 'Ask them to extend or regenerate the share link.',
4747+ primary: { label: 'Back to home', href: '/' },
4848+ };
4949+ case 'not-found':
5050+ return {
5151+ title: 'This document no longer exists',
5252+ body:
5353+ 'The document may have been deleted, or the link may be wrong. ' +
5454+ 'If it was in your trash, it has been permanently purged. ' +
5555+ 'Check the URL, or head back to your documents to find something else.',
5656+ primary: { label: 'Back to home', href: '/' },
5757+ };
5858+ }
5959+}
6060+6161+function renderOverlay(payload: DocGonePayload): void {
6262+ if (typeof document === 'undefined') return;
2763 if (overlayShown) return;
2864 overlayShown = true;
29656666+ const copy = buildGoneOverlayCopy(payload);
6767+3068 const overlay = document.createElement('div');
3169 overlay.setAttribute('role', 'alertdialog');
3270 overlay.setAttribute('aria-labelledby', 'doc-gone-title');
7171+ overlay.setAttribute('data-gone-reason', payload.reason);
3372 overlay.style.cssText = [
3473 'position:fixed',
3574 'inset:0',
···56955796 const title = document.createElement('h2');
5897 title.id = 'doc-gone-title';
5959- title.textContent = 'This share link has expired';
9898+ title.textContent = copy.title;
6099 title.style.cssText = 'margin:0 0 12px;font-size:18px;font-weight:600;';
6110062101 const body = document.createElement('p');
6363- body.textContent =
6464- `The document owner set this link to expire${formatExpiry(payload.expiresAt)}. ` +
6565- 'Ask them to extend or regenerate the share link.';
102102+ body.textContent = copy.body;
66103 body.style.cssText = 'margin:0 0 20px;line-height:1.5;opacity:0.85;';
6710468105 const btn = document.createElement('button');
69106 btn.type = 'button';
7070- btn.textContent = 'Back to home';
107107+ btn.textContent = copy.primary.label;
71108 btn.style.cssText = [
72109 'appearance:none',
73110 'border:none',
···80117 'font-weight:500',
81118 ].join(';');
82119 btn.addEventListener('click', () => {
8383- window.location.href = '/';
120120+ window.location.href = copy.primary.href;
84121 });
8512286123 card.append(title, body, btn);
···95132 */
96133export function installDocGoneHandler(provider: EncryptedProvider): void {
97134 provider.on('doc-gone', (payload: DocGonePayload) => {
9898- if (payload.reason === 'expired') {
9999- showExpiredOverlay(payload);
100100- }
135135+ renderOverlay(payload);
101136 });
102137}
138138+139139+// Exported for tests only — resets the single-shot overlay guard.
140140+export function _resetDocGoneOverlayGuard(): void {
141141+ overlayShown = false;
142142+ if (typeof document !== 'undefined') {
143143+ document.querySelectorAll('[data-gone-reason]').forEach(el => el.remove());
144144+ }
145145+}
146146+147147+// Re-export for callers that want the union type without importing provider.
148148+export type { DocGoneReason };
+20-4
src/lib/provider.ts
···433433 this._emit('doc-gone', { reason: 'expired', expiresAt });
434434 return;
435435 }
436436- // 404 = no snapshot exists (new doc) — not a failure
437437- // Other errors = server had data we couldn't get
438438- if (res.status !== 404) {
439439- this._snapshotLoadFailed = true;
436436+ // 404: differentiate "doc does not exist" (#672) from "doc exists but has no snapshot yet".
437437+ // - `not-found` — the row is gone. Emit doc-gone with reason 'not-found'.
438438+ // - `no-snapshot` (or missing code) — treat as new doc and fall back to local backup.
439439+ if (res.status === 404) {
440440+ let code: string | null = null;
441441+ try {
442442+ const body = await res.clone().json();
443443+ code = typeof body?.code === 'string' ? body.code : null;
444444+ } catch {
445445+ // Non-JSON body — treat as legacy "no snapshot" (safe default).
446446+ }
447447+ if (code === 'not-found') {
448448+ this._snapshotLoadFailed = true;
449449+ this._emit('doc-gone', { reason: 'not-found' });
450450+ return;
451451+ }
452452+ await this._loadFromLocalBackup();
453453+ return;
440454 }
455455+ // Other errors = server had data we couldn't get
456456+ this._snapshotLoadFailed = true;
441457 await this._loadFromLocalBackup();
442458 return;
443459 }
+103
tests/doc-gone-handler.test.ts
···11+// @vitest-environment jsdom
22+/**
33+ * #672 — 404 overlay for deleted/missing documents.
44+ *
55+ * The handler is split into a pure copy-builder and a DOM-renderer. We test
66+ * the copy branches directly, and the install/render path by driving a
77+ * lightweight stub provider and asserting on the live DOM.
88+ */
99+1010+import { describe, it, expect, beforeEach } from 'vitest';
1111+import type { DocGonePayload } from '../src/lib/provider.js';
1212+import {
1313+ buildGoneOverlayCopy,
1414+ installDocGoneHandler,
1515+ _resetDocGoneOverlayGuard,
1616+} from '../src/lib/doc-gone-handler.js';
1717+1818+describe('buildGoneOverlayCopy', () => {
1919+ it('produces the expired-link copy for reason=expired', () => {
2020+ const copy = buildGoneOverlayCopy({ reason: 'expired', expiresAt: null });
2121+ expect(copy.title).toMatch(/expired/i);
2222+ expect(copy.body).toMatch(/owner set this link to expire/i);
2323+ expect(copy.primary.label).toBe('Back to home');
2424+ expect(copy.primary.href).toBe('/');
2525+ });
2626+2727+ it('includes a formatted expiry date when one is provided', () => {
2828+ const copy = buildGoneOverlayCopy({
2929+ reason: 'expired',
3030+ expiresAt: '2026-04-15T12:00:00Z',
3131+ });
3232+ // toLocaleString output varies by locale, so just check the parens tag shows up.
3333+ expect(copy.body).toMatch(/\(expired .+\)/);
3434+ });
3535+3636+ it('produces the 404 copy for reason=not-found', () => {
3737+ const copy = buildGoneOverlayCopy({ reason: 'not-found' });
3838+ expect(copy.title).toMatch(/no longer exists/i);
3939+ expect(copy.body).toMatch(/deleted|purged/i);
4040+ expect(copy.primary.label).toBe('Back to home');
4141+ expect(copy.primary.href).toBe('/');
4242+ });
4343+4444+ it('does not leak expiry wording into the 404 copy', () => {
4545+ // Regression guard: the old handler hardcoded expiry copy and ignored reason.
4646+ const copy = buildGoneOverlayCopy({ reason: 'not-found' });
4747+ expect(copy.title).not.toMatch(/expired/i);
4848+ expect(copy.body).not.toMatch(/expire/i);
4949+ });
5050+});
5151+5252+// Minimal stub — the real provider has a huge surface; we only need `on('doc-gone', ...)`.
5353+function makeStubProvider() {
5454+ const listeners: Array<(p: DocGonePayload) => void> = [];
5555+ return {
5656+ on(_evt: string, fn: (p: DocGonePayload) => void) {
5757+ listeners.push(fn);
5858+ },
5959+ fire(payload: DocGonePayload) {
6060+ for (const fn of listeners) fn(payload);
6161+ },
6262+ };
6363+}
6464+6565+describe('installDocGoneHandler', () => {
6666+ beforeEach(() => {
6767+ _resetDocGoneOverlayGuard();
6868+ });
6969+7070+ it('renders the not-found overlay when the provider fires reason=not-found', () => {
7171+ const stub = makeStubProvider();
7272+ // The stub's shape satisfies the one method the handler uses.
7373+ installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]);
7474+7575+ stub.fire({ reason: 'not-found' });
7676+7777+ const overlay = document.querySelector<HTMLDivElement>('[data-gone-reason="not-found"]');
7878+ expect(overlay).not.toBeNull();
7979+ expect(overlay!.textContent).toMatch(/no longer exists/i);
8080+ });
8181+8282+ it('renders the expired overlay when the provider fires reason=expired', () => {
8383+ const stub = makeStubProvider();
8484+ installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]);
8585+8686+ stub.fire({ reason: 'expired', expiresAt: null });
8787+8888+ const overlay = document.querySelector<HTMLDivElement>('[data-gone-reason="expired"]');
8989+ expect(overlay).not.toBeNull();
9090+ expect(overlay!.textContent).toMatch(/share link has expired/i);
9191+ });
9292+9393+ it('only renders one overlay even if the event fires multiple times', () => {
9494+ const stub = makeStubProvider();
9595+ installDocGoneHandler(stub as unknown as Parameters<typeof installDocGoneHandler>[0]);
9696+9797+ stub.fire({ reason: 'not-found' });
9898+ stub.fire({ reason: 'not-found' });
9999+ stub.fire({ reason: 'expired', expiresAt: null });
100100+101101+ expect(document.querySelectorAll('[data-gone-reason]').length).toBe(1);
102102+ });
103103+});
+28-1
tests/server/routes.test.ts
···303303304304 app.get('/api/documents/:id/snapshot', (req: Req, res: Res) => {
305305 const row = stmts.getSnapshot.get(req.params.id) as { snapshot: Buffer | null; expires_at: string | null } | undefined;
306306- if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; }
306306+ // #672 — distinguish row-missing from snapshot-missing so the client can show a 404 overlay.
307307+ if (!row) { res.status(404).json({ error: 'Not found', code: 'not-found' }); return; }
308308+ if (!row.snapshot) { res.status(404).json({ error: 'No snapshot', code: 'no-snapshot' }); return; }
307309 if (row.expires_at) {
308310 const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z');
309311 if (expiresAt <= new Date()) {
···736738 body: new Uint8Array(0),
737739 });
738740 expect(res.status).toBe(400);
741741+ });
742742+});
743743+744744+describe('GET /snapshot 404 variants (#672)', () => {
745745+ it('returns code=not-found when the document row does not exist', async () => {
746746+ const missing = randomUUID();
747747+ const res = await fetch(`${baseUrl}/api/documents/${missing}/snapshot`);
748748+ expect(res.status).toBe(404);
749749+ const body = await res.json() as { code?: string };
750750+ expect(body.code).toBe('not-found');
751751+ });
752752+753753+ it('returns code=no-snapshot when the document exists but has not been saved', async () => {
754754+ // Create the doc via the metadata endpoint so it has a row but no snapshot.
755755+ const create = await fetch(`${baseUrl}/api/documents`, {
756756+ method: 'POST',
757757+ headers: { 'Content-Type': 'application/json' },
758758+ body: JSON.stringify({ type: 'doc' }),
759759+ });
760760+ const { id } = await create.json() as { id: string };
761761+762762+ const res = await fetch(`${baseUrl}/api/documents/${id}/snapshot`);
763763+ expect(res.status).toBe(404);
764764+ const body = await res.json() as { code?: string };
765765+ expect(body.code).toBe('no-snapshot');
739766 });
740767});
741768