@gotcos/glasses-server 6.46.0 → 6.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -1,23 +1,23 @@
1
1
  import {
2
2
  chmodSync,
3
- closeSync,
4
- constants,
5
3
  existsSync,
6
- fstatSync,
7
4
  lstatSync,
8
5
  mkdirSync,
9
- openSync,
10
- readFileSync,
11
- readSync,
12
6
  readdirSync,
13
- realpathSync,
14
7
  unlinkSync,
15
8
  } from 'node:fs'
16
9
  import { createHash } from 'node:crypto'
17
- import { basename, dirname, join, resolve, sep } from 'node:path'
10
+ import { basename, join, resolve } from 'node:path'
18
11
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
19
12
  import { dataPath } from './data-dir.js'
20
- import { FALLBACK_DOMAIN, domainAbbreviation, isSafeDomainName } from './domains.js'
13
+ import { FALLBACK_DOMAIN, isSafeDomainName } from './domains.js'
14
+ import {
15
+ existingRootRealpath,
16
+ safeDirectoryRealpath,
17
+ safeReadFile,
18
+ safeReadFileHead,
19
+ } from './meeting-file-guards.js'
20
+ import { parseField, parseMeeting, toMeta } from './meeting-parse.js'
21
21
  import type {
22
22
  ProviderCandidateRecord,
23
23
  IndexedTranscriptChunk,
@@ -28,6 +28,9 @@ import type {
28
28
  const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
29
29
  const DAY_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
30
30
  const DAY_FILE_PREFIX = /^(\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))_/
31
+
32
+ /** Enough to clear a scribe's metadata table whatever order its rows are in. */
33
+ const DOMAIN_HEAD_BYTES = 4096
31
34
  const LIST_CAP = 50
32
35
  const LIST_CAP_SCOPED = 200
33
36
 
@@ -51,9 +54,19 @@ export function meetingDayCountsFromNames(names: string[]): Array<{ date: string
51
54
 
52
55
  const SAFE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.md$/
53
56
  const DOMAIN_PATTERN = /^[a-z][a-z0-9_]{0,31}$/
54
- const MAX_MEETING_BYTES = 10 * 1024 * 1024
55
- const DETAIL_CHUNK_ESTIMATE_CHARS = 1_700
56
- export const MEETING_SOURCE_MAX_BYTES = 100_000
57
+
58
+ // The parser and the filesystem guards moved to their own modules in 6.47.0 so
59
+ // the imported library can share them rather than grow a second copy. They are
60
+ // re-exported here because every existing caller and test imports them from
61
+ // this module, and a shared helper is not a reason to move anyone's import.
62
+ export {
63
+ MEETING_SOURCE_MAX_BYTES,
64
+ boundedMeetingSource,
65
+ parseField,
66
+ parseMeeting,
67
+ toMeta,
68
+ } from './meeting-parse.js'
69
+
57
70
 
58
71
  export class MeetingStoreError extends Error {
59
72
  constructor(
@@ -88,10 +101,39 @@ export interface MeetingMeta {
88
101
  decisionCount?: number
89
102
  actionCount?: number
90
103
  attendeeCount?: number
91
- /** Additive archive identity. Older companions ignore these fields. */
92
- librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings'
104
+ /** Additive archive identity. Older companions ignore these fields.
105
+ *
106
+ * `imported` is a meeting this Mac never recorded (Fireflies, 6.47.0) and
107
+ * `blended` is a record DERIVED from other records — a merge of one meeting's
108
+ * Fireflies transcript with its G2 captures, or one piece of a split long
109
+ * recording. Both are read-only: their content is a function of their inputs,
110
+ * so editing one would be overwritten by the next re-derive. */
111
+ librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings' | 'imported' | 'blended'
93
112
  recordId?: string
94
113
  mutable?: boolean
114
+ /** The vendor's own id for an imported meeting (the Fireflies transcript id).
115
+ * Carried so no client has to re-derive `imported:fireflies:<h16>` from a hash
116
+ * rule written in a comment. Absent on every non-imported row. */
117
+ vendorId?: string
118
+ /** Where the meeting actually came from, when `domain` is a routing value
119
+ * rather than the meeting's own domain. Every imported row carries
120
+ * `domain: 'imported'`, so without this a Quilt call and a personal one are
121
+ * indistinguishable on the row. */
122
+ originDomain?: string
123
+ /** How a `blended` record was derived. Absent on every other source. */
124
+ derivedKind?: 'merge' | 'split'
125
+ /** The action that produced a derived record, so Control can offer its Undo. */
126
+ actionId?: string
127
+ /** Every G2 capture a merged record holds, in start order. `sessionId` carries
128
+ * the earliest of them so session-keyed surfaces keep working. */
129
+ g2SessionIds?: string[]
130
+ /** A split piece carries no `sessionId` of its own — it is a span of a longer
131
+ * recording, not a capture — but names the capture whose content defined it. */
132
+ sourceSessionId?: string
133
+ /** 0-based position of a split piece inside its source recording. */
134
+ pieceIndex?: number
135
+ /** Inputs of a derived record, for the detail view's source links. */
136
+ sources?: Array<{ kind: 'g2' | 'fireflies'; id: string; recordId?: string }>
95
137
  /** Present only when the server can state a truthful local record. */
96
138
  canonicalRecord?: string
97
139
  /** Additive. Unique sidecar speakers + whether a human correction landed. */
@@ -241,180 +283,9 @@ function canonicalProvider(chunks: TranscriptChunk[]): 'server-whisper' | 'iphon
241
283
  return 'mixed'
242
284
  }
243
285
 
244
- function parseField(content: string, field: string): string {
245
- const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
246
- const table = content.match(new RegExp(`\\*\\*${escaped}\\*\\*\\s*\\|\\s*(.+)`, 'i'))
247
- if (table) return table[1].replace(/\s*\|?\s*$/, '').trim()
248
- const plain = content.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'))
249
- return plain ? plain[1].trim() : ''
250
- }
251
-
252
- function extractSection(content: string, headings: string[], toEnd = false): string {
253
- for (const heading of headings) {
254
- const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
255
- const pattern = toEnd
256
- ? new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*)$`, 'i')
257
- : new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
258
- const match = content.match(pattern)
259
- if (match) return match[1].trim()
260
- }
261
- return ''
262
- }
263
-
264
- function parseListSection(content: string, headings: string[], limit: number): string[] {
265
- const section = extractSection(content, headings)
266
- if (!section) return []
267
- return section
268
- .split('\n')
269
- .filter(line => /^\s*[-*]\s+/.test(line))
270
- .map(line => line.replace(/^\s*[-*]\s+/, '').trim())
271
- .filter(Boolean)
272
- .slice(0, limit)
273
- }
274
-
275
- function parseActions(content: string): MeetingActionItem[] {
276
- const section = extractSection(content, ['Action Items', 'Tasks', 'Next Steps'])
277
- if (!section) return []
278
- return section
279
- .split('\n')
280
- .filter(line => /^\s*(?:[-*]|\[[ xX]\])\s+/.test(line))
281
- .map(line => {
282
- const cleaned = line
283
- .replace(/^\s*[-*]\s+/, '')
284
- .replace(/^\[[ xX]\]\s*/, '')
285
- .replace(/`\[REVIEW\]`\s*/i, '')
286
- .trim()
287
- const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
288
- return {
289
- task: ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned,
290
- owner: ownerMatch ? ownerMatch[1] : '',
291
- }
292
- })
293
- .filter(item => item.task.length > 0)
294
- .slice(0, 15)
295
- }
296
-
297
- function parseAttendees(content: string): string[] {
298
- return parseListSection(content, ['Attendees'], 20).map(line => {
299
- const match = line.match(/^\*\*(.+?)\*\*/) || line.match(/^([^(]+)/)
300
- return match ? match[1].trim() : line
301
- })
302
- }
303
-
304
- function parseDurationMinutes(duration: string): number | undefined {
305
- if (!duration) return undefined
306
- const value = duration.toLowerCase()
307
- const colon = value.match(/\b(\d+):(\d{2})\b/)
308
- if (colon) return Number(colon[1]) * 60 + Number(colon[2])
309
- let total = 0
310
- const hours = value.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
311
- if (hours) total += Math.round(Number(hours[1]) * 60)
312
- const minutes = value.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
313
- if (minutes) total += Number(minutes[1])
314
- return total > 0 ? total : undefined
315
- }
316
-
317
- function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
318
- const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
319
- const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
320
- const domain = parseField(content, 'Domain') || FALLBACK_DOMAIN
321
- const duration = parseField(content, 'Duration')
322
- const transcript = extractSection(content, ['Transcript'], true)
323
- const storedSummary = extractSection(content, ['Summary'])
324
- // A placeholder is not a summary. Until 6.37 this returned the TRANSCRIPT as
325
- // the summary, which put the transcript in the summary slot on every surface
326
- // and read as "the summary is broken" — the bug this replaces. The transcript
327
- // is returned in its own `transcript` field and each surface renders it in
328
- // its own section. An empty summary is the honest state, and every consumer
329
- // already handles it: display-pages.ts falls back to 'No summary available.',
330
- // and COS Control's Copy-summary button correctly disables on empty.
331
- const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
332
- ? ''
333
- : storedSummary
334
- const topics = parseListSection(content, ['Topics Discussed'], 10)
335
- const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
336
- const actionItems = parseActions(content)
337
- const attendees = parseAttendees(content)
338
- const source = boundedMeetingSource(content)
339
-
340
- return {
341
- filename,
342
- title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
343
- date,
344
- domain,
345
- // Shared derivation. This was slice(0,2), which rendered sprocket_rocket as
346
- // "SP" while cos-operations-meetings rendered it "SR" — two schemes in one
347
- // codebase, disagreeing with each other.
348
- domainAbbr: domainAbbreviation(domain),
349
- source: parseField(content, 'Source'),
350
- duration,
351
- ...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
352
- month,
353
- summary,
354
- topics,
355
- decisions,
356
- actionItems,
357
- attendees,
358
- transcript,
359
- ...source,
360
- }
361
- }
362
-
363
- /** Return enough canonical source for grounded meeting follow-ups without
364
- * allowing an unexpectedly large archive record to inflate every response.
365
- * The boundary backs up over UTF-8 continuation bytes so the prefix never
366
- * ends with a replacement character. */
367
- export function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
368
- const bytes = Buffer.from(content, 'utf8')
369
- if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
370
- let end = MEETING_SOURCE_MAX_BYTES
371
- while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1
372
- return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
373
- }
374
-
375
- function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
376
- // Must mirror what the reader actually renders (display-pages.ts
377
- // formatMeetingDetailBody), or the list row advertises a page count the
378
- // reader does not honour. The transcript is part of that body since 6.37;
379
- // omitting it here reported every standalone meeting as ~1p.
380
- const detailCharEstimate = [
381
- detail.title,
382
- detail.date,
383
- detail.duration || detail.source,
384
- detail.summary,
385
- detail.topics.join('\n'),
386
- detail.decisions.join('\n'),
387
- detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
388
- detail.attendees.join(', '),
389
- detail.transcript,
390
- ].join('\n\n').trim().length
391
- return {
392
- filename: detail.filename,
393
- ...(sessionId ? { sessionId } : {}),
394
- title: detail.title,
395
- date: detail.date,
396
- domain: detail.domain,
397
- domainAbbr: detail.domainAbbr,
398
- source: detail.source,
399
- duration: detail.duration,
400
- ...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
401
- month: detail.month,
402
- detailCharEstimate,
403
- estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
404
- topicCount: detail.topics.length,
405
- decisionCount: detail.decisions.length,
406
- actionCount: detail.actionItems.length,
407
- attendeeCount: detail.attendees.length,
408
- }
409
- }
410
-
411
286
  /** Enough to clear the sidecar's leading metadata keys whatever their order. */
412
287
  const SIDECAR_HEAD_BYTES = 4096
413
288
 
414
- function isContained(parent: string, child: string): boolean {
415
- return child === parent || child.startsWith(`${parent}${sep}`)
416
- }
417
-
418
289
  export class MeetingStore {
419
290
  readonly root: string
420
291
 
@@ -548,24 +419,24 @@ export class MeetingStore {
548
419
  /** Durable idempotency lookup for a client retry after its save response was lost. */
549
420
  findBySessionId(rawSessionId: string): SavedMeeting | null {
550
421
  const sessionId = normalizeSessionId(rawSessionId)
551
- const rootReal = this.existingRootRealpath()
422
+ const rootReal = this.rootRealpath()
552
423
  if (!rootReal) return null
553
424
  for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
554
425
  const monthDir = join(this.root, month)
555
- const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
426
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
556
427
  if (!monthReal) continue
557
428
  const sidecars = readdirSync(monthDir)
558
429
  .filter(name => /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.g2-chunks\.json$/.test(name))
559
430
  .sort()
560
431
  .reverse()
561
432
  for (const sidecarName of sidecars) {
562
- const sidecarText = this.safeReadFile(monthDir, monthReal, sidecarName)
433
+ const sidecarText = safeReadFile(monthDir, monthReal, sidecarName)
563
434
  if (sidecarText === null) continue
564
435
  try {
565
436
  const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
566
437
  if (sidecar.sessionId !== sessionId) continue
567
438
  const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
568
- const markdown = this.safeReadMeeting(monthDir, monthReal, filename)
439
+ const markdown = safeReadFile(monthDir, monthReal, filename)
569
440
  if (markdown === null) continue
570
441
  const detail = parseMeeting(markdown, filename, month)
571
442
  const durationMs = typeof sidecar.durationMs === 'number' && Number.isFinite(sidecar.durationMs)
@@ -602,18 +473,18 @@ export class MeetingStore {
602
473
  if (options.day && !DAY_PATTERN.test(options.day)) {
603
474
  throw new MeetingStoreError('Invalid day filter', 400, 'invalid_day')
604
475
  }
605
- const rootReal = this.existingRootRealpath()
476
+ const rootReal = this.rootRealpath()
606
477
  if (!rootReal) return []
607
478
  const meetings: MeetingMeta[] = []
608
479
 
609
480
  for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
610
481
  if (options.month && month !== options.month) continue
611
482
  const monthDir = join(this.root, month)
612
- const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
483
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
613
484
  if (!monthReal) continue
614
485
  for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name)).sort().reverse()) {
615
486
  try {
616
- const content = this.safeReadMeeting(monthDir, monthReal, filename)
487
+ const content = safeReadFile(monthDir, monthReal, filename)
617
488
  if (content === null) continue
618
489
  const detail = parseMeeting(content, filename, month)
619
490
  if (domain !== 'all' && detail.domain !== domain) continue
@@ -632,21 +503,52 @@ export class MeetingStore {
632
503
 
633
504
  /** Folder names only. Used by the Control calendar pager. */
634
505
  listMonths(): string[] {
635
- const rootReal = this.existingRootRealpath()
506
+ const rootReal = this.rootRealpath()
636
507
  if (!rootReal) return []
637
508
  return readdirSync(this.root)
638
- .filter(name => MONTH_PATTERN.test(name) && this.safeDirectoryRealpath(join(this.root, name), rootReal))
509
+ .filter(name => MONTH_PATTERN.test(name) && safeDirectoryRealpath(join(this.root, name), rootReal))
639
510
  .sort()
640
511
  .reverse()
641
512
  }
642
513
 
643
- listDayCounts(month: string): Array<{ date: string; count: number }> {
514
+ /**
515
+ * How many recordings this store holds per day in one month.
516
+ *
517
+ * WITHOUT A DOMAIN this is a FILENAME scan: uncapped, opens nothing, and therefore able to
518
+ * describe a whole month the 50-row list cannot. That property is why the calendar uses it.
519
+ *
520
+ * WITH A DOMAIN it has to read, because a recording's domain is inside the file and not in
521
+ * its name. A count that ignored the filter answered "3 meetings" for a day whose filtered
522
+ * list showed one, which is the 6.46.x upgrade bug: on a Mac with zero imports the
523
+ * domain-filtered calendar counted every recording in the store, whatever domain it was.
524
+ */
525
+ listDayCounts(month: string, domain = 'all'): Array<{ date: string; count: number }> {
644
526
  if (!MONTH_PATTERN.test(month)) return []
645
- const rootReal = this.existingRootRealpath()
527
+ const rootReal = this.rootRealpath()
646
528
  if (!rootReal) return []
647
529
  const monthDir = join(this.root, month)
648
- const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
530
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
649
531
  if (!monthReal) return []
532
+ if (domain !== 'all') {
533
+ if (!isSafeDomainName(domain)) return []
534
+ const counts = new Map<string, number>()
535
+ // Deliberately NOT `list()`: that caps at 200 rows for a scoped query, and a day count
536
+ // that silently stops counting is worse than one that costs a read. One month, heads
537
+ // only, through the same safe reader the list uses.
538
+ for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name))) {
539
+ try {
540
+ const content = safeReadFileHead(monthDir, monthReal, filename, DOMAIN_HEAD_BYTES)
541
+ if (content === null) continue
542
+ if (parseField(content, 'Domain') !== domain) continue
543
+ const date = filename.match(DAY_FILE_PREFIX)?.[1]
544
+ if (!date) continue
545
+ counts.set(date, (counts.get(date) ?? 0) + 1)
546
+ } catch {
547
+ // One unreadable entry must not hide the rest of the month.
548
+ }
549
+ }
550
+ return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([date, count]) => ({ date, count }))
551
+ }
650
552
  return meetingDayCountsFromNames(
651
553
  readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name) || name.endsWith('.md')),
652
554
  )
@@ -663,71 +565,23 @@ export class MeetingStore {
663
565
  throw new MeetingStoreError('Invalid filename', 400, 'invalid_filename')
664
566
  }
665
567
 
666
- const rootReal = this.existingRootRealpath()
568
+ const rootReal = this.rootRealpath()
667
569
  if (!rootReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
668
570
  const monthDir = join(this.root, month)
669
- const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
571
+ const monthReal = safeDirectoryRealpath(monthDir, rootReal)
670
572
  if (!monthReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
671
- const content = this.safeReadMeeting(monthDir, monthReal, filename)
573
+ const content = safeReadFile(monthDir, monthReal, filename)
672
574
  if (content === null) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
673
575
  const detail = parseMeeting(content, filename, month)
674
576
  if (detail.domain !== domain) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
675
577
  return detail
676
578
  }
677
579
 
678
- private existingRootRealpath(): string | null {
679
- if (!existsSync(this.root)) return null
680
- const stat = lstatSync(this.root)
681
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
682
- throw new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store')
683
- }
684
- return realpathSync(this.root)
685
- }
686
-
687
- private safeDirectoryRealpath(path: string, parentReal: string): string | null {
688
- try {
689
- const stat = lstatSync(path)
690
- if (stat.isSymbolicLink() || !stat.isDirectory()) return null
691
- const real = realpathSync(path)
692
- return isContained(parentReal, real) && dirname(real) === parentReal ? real : null
693
- } catch {
694
- return null
695
- }
696
- }
697
-
698
- private safeReadMeeting(monthDir: string, monthReal: string, filename: string): string | null {
699
- return this.safeReadFile(monthDir, monthReal, filename)
700
- }
701
-
702
- /** Read only the first `bytes` of a file, with the same symlink,
703
- * containment, and O_NOFOLLOW guards as safeReadFile.
704
- *
705
- * Exists so `list()` can lift one field out of a chunk sidecar without
706
- * reading it whole: sidecars run to megabytes (1.3 MB for a 32-minute
707
- * meeting) and would also trip safeReadFile's MAX_MEETING_BYTES cap, which
708
- * is sized for markdown. */
709
- private safeReadFileHead(monthDir: string, monthReal: string, filename: string, bytes: number): string | null {
710
- const filepath = join(monthDir, filename)
711
- let fd: number | null = null
712
- try {
713
- const linkStat = lstatSync(filepath)
714
- if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
715
- const real = realpathSync(filepath)
716
- if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
717
- fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
718
- const stat = fstatSync(fd)
719
- if (!stat.isFile()) return null
720
- const buffer = Buffer.alloc(Math.min(bytes, stat.size))
721
- if (buffer.length === 0) return ''
722
- const read = readSync(fd, buffer, 0, buffer.length, 0)
723
- return buffer.subarray(0, read).toString('utf8')
724
- } catch {
725
- return null
726
- } finally {
727
- if (fd !== null) {
728
- try { closeSync(fd) } catch { /* already closed */ }
729
- }
730
- }
580
+ private rootRealpath(): string | null {
581
+ return existingRootRealpath(
582
+ this.root,
583
+ () => new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store'),
584
+ )
731
585
  }
732
586
 
733
587
  /** The sessionId recorded in a meeting's chunk sidecar, if it has one.
@@ -738,32 +592,11 @@ export class MeetingStore {
738
592
  private sidecarSessionId(monthDir: string, monthReal: string, meetingFilename: string): string | undefined {
739
593
  const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
740
594
  if (sidecarName === meetingFilename) return undefined
741
- const head = this.safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES)
595
+ const head = safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES)
742
596
  if (!head) return undefined
743
597
  const match = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
744
598
  return match ? match[1] : undefined
745
599
  }
746
-
747
- private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
748
- const filepath = join(monthDir, filename)
749
- let fd: number | null = null
750
- try {
751
- const linkStat = lstatSync(filepath)
752
- if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
753
- const real = realpathSync(filepath)
754
- if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
755
- fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
756
- const stat = fstatSync(fd)
757
- if (!stat.isFile() || stat.size > MAX_MEETING_BYTES) return null
758
- return readFileSync(fd, 'utf8')
759
- } catch {
760
- return null
761
- } finally {
762
- if (fd !== null) {
763
- try { closeSync(fd) } catch { /* already closed */ }
764
- }
765
- }
766
- }
767
600
  }
768
601
 
769
602
  let defaultMeetingStore: MeetingStore | null = null