@gotcos/glasses-server 6.6.0 → 6.8.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.
@@ -0,0 +1,594 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ constants,
5
+ existsSync,
6
+ fstatSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ readdirSync,
12
+ realpathSync,
13
+ unlinkSync,
14
+ } from 'node:fs'
15
+ import { createHash } from 'node:crypto'
16
+ import { basename, dirname, join, resolve, sep } from 'node:path'
17
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
18
+ import { dataPath } from './data-dir.js'
19
+ import type {
20
+ ProviderCandidateRecord,
21
+ IndexedTranscriptChunk,
22
+ TranscriptChunk,
23
+ TranscriptGapReport,
24
+ } from '../routes/transcribe-stream.js'
25
+
26
+ const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
27
+ const SAFE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.md$/
28
+ const DOMAIN_PATTERN = /^[a-z][a-z0-9_]{0,31}$/
29
+ const MAX_MEETING_BYTES = 10 * 1024 * 1024
30
+ const DETAIL_CHUNK_ESTIMATE_CHARS = 1_700
31
+
32
+ export class MeetingStoreError extends Error {
33
+ constructor(
34
+ message: string,
35
+ readonly status: number,
36
+ readonly code: string,
37
+ ) {
38
+ super(message)
39
+ this.name = 'MeetingStoreError'
40
+ }
41
+ }
42
+
43
+ export interface MeetingMeta {
44
+ filename: string
45
+ title: string
46
+ date: string
47
+ domain: string
48
+ domainAbbr: string
49
+ source: string
50
+ duration: string
51
+ durationMinutes?: number
52
+ month: string
53
+ estimatedDetailPages?: number
54
+ detailCharEstimate?: number
55
+ topicCount?: number
56
+ decisionCount?: number
57
+ actionCount?: number
58
+ attendeeCount?: number
59
+ }
60
+
61
+ export interface MeetingActionItem {
62
+ task: string
63
+ owner: string
64
+ }
65
+
66
+ export interface MeetingDetail extends MeetingMeta {
67
+ summary: string
68
+ topics: string[]
69
+ decisions: string[]
70
+ actionItems: MeetingActionItem[]
71
+ attendees: string[]
72
+ /** Additive field for API consumers that want the exact canonical record. */
73
+ transcript: string
74
+ }
75
+
76
+ export interface SaveMeetingInput {
77
+ sessionId: string
78
+ title?: string
79
+ domain?: string
80
+ transcript: string
81
+ startTime: number
82
+ durationMs: number
83
+ chunks: TranscriptChunk[]
84
+ chunkEntries?: IndexedTranscriptChunk[]
85
+ providerCandidates?: Record<string, ProviderCandidateRecord>
86
+ transferIntegrity?: TranscriptGapReport | null
87
+ }
88
+
89
+ export interface SavedMeeting {
90
+ filepath: string
91
+ sidecarPath: string
92
+ filename: string
93
+ month: string
94
+ title: string
95
+ domain: string
96
+ durationMin: number
97
+ transferIntegrity?: TranscriptGapReport | null
98
+ }
99
+
100
+ function ensurePrivateDirectory(path: string): void {
101
+ mkdirSync(path, { recursive: true, mode: 0o700 })
102
+ const stat = lstatSync(path)
103
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
104
+ throw new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store')
105
+ }
106
+ chmodSync(path, 0o700)
107
+ }
108
+
109
+ function normalizeSessionId(value: string): string {
110
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(value)) {
111
+ throw new MeetingStoreError('Invalid sessionId', 400, 'invalid_session_id')
112
+ }
113
+ return value
114
+ }
115
+
116
+ function normalizeDomain(value?: string): string {
117
+ const domain = (value || 'personal').trim().toLowerCase()
118
+ if (!DOMAIN_PATTERN.test(domain)) {
119
+ throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
120
+ }
121
+ return domain
122
+ }
123
+
124
+ function normalizeTitle(value: string | undefined, fallback: string): string {
125
+ const normalized = (value || fallback)
126
+ .normalize('NFKC')
127
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
128
+ .replace(/[\\/]+/g, ' ')
129
+ .replace(/\s+/g, ' ')
130
+ .trim()
131
+ .slice(0, 160)
132
+ return normalized || fallback
133
+ }
134
+
135
+ function filenameStem(title: string, fallback: string): string {
136
+ const safe = title
137
+ .normalize('NFKD')
138
+ .replace(/[\u0300-\u036f]/g, '')
139
+ .replace(/[^A-Za-z0-9_-]+/g, '_')
140
+ .replace(/^[_-]+|[_-]+$/g, '')
141
+ .replace(/_{2,}/g, '_')
142
+ .slice(0, 72)
143
+ return safe || fallback
144
+ }
145
+
146
+ function localParts(timestamp: number): {
147
+ date: string
148
+ month: string
149
+ time: string
150
+ hourMinute: string
151
+ } {
152
+ const date = new Date(timestamp)
153
+ if (!Number.isFinite(timestamp) || Number.isNaN(date.getTime())) {
154
+ throw new MeetingStoreError('Invalid meeting start time', 400, 'invalid_start_time')
155
+ }
156
+ const yyyy = String(date.getFullYear())
157
+ const mm = String(date.getMonth() + 1).padStart(2, '0')
158
+ const dd = String(date.getDate()).padStart(2, '0')
159
+ const hh = String(date.getHours()).padStart(2, '0')
160
+ const minute = String(date.getMinutes()).padStart(2, '0')
161
+ return {
162
+ date: `${yyyy}-${mm}-${dd}`,
163
+ month: `${yyyy}-${mm}`,
164
+ hourMinute: `${hh}${minute}`,
165
+ time: date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }),
166
+ }
167
+ }
168
+
169
+ function escapeTableValue(value: string): string {
170
+ return value.replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ')
171
+ }
172
+
173
+ function wordCount(text: string): number {
174
+ return text.trim().split(/\s+/).filter(Boolean).length
175
+ }
176
+
177
+ function canonicalProvider(chunks: TranscriptChunk[]): 'server-whisper' | 'iphone-whisperkit-beta' | 'mixed' {
178
+ const providers = new Set(chunks.map(chunk => chunk.asrProvider || 'server-whisper'))
179
+ if (providers.size === 0) return 'server-whisper'
180
+ if (providers.size === 1) return [...providers][0] as 'server-whisper' | 'iphone-whisperkit-beta'
181
+ return 'mixed'
182
+ }
183
+
184
+ function parseField(content: string, field: string): string {
185
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
186
+ const table = content.match(new RegExp(`\\*\\*${escaped}\\*\\*\\s*\\|\\s*(.+)`, 'i'))
187
+ if (table) return table[1].replace(/\s*\|?\s*$/, '').trim()
188
+ const plain = content.match(new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i'))
189
+ return plain ? plain[1].trim() : ''
190
+ }
191
+
192
+ function extractSection(content: string, headings: string[], toEnd = false): string {
193
+ for (const heading of headings) {
194
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
195
+ const pattern = toEnd
196
+ ? new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*)$`, 'i')
197
+ : new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i')
198
+ const match = content.match(pattern)
199
+ if (match) return match[1].trim()
200
+ }
201
+ return ''
202
+ }
203
+
204
+ function parseListSection(content: string, headings: string[], limit: number): string[] {
205
+ const section = extractSection(content, headings)
206
+ if (!section) return []
207
+ return section
208
+ .split('\n')
209
+ .filter(line => /^\s*[-*]\s+/.test(line))
210
+ .map(line => line.replace(/^\s*[-*]\s+/, '').trim())
211
+ .filter(Boolean)
212
+ .slice(0, limit)
213
+ }
214
+
215
+ function parseActions(content: string): MeetingActionItem[] {
216
+ const section = extractSection(content, ['Action Items', 'Tasks', 'Next Steps'])
217
+ if (!section) return []
218
+ return section
219
+ .split('\n')
220
+ .filter(line => /^\s*(?:[-*]|\[[ xX]\])\s+/.test(line))
221
+ .map(line => {
222
+ const cleaned = line
223
+ .replace(/^\s*[-*]\s+/, '')
224
+ .replace(/^\[[ xX]\]\s*/, '')
225
+ .replace(/`\[REVIEW\]`\s*/i, '')
226
+ .trim()
227
+ const ownerMatch = cleaned.match(/\(\*\*(.+?)\*\*\)\s*$/)
228
+ return {
229
+ task: ownerMatch ? cleaned.replace(ownerMatch[0], '').trim() : cleaned,
230
+ owner: ownerMatch ? ownerMatch[1] : '',
231
+ }
232
+ })
233
+ .filter(item => item.task.length > 0)
234
+ .slice(0, 15)
235
+ }
236
+
237
+ function parseAttendees(content: string): string[] {
238
+ return parseListSection(content, ['Attendees'], 20).map(line => {
239
+ const match = line.match(/^\*\*(.+?)\*\*/) || line.match(/^([^(]+)/)
240
+ return match ? match[1].trim() : line
241
+ })
242
+ }
243
+
244
+ function parseDurationMinutes(duration: string): number | undefined {
245
+ if (!duration) return undefined
246
+ const value = duration.toLowerCase()
247
+ const colon = value.match(/\b(\d+):(\d{2})\b/)
248
+ if (colon) return Number(colon[1]) * 60 + Number(colon[2])
249
+ let total = 0
250
+ const hours = value.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/)
251
+ if (hours) total += Math.round(Number(hours[1]) * 60)
252
+ const minutes = value.match(/(\d+)\s*(?:m|min|mins|minute|minutes)\b/)
253
+ if (minutes) total += Number(minutes[1])
254
+ return total > 0 ? total : undefined
255
+ }
256
+
257
+ function parseMeeting(content: string, filename: string, month: string): MeetingDetail {
258
+ const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
259
+ const date = parseField(content, 'Date') || filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] || 'unknown'
260
+ const domain = parseField(content, 'Domain') || 'personal'
261
+ const duration = parseField(content, 'Duration')
262
+ const transcript = extractSection(content, ['Transcript'], true)
263
+ const storedSummary = extractSection(content, ['Summary'])
264
+ // Standalone recordings have no private enrichment pipeline. Returning the
265
+ // canonical transcript as the detail summary lets the build199 reader review
266
+ // the saved meeting instead of displaying only a placeholder.
267
+ const summary = !storedSummary || /standalone recording|summary unavailable/i.test(storedSummary)
268
+ ? transcript
269
+ : storedSummary
270
+ const topics = parseListSection(content, ['Topics Discussed'], 10)
271
+ const decisions = parseListSection(content, ['Decisions', 'Decisions Made'], 10)
272
+ const actionItems = parseActions(content)
273
+ const attendees = parseAttendees(content)
274
+
275
+ return {
276
+ filename,
277
+ title: heading || basename(filename, '.md').split('_').slice(1).join(' ') || 'Untitled Meeting',
278
+ date,
279
+ domain,
280
+ domainAbbr: domain === 'personal' ? 'P' : domain.slice(0, 2).toUpperCase() || '?',
281
+ source: parseField(content, 'Source'),
282
+ duration,
283
+ ...(parseDurationMinutes(duration) !== undefined ? { durationMinutes: parseDurationMinutes(duration) } : {}),
284
+ month,
285
+ summary,
286
+ topics,
287
+ decisions,
288
+ actionItems,
289
+ attendees,
290
+ transcript,
291
+ }
292
+ }
293
+
294
+ function toMeta(detail: MeetingDetail): MeetingMeta {
295
+ const detailCharEstimate = [
296
+ detail.title,
297
+ detail.date,
298
+ detail.duration || detail.source,
299
+ detail.summary,
300
+ detail.topics.join('\n'),
301
+ detail.decisions.join('\n'),
302
+ detail.actionItems.map(item => `${item.owner ? `[${item.owner}] ` : ''}${item.task}`).join('\n'),
303
+ detail.attendees.join(', '),
304
+ ].join('\n\n').trim().length
305
+ return {
306
+ filename: detail.filename,
307
+ title: detail.title,
308
+ date: detail.date,
309
+ domain: detail.domain,
310
+ domainAbbr: detail.domainAbbr,
311
+ source: detail.source,
312
+ duration: detail.duration,
313
+ ...(detail.durationMinutes !== undefined ? { durationMinutes: detail.durationMinutes } : {}),
314
+ month: detail.month,
315
+ detailCharEstimate,
316
+ estimatedDetailPages: Math.max(1, Math.ceil(detailCharEstimate / DETAIL_CHUNK_ESTIMATE_CHARS)),
317
+ topicCount: detail.topics.length,
318
+ decisionCount: detail.decisions.length,
319
+ actionCount: detail.actionItems.length,
320
+ attendeeCount: detail.attendees.length,
321
+ }
322
+ }
323
+
324
+ function isContained(parent: string, child: string): boolean {
325
+ return child === parent || child.startsWith(`${parent}${sep}`)
326
+ }
327
+
328
+ export class MeetingStore {
329
+ readonly root: string
330
+
331
+ constructor(root = dataPath('recordings')) {
332
+ this.root = resolve(root)
333
+ }
334
+
335
+ save(input: SaveMeetingInput): SavedMeeting {
336
+ const sessionId = normalizeSessionId(input.sessionId)
337
+ const domain = normalizeDomain(input.domain)
338
+ const transcript = input.transcript
339
+ .replace(/\u0000/g, '')
340
+ .replace(/\r\n?/g, '\n')
341
+ .trim()
342
+ if (!transcript) throw new MeetingStoreError('Transcript is empty', 400, 'empty_transcript')
343
+
344
+ const parts = localParts(input.startTime)
345
+ const fallbackTitle = `G2 Recording ${parts.date} ${parts.hourMinute}`
346
+ const title = normalizeTitle(input.title, fallbackTitle)
347
+ const stem = filenameStem(title, `G2_Recording_${parts.hourMinute}`)
348
+ const durationMs = Number.isFinite(input.durationMs) ? Math.max(0, input.durationMs) : 0
349
+ const durationMin = Math.round(durationMs / 60_000)
350
+
351
+ ensurePrivateDirectory(this.root)
352
+ const meetingDir = join(this.root, parts.month)
353
+ ensurePrivateDirectory(meetingDir)
354
+
355
+ const suffix = createHash('sha256').update(sessionId).digest('hex').slice(0, 8)
356
+ // Include the session-derived suffix up front. The process-wide server lock
357
+ // gives us one writer, and distinct sessions cannot choose the same target
358
+ // merely because their title/date match.
359
+ let filename = `${parts.date}_${stem}_${suffix}.md`
360
+ let filepath = join(meetingDir, filename)
361
+ if (existsSync(filepath) || existsSync(filepath.replace(/\.md$/, '.g2-chunks.json'))) {
362
+ let collision = 2
363
+ while (existsSync(filepath) || existsSync(filepath.replace(/\.md$/, '.g2-chunks.json'))) {
364
+ filename = `${parts.date}_${stem}_${suffix}_${collision}.md`
365
+ filepath = join(meetingDir, filename)
366
+ collision++
367
+ }
368
+ }
369
+ const sidecarPath = filepath.replace(/\.md$/, '.g2-chunks.json')
370
+
371
+ const missing = input.transferIntegrity?.missingIndices.length ?? 0
372
+ const completenessPct = input.transferIntegrity
373
+ ? Math.floor(input.transferIntegrity.completeness * 1_000) / 10
374
+ : 100
375
+ const integrityValue = missing > 0
376
+ ? `${completenessPct}% — ${missing} chunk${missing === 1 ? '' : 's'} not received`
377
+ : '100%'
378
+ const markdown = [
379
+ `# ${title}`,
380
+ '',
381
+ '| Field | Value |',
382
+ '|-------|-------|',
383
+ `| **Date** | ${parts.date} |`,
384
+ `| **Time** | ${parts.time} |`,
385
+ `| **Duration** | ${durationMin} minutes |`,
386
+ '| **Source** | G2 Glasses |',
387
+ `| **Domain** | ${escapeTableValue(domain)} |`,
388
+ `| **Transfer integrity** | ${integrityValue} |`,
389
+ '| **Transcription quality** | streaming |',
390
+ '',
391
+ '## Summary',
392
+ '',
393
+ '*Standalone recording — canonical transcript shown in meeting detail.*',
394
+ '',
395
+ '## Transcript',
396
+ '',
397
+ transcript,
398
+ '',
399
+ ].join('\n')
400
+
401
+ const providers = [...new Set(input.chunks.map(chunk => chunk.asrProvider || 'server-whisper'))]
402
+ const sidecar = {
403
+ schemaVersion: 2,
404
+ sessionId,
405
+ startTime: input.startTime,
406
+ durationMs,
407
+ domain,
408
+ title,
409
+ canonicalProvider: canonicalProvider(input.chunks),
410
+ providers,
411
+ providerCandidates: input.providerCandidates ?? {},
412
+ speakers: [...new Set(input.chunks.map(chunk => chunk.speaker).filter(speaker => speaker && speaker !== 'Ext'))],
413
+ chunks: input.chunks,
414
+ chunkEntries: input.chunkEntries ?? input.chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk })),
415
+ transferIntegrity: input.transferIntegrity ?? null,
416
+ transcriptionQuality: 'streaming',
417
+ batchApplied: false,
418
+ streamingWordCount: wordCount(transcript),
419
+ }
420
+
421
+ // Sidecar first, markdown second: the markdown is the visible commit marker.
422
+ // A crash can leave an orphan sidecar, but never a listed meeting whose
423
+ // canonical chunk metadata was not durably published.
424
+ durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
425
+ try {
426
+ durableAtomicWriteFileSync(filepath, markdown, { mode: 0o600 })
427
+ } catch (error) {
428
+ try { unlinkSync(sidecarPath) } catch { /* orphan stays hidden without markdown */ }
429
+ throw error
430
+ }
431
+
432
+ return {
433
+ filepath,
434
+ sidecarPath,
435
+ filename,
436
+ month: parts.month,
437
+ title,
438
+ domain,
439
+ durationMin,
440
+ transferIntegrity: input.transferIntegrity ?? null,
441
+ }
442
+ }
443
+
444
+ /** Durable idempotency lookup for a client retry after its save response was lost. */
445
+ findBySessionId(rawSessionId: string): SavedMeeting | null {
446
+ const sessionId = normalizeSessionId(rawSessionId)
447
+ const rootReal = this.existingRootRealpath()
448
+ if (!rootReal) return null
449
+ for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
450
+ const monthDir = join(this.root, month)
451
+ const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
452
+ if (!monthReal) continue
453
+ const sidecars = readdirSync(monthDir)
454
+ .filter(name => /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.g2-chunks\.json$/.test(name))
455
+ .sort()
456
+ .reverse()
457
+ for (const sidecarName of sidecars) {
458
+ const sidecarText = this.safeReadFile(monthDir, monthReal, sidecarName)
459
+ if (sidecarText === null) continue
460
+ try {
461
+ const sidecar = JSON.parse(sidecarText) as Record<string, unknown>
462
+ if (sidecar.sessionId !== sessionId) continue
463
+ const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
464
+ const markdown = this.safeReadMeeting(monthDir, monthReal, filename)
465
+ if (markdown === null) continue
466
+ const detail = parseMeeting(markdown, filename, month)
467
+ const durationMs = typeof sidecar.durationMs === 'number' && Number.isFinite(sidecar.durationMs)
468
+ ? Math.max(0, sidecar.durationMs)
469
+ : 0
470
+ return {
471
+ filepath: join(monthDir, filename),
472
+ sidecarPath: join(monthDir, sidecarName),
473
+ filename,
474
+ month,
475
+ title: detail.title,
476
+ domain: detail.domain,
477
+ durationMin: Math.round(durationMs / 60_000),
478
+ transferIntegrity: sidecar.transferIntegrity as TranscriptGapReport | null | undefined,
479
+ }
480
+ } catch {
481
+ // Malformed diagnostic metadata is not a valid idempotency record.
482
+ }
483
+ }
484
+ }
485
+ return null
486
+ }
487
+
488
+ list(options: { limit?: number; domain?: string } = {}): MeetingMeta[] {
489
+ const limit = Math.max(1, Math.min(50, Math.trunc(options.limit ?? 20)))
490
+ const domain = options.domain ?? 'all'
491
+ if (domain !== 'all' && !DOMAIN_PATTERN.test(domain)) {
492
+ throw new MeetingStoreError('Invalid domain filter', 400, 'invalid_domain')
493
+ }
494
+ const rootReal = this.existingRootRealpath()
495
+ if (!rootReal) return []
496
+ const meetings: MeetingMeta[] = []
497
+
498
+ for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
499
+ const monthDir = join(this.root, month)
500
+ const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
501
+ if (!monthReal) continue
502
+ for (const filename of readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name)).sort().reverse()) {
503
+ try {
504
+ const content = this.safeReadMeeting(monthDir, monthReal, filename)
505
+ if (content === null) continue
506
+ const detail = parseMeeting(content, filename, month)
507
+ if (domain !== 'all' && detail.domain !== domain) continue
508
+ meetings.push(toMeta(detail))
509
+ } catch {
510
+ // One unreadable/corrupt entry must not hide the rest of the store.
511
+ }
512
+ }
513
+ }
514
+ meetings.sort((left, right) => (
515
+ right.date.localeCompare(left.date) || right.filename.localeCompare(left.filename)
516
+ ))
517
+ return meetings.slice(0, limit)
518
+ }
519
+
520
+ detail(domain: string, month: string, filename: string): MeetingDetail {
521
+ if (!DOMAIN_PATTERN.test(domain)) {
522
+ throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
523
+ }
524
+ if (!MONTH_PATTERN.test(month)) {
525
+ throw new MeetingStoreError('Invalid month', 400, 'invalid_month')
526
+ }
527
+ if (!SAFE_FILENAME_PATTERN.test(filename) || basename(filename) !== filename) {
528
+ throw new MeetingStoreError('Invalid filename', 400, 'invalid_filename')
529
+ }
530
+
531
+ const rootReal = this.existingRootRealpath()
532
+ if (!rootReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
533
+ const monthDir = join(this.root, month)
534
+ const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
535
+ if (!monthReal) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
536
+ const content = this.safeReadMeeting(monthDir, monthReal, filename)
537
+ if (content === null) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
538
+ const detail = parseMeeting(content, filename, month)
539
+ if (detail.domain !== domain) throw new MeetingStoreError('Meeting not found', 404, 'meeting_not_found')
540
+ return detail
541
+ }
542
+
543
+ private existingRootRealpath(): string | null {
544
+ if (!existsSync(this.root)) return null
545
+ const stat = lstatSync(this.root)
546
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
547
+ throw new MeetingStoreError('Unsafe recordings directory', 500, 'unsafe_recordings_store')
548
+ }
549
+ return realpathSync(this.root)
550
+ }
551
+
552
+ private safeDirectoryRealpath(path: string, parentReal: string): string | null {
553
+ try {
554
+ const stat = lstatSync(path)
555
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return null
556
+ const real = realpathSync(path)
557
+ return isContained(parentReal, real) && dirname(real) === parentReal ? real : null
558
+ } catch {
559
+ return null
560
+ }
561
+ }
562
+
563
+ private safeReadMeeting(monthDir: string, monthReal: string, filename: string): string | null {
564
+ return this.safeReadFile(monthDir, monthReal, filename)
565
+ }
566
+
567
+ private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
568
+ const filepath = join(monthDir, filename)
569
+ let fd: number | null = null
570
+ try {
571
+ const linkStat = lstatSync(filepath)
572
+ if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
573
+ const real = realpathSync(filepath)
574
+ if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
575
+ fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
576
+ const stat = fstatSync(fd)
577
+ if (!stat.isFile() || stat.size > MAX_MEETING_BYTES) return null
578
+ return readFileSync(fd, 'utf8')
579
+ } catch {
580
+ return null
581
+ } finally {
582
+ if (fd !== null) {
583
+ try { closeSync(fd) } catch { /* already closed */ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ let defaultMeetingStore: MeetingStore | null = null
590
+
591
+ export function getMeetingStore(): MeetingStore {
592
+ defaultMeetingStore ??= new MeetingStore()
593
+ return defaultMeetingStore
594
+ }