memory-journal-mcp 4.3.0 → 4.4.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 (109) hide show
  1. package/.dockerignore +131 -122
  2. package/.gitattributes +29 -0
  3. package/.github/workflows/docker-publish.yml +1 -1
  4. package/.github/workflows/lint-and-test.yml +1 -2
  5. package/.github/workflows/secrets-scanning.yml +0 -1
  6. package/.github/workflows/security-update.yml +6 -6
  7. package/.vscode/settings.json +17 -15
  8. package/CHANGELOG.md +1065 -11
  9. package/DOCKER_README.md +51 -33
  10. package/Dockerfile +14 -12
  11. package/README.md +68 -33
  12. package/SECURITY.md +225 -220
  13. package/dist/cli.js +7 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/constants/ServerInstructions.d.ts +1 -1
  16. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  17. package/dist/constants/ServerInstructions.js +70 -26
  18. package/dist/constants/ServerInstructions.js.map +1 -1
  19. package/dist/constants/icons.d.ts +2 -0
  20. package/dist/constants/icons.d.ts.map +1 -1
  21. package/dist/constants/icons.js +6 -0
  22. package/dist/constants/icons.js.map +1 -1
  23. package/dist/database/SqliteAdapter.d.ts +51 -10
  24. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  25. package/dist/database/SqliteAdapter.js +143 -43
  26. package/dist/database/SqliteAdapter.js.map +1 -1
  27. package/dist/filtering/ToolFilter.d.ts +1 -1
  28. package/dist/filtering/ToolFilter.d.ts.map +1 -1
  29. package/dist/filtering/ToolFilter.js +7 -1
  30. package/dist/filtering/ToolFilter.js.map +1 -1
  31. package/dist/github/GitHubIntegration.d.ts +74 -2
  32. package/dist/github/GitHubIntegration.d.ts.map +1 -1
  33. package/dist/github/GitHubIntegration.js +508 -7
  34. package/dist/github/GitHubIntegration.js.map +1 -1
  35. package/dist/handlers/prompts/index.js +1 -0
  36. package/dist/handlers/prompts/index.js.map +1 -1
  37. package/dist/handlers/resources/index.d.ts.map +1 -1
  38. package/dist/handlers/resources/index.js +257 -13
  39. package/dist/handlers/resources/index.js.map +1 -1
  40. package/dist/handlers/tools/index.d.ts.map +1 -1
  41. package/dist/handlers/tools/index.js +595 -8
  42. package/dist/handlers/tools/index.js.map +1 -1
  43. package/dist/server/McpServer.d.ts +2 -0
  44. package/dist/server/McpServer.d.ts.map +1 -1
  45. package/dist/server/McpServer.js +69 -26
  46. package/dist/server/McpServer.js.map +1 -1
  47. package/dist/types/index.d.ts +97 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/utils/logger.d.ts +1 -0
  51. package/dist/utils/logger.d.ts.map +1 -1
  52. package/dist/utils/logger.js +8 -1
  53. package/dist/utils/logger.js.map +1 -1
  54. package/dist/utils/progress-utils.d.ts +18 -3
  55. package/dist/utils/progress-utils.d.ts.map +1 -1
  56. package/dist/utils/progress-utils.js.map +1 -1
  57. package/dist/utils/security-utils.d.ts +91 -0
  58. package/dist/utils/security-utils.d.ts.map +1 -0
  59. package/dist/utils/security-utils.js +184 -0
  60. package/dist/utils/security-utils.js.map +1 -0
  61. package/dist/vector/VectorSearchManager.d.ts +2 -1
  62. package/dist/vector/VectorSearchManager.d.ts.map +1 -1
  63. package/dist/vector/VectorSearchManager.js +100 -34
  64. package/dist/vector/VectorSearchManager.js.map +1 -1
  65. package/docker-compose.yml +46 -37
  66. package/mcp-config-example.json +0 -2
  67. package/package.json +21 -14
  68. package/releases/v4.3.1.md +69 -0
  69. package/releases/v4.4.0.md +120 -0
  70. package/server.json +3 -3
  71. package/src/cli.ts +11 -0
  72. package/src/constants/ServerInstructions.ts +70 -26
  73. package/src/constants/icons.ts +7 -0
  74. package/src/database/SqliteAdapter.ts +165 -44
  75. package/src/filtering/ToolFilter.ts +7 -1
  76. package/src/github/GitHubIntegration.ts +588 -8
  77. package/src/handlers/prompts/index.ts +1 -0
  78. package/src/handlers/resources/index.ts +318 -12
  79. package/src/handlers/tools/index.ts +686 -13
  80. package/src/server/McpServer.ts +79 -37
  81. package/src/types/index.ts +98 -0
  82. package/src/utils/logger.ts +10 -1
  83. package/src/utils/progress-utils.ts +17 -6
  84. package/src/utils/security-utils.ts +205 -0
  85. package/src/vector/VectorSearchManager.ts +110 -39
  86. package/tests/constants/icons.test.ts +102 -0
  87. package/tests/constants/server-instructions.test.ts +549 -0
  88. package/tests/database/sqlite-adapter.bench.ts +63 -0
  89. package/tests/database/sqlite-adapter.test.ts +555 -0
  90. package/tests/filtering/tool-filter.test.ts +266 -0
  91. package/tests/github/github-integration.test.ts +1024 -0
  92. package/tests/handlers/github-resource-handlers.test.ts +473 -0
  93. package/tests/handlers/github-tool-handlers.test.ts +556 -0
  94. package/tests/handlers/prompt-handlers.test.ts +91 -0
  95. package/tests/handlers/resource-handlers.test.ts +339 -0
  96. package/tests/handlers/tool-handlers.test.ts +497 -0
  97. package/tests/handlers/vector-tool-handlers.test.ts +238 -0
  98. package/tests/security/sql-injection.test.ts +347 -0
  99. package/tests/server/mcp-server.bench.ts +55 -0
  100. package/tests/server/mcp-server.test.ts +675 -0
  101. package/tests/utils/logger.test.ts +180 -0
  102. package/tests/utils/mcp-logger.test.ts +212 -0
  103. package/tests/utils/progress-utils.test.ts +156 -0
  104. package/tests/utils/security-utils.test.ts +82 -0
  105. package/tests/vector/vector-search-manager.test.ts +335 -0
  106. package/tests/vector/vector-search.bench.ts +53 -0
  107. package/vitest.config.ts +15 -0
  108. package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
  109. package/.github/workflows/dependabot-auto-merge.yml +0 -42
@@ -31,14 +31,22 @@ import { getPrompts, getPrompt } from '../handlers/prompts/index.js'
31
31
  import { generateInstructions } from '../constants/ServerInstructions.js'
32
32
  import pkg from '../../package.json' with { type: 'json' }
33
33
 
34
+ /** Session timeout for stateful HTTP mode (30 minutes) */
35
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000
36
+
37
+ /** Session timeout sweep interval (5 minutes) */
38
+ const SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1000
39
+
34
40
  export interface ServerOptions {
35
41
  transport: 'stdio' | 'http'
36
42
  port?: number
43
+ host?: string
37
44
  dbPath: string
38
45
  toolFilter?: string
39
46
  defaultProjectNumber?: number
40
47
  autoRebuildIndex?: boolean
41
48
  statelessHttp?: boolean
49
+ corsOrigin?: string
42
50
  }
43
51
 
44
52
  /**
@@ -107,14 +115,13 @@ export async function createServer(options: ServerOptions): Promise<void> {
107
115
  }
108
116
  : undefined
109
117
 
118
+ // Get all tools once (unfiltered) for both instruction generation and registration
119
+ const allTools = getTools(db, null, vectorManager, github, { defaultProjectNumber })
120
+ const allToolNames = new Set(allTools.map((t) => (t as { name: string }).name))
121
+
110
122
  // Generate dynamic instructions based on enabled tools, resources, prompts, and latest entry
111
123
  const instructions = generateInstructions(
112
- filterConfig?.enabledTools ??
113
- new Set(
114
- getTools(db, null, vectorManager, github, { defaultProjectNumber }).map(
115
- (t) => (t as { name: string }).name
116
- )
117
- ),
124
+ filterConfig?.enabledTools ?? allToolNames,
118
125
  resources.map((r) => {
119
126
  const res = r as { uri: string; name: string; description?: string }
120
127
  return { uri: res.uri, name: res.name, description: res.description }
@@ -140,8 +147,10 @@ export async function createServer(options: ServerOptions): Promise<void> {
140
147
  }
141
148
  )
142
149
 
143
- // Get filtered tools and register them dynamically
144
- const tools = getTools(db, filterConfig, vectorManager, github, { defaultProjectNumber })
150
+ // Apply filter to get the set of tools to register
151
+ const tools = filterConfig
152
+ ? getTools(db, filterConfig, vectorManager, github, { defaultProjectNumber })
153
+ : allTools
145
154
  for (const tool of tools) {
146
155
  const toolDef = tool as {
147
156
  name: string
@@ -394,12 +403,21 @@ export async function createServer(options: ServerOptions): Promise<void> {
394
403
  } else {
395
404
  // HTTP transport with SSE support
396
405
  const port = options.port ?? 3000
406
+ const host = options.host ?? 'localhost'
407
+ const corsOrigin = options.corsOrigin ?? process.env['MCP_CORS_ORIGIN'] ?? '*'
397
408
  const app: Express = express()
398
409
 
399
- // Manual CORS middleware for browser-based clients (e.g., MCP Inspector)
410
+ // Security headers middleware
411
+ app.use((_req: Request, res: Response, next: () => void) => {
412
+ res.setHeader('X-Content-Type-Options', 'nosniff')
413
+ res.setHeader('X-Frame-Options', 'DENY')
414
+ next()
415
+ })
416
+
417
+ // CORS middleware for browser-based clients (e.g., MCP Inspector)
418
+ // Origin is configurable via --cors-origin flag or MCP_CORS_ORIGIN env var
400
419
  app.use((req: Request, res: Response, next: () => void) => {
401
- // Set CORS headers on all responses
402
- res.setHeader('Access-Control-Allow-Origin', '*')
420
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin)
403
421
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
404
422
  res.setHeader(
405
423
  'Access-Control-Allow-Headers',
@@ -416,22 +434,14 @@ export async function createServer(options: ServerOptions): Promise<void> {
416
434
  next()
417
435
  })
418
436
 
419
- // JSON body parser for MCP requests
420
- app.use(express.json())
437
+ // JSON body parser with size limit to prevent memory exhaustion (DoS)
438
+ app.use(express.json({ limit: '1mb' }))
421
439
 
422
440
  // Explicit OPTIONS handler for /mcp - MUST be before other /mcp routes
423
441
  // Using app.all to intercept before Express 5's auto-OPTIONS
424
442
  app.all('/mcp', (req: Request, res: Response, next: () => void) => {
425
- // Set CORS headers on ALL responses
426
- res.setHeader('Access-Control-Allow-Origin', '*')
427
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
428
- res.setHeader(
429
- 'Access-Control-Allow-Headers',
430
- 'Content-Type, Accept, mcp-session-id, Last-Event-ID, mcp-protocol-version'
431
- )
432
- res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id')
433
-
434
- // For OPTIONS, respond immediately with CORS headers
443
+ // CORS headers are already set by the middleware above.
444
+ // For OPTIONS, respond immediately.
435
445
  if (req.method === 'OPTIONS') {
436
446
  res.status(204).end()
437
447
  return
@@ -479,11 +489,12 @@ export async function createServer(options: ServerOptions): Promise<void> {
479
489
  })
480
490
 
481
491
  // Start HTTP server
482
- const httpServer = app.listen(port, () => {
492
+ const httpServer = app.listen(port, host, () => {
483
493
  logger.info('MCP server started on HTTP (stateless)', {
484
494
  module: 'McpServer',
485
495
  port,
486
- endpoint: `http://localhost:${port}/mcp`,
496
+ host,
497
+ endpoint: `http://${host}:${port}/mcp`,
487
498
  })
488
499
  })
489
500
 
@@ -521,8 +532,37 @@ export async function createServer(options: ServerOptions): Promise<void> {
521
532
  // === STATEFUL MODE ===
522
533
  // Session-based transport with SSE support for notifications
523
534
 
524
- // Session transport storage
535
+ // Session transport storage with last-activity timestamps
525
536
  const transports = new Map<string, StreamableHTTPServerTransport>()
537
+ const sessionLastActivity = new Map<string, number>()
538
+
539
+ /** Update the last-activity timestamp for a session */
540
+ const touchSession = (sid: string): void => {
541
+ sessionLastActivity.set(sid, Date.now())
542
+ }
543
+
544
+ /** Sweep expired sessions (called periodically) */
545
+ const sweepExpiredSessions = (): void => {
546
+ const now = Date.now()
547
+ for (const [sid, lastActivity] of sessionLastActivity) {
548
+ if (now - lastActivity > SESSION_TIMEOUT_MS && transports.has(sid)) {
549
+ logger.info('Expiring idle HTTP session', {
550
+ module: 'McpServer',
551
+ sessionId: sid,
552
+ idleMinutes: Math.round((now - lastActivity) / 60_000),
553
+ })
554
+ const t = transports.get(sid)
555
+ if (t) {
556
+ void t.close()
557
+ }
558
+ transports.delete(sid)
559
+ sessionLastActivity.delete(sid)
560
+ }
561
+ }
562
+ }
563
+
564
+ // Start session timeout sweep (runs every 5 minutes)
565
+ const sessionSweepTimer = setInterval(sweepExpiredSessions, SESSION_SWEEP_INTERVAL_MS)
526
566
 
527
567
  // POST /mcp - Handle JSON-RPC requests
528
568
  app.post('/mcp', (req: Request, res: Response): void => {
@@ -533,7 +573,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
533
573
  let httpTransport: StreamableHTTPServerTransport | undefined
534
574
 
535
575
  if (sessionId && transports.has(sessionId)) {
536
- // Reuse existing transport
576
+ // Reuse existing transport and refresh session activity
577
+ touchSession(sessionId)
537
578
  httpTransport = transports.get(sessionId)
538
579
  } else if (sessionId === undefined && isInitializeRequest(req.body)) {
539
580
  // New initialization request - create transport
@@ -545,6 +586,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
545
586
  sessionId: sid,
546
587
  })
547
588
  transports.set(sid, newTransport)
589
+ touchSession(sid)
548
590
  },
549
591
  })
550
592
 
@@ -557,6 +599,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
557
599
  sessionId: sid,
558
600
  })
559
601
  transports.delete(sid)
602
+ sessionLastActivity.delete(sid)
560
603
  }
561
604
  }
562
605
 
@@ -614,6 +657,9 @@ export async function createServer(options: ServerOptions): Promise<void> {
614
657
  return
615
658
  }
616
659
 
660
+ // Refresh session activity on SSE reconnect
661
+ touchSession(sessionId)
662
+
617
663
  const lastEventId = req.headers['last-event-id']
618
664
  if (lastEventId !== undefined) {
619
665
  logger.debug('Client reconnecting with Last-Event-ID', {
@@ -656,11 +702,12 @@ export async function createServer(options: ServerOptions): Promise<void> {
656
702
  })
657
703
 
658
704
  // Start HTTP server
659
- const httpServer = app.listen(port, () => {
705
+ const httpServer = app.listen(port, host, () => {
660
706
  logger.info('MCP server started on HTTP (stateful)', {
661
707
  module: 'McpServer',
662
708
  port,
663
- endpoint: `http://localhost:${port}/mcp`,
709
+ host,
710
+ endpoint: `http://${host}:${port}/mcp`,
664
711
  })
665
712
  })
666
713
 
@@ -689,6 +736,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
689
736
  }
690
737
  }
691
738
  transports.clear()
739
+ sessionLastActivity.clear()
740
+ clearInterval(sessionSweepTimer)
692
741
 
693
742
  httpServer.close()
694
743
  db.close()
@@ -697,14 +746,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
697
746
  })()
698
747
  })
699
748
 
700
- // Keep process alive with a heartbeat timer
701
- // setInterval keeps the event loop active and prevents exit
702
- setInterval(
703
- () => {
704
- // Heartbeat - keeps event loop active
705
- },
706
- 1000 * 60 * 60
707
- ) // 1 hour interval (just needs to exist)
749
+ // sessionSweepTimer keeps the event loop active (no additional heartbeat needed)
708
750
  }
709
751
  }
710
752
  }
@@ -266,6 +266,17 @@ export interface JournalEntry {
266
266
  autoContext: string | null
267
267
  deletedAt: string | null
268
268
  tags: string[]
269
+ // GitHub integration fields
270
+ projectNumber?: number | null
271
+ projectOwner?: string | null
272
+ issueNumber?: number | null
273
+ issueUrl?: string | null
274
+ prNumber?: number | null
275
+ prUrl?: string | null
276
+ prStatus?: string | null
277
+ workflowRunId?: number | null
278
+ workflowName?: string | null
279
+ workflowStatus?: string | null
269
280
  }
270
281
 
271
282
  /**
@@ -298,6 +309,30 @@ export interface Embedding {
298
309
  modelName: string
299
310
  }
300
311
 
312
+ /**
313
+ * Importance scoring breakdown showing weighted component contributions
314
+ */
315
+ export interface ImportanceBreakdown {
316
+ /** Significance type contribution (weight: 0.30) */
317
+ significance: number
318
+ /** Relationship count contribution (weight: 0.35) */
319
+ relationships: number
320
+ /** Causal relationship contribution (weight: 0.20) */
321
+ causal: number
322
+ /** Recency decay contribution (weight: 0.15) */
323
+ recency: number
324
+ }
325
+
326
+ /**
327
+ * Importance calculation result with total score and component breakdown
328
+ */
329
+ export interface ImportanceResult {
330
+ /** Total importance score (0.0-1.0) */
331
+ score: number
332
+ /** Weighted component contributions */
333
+ breakdown: ImportanceBreakdown
334
+ }
335
+
301
336
  // ============================================================================
302
337
  // GitHub Integration Types
303
338
  // ============================================================================
@@ -320,6 +355,7 @@ export interface GitHubIssue {
320
355
  title: string
321
356
  url: string
322
357
  state: 'OPEN' | 'CLOSED'
358
+ milestone?: { number: number; title: string } | null
323
359
  }
324
360
 
325
361
  /**
@@ -332,6 +368,23 @@ export interface GitHubPullRequest {
332
368
  state: 'OPEN' | 'CLOSED' | 'MERGED'
333
369
  }
334
370
 
371
+ /**
372
+ * GitHub milestone information
373
+ */
374
+ export interface GitHubMilestone {
375
+ number: number
376
+ title: string
377
+ description: string | null
378
+ state: 'open' | 'closed'
379
+ url: string
380
+ dueOn: string | null
381
+ openIssues: number
382
+ closedIssues: number
383
+ createdAt: string
384
+ updatedAt: string
385
+ creator: string | null
386
+ }
387
+
335
388
  /**
336
389
  * GitHub workflow run information
337
390
  */
@@ -359,6 +412,51 @@ export interface ProjectContext {
359
412
  issues: GitHubIssue[]
360
413
  pullRequests: GitHubPullRequest[]
361
414
  workflowRuns: GitHubWorkflowRun[]
415
+ milestones: GitHubMilestone[]
416
+ }
417
+
418
+ // ============================================================================
419
+ // Repository Insights/Traffic Types
420
+ // ============================================================================
421
+
422
+ /**
423
+ * Repository statistics (stars, forks, watchers)
424
+ */
425
+ export interface RepoStats {
426
+ stars: number
427
+ forks: number
428
+ watchers: number
429
+ openIssues: number
430
+ size: number // KB
431
+ defaultBranch: string
432
+ }
433
+
434
+ /**
435
+ * Aggregated traffic data (14-day rolling)
436
+ */
437
+ export interface TrafficData {
438
+ clones: { total: number; unique: number; dailyAvg: number }
439
+ views: { total: number; unique: number; dailyAvg: number }
440
+ period: string // e.g. "14 days"
441
+ }
442
+
443
+ /**
444
+ * Traffic referrer source
445
+ */
446
+ export interface TrafficReferrer {
447
+ referrer: string
448
+ count: number
449
+ uniques: number
450
+ }
451
+
452
+ /**
453
+ * Popular repository path
454
+ */
455
+ export interface PopularPath {
456
+ path: string
457
+ title: string
458
+ count: number
459
+ uniques: number
362
460
  }
363
461
 
364
462
  // ============================================================================
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Centralized logging to stderr only (stdout reserved for MCP protocol).
5
5
  * Follows RFC 5424 severity levels.
6
+ * Automatically sanitizes error fields to prevent token leakage.
6
7
  */
7
8
 
9
+ import { sanitizeErrorForLogging } from './security-utils.js'
10
+
8
11
  type LogLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical'
9
12
 
10
13
  interface LogContext {
@@ -57,7 +60,13 @@ class Logger {
57
60
  private log(level: LogLevel, message: string, context?: LogContext): void {
58
61
  if (!this.shouldLog(level)) return
59
62
 
60
- const formatted = this.formatMessage(level, message, context)
63
+ // Sanitize error fields to prevent token leakage in logs
64
+ let sanitizedContext = context
65
+ if (context?.['error'] != null && typeof context['error'] === 'string') {
66
+ sanitizedContext = { ...context, error: sanitizeErrorForLogging(context['error']) }
67
+ }
68
+
69
+ const formatted = this.formatMessage(level, message, sanitizedContext)
61
70
 
62
71
  // Always write to stderr (stdout is reserved for MCP protocol)
63
72
  console.error(formatted)
@@ -5,18 +5,29 @@
5
5
  * Follows MCP 2025-11-25 specification for notifications/progress.
6
6
  */
7
7
 
8
- // We intentionally use the lower-level Server class to access the notification method
9
-
10
- import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
8
+ /**
9
+ * Minimal interface for sending MCP notifications.
10
+ * Uses structural typing to avoid importing the deprecated Server class.
11
+ */
12
+ interface NotificationSender {
13
+ notification(notification: {
14
+ method: 'notifications/progress'
15
+ params: {
16
+ progressToken: string | number
17
+ progress: number
18
+ total?: number
19
+ message?: string
20
+ }
21
+ }): Promise<void>
22
+ }
11
23
 
12
24
  /** Progress token from client request _meta */
13
25
  export type ProgressToken = string | number
14
26
 
15
27
  /** Context required to send progress notifications */
16
28
  export interface ProgressContext {
17
- /** MCP Server instance for sending notifications */
18
- // eslint-disable-next-line @typescript-eslint/no-deprecated
19
- server: Server
29
+ /** Object with notification method for sending progress updates */
30
+ server: NotificationSender
20
31
  /** Progress token from request _meta (if client requested progress) */
21
32
  progressToken?: ProgressToken
22
33
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Security Utilities for Memory Journal MCP Server
3
+ *
4
+ * Centralized security validation following MCP Security Patterns.
5
+ * Uses typed errors for consistent error handling across the codebase.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Typed Security Errors
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Base class for security-related errors
14
+ */
15
+ export class SecurityError extends Error {
16
+ readonly code: string
17
+
18
+ constructor(message: string, code: string) {
19
+ super(message)
20
+ this.name = 'SecurityError'
21
+ this.code = code
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Thrown when an invalid date format pattern is detected
27
+ */
28
+ export class InvalidDateFormatError extends SecurityError {
29
+ constructor(value: string) {
30
+ super(`Invalid date format pattern: '${value}'`, 'INVALID_DATE_FORMAT')
31
+ this.name = 'InvalidDateFormatError'
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Thrown when SQL injection patterns are detected in input
37
+ */
38
+ export class SqlInjectionError extends SecurityError {
39
+ constructor(pattern: string) {
40
+ super(`Potential SQL injection detected: '${pattern}'`, 'SQL_INJECTION')
41
+ this.name = 'SqlInjectionError'
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Thrown when path traversal is detected in input
47
+ */
48
+ export class PathTraversalError extends SecurityError {
49
+ constructor(path: string) {
50
+ super(`Path traversal detected: '${path}'`, 'PATH_TRAVERSAL')
51
+ this.name = 'PathTraversalError'
52
+ }
53
+ }
54
+
55
+ // ============================================================================
56
+ // Date Format Validation
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Whitelist of allowed strftime format patterns for SQLite.
61
+ * These are the only patterns allowed to be interpolated into SQL.
62
+ */
63
+ const ALLOWED_DATE_FORMATS: Record<string, string> = {
64
+ day: '%Y-%m-%d',
65
+ week: '%Y-W%W',
66
+ month: '%Y-%m',
67
+ } as const
68
+
69
+ export type DateGroupBy = 'day' | 'week' | 'month'
70
+
71
+ /**
72
+ * Validates and returns a safe strftime format pattern.
73
+ *
74
+ * @param groupBy - The grouping period ('day', 'week', or 'month')
75
+ * @returns The validated strftime format pattern
76
+ * @throws InvalidDateFormatError if the groupBy value is invalid
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * const format = validateDateFormatPattern('day') // Returns '%Y-%m-%d'
81
+ * const format = validateDateFormatPattern('invalid') // Throws InvalidDateFormatError
82
+ * ```
83
+ */
84
+ export function validateDateFormatPattern(groupBy: string): string {
85
+ const format = ALLOWED_DATE_FORMATS[groupBy]
86
+ if (!format) {
87
+ throw new InvalidDateFormatError(groupBy)
88
+ }
89
+ return format
90
+ }
91
+
92
+ // ============================================================================
93
+ // Search Query Sanitization
94
+ // ============================================================================
95
+
96
+ /**
97
+ * Escapes special characters in LIKE patterns to prevent injection.
98
+ * SQLite LIKE uses % and _ as wildcards.
99
+ *
100
+ * @param query - The user-provided search query
101
+ * @returns Escaped query safe for use in LIKE patterns
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * sanitizeSearchQuery('100%') // Returns '100\\%'
106
+ * sanitizeSearchQuery('test_value') // Returns 'test\\_value'
107
+ * ```
108
+ */
109
+ export function sanitizeSearchQuery(query: string): string {
110
+ // Escape backslashes first, then LIKE wildcards
111
+ return query.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_')
112
+ }
113
+
114
+ // ============================================================================
115
+ // SQL Injection Detection
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Patterns that indicate SQL injection attempts.
120
+ * Used for validation in edge cases where parameterized queries aren't possible.
121
+ */
122
+ const SQL_INJECTION_PATTERNS = [
123
+ /;\s*(DROP|DELETE|INSERT|UPDATE|CREATE|ALTER|TRUNCATE)/i,
124
+ /--\s*/,
125
+ /\/\*[\s\S]*?\*\//,
126
+ /UNION\s+(ALL\s+)?SELECT/i,
127
+ /'\s*OR\s+['"]?1['"]?\s*=\s*['"]?1/i,
128
+ /ATTACH\s+DATABASE/i,
129
+ /DETACH\s+DATABASE/i,
130
+ /load_extension\s*\(/i,
131
+ ] as const
132
+
133
+ /**
134
+ * Checks if a string contains potential SQL injection patterns.
135
+ * This is a secondary defense layer; parameterized queries are the primary defense.
136
+ *
137
+ * @param input - The string to check
138
+ * @returns true if injection patterns are detected, false otherwise
139
+ */
140
+ export function containsSqlInjection(input: string): boolean {
141
+ return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(input))
142
+ }
143
+
144
+ /**
145
+ * Validates that input does not contain SQL injection patterns.
146
+ *
147
+ * @param input - The string to validate
148
+ * @throws SqlInjectionError if injection patterns are detected
149
+ */
150
+ export function assertNoSqlInjection(input: string): void {
151
+ if (containsSqlInjection(input)) {
152
+ throw new SqlInjectionError(input.substring(0, 50))
153
+ }
154
+ }
155
+
156
+ // ============================================================================
157
+ // Path Validation
158
+ // ============================================================================
159
+
160
+ /**
161
+ * Validates that a filename does not contain path traversal characters.
162
+ *
163
+ * @param filename - The filename to validate
164
+ * @throws PathTraversalError if path traversal is detected
165
+ */
166
+ export function assertNoPathTraversal(filename: string): void {
167
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
168
+ throw new PathTraversalError(filename)
169
+ }
170
+ }
171
+
172
+ // ============================================================================
173
+ // Error Message Sanitization
174
+ // ============================================================================
175
+
176
+ /**
177
+ * Patterns that may contain sensitive tokens in error messages.
178
+ * Used to scrub error output before logging.
179
+ */
180
+ const TOKEN_PATTERNS = [
181
+ // GitHub personal access tokens (classic and fine-grained)
182
+ /ghp_[A-Za-z0-9_]{36,}/g,
183
+ /github_pat_[A-Za-z0-9_]{82,}/g,
184
+ // Authorization headers in error dumps
185
+ /Authorization:\s*(?:token|Bearer)\s+\S+/gi,
186
+ // Generic Bearer tokens
187
+ /Bearer\s+[A-Za-z0-9._\-~+/]+=*/gi,
188
+ ] as const
189
+
190
+ /**
191
+ * Sanitizes an error message by replacing any detected tokens with '[REDACTED]'.
192
+ * This is a defense-in-depth measure for error logging paths.
193
+ *
194
+ * @param message - The error message to sanitize
195
+ * @returns The sanitized message with tokens replaced
196
+ */
197
+ export function sanitizeErrorForLogging(message: string): string {
198
+ let sanitized = message
199
+ for (const pattern of TOKEN_PATTERNS) {
200
+ // Reset lastIndex for global regex patterns
201
+ pattern.lastIndex = 0
202
+ sanitized = sanitized.replace(pattern, '[REDACTED]')
203
+ }
204
+ return sanitized
205
+ }