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

Configure Feed

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

Merge pull request 'fix: enforce share link expiry server-side (#673)' (#388) from fix/673-share-expiry-enforcement into main

scott 3c15359c a1283c00

+323 -14
+3
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ### Fixed 11 + - 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) 12 + 10 13 ## [0.48.0] — 2026-04-15 11 14 12 15 ### Added
+1 -1
server/db.ts
··· 169 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 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 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'), 172 - getSnapshot: db.prepare('SELECT snapshot, expires_at FROM documents WHERE id = ?'), 172 + getSnapshot: db.prepare('SELECT snapshot, expires_at, owner FROM documents WHERE id = ?'), 173 173 putSnapshot: db.prepare("UPDATE documents SET snapshot = ?, updated_at = datetime('now') WHERE id = ?"), 174 174 putName: db.prepare("UPDATE documents SET name_encrypted = ?, updated_at = datetime('now') WHERE id = ?"), 175 175 trashDoc: db.prepare("UPDATE documents SET deleted_at = datetime('now') WHERE id = ? AND deleted_at IS NULL"),
+50
server/expiry.ts
··· 1 + /** 2 + * Share link expiry logic. 3 + * 4 + * The document's `expires_at` timestamp gates anonymous and non-owner access. 5 + * The owner of a document is never subject to the expiry — this lets them 6 + * regenerate, extend, or clear the expiry even after it has passed. 7 + * 8 + * Issue #673 — enforce expiry server-side so UI controls are honored. 9 + */ 10 + 11 + import type { TailscaleUser } from './types.js'; 12 + 13 + /** Parse a SQLite-stored timestamp as UTC. */ 14 + export function parseExpiresAt(raw: string | null | undefined): Date | null { 15 + if (!raw) return null; 16 + const iso = raw.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(raw) ? raw : raw + 'Z'; 17 + const d = new Date(iso); 18 + if (isNaN(d.getTime())) return null; 19 + return d; 20 + } 21 + 22 + /** True when `expires_at` is set and in the past relative to `now`. */ 23 + export function isExpired( 24 + expiresAt: string | null | undefined, 25 + now: Date = new Date(), 26 + ): boolean { 27 + const parsed = parseExpiresAt(expiresAt); 28 + if (!parsed) return false; 29 + return parsed.getTime() <= now.getTime(); 30 + } 31 + 32 + /** 33 + * True when the document is expired AND the requesting user is not its owner. 34 + * 35 + * Non-owners include: anonymous/tailnet-free requests, and authenticated 36 + * users whose login differs from the document's owner. Owners always pass. 37 + */ 38 + export function isExpiredForUser( 39 + expiresAt: string | null | undefined, 40 + docOwner: string | null | undefined, 41 + user: TailscaleUser | null | undefined, 42 + now: Date = new Date(), 43 + ): boolean { 44 + if (!isExpired(expiresAt, now)) return false; 45 + // Anonymous owner docs (no owner recorded) are treated as expired to anyone 46 + // — without an owner column, there is no one to bypass the gate. 47 + if (!docOwner) return true; 48 + if (!user) return true; 49 + return user.login !== docOwner; 50 + }
+16 -8
server/routes/documents.ts
··· 7 7 import { randomUUID } from 'crypto'; 8 8 import { db, stmts } from '../db.js'; 9 9 import { isValidDocType, RateLimiter } from '../validation.js'; 10 + import { isExpiredForUser } from '../expiry.js'; 10 11 import type { 11 12 TailscaleUser, 12 13 UserRow, ··· 133 134 res.json(stmts.getTrash.all() as DocumentListRow[]); 134 135 }); 135 136 136 - router.get('/api/documents/:id', (req: Request<{ id: string }>, res: Response) => { 137 + router.get('/api/documents/:id', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 137 138 const doc = stmts.getOne.get(req.params.id) as DocumentListRow | undefined; 138 139 if (!doc) { res.status(404).json({ error: 'Not found' }); return; } 140 + if (isExpiredForUser(doc.expires_at, doc.owner, req.tsUser)) { 141 + res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: doc.expires_at }); 142 + return; 143 + } 139 144 res.json(doc); 140 145 }); 141 146 ··· 228 233 return; 229 234 } 230 235 236 + // Expiry check: non-owners cannot write to an expired share 237 + if (existing && isExpiredForUser(existing.expires_at, existing.owner, req.tsUser)) { 238 + res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: existing.expires_at }); 239 + return; 240 + } 241 + 231 242 try { 232 243 const result = stmts.putSnapshot.run(req.body, req.params.id); 233 244 if (result.changes === 0) { ··· 250 261 router.put('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 251 262 router.post('/api/documents/:id/snapshot', snapshotMiddleware, snapshotHandler); 252 263 253 - router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }>, res: Response) => { 264 + router.get('/api/documents/:id/snapshot', (req: Request<{ id: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 254 265 const row = stmts.getSnapshot.get(req.params.id) as SnapshotRow | undefined; 255 266 if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 256 267 257 - if (row.expires_at) { 258 - const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z'); 259 - if (expiresAt <= new Date()) { 260 - res.status(410).json({ error: 'Document link has expired' }); 261 - return; 262 - } 268 + if (isExpiredForUser(row.expires_at, row.owner, req.tsUser)) { 269 + res.status(410).json({ error: 'Document link has expired', code: 'expired', expires_at: row.expires_at }); 270 + return; 263 271 } 264 272 265 273 res.type('application/octet-stream').send(row.snapshot);
+1
server/types.ts
··· 34 34 export interface SnapshotRow { 35 35 snapshot: Buffer | null; 36 36 expires_at: string | null; 37 + owner: string | null; 37 38 } 38 39 39 40 export interface VersionRow {
+2
src/calendar/main.ts
··· 10 10 import * as Y from 'yjs'; 11 11 import { importKey } from '../lib/crypto.js'; 12 12 import { EncryptedProvider } from '../lib/provider.js'; 13 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 13 14 import { setupTooltips } from '../lib/tooltips.js'; 14 15 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 15 16 import { createCommandPalette } from '../command-palette.js'; ··· 2328 2329 2329 2330 if (cryptoKey) { 2330 2331 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 2332 + installDocGoneHandler(provider); 2331 2333 2332 2334 provider.on('sync', () => { 2333 2335 loadEventsFromYjs();
+2
src/diagrams/main.ts
··· 7 7 import * as Y from 'yjs'; 8 8 import { importKey } from '../lib/crypto.js'; 9 9 import { EncryptedProvider } from '../lib/provider.js'; 10 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 10 11 import { setupTooltips } from '../lib/tooltips.js'; 11 12 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 12 13 import { ··· 379 380 380 381 if (cryptoKey) { 381 382 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 383 + installDocGoneHandler(provider); 382 384 provider.on('sync', () => { 383 385 loadFromYjs(); 384 386 pushHistory();
+2
src/docs/main.ts
··· 35 35 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 36 36 import { ensureWrappingKey } from '../lib/key-passphrase.js'; 37 37 import { EncryptedProvider } from '../lib/provider.js'; 38 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 38 39 import { FontSize } from './extensions/font-size.js'; 39 40 import { Indent } from './extensions/indent.js'; 40 41 import { Comment } from './extensions/comment.js'; ··· 137 138 const cryptoKey = await importKey(keyString); 138 139 const ydoc = new Y.Doc(); 139 140 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 141 + installDocGoneHandler(provider); 140 142 141 143 // Wait for snapshot to load before creating the editor — prevents CRDT conflict 142 144 // where TipTap writes default content that conflicts with loaded data
+2
src/forms/main.ts
··· 8 8 import * as Y from 'yjs'; 9 9 import { importKey } from '../lib/crypto.js'; 10 10 import { EncryptedProvider } from '../lib/provider.js'; 11 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 11 12 import { setupTooltips } from '../lib/tooltips.js'; 12 13 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 13 14 import { createForm, setTargetSheet, type FormSchema } from './form-builder.js'; ··· 210 211 211 212 if (cryptoKey) { 212 213 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 214 + installDocGoneHandler(provider); 213 215 214 216 provider.on('sync', () => { 215 217 loadFormFromYjs();
+102
src/lib/doc-gone-handler.ts
··· 1 + /** 2 + * Shared handler for the provider's `doc-gone` event. 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. 8 + */ 9 + 10 + import type { EncryptedProvider, DocGonePayload } from './provider.js'; 11 + 12 + let overlayShown = false; 13 + 14 + function formatExpiry(expiresAt: string | null | undefined): string { 15 + if (!expiresAt) return ''; 16 + const iso = expiresAt.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(expiresAt) ? expiresAt : expiresAt + 'Z'; 17 + const d = new Date(iso); 18 + if (isNaN(d.getTime())) return ''; 19 + try { 20 + return ` (expired ${d.toLocaleString()})`; 21 + } catch { 22 + return ''; 23 + } 24 + } 25 + 26 + function showExpiredOverlay(payload: DocGonePayload): void { 27 + if (overlayShown) return; 28 + overlayShown = true; 29 + 30 + const overlay = document.createElement('div'); 31 + overlay.setAttribute('role', 'alertdialog'); 32 + overlay.setAttribute('aria-labelledby', 'doc-gone-title'); 33 + overlay.style.cssText = [ 34 + 'position:fixed', 35 + 'inset:0', 36 + 'z-index:10000', 37 + 'background:rgba(0,0,0,0.55)', 38 + 'display:flex', 39 + 'align-items:center', 40 + 'justify-content:center', 41 + 'padding:16px', 42 + 'font-family:system-ui,-apple-system,sans-serif', 43 + ].join(';'); 44 + 45 + const card = document.createElement('div'); 46 + card.style.cssText = [ 47 + 'background:var(--bg,#fff)', 48 + 'color:var(--fg,#111)', 49 + 'max-width:420px', 50 + 'width:100%', 51 + 'padding:24px', 52 + 'border-radius:12px', 53 + 'box-shadow:0 16px 40px rgba(0,0,0,0.25)', 54 + 'text-align:center', 55 + ].join(';'); 56 + 57 + const title = document.createElement('h2'); 58 + title.id = 'doc-gone-title'; 59 + title.textContent = 'This share link has expired'; 60 + title.style.cssText = 'margin:0 0 12px;font-size:18px;font-weight:600;'; 61 + 62 + 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.'; 66 + body.style.cssText = 'margin:0 0 20px;line-height:1.5;opacity:0.85;'; 67 + 68 + const btn = document.createElement('button'); 69 + btn.type = 'button'; 70 + btn.textContent = 'Back to home'; 71 + btn.style.cssText = [ 72 + 'appearance:none', 73 + 'border:none', 74 + 'background:var(--accent,#2563eb)', 75 + 'color:#fff', 76 + 'padding:10px 18px', 77 + 'border-radius:8px', 78 + 'font-size:14px', 79 + 'cursor:pointer', 80 + 'font-weight:500', 81 + ].join(';'); 82 + btn.addEventListener('click', () => { 83 + window.location.href = '/'; 84 + }); 85 + 86 + card.append(title, body, btn); 87 + overlay.append(card); 88 + document.body.append(overlay); 89 + } 90 + 91 + /** 92 + * Install the default `doc-gone` handler on a provider. 93 + * 94 + * Safe to call multiple times — the overlay dedupes itself. 95 + */ 96 + export function installDocGoneHandler(provider: EncryptedProvider): void { 97 + provider.on('doc-gone', (payload: DocGonePayload) => { 98 + if (payload.reason === 'expired') { 99 + showExpiredOverlay(payload); 100 + } 101 + }); 102 + }
+43 -5
src/lib/provider.ts
··· 35 35 status: SaveStatus; 36 36 } 37 37 38 - type ProviderEvent = 'sync' | 'status' | 'awareness' | 'save-status'; 38 + export type DocGoneReason = 'not-found' | 'expired'; 39 + 40 + export interface DocGonePayload { 41 + reason: DocGoneReason; 42 + expiresAt?: string | null; 43 + } 44 + 45 + type ProviderEvent = 'sync' | 'status' | 'awareness' | 'save-status' | 'doc-gone'; 39 46 40 47 interface StatusPayload { 41 48 connected: boolean; ··· 57 64 apiUrl?: string; 58 65 } 59 66 60 - type ProviderEventCallback = ((...args: [boolean] | [StatusPayload] | [SaveStatusPayload]) => void); 67 + type ProviderEventCallback = ((...args: [boolean] | [StatusPayload] | [SaveStatusPayload] | [DocGonePayload]) => void); 61 68 62 69 type EventCallbackMap = { 63 70 'sync': (synced: boolean) => void; 64 71 'status': (payload: StatusPayload) => void; 65 72 'awareness': (payload: StatusPayload) => void; 66 73 'save-status': (payload: SaveStatusPayload) => void; 74 + 'doc-gone': (payload: DocGonePayload) => void; 67 75 }; 68 76 69 77 export class EncryptedProvider { ··· 113 121 this.connected = false; 114 122 this.synced = false; 115 123 this.saveStatus = 'saved'; 116 - this._listeners = { sync: [], status: [], awareness: [], 'save-status': [] }; 124 + this._listeners = { sync: [], status: [], awareness: [], 'save-status': [], 'doc-gone': [] }; 117 125 this._snapshotTimer = null; 118 126 this._saveDebounce = null; 119 127 this._destroyed = false; ··· 154 162 (this._listeners[event] || []).push(fn as ProviderEventCallback); 155 163 } 156 164 157 - _emit(event: ProviderEvent, ...args: [boolean] | [StatusPayload] | [SaveStatusPayload]): void { 158 - for (const fn of this._listeners[event] || []) fn(...args as [boolean & StatusPayload & SaveStatusPayload]); 165 + _emit(event: ProviderEvent, ...args: [boolean] | [StatusPayload] | [SaveStatusPayload] | [DocGonePayload]): void { 166 + for (const fn of this._listeners[event] || []) fn(...args as [boolean & StatusPayload & SaveStatusPayload & DocGonePayload]); 159 167 } 160 168 161 169 _setSaveStatus(status: SaveStatus): void { ··· 412 420 try { 413 421 const res = await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`); 414 422 if (!res.ok) { 423 + // 410 Gone = share link expired (#673) — surface to UI, do not fall back 424 + if (res.status === 410) { 425 + let expiresAt: string | null = null; 426 + try { 427 + const body = await res.clone().json(); 428 + expiresAt = typeof body?.expires_at === 'string' ? body.expires_at : null; 429 + } catch { 430 + // Non-JSON body — no details to surface beyond the status 431 + } 432 + this._snapshotLoadFailed = true; 433 + this._emit('doc-gone', { reason: 'expired', expiresAt }); 434 + return; 435 + } 415 436 // 404 = no snapshot exists (new doc) — not a failure 416 437 // Other errors = server had data we couldn't get 417 438 if (res.status !== 404) { ··· 541 562 542 563 // Save to server with retry logic 543 564 let saved = false; 565 + let gone = false; 566 + let goneExpiresAt: string | null = null; 544 567 for (let attempt = 0; attempt < MAX_SAVE_RETRIES; attempt++) { 545 568 try { 546 569 const res = await fetch(`${this.apiUrl}/api/documents/${this.roomId}/snapshot`, { ··· 552 575 saved = true; 553 576 break; 554 577 } 578 + // 410 Gone = share expired (#673). Stop retrying, surface to UI. 579 + if (res.status === 410) { 580 + try { 581 + const body = await res.clone().json(); 582 + goneExpiresAt = typeof body?.expires_at === 'string' ? body.expires_at : null; 583 + } catch { 584 + // Non-JSON body 585 + } 586 + gone = true; 587 + break; 588 + } 555 589 } catch { /* retry */ } 556 590 557 591 // Exponential backoff: 1s, 2s, 4s 558 592 if (attempt < MAX_SAVE_RETRIES - 1) { 559 593 await new Promise(resolve => setTimeout(resolve, RETRY_BASE_MS * Math.pow(2, attempt))); 560 594 } 595 + } 596 + 597 + if (gone) { 598 + this._emit('doc-gone', { reason: 'expired', expiresAt: goneExpiresAt }); 561 599 } 562 600 563 601 // Always save to local IDB backup (regardless of server success)
+2
src/sheets/session-bootstrap.ts
··· 3 3 import { storeKey, pushKeysToServer, fetchServerKeys, getLocalKeys } from '../lib/key-sync.js'; 4 4 import { ensureWrappingKey } from '../lib/key-passphrase.js'; 5 5 import { EncryptedProvider } from '../lib/provider.js'; 6 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 6 7 7 8 export interface BootstrapResult { 8 9 docId: string; ··· 53 54 54 55 const ydoc = new Y.Doc(); 55 56 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 57 + installDocGoneHandler(provider); 56 58 await provider.whenReady; 57 59 58 60 const ySheets = ydoc.getMap('sheets') as Y.Map<Y.Map<unknown>>;
+2
src/slides/main.ts
··· 9 9 import * as Y from 'yjs'; 10 10 import { importKey } from '../lib/crypto.js'; 11 11 import { EncryptedProvider } from '../lib/provider.js'; 12 + import { installDocGoneHandler } from '../lib/doc-gone-handler.js'; 12 13 import { setupTooltips } from '../lib/tooltips.js'; 13 14 import { mountOfflineIndicator } from '../lib/offline-indicator.js'; 14 15 import { createDeck, slideCount } from './canvas-engine.js'; ··· 172 173 173 174 if (state.cryptoKey) { 174 175 const provider = new EncryptedProvider(ydoc, state.docId, state.cryptoKey); 176 + installDocGoneHandler(provider); 175 177 provider.on('sync', () => { 176 178 loadDeckFromYjs(); 177 179 actions.render();
+95
tests/share-expiry.test.ts
··· 1 + /** 2 + * Share expiry enforcement (#673). 3 + * 4 + * Covers the pure expiry helpers. Route-level enforcement is tested in 5 + * integration tests that drive the Express app. 6 + */ 7 + 8 + import { describe, it, expect } from 'vitest'; 9 + import { parseExpiresAt, isExpired, isExpiredForUser } from '../server/expiry.js'; 10 + import type { TailscaleUser } from '../server/types.js'; 11 + 12 + const now = new Date('2026-04-16T12:00:00Z'); 13 + const past = '2026-04-15T12:00:00Z'; 14 + const future = '2026-04-17T12:00:00Z'; 15 + 16 + const ownerUser: TailscaleUser = { login: 'scott@example.com', name: 'Scott', profilePic: null }; 17 + const otherUser: TailscaleUser = { login: 'alex@example.com', name: 'Alex', profilePic: null }; 18 + 19 + describe('parseExpiresAt', () => { 20 + it('returns null for null/empty input', () => { 21 + expect(parseExpiresAt(null)).toBeNull(); 22 + expect(parseExpiresAt(undefined)).toBeNull(); 23 + expect(parseExpiresAt('')).toBeNull(); 24 + }); 25 + 26 + it('parses ISO-with-Z as UTC', () => { 27 + const d = parseExpiresAt('2026-04-16T12:00:00Z'); 28 + expect(d).not.toBeNull(); 29 + expect(d!.toISOString()).toBe('2026-04-16T12:00:00.000Z'); 30 + }); 31 + 32 + it('treats naive SQLite timestamps as UTC', () => { 33 + // SQLite's datetime('now') emits "YYYY-MM-DD HH:MM:SS" without a zone. 34 + const d = parseExpiresAt('2026-04-16 12:00:00'); 35 + expect(d).not.toBeNull(); 36 + expect(d!.toISOString()).toBe('2026-04-16T12:00:00.000Z'); 37 + }); 38 + 39 + it('preserves explicit offsets', () => { 40 + const d = parseExpiresAt('2026-04-16T12:00:00+02:00'); 41 + expect(d).not.toBeNull(); 42 + expect(d!.toISOString()).toBe('2026-04-16T10:00:00.000Z'); 43 + }); 44 + 45 + it('returns null for unparseable input', () => { 46 + expect(parseExpiresAt('not-a-date')).toBeNull(); 47 + }); 48 + }); 49 + 50 + describe('isExpired', () => { 51 + it('is false when expires_at is null', () => { 52 + expect(isExpired(null, now)).toBe(false); 53 + }); 54 + 55 + it('is true when expiry is in the past', () => { 56 + expect(isExpired(past, now)).toBe(true); 57 + }); 58 + 59 + it('is true at exact expiry boundary', () => { 60 + expect(isExpired('2026-04-16T12:00:00Z', now)).toBe(true); 61 + }); 62 + 63 + it('is false when expiry is in the future', () => { 64 + expect(isExpired(future, now)).toBe(false); 65 + }); 66 + }); 67 + 68 + describe('isExpiredForUser', () => { 69 + it('is false when doc is not expired, regardless of user', () => { 70 + expect(isExpiredForUser(future, 'scott@example.com', ownerUser, now)).toBe(false); 71 + expect(isExpiredForUser(future, 'scott@example.com', otherUser, now)).toBe(false); 72 + expect(isExpiredForUser(future, 'scott@example.com', null, now)).toBe(false); 73 + expect(isExpiredForUser(null, 'scott@example.com', null, now)).toBe(false); 74 + }); 75 + 76 + it('lets the owner through even when expired', () => { 77 + expect(isExpiredForUser(past, 'scott@example.com', ownerUser, now)).toBe(false); 78 + }); 79 + 80 + it('blocks a non-owner when expired', () => { 81 + expect(isExpiredForUser(past, 'scott@example.com', otherUser, now)).toBe(true); 82 + }); 83 + 84 + it('blocks an anonymous viewer when expired', () => { 85 + expect(isExpiredForUser(past, 'scott@example.com', null, now)).toBe(true); 86 + expect(isExpiredForUser(past, 'scott@example.com', undefined, now)).toBe(true); 87 + }); 88 + 89 + it('blocks everyone on an expired doc with no recorded owner', () => { 90 + // Without an owner to bypass, expiry gates anonymous uploads too — 91 + // otherwise expiry would be meaningless for ownerless docs. 92 + expect(isExpiredForUser(past, null, ownerUser, now)).toBe(true); 93 + expect(isExpiredForUser(past, null, null, now)).toBe(true); 94 + }); 95 + });