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: address 9 issues from QA audit round 2' (#349) from fix/qa-audit-round2-fixes into main

scott 5c7d8e2d fa7cef38

+181 -20
+4 -1
server/index.ts
··· 173 173 return; 174 174 } 175 175 } 176 - } catch { /* allow connection if DB check fails — fail open for relay */ } 176 + } catch { 177 + ws.close(4500, 'Internal error checking document access'); 178 + return; 179 + } 177 180 178 181 // Extract Tailscale identity from the upgrade request headers 179 182 const wsUserLogin = request.headers['tailscale-user-login'] as string | undefined;
+6 -2
server/routes/api-v1.ts
··· 17 17 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' 18 18 ); 19 19 const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL'); 20 + const countByTypeStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL AND type = ?'); 20 21 const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?'); 21 22 22 23 // Search documents by name (encrypted names are matched client-side, but ··· 26 27 const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 27 28 const offset = Math.max(parseInt(off as string) || 0, 0); 28 29 29 - const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number])) 30 + const isTypeFilter = type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]); 31 + const rows = isTypeFilter 30 32 ? listByTypeStmt.all(type, limit, offset) 31 33 : listAllStmt.all(limit, offset); 32 34 33 - const total = (countStmt.get() as { count: number }).count; 35 + const total = isTypeFilter 36 + ? (countByTypeStmt.get(type) as { count: number }).count 37 + : (countStmt.get() as { count: number }).count; 34 38 res.json({ data: rows, total, limit, offset }); 35 39 }); 36 40
+1 -1
server/routes/blobs.ts
··· 43 43 res.set('Content-Type', row.mime_type); 44 44 res.set('Content-Length', String(row.size)); 45 45 const safeName = row.file_name.replace(/["\\\r\n]/g, '_'); 46 - res.set('Content-Disposition', `inline; filename="${safeName}"`); 46 + res.set('Content-Disposition', `attachment; filename="${safeName}"`); 47 47 res.send(row.data); 48 48 }); 49 49
+1 -1
server/routes/documents.ts
··· 254 254 if (!row || !row.snapshot) { res.status(404).json({ error: 'No snapshot' }); return; } 255 255 256 256 if (row.expires_at) { 257 - const expiresAt = new Date(row.expires_at); 257 + const expiresAt = new Date(row.expires_at.endsWith('Z') ? row.expires_at : row.expires_at + 'Z'); 258 258 if (expiresAt <= new Date()) { 259 259 res.status(410).json({ error: 'Document link has expired' }); 260 260 return;
+10 -2
server/routes/versions.ts
··· 30 30 } 31 31 32 32 const id = randomUUID(); 33 - const metadata = req.headers['x-version-metadata'] as string | undefined ?? null; 33 + const rawMetadata = req.headers['x-version-metadata'] as string | undefined ?? null; 34 + const metadata = rawMetadata && rawMetadata.length > 4096 ? rawMetadata.slice(0, 4096) : rawMetadata; 34 35 stmts.insertVersion.run(id, docId, req.body, metadata); 35 36 // FIFO: prune if over limit 36 37 const countRow = stmts.countVersions.get(docId) as VersionCountRow | undefined; ··· 54 55 res.type('application/octet-stream').send(row.snapshot); 55 56 }); 56 57 57 - router.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }>, res: Response) => { 58 + router.put('/api/documents/:id/versions/:versionId/metadata', express.json(), (req: Request<{ id: string; versionId: string }> & { tsUser?: TailscaleUser | null }, res: Response) => { 58 59 const { id: docId, versionId } = req.params; 60 + 61 + // Owner check: allow owner, anonymous docs, and shared-edit docs 62 + const doc = stmts.getOne.get(docId) as DocumentListRow | undefined; 63 + if (doc?.owner && req.tsUser?.login !== doc.owner && doc.share_mode !== 'edit') { 64 + res.status(403).json({ error: 'Only the document owner can update version metadata' }); 65 + return; 66 + } 59 67 60 68 const version = db.prepare('SELECT id, metadata FROM versions WHERE id = ? AND document_id = ?') 61 69 .get(versionId, docId) as Pick<VersionRow, 'id' | 'metadata'> | undefined;
+10 -4
src/calendar/helpers.ts
··· 167 167 : 0; 168 168 169 169 let current = parseEventDate(evt.date); 170 + const startDay = current.getDate(); // preserve original day-of-month for monthly recurrence 170 171 const endDate = parseEventDate(effectiveEnd); 171 172 const startRange = parseEventDate(rangeStart); 172 173 ··· 189 190 result.push(instance); 190 191 } 191 192 192 - // Advance to next occurrence 193 - current = nextOccurrence(current, type); 193 + // Advance to next occurrence (pass startDay for monthly to recover from clamping) 194 + current = nextOccurrence(current, type, startDay); 194 195 iterations++; 195 196 196 197 // Skip instances before our visible range ··· 215 216 return result; 216 217 } 217 218 218 - function nextOccurrence(current: Date, type: RecurrenceType): Date { 219 + function nextOccurrence(current: Date, type: RecurrenceType, startDay?: number): Date { 219 220 const next = new Date(current); 220 221 switch (type) { 221 222 case 'daily': ··· 224 225 case 'weekly': 225 226 next.setDate(next.getDate() + 7); 226 227 break; 227 - case 'monthly': 228 + case 'monthly': { 229 + const targetDay = startDay ?? next.getDate(); 230 + next.setDate(1); // prevent day overflow (e.g. Jan 31 → Mar 3) 228 231 next.setMonth(next.getMonth() + 1); 232 + const maxDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); 233 + next.setDate(Math.min(targetDay, maxDay)); 229 234 break; 235 + } 230 236 case 'yearly': 231 237 next.setFullYear(next.getFullYear() + 1); 232 238 break;
+2 -2
src/calendar/ics-export.ts
··· 106 106 107 107 if (event.allDay) { 108 108 lines.push(`DTSTART;VALUE=DATE:${toIcsDate(event.date)}`); 109 - lines.push(`DTEND;VALUE=DATE:${toIcsDate(nextDay(event.date))}`); 109 + lines.push(`DTEND;VALUE=DATE:${toIcsDate(nextDay(event.endDate || event.date))}`); 110 110 } else { 111 111 lines.push(`DTSTART:${toIcsDateTime(event.date, event.startTime)}`); 112 - lines.push(`DTEND:${toIcsDateTime(event.date, event.endTime)}`); 112 + lines.push(`DTEND:${toIcsDateTime(event.endDate || event.date, event.endTime)}`); 113 113 } 114 114 115 115 lines.push(`SUMMARY:${escapeIcsText(event.title)}`);
+13 -5
src/calendar/ics-parser.ts
··· 170 170 // Duration parsing (RFC 5545 §3.3.6) 171 171 // --------------------------------------------------------------------------- 172 172 173 - /** Parse an iCal DURATION like P1DT2H30M into minutes */ 173 + /** Parse an iCal DURATION like P1DT2H30M into minutes. 174 + * RFC 5545 §3.3.6: format is P[nW] or P[nY][nM][nD][T[nH][nM][nS]] 175 + * M before T = months, M after T = minutes. */ 174 176 function parseDuration(dur: string): number { 175 177 let minutes = 0; 176 - const weekMatch = dur.match(/(\d+)W/); 177 - const dayMatch = dur.match(/(\d+)D/); 178 - const hourMatch = dur.match(/(\d+)H/); 179 - const minMatch = dur.match(/(\d+)M/); 178 + const tIdx = dur.indexOf('T'); 179 + const datePart = tIdx >= 0 ? dur.slice(0, tIdx) : dur; 180 + const timePart = tIdx >= 0 ? dur.slice(tIdx + 1) : ''; 181 + 182 + const weekMatch = datePart.match(/(\d+)W/); 183 + const monthMatch = datePart.match(/(\d+)M/); 184 + const dayMatch = datePart.match(/(\d+)D/); 185 + const hourMatch = timePart.match(/(\d+)H/); 186 + const minMatch = timePart.match(/(\d+)M/); 180 187 181 188 if (weekMatch) minutes += parseInt(weekMatch[1]!, 10) * 7 * 24 * 60; 189 + if (monthMatch) minutes += parseInt(monthMatch[1]!, 10) * 30 * 24 * 60; // approximate month as 30 days 182 190 if (dayMatch) minutes += parseInt(dayMatch[1]!, 10) * 24 * 60; 183 191 if (hourMatch) minutes += parseInt(hourMatch[1]!, 10) * 60; 184 192 if (minMatch) minutes += parseInt(minMatch[1]!, 10);
+47
tests/ics-export.test.ts
··· 127 127 }); 128 128 129 129 // --------------------------------------------------------------------------- 130 + // Multi-day events 131 + // --------------------------------------------------------------------------- 132 + 133 + describe('exportIcsFile multi-day events', () => { 134 + it('exports an all-day multi-day event with DTEND as next day after endDate', () => { 135 + const events = [makeEvent({ 136 + allDay: true, 137 + date: '2026-04-01', 138 + endDate: '2026-04-03', 139 + startTime: '', 140 + endTime: '', 141 + })]; 142 + const ics = exportIcsFile(events, 'Cal'); 143 + const lines = icsLines(ics); 144 + expect(lines).toContain('DTSTART;VALUE=DATE:20260401'); 145 + // RFC 5545: DTEND is exclusive, so endDate 2026-04-03 → DTEND 2026-04-04 146 + expect(lines).toContain('DTEND;VALUE=DATE:20260404'); 147 + }); 148 + 149 + it('exports a timed multi-day event with DTEND using endDate, not start date', () => { 150 + const events = [makeEvent({ 151 + date: '2026-04-01', 152 + endDate: '2026-04-03', 153 + startTime: '09:00', 154 + endTime: '17:00', 155 + })]; 156 + const ics = exportIcsFile(events, 'Cal'); 157 + const lines = icsLines(ics); 158 + expect(lines).toContain('DTSTART:20260401T090000'); 159 + // DTEND should use endDate (Apr 3), not start date (Apr 1) 160 + expect(lines).toContain('DTEND:20260403T170000'); 161 + }); 162 + 163 + it('falls back to start date for DTEND when endDate is absent', () => { 164 + const events = [makeEvent({ 165 + date: '2026-04-15', 166 + startTime: '09:00', 167 + endTime: '10:00', 168 + })]; 169 + const ics = exportIcsFile(events, 'Cal'); 170 + const lines = icsLines(ics); 171 + expect(lines).toContain('DTSTART:20260415T090000'); 172 + expect(lines).toContain('DTEND:20260415T100000'); 173 + }); 174 + }); 175 + 176 + // --------------------------------------------------------------------------- 130 177 // Description and escaping 131 178 // --------------------------------------------------------------------------- 132 179
+20
tests/ics-parser.test.ts
··· 117 117 it('handles minutes only', () => { 118 118 expect(parseDuration('PT45M')).toBe(45); 119 119 }); 120 + 121 + it('treats M before T as months (~30 days), not minutes', () => { 122 + // P1M = 1 month ≈ 30 days = 43200 minutes 123 + expect(parseDuration('P1M')).toBe(30 * 24 * 60); 124 + }); 125 + 126 + it('treats M after T as minutes', () => { 127 + // PT1M = 1 minute 128 + expect(parseDuration('PT1M')).toBe(1); 129 + }); 130 + 131 + it('handles month M and minute M in the same duration', () => { 132 + // P1MT30M = 1 month + 30 minutes = 43200 + 30 = 43230 133 + expect(parseDuration('P1MT30M')).toBe(30 * 24 * 60 + 30); 134 + }); 135 + 136 + it('handles days + hours + minutes correctly (no month ambiguity)', () => { 137 + // P1DT2H30M = 1 day + 2 hours + 30 minutes = 1440 + 120 + 30 = 1590 138 + expect(parseDuration('P1DT2H30M')).toBe(1590); 139 + }); 120 140 }); 121 141 122 142 // ---------------------------------------------------------------------------
+42
tests/recurring-events.test.ts
··· 90 90 expect(result[0].date).toBe('2026-01-15'); 91 91 expect(result[5].date).toBe('2026-06-15'); 92 92 }); 93 + 94 + it('does not overflow Jan 31 into March (pins to Feb 28)', () => { 95 + // The primary fix: Jan 31 monthly should land on Feb 28, NOT Mar 3 96 + const events = [makeEvent({ 97 + date: '2026-01-31', 98 + recurrence: { type: 'monthly' }, 99 + })]; 100 + const result = expandRecurringEvents(events, '2026-01-01', '2026-03-31'); 101 + expect(result.length).toBeGreaterThanOrEqual(3); 102 + expect(result[0].date).toBe('2026-01-31'); 103 + expect(result[1].date).toBe('2026-02-28'); // pinned, not Mar 03 104 + // Recovers to 31 for months that support it 105 + expect(result[2].date).toBe('2026-03-31'); 106 + }); 107 + 108 + it('does not overflow Jan 31 into March in a leap year (pins to Feb 29)', () => { 109 + // 2028 is a leap year 110 + const events = [makeEvent({ 111 + date: '2028-01-31', 112 + recurrence: { type: 'monthly' }, 113 + })]; 114 + const result = expandRecurringEvents(events, '2028-01-01', '2028-03-31'); 115 + expect(result.length).toBeGreaterThanOrEqual(3); 116 + expect(result[0].date).toBe('2028-01-31'); 117 + expect(result[1].date).toBe('2028-02-29'); // leap year, pinned 118 + // Recovers to 31 for March 119 + expect(result[2].date).toBe('2028-03-31'); 120 + }); 121 + 122 + it('does not overflow May 31 into July (pins to Jun 30)', () => { 123 + // May 31 → Jun 30 (not Jul 01) 124 + const events = [makeEvent({ 125 + date: '2026-05-31', 126 + recurrence: { type: 'monthly' }, 127 + })]; 128 + const result = expandRecurringEvents(events, '2026-05-01', '2026-07-31'); 129 + expect(result.length).toBeGreaterThanOrEqual(3); 130 + expect(result[0].date).toBe('2026-05-31'); 131 + expect(result[1].date).toBe('2026-06-30'); // pinned, not Jul 01 132 + // Recovers to 31 for July 133 + expect(result[2].date).toBe('2026-07-31'); 134 + }); 93 135 }); 94 136 95 137 describe('yearly recurrence', () => {
+25 -2
tests/server/api-v1.test.ts
··· 46 46 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' 47 47 ); 48 48 const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL'); 49 + const countByTypeStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL AND type = ?'); 49 50 const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?'); 50 51 51 52 const resolveStmtCache = new Map<number, ReturnType<typeof db.prepare>>(); ··· 77 78 const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 78 79 const offset = Math.max(parseInt(off as string) || 0, 0); 79 80 80 - const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number])) 81 + const isTypeFilter = type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]); 82 + const rows = isTypeFilter 81 83 ? listByTypeStmt.all(type, limit, offset) 82 84 : listAllStmt.all(limit, offset); 83 85 84 - const total = (countStmt.get() as { count: number }).count; 86 + const total = isTypeFilter 87 + ? (countByTypeStmt.get(type) as { count: number }).count 88 + : (countStmt.get() as { count: number }).count; 85 89 res.json({ data: rows, total, limit, offset }); 86 90 }); 87 91 ··· 221 225 const res = await fetch(`${baseUrl}/api/v1/documents`); 222 226 const body = await res.json() as { data: Array<{ id: string }> }; 223 227 expect(body.data.some(d => d.id === deletedId)).toBe(false); 228 + }); 229 + 230 + it('returns type-filtered total count when type param is provided', async () => { 231 + // Seed known quantities of each type 232 + const calId1 = await seedDoc('calendar'); 233 + const calId2 = await seedDoc('calendar'); 234 + const docId = await seedDoc('doc'); 235 + 236 + // Fetch only calendars 237 + const calRes = await fetch(`${baseUrl}/api/v1/documents?type=calendar`); 238 + const calBody = await calRes.json() as { data: Array<{ id: string; type: string }>; total: number }; 239 + // total should count only calendars, not all documents 240 + expect(calBody.total).toBe(calBody.data.length); 241 + expect(calBody.data.every(d => d.type === 'calendar')).toBe(true); 242 + 243 + // Fetch all documents — total should be larger 244 + const allRes = await fetch(`${baseUrl}/api/v1/documents`); 245 + const allBody = await allRes.json() as { total: number }; 246 + expect(allBody.total).toBeGreaterThan(calBody.total); 224 247 }); 225 248 }); 226 249