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

Configure Feed

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

chore: polish pass — TS strictness, CHANGELOG cleanup, E2E restore test

- Replace 5 unsafe `as string` header/query casts with proper typeof
narrowing in blobs, api-v1, versions, and documents routes
- Clean up CHANGELOG: remove 70-line Chainlink task dump from 0.12.1,
move [Unreleased] to top, consolidate entries into proper categories
- Add E2E tests for version history restore action (confirm + cancel)
- Close 6 stale PRs: 5 TipTap v3 Renovate PRs (must upgrade all 24
packages together) and PR #123 (conflicted, incomplete) (#597)

+84 -73
+8 -65
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [Unreleased] 9 + 8 10 ## [0.29.0] — 2026-04-09 9 11 10 12 ### Added ··· 296 298 - Use correct org secret names for keychain unlock 297 299 - Switch notarization to App Store Connect API key auth 298 300 299 - ## [Unreleased] 300 - 301 301 ## [0.14.1] — 2026-03-28 302 302 303 303 ### Fixed ··· 312 312 - Fix E2E test flakiness: replace page reload with addInitScript, add waitForURL before waitForSelector (#305) 313 313 314 314 ### Changed 315 - - Create PRs for QA audit fix branches (#566) 316 - - Missing test coverage: Drag-fill pattern detection edge cases (#553) 317 - - Missing test coverage: Filter with blank cells, error values, and boolean cells (#538) 318 - - Share dialog: buildShareUrl puts query param after hash fragment (unreachable) (#534) 319 - - Missing test coverage: Recalc after undo/redo and incremental graph consistency (#531) 320 - - Missing test coverage: Pivot table with empty data, null values, and mixed column types (#529) 321 - - Diagrams: hitTestShape ignores rotation — rotated shapes have wrong click target (#528) 322 - - Forms: validateSubmission does not respect conditional visibility (validates hidden required fields) (#520) 323 - - Missing test coverage: CSV export with unicode, multi-byte chars, and formula injection (#511) 324 - - Code quality: API v1 builds SQL via string concatenation instead of prepared statements (#510) 325 - - Bug: API v1 document listing missing 'calendar' in valid types filter (#506) 326 - - Markdown import/export roundtrip loses table alignment and nested list indentation (#505) 327 - - BUG: HOUR/MINUTE/SECOND return NaN for invalid date inputs instead of #VALUE! (#504) 328 - - Bug: Document deletion does not cascade to versions and blobs (#502) 329 - - BUG: VLOOKUP/HLOOKUP approximate match returns wrong result with unsorted data (#501) 330 - - Correctness: Snapshot auto-create inserts with hardcoded type 'doc' for any document (#499) 331 - - Security: WebSocket relay has no message size limit (#498) 332 - - Security: Missing authorization checks on sensitive API endpoints (#496) 333 - - Zero test coverage for server routes, crypto library, DB layer, and validation (#495) 334 - - Bug: DocType type definition missing 'calendar' variant (#494) 335 - - Calendar: comprehensive tests and visual fixes (#482) 336 - - Debug: calendar document creation failing on production (#481) 337 - - Calendar polish: CSS/HTML class alignment, color fixes, tests (#480) 338 - - Decompose docs/main.ts into focused modules (#464) 339 - - Decompose diagrams/main.ts into focused modules (#463) 340 - - Phase 5: extract toolbar, keyboard, cell-editing, grid-rendering from sheets main.ts (#462) 341 - - Phase 4: extract formula-bar, keyboard-shortcuts, clipboard-selection from sheets main.ts (#461) 342 - - Aggressively decompose sheets/main.ts - extract all major UI blocks (#459) 343 - - Decompose sheets/main.ts monolith into focused modules (#458) 344 - - Polish task list checkbox alignment and spacing (#457) 345 - - Bump version to 0.24.0 and update CHANGELOG (#456) 315 + - Refactor duplicated AI chat wiring into shared `initChatWiring()` (#234) 316 + - Decompose monolithic editor entry points into focused modules (#458-464) 346 317 - Consolidate z-index values into documented CSS custom properties (#450) 347 - - QA batch 22: tests for cross-sheet, custom-format, permissions, named-ranges (#444) 348 - - QA: batch 21 edge case tests for untested modules (#442) 349 - - QA batch 20: continued edge case coverage expansion (#439) 350 - - QA batch 19: theming, automations, range-highlight, export edge cases (#438) 351 - - QA batch 18: formulas & pivot-table edge cases (#437) 352 - - QA batch 17: more untested pure-logic modules (#436) 353 - - No unit tests for server API endpoints or WebSocket relay (#413) 354 - - No unit tests for forms builder, conditional logic, or response pipeline (#412) 355 - - CSS: dark mode colors use oklch() which is not supported in Firefox <113 or Safari <15.4 - no fallback colors defined (#408) 356 - - Fit and finish round 3: provider listener leak, fetch error handling, test gaps (#361) 357 - - Add E2E tests for database views (kanban, gallery, calendar, timeline, pivot) (#302) 358 - - Add E2E tests for forms builder and submission (#301) 359 - - Add E2E tests for diagrams whiteboard (#300) 360 - - Add E2E tests for slides presentations (#299) 361 - - Replace stub tests with real behavioral tests (#298) 362 - - Add unit tests for AI doc actions and blob upload (#297) 363 - - Add unit tests for slides, diagrams, and forms entry points (#296) 364 - - Fix Electron code signing: Developer ID cert + notarization (#264) 365 - - Electron thin client: auto-connect Tailnet backend (#261) 366 - - Wire up Apple notarization for Electron builds (#260) 367 - - Monitor CI for PR #178, merge when green, verify deployment (#255) 368 - - Electron desktop app wrapper (#254) 369 - - Tailscale identity auth - zero-password login via TS headers (#253) 370 - - Monitor and merge PR #176 - CI rename tools Nomad job (#252) 371 - - Refactor duplicated AI chat wiring into shared `initChatWiring()`, removing ~230 lines of duplication (#234) 372 - - Add 11 tests for `initChatWiring` (config propagation, toggle, send/stop/clear, editor-type labels) 318 + - Electron desktop app with Tailnet auto-connect and code signing (#254, #261, #264) 319 + - Tailscale identity auth — zero-password login via TS headers (#253) 373 320 374 321 ### Security 375 - - Key-sync: encryption keys stored in plaintext in localStorage and sent to server in plaintext - keys should be wrapped with a user-derived key (#405) 376 - - Replace `Math.random()` with `crypto.getRandomValues`/`crypto.randomUUID` in 6 files (#239) 322 + - Replace `Math.random()` with `crypto.getRandomValues`/`crypto.randomUUID` (#239) 377 323 - Add defense-in-depth XSS escaping for code block language attributes (#238) 378 - - Fix XSS + review findings from AI chat PR #160 (#232) 324 + - Fix XSS findings from AI chat (#232) 379 325 380 326 ### Accessibility 381 327 - Add ARIA roles, labels, and live regions to AI chat sidebar (#241) 382 - 383 - ### Tests 384 - - Add XSS escaping, ARIA attribute, code block rendering, and sheet action edge case tests (#242) 385 328 386 329 ## [0.12.0] — 2026-03-24 387 330
+60
e2e/version-history.spec.ts
··· 156 156 } 157 157 }); 158 158 159 + test('clicking restore button restores the version after confirmation', async ({ page }) => { 160 + const editor = page.locator('.tiptap'); 161 + await editor.click(); 162 + await page.keyboard.type('Original content'); 163 + await waitForSaved(page); 164 + await page.waitForTimeout(1000); 165 + 166 + // Overwrite with different content 167 + await editor.click(); 168 + await page.keyboard.press('Meta+a'); 169 + await page.keyboard.type('Replaced content'); 170 + await waitForSaved(page); 171 + await page.waitForTimeout(1000); 172 + 173 + // Open version panel and click the first (oldest) version 174 + await page.click('#btn-history'); 175 + const panel = page.locator('.version-panel'); 176 + await expect(panel).toHaveClass(/open/, { timeout: 5000 }); 177 + 178 + const items = panel.locator('.version-panel-item'); 179 + if (await items.count() > 1) { 180 + // Click the oldest version (last in the list — newest first) 181 + await items.last().click(); 182 + await expect(panel.locator('.version-panel-preview')).toBeVisible({ timeout: 5000 }); 183 + 184 + // Accept the confirm() dialog when restore is clicked 185 + page.on('dialog', dialog => dialog.accept()); 186 + await page.click('.version-panel-restore'); 187 + 188 + // Panel should close after restore 189 + await expect(panel).not.toHaveClass(/open/, { timeout: 5000 }); 190 + } 191 + }); 192 + 193 + test('clicking restore cancel does not restore', async ({ page }) => { 194 + const editor = page.locator('.tiptap'); 195 + await editor.click(); 196 + await page.keyboard.type('Keep this content'); 197 + await waitForSaved(page); 198 + await page.waitForTimeout(1000); 199 + 200 + await page.click('#btn-history'); 201 + const panel = page.locator('.version-panel'); 202 + await expect(panel).toHaveClass(/open/, { timeout: 5000 }); 203 + 204 + const items = panel.locator('.version-panel-item'); 205 + if (await items.count() > 0) { 206 + await items.first().click(); 207 + await expect(panel.locator('.version-panel-preview')).toBeVisible({ timeout: 5000 }); 208 + 209 + // Dismiss the confirm() dialog 210 + page.on('dialog', dialog => dialog.dismiss()); 211 + await page.click('.version-panel-restore'); 212 + 213 + // Panel should remain open (restore was cancelled) 214 + await expect(panel).toHaveClass(/open/); 215 + await expect(panel.locator('.version-panel-preview')).toBeVisible(); 216 + } 217 + }); 218 + 159 219 test('name version button is present on version items', async ({ page }) => { 160 220 const editor = page.locator('.tiptap'); 161 221 await editor.click();
+6 -3
server/routes/api-v1.ts
··· 23 23 // Search documents by name (encrypted names are matched client-side, but 24 24 // the server can filter by type and return metadata for cross-doc linking) 25 25 router.get('/api/v1/documents', (req: Request, res: Response) => { 26 - const { type, limit: lim, offset: off } = req.query; 27 - const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 28 - const offset = Math.max(parseInt(off as string) || 0, 0); 26 + const { type: rawType, limit: rawLim, offset: rawOff } = req.query; 27 + const lim = Array.isArray(rawLim) ? rawLim[0] : rawLim; 28 + const off = Array.isArray(rawOff) ? rawOff[0] : rawOff; 29 + const type = Array.isArray(rawType) ? rawType[0] : rawType; 30 + const limit = Math.min(Math.max(parseInt(lim || '50'), 1), 200); 31 + const offset = Math.max(parseInt(off || '0'), 0); 29 32 30 33 const isTypeFilter = type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]); 31 34 const rows = isTypeFilter
+6 -3
server/routes/blobs.ts
··· 14 14 const BLOB_MAX_SIZE = 10 * 1024 * 1024; // 10MB per blob 15 15 16 16 router.post('/api/blobs', express.raw({ limit: '10mb', type: '*/*' }), (req: Request<Record<string, string>, unknown, Buffer> & { tsUser?: TailscaleUser | null }, res: Response) => { 17 - const docId = req.headers['x-document-id'] as string; 18 - const fileName = (req.headers['x-file-name'] as string || 'file').slice(0, 255); 19 - const rawMime = (req.headers['x-mime-type'] as string || 'application/octet-stream').slice(0, 255); 17 + const docIdHeader = req.headers['x-document-id']; 18 + const docId = typeof docIdHeader === 'string' ? docIdHeader : undefined; 19 + const fileNameHeader = req.headers['x-file-name']; 20 + const fileName = (typeof fileNameHeader === 'string' ? fileNameHeader : 'file').slice(0, 255); 21 + const rawMimeHeader = req.headers['x-mime-type']; 22 + const rawMime = (typeof rawMimeHeader === 'string' ? rawMimeHeader : 'application/octet-stream').slice(0, 255); 20 23 const mimeType = isValidMimeType(rawMime) ? rawMime : 'application/octet-stream'; 21 24 if (!docId) return res.status(400).json({ error: 'x-document-id header required' }); 22 25
+2 -1
server/routes/documents.ts
··· 232 232 const result = stmts.putSnapshot.run(req.body, req.params.id); 233 233 if (result.changes === 0) { 234 234 // Issue #499: use actual doc type from header instead of hardcoded 'doc' 235 - const docType = (req.headers['x-document-type'] as string) || 'doc'; 235 + const docTypeHeader = req.headers['x-document-type']; 236 + const docType = (typeof docTypeHeader === 'string' ? docTypeHeader : '') || 'doc'; 236 237 const validType = isValidDocType(docType) ? docType : 'doc'; 237 238 db.transaction(() => { 238 239 db.prepare("INSERT OR IGNORE INTO documents (id, type, name_encrypted) VALUES (?, ?, NULL)").run(req.params.id, validType);
+2 -1
server/routes/versions.ts
··· 30 30 } 31 31 32 32 const id = randomUUID(); 33 - const rawMetadata = req.headers['x-version-metadata'] as string | undefined ?? null; 33 + const metadataHeader = req.headers['x-version-metadata']; 34 + const rawMetadata = typeof metadataHeader === 'string' ? metadataHeader : null; 34 35 const metadata = rawMetadata && rawMetadata.length > 4096 ? rawMetadata.slice(0, 4096) : rawMetadata; 35 36 stmts.insertVersion.run(id, docId, req.body, metadata); 36 37 // FIFO: prune if over limit