···293293- Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305)
294294295295### Changed
296296+- Create PRs for QA audit fix branches (#566)
297297+- Missing test coverage: Drag-fill pattern detection edge cases (#553)
298298+- Missing test coverage: Filter with blank cells, error values, and boolean cells (#538)
299299+- Share dialog: buildShareUrl puts query param after hash fragment (unreachable) (#534)
300300+- Missing test coverage: Recalc after undo/redo and incremental graph consistency (#531)
301301+- Missing test coverage: Pivot table with empty data, null values, and mixed column types (#529)
302302+- Diagrams: hitTestShape ignores rotation — rotated shapes have wrong click target (#528)
303303+- Forms: validateSubmission does not respect conditional visibility (validates hidden required fields) (#520)
304304+- Missing test coverage: CSV export with unicode, multi-byte chars, and formula injection (#511)
305305+- Code quality: API v1 builds SQL via string concatenation instead of prepared statements (#510)
306306+- Bug: API v1 document listing missing 'calendar' in valid types filter (#506)
307307+- Markdown import/export roundtrip loses table alignment and nested list indentation (#505)
308308+- BUG: HOUR/MINUTE/SECOND return NaN for invalid date inputs instead of #VALUE! (#504)
309309+- Bug: Document deletion does not cascade to versions and blobs (#502)
310310+- BUG: VLOOKUP/HLOOKUP approximate match returns wrong result with unsorted data (#501)
311311+- Correctness: Snapshot auto-create inserts with hardcoded type 'doc' for any document (#499)
312312+- Security: WebSocket relay has no message size limit (#498)
313313+- Security: Missing authorization checks on sensitive API endpoints (#496)
314314+- Zero test coverage for server routes, crypto library, DB layer, and validation (#495)
315315+- Bug: DocType type definition missing 'calendar' variant (#494)
296316- Calendar: comprehensive tests and visual fixes (#482)
297317- Debug: calendar document creation failing on production (#481)
298318- Calendar polish: CSS/HTML class alignment, color fixes, tests (#480)
+22-19
server/db.ts
···8080}
81818282// Expand type CHECK constraint to include form, slide, diagram, calendar
8383+// #525: Wrap table rebuild in a transaction so a crash mid-migration can't corrupt the DB
8384try {
8485 const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='documents'").get() as { sql: string } | undefined;
8586 if (tableInfo && !tableInfo.sql.includes("'calendar'")) {
8686- db.exec("DROP TABLE IF EXISTS documents_new");
8787- db.exec(`
8888- CREATE TABLE documents_new (
8989- id TEXT PRIMARY KEY,
9090- type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')),
9191- name_encrypted TEXT,
9292- snapshot BLOB,
9393- share_mode TEXT DEFAULT 'edit',
9494- expires_at TEXT,
9595- deleted_at TEXT,
9696- tags TEXT,
9797- owner TEXT,
9898- created_at TEXT DEFAULT (datetime('now')),
9999- updated_at TEXT DEFAULT (datetime('now'))
100100- );
101101- INSERT INTO documents_new SELECT id, type, name_encrypted, snapshot, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents;
102102- DROP TABLE documents;
103103- ALTER TABLE documents_new RENAME TO documents;
104104- `);
8787+ db.transaction(() => {
8888+ db.exec("DROP TABLE IF EXISTS documents_new");
8989+ db.exec(`
9090+ CREATE TABLE documents_new (
9191+ id TEXT PRIMARY KEY,
9292+ type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram','calendar')),
9393+ name_encrypted TEXT,
9494+ snapshot BLOB,
9595+ share_mode TEXT DEFAULT 'edit',
9696+ expires_at TEXT,
9797+ deleted_at TEXT,
9898+ tags TEXT,
9999+ owner TEXT,
100100+ created_at TEXT DEFAULT (datetime('now')),
101101+ updated_at TEXT DEFAULT (datetime('now'))
102102+ )
103103+ `);
104104+ db.exec("INSERT INTO documents_new SELECT id, type, name_encrypted, snapshot, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents");
105105+ db.exec("DROP TABLE documents");
106106+ db.exec("ALTER TABLE documents_new RENAME TO documents");
107107+ })();
105108 console.log('Migrated: expanded type CHECK for calendar');
106109 }
107110} catch (e) {
+20-4
server/index.ts
···8484// Room management for E2EE relay (referenced by health check)
8585const rooms = new Map<string, Set<WebSocket>>();
86868787-// Health check
8888-app.get('/health', (_req: Request, res: Response) => {
8787+// Health check — detailed metrics only for authenticated (Tailscale) users
8888+app.get('/health', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => {
8989 try {
9090 db.prepare('SELECT 1').get();
9191- const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
9292- res.json({ status: 'ok', rooms: rooms.size, users: userCount });
9191+ if (req.tsUser) {
9292+ const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
9393+ res.json({ status: 'ok', rooms: rooms.size, users: userCount });
9494+ } else {
9595+ res.json({ status: 'ok' });
9696+ }
9397 } catch (err: unknown) {
9498 const message = err instanceof Error ? err.message : 'Unknown error';
9599 res.status(500).json({ status: 'error', error: message });
···158162 wss.handleUpgrade(request, socket, head, (ws: WebSocket) => {
159163 const room = url.searchParams.get('room');
160164 if (!room || room.length > 200 || !/^[a-zA-Z0-9_-]+$/.test(room)) { ws.close(); return; }
165165+166166+ // #500: Check share link expiry before allowing WebSocket connection
167167+ try {
168168+ const doc = stmts.getOne.get(room) as { expires_at: string | null } | undefined;
169169+ if (doc?.expires_at) {
170170+ const expiresAt = new Date(doc.expires_at.endsWith('Z') ? doc.expires_at : doc.expires_at + 'Z');
171171+ if (expiresAt <= new Date()) {
172172+ ws.close(4410, 'Document link has expired');
173173+ return;
174174+ }
175175+ }
176176+ } catch { /* allow connection if DB check fails — fail open for relay */ }
161177162178 // Extract Tailscale identity from the upgrade request headers
163179 const wsUserLogin = request.headers['tailscale-user-login'] as string | undefined;
···5757router.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }>, res: Response) => {
5858 const { id: docId, versionId } = req.params;
59596060- const version = (stmts.getVersions.all(docId) as VersionRow[]).find(v => v.id === versionId);
6060+ const version = db.prepare('SELECT id, metadata FROM versions WHERE id = ? AND document_id = ?')
6161+ .get(versionId, docId) as Pick<VersionRow, 'id' | 'metadata'> | undefined;
6162 if (!version) {
6263 res.status(404).json({ error: 'Version not found' });
6364 return;
+4-4
src/docs/outline.ts
···11/**
22 * Document Outline Sidebar
33 *
44- * Extracts headings (H1, H2, H3) from editor content and builds
44+ * Extracts headings (H1-H6) from editor content and builds
55 * a navigable tree for the outline sidebar panel.
66 */
77import type { OutlineItem, OutlineTreeNode } from './types.js';
···5151}
52525353/**
5454- * Extract all H1, H2, H3 headings from editor JSON content.
5454+ * Extract all H1-H6 headings from editor JSON content.
5555 * Returns a flat array of { level, text, id } objects.
5656 */
5757export function extractHeadings(json: EditorJson): OutlineItem[] {
···6363 for (const node of json.content) {
6464 if (node.type !== 'heading') continue;
6565 const level = node.attrs?.level;
6666- if (level === undefined || level < 1 || level > 3) continue;
6666+ if (level === undefined || level < 1 || level > 6) continue;
67676868 const text = getHeadingText(node);
6969 const baseId = generateHeadingId(text);
···80808181/**
8282 * Build a nested tree from a flat list of headings.
8383- * H2 nests under preceding H1, H3 nests under preceding H2.
8383+ * Each heading nests under the nearest preceding heading with a lower level.
8484 */
8585export function buildOutlineTree(headings: OutlineItem[]): OutlineTreeNode[] {
8686 if (!headings || headings.length === 0) return [];
+1-1
src/docs/pdf-export.ts
···4848/**
4949 * Derive a safe filename from a document title.
5050 */
5151-export function pdfFilename(title: string): string {
5151+export function pdfFilename(title: string | null | undefined): string {
5252 const clean = (title || '').trim() || 'Untitled Document';
5353 return clean.replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_');
5454}
+73
src/forms/conditional-logic.ts
···217217export function ruleCount(state: ConditionalLogicState): number {
218218 return state.rules.length;
219219}
220220+221221+/**
222222+ * Detect circular dependencies in conditional logic rules.
223223+ *
224224+ * Builds a directed graph from rules: each rule creates an edge from
225225+ * sourceQuestionId → targetQuestionId (the target's visibility depends
226226+ * on the source's answer, so the target depends on the source).
227227+ * skip_to rules also create an edge to the skipToQuestionId.
228228+ *
229229+ * Returns the first cycle found as an array of question IDs, or null
230230+ * if no cycles exist.
231231+ */
232232+export function detectCircularDependencies(
233233+ state: ConditionalLogicState,
234234+): string[] | null {
235235+ // Build adjacency list: target depends on each source in its conditions
236236+ // An edge from A → B means "A's answer affects B's visibility"
237237+ // A cycle means A depends on B depends on ... depends on A
238238+ const graph = new Map<string, Set<string>>();
239239+240240+ for (const rule of state.rules) {
241241+ for (const condition of rule.conditions) {
242242+ const from = condition.sourceQuestionId;
243243+ const to = rule.targetQuestionId;
244244+ if (!graph.has(from)) graph.set(from, new Set());
245245+ graph.get(from)!.add(to);
246246+ }
247247+ // skip_to creates an additional dependency edge
248248+ if (rule.action === 'skip_to' && rule.skipToQuestionId) {
249249+ for (const condition of rule.conditions) {
250250+ const from = condition.sourceQuestionId;
251251+ if (!graph.has(from)) graph.set(from, new Set());
252252+ graph.get(from)!.add(rule.skipToQuestionId);
253253+ }
254254+ }
255255+ }
256256+257257+ // DFS cycle detection with path tracking
258258+ const visited = new Set<string>();
259259+ const inStack = new Set<string>();
260260+261261+ function dfs(node: string, path: string[]): string[] | null {
262262+ if (inStack.has(node)) {
263263+ // Found a cycle — extract the cycle portion from the path
264264+ const cycleStart = path.indexOf(node);
265265+ return path.slice(cycleStart).concat(node);
266266+ }
267267+ if (visited.has(node)) return null;
268268+269269+ visited.add(node);
270270+ inStack.add(node);
271271+ path.push(node);
272272+273273+ const neighbors = graph.get(node);
274274+ if (neighbors) {
275275+ for (const neighbor of neighbors) {
276276+ const cycle = dfs(neighbor, path);
277277+ if (cycle) return cycle;
278278+ }
279279+ }
280280+281281+ path.pop();
282282+ inStack.delete(node);
283283+ return null;
284284+ }
285285+286286+ for (const node of graph.keys()) {
287287+ const cycle = dfs(node, []);
288288+ if (cycle) return cycle;
289289+ }
290290+291291+ return null;
292292+}
+11-4
src/forms/form-builder.ts
···226226 }
227227 }
228228229229- // Custom validation pattern (length-limited to prevent ReDoS)
229229+ // Custom validation pattern (length-limited + ReDoS-safe)
230230+ // #540: Reject patterns with nested quantifiers that cause catastrophic backtracking
230231 if (question.validationPattern && question.validationPattern.length <= 200) {
231232 try {
232232- const re = new RegExp(question.validationPattern);
233233- // Test against a truncated string to bound worst-case backtracking
234234- if (!re.test(str.slice(0, 1000))) return 'Invalid format';
233233+ // Block nested quantifiers: (x+)+, (x*)+, (x+)*, etc. — common ReDoS vectors
234234+ if (/([+*])\s*[)]\s*[+*{]/.test(question.validationPattern) ||
235235+ /([+*])\s*[+*]/.test(question.validationPattern)) {
236236+ // Dangerous pattern — skip validation rather than risk hanging
237237+ } else {
238238+ const re = new RegExp(question.validationPattern);
239239+ // Test against a truncated string to bound worst-case backtracking
240240+ if (!re.test(str.slice(0, 1000))) return 'Invalid format';
241241+ }
235242 } catch {
236243 // Invalid pattern, skip
237244 }
+47-7
src/landing-toast.ts
···11/**
22 * Toast notification system for the landing page.
33 * Standalone — no external dependencies.
44+ *
55+ * Toasts are queued: if a toast with an undo action is visible, new toasts
66+ * wait until it is dismissed or expires rather than replacing it and losing
77+ * the undo button.
48 */
5966-export function showToast(message: string, duration = 3000, isError = false, onUndo?: () => void): void {
77- const existing = document.querySelector('.toast-notification');
88- if (existing) existing.remove();
1010+interface QueuedToast {
1111+ message: string;
1212+ duration: number;
1313+ isError: boolean;
1414+ onUndo?: () => void;
1515+}
1616+1717+const toastQueue: QueuedToast[] = [];
1818+let activeToast: HTMLElement | null = null;
1919+2020+function processQueue(): void {
2121+ if (activeToast || toastQueue.length === 0) return;
2222+ const next = toastQueue.shift()!;
2323+ displayToast(next.message, next.duration, next.isError, next.onUndo);
2424+}
2525+2626+function dismissToast(toast: HTMLElement): void {
2727+ toast.classList.remove('toast-visible');
2828+ setTimeout(() => {
2929+ toast.remove();
3030+ if (activeToast === toast) activeToast = null;
3131+ processQueue();
3232+ }, 300);
3333+}
3434+3535+function displayToast(message: string, duration: number, isError: boolean, onUndo?: () => void): void {
936 const toast = document.createElement('div');
1037 toast.className = 'toast-notification' + (isError ? ' toast-error' : '');
1138 if (onUndo) {
···2047 undoBtn.setAttribute('tabindex', '0');
2148 undoBtn.addEventListener('click', () => {
2249 onUndo();
2323- toast.classList.remove('toast-visible');
2424- setTimeout(() => toast.remove(), 300);
5050+ dismissToast(toast);
2551 });
2652 undoBtn.addEventListener('keydown', (e) => {
2753 if (e.key === 'Enter' || e.key === ' ') {
···3359 } else {
3460 toast.textContent = message;
3561 }
6262+ activeToast = toast;
3663 document.body.appendChild(toast);
3764 toast.offsetHeight; // force reflow
3865 toast.classList.add('toast-visible');
3966 setTimeout(() => {
4040- toast.classList.remove('toast-visible');
4141- setTimeout(() => toast.remove(), 300);
6767+ if (activeToast === toast) dismissToast(toast);
4268 }, duration);
4369}
7070+7171+export function showToast(message: string, duration = 3000, isError = false, onUndo?: () => void): void {
7272+ // If there's an active toast with an undo button, queue instead of replacing
7373+ if (activeToast && activeToast.querySelector('.toast-undo')) {
7474+ toastQueue.push({ message, duration, isError, onUndo });
7575+ return;
7676+ }
7777+ // If there's a non-interactive active toast, replace it immediately
7878+ if (activeToast) {
7979+ activeToast.remove();
8080+ activeToast = null;
8181+ }
8282+ displayToast(message, duration, isError, onUndo);
8383+}
+2-2
src/lib/key-passphrase.ts
···209209210210 function submit() {
211211 const passphrase = input.value;
212212- if (!passphrase || passphrase.length < 4) {
213213- errorEl.textContent = 'Passphrase must be at least 4 characters.';
212212+ if (!passphrase || passphrase.length < 8) {
213213+ errorEl.textContent = 'Passphrase must be at least 8 characters.';
214214 return;
215215 }
216216 if (mode === 'setup' && confirm) {
+21-16
src/lib/provider.ts
···8888 _snapshotLoadFailed: boolean;
8989 _lastDebounceTrigger: number;
9090 _lastEncrypted: ArrayBuffer | Uint8Array | null;
9191+ _lastEncryptedAt: number;
9192 _saveInProgress: boolean;
9293 _reconnectAttempts: number;
9394···121122 this._snapshotLoadFailed = false; // Track if server had data we couldn't load
122123 this._lastDebounceTrigger = 0; // Timestamp of first debounce in current burst
123124 this._lastEncrypted = null; // Cached encrypted state for synchronous sendBeacon
125125+ this._lastEncryptedAt = 0; // Timestamp when _lastEncrypted was created
124126 this._reconnectAttempts = 0;
125127 this._saveInProgress = false; // Prevents concurrent _saveSnapshot calls
126128 this.whenReady = new Promise<void>(resolve => { this._resolveReady = resolve; });
···347349 this._emergencySave();
348350 };
349351350350- /** Emergency save for page teardown: uses cached encrypted state + sendBeacon + IDB. */
352352+ /** Emergency save for page teardown: uses cached encrypted state + sendBeacon + IDB.
353353+ * #535: Tries fresh encryption first; only falls back to cached state if fresh fails
354354+ * or cached state is recent enough (within SAVE_DEBOUNCE window). */
351355 private _emergencySave(): void {
352356 try {
353357 if (!this._hasUnsavedChanges && this._lastEncrypted) {
···357361358362 const snapshotUrl = `${this.apiUrl}/api/documents/${this.roomId}/snapshot`;
359363360360- // Step 1: If we have cached encrypted state, fire sendBeacon immediately.
361361- // sendBeacon enqueues synchronously — guaranteed to survive page teardown.
362362- // This may be slightly stale if edits happened since last _saveSnapshot,
363363- // but it's better than losing everything.
364364- if (this._lastEncrypted && (this._hadSnapshot || this._snapshotLoadFailed)) {
364364+ // Step 1: Encode fresh Yjs state synchronously (this is fast and sync-safe)
365365+ const state = Y.encodeStateAsUpdate(this.doc);
366366+ if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) {
367367+ return;
368368+ }
369369+370370+ // Step 2: If cached state is stale (older than MAX_SAVE_WAIT), skip it entirely.
371371+ // Only send cached state as immediate fallback if it's reasonably fresh.
372372+ const cachedIsFresh = this._lastEncrypted && this._lastEncryptedAt > 0 &&
373373+ (Date.now() - this._lastEncryptedAt) < MAX_SAVE_WAIT;
374374+375375+ if (cachedIsFresh && (this._hadSnapshot || this._snapshotLoadFailed)) {
365376 if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
366377 try {
367378 const blob = new Blob([new Uint8Array(this._lastEncrypted as ArrayBuffer)], { type: 'application/octet-stream' });
···370381 }
371382 }
372383373373- // Step 2: Encode fresh state and attempt save (may or may not complete
374374- // during teardown — browser gives a brief window for async work).
375375- const state = Y.encodeStateAsUpdate(this.doc);
376376- if ((this._hadSnapshot || this._snapshotLoadFailed) && state.byteLength < MIN_SNAPSHOT_BYTES) {
377377- return;
378378- }
379379-380380- // Fresh encrypt → sendBeacon (replaces the stale one if it completes in time)
384384+ // Step 3: Fresh encrypt -> sendBeacon (replaces cached one if it completes in time)
381385 encrypt(state, this.cryptoKey).then(encrypted => {
382386 if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
383387 try {
···388392 // IDB: also save fresh state
389393 saveLocalBackup(this.roomId, encrypted).catch(() => { /* best effort */ });
390394 }).catch(() => {
391391- // Encrypt failed — fall back to saving stale cached state to IDB
392392- if (this._lastEncrypted) {
395395+ // Encrypt failed — fall back to saving cached state to IDB (only if fresh)
396396+ if (cachedIsFresh && this._lastEncrypted) {
393397 saveLocalBackup(this.roomId, this._lastEncrypted).catch(() => { /* best effort */ });
394398 }
395399 });
···500504501505 // Cache encrypted state for synchronous use in emergency save (sendBeacon)
502506 this._lastEncrypted = encrypted;
507507+ this._lastEncryptedAt = Date.now();
503508504509 // When disconnected (synced=false but hadSnapshot=true), skip server calls
505510 // and save to IDB only — protects offline edits against browser crash.