@twelvehart/supermemory-runtime 1.0.0-next.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 (156) hide show
  1. package/.env.example +57 -0
  2. package/README.md +374 -0
  3. package/dist/index.js +189 -0
  4. package/dist/mcp/index.js +1132 -0
  5. package/docker-compose.prod.yml +91 -0
  6. package/docker-compose.yml +358 -0
  7. package/drizzle/0000_dapper_the_professor.sql +159 -0
  8. package/drizzle/0001_api_keys.sql +51 -0
  9. package/drizzle/meta/0000_snapshot.json +1532 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +20 -0
  12. package/package.json +114 -0
  13. package/scripts/add-extraction-job.ts +122 -0
  14. package/scripts/benchmark-pgvector.ts +122 -0
  15. package/scripts/bootstrap.sh +209 -0
  16. package/scripts/check-runtime-pack.ts +111 -0
  17. package/scripts/claude-mcp-config.ts +336 -0
  18. package/scripts/docker-entrypoint.sh +183 -0
  19. package/scripts/doctor.ts +377 -0
  20. package/scripts/init-db.sql +33 -0
  21. package/scripts/install.sh +1110 -0
  22. package/scripts/mcp-setup.ts +271 -0
  23. package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
  24. package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
  25. package/scripts/migrations/003_create_hnsw_index.sql +94 -0
  26. package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
  27. package/scripts/migrations/005_create_chunks_table.sql +95 -0
  28. package/scripts/migrations/006_create_processing_queue.sql +45 -0
  29. package/scripts/migrations/generate_test_data.sql +42 -0
  30. package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
  31. package/scripts/migrations/run_migrations.sh +286 -0
  32. package/scripts/migrations/test_hnsw_index.sql +255 -0
  33. package/scripts/pre-commit-secrets +282 -0
  34. package/scripts/run-extraction-worker.ts +46 -0
  35. package/scripts/run-phase1-tests.sh +291 -0
  36. package/scripts/setup.ts +222 -0
  37. package/scripts/smoke-install.sh +12 -0
  38. package/scripts/test-health-endpoint.sh +328 -0
  39. package/src/api/index.ts +2 -0
  40. package/src/api/middleware/auth.ts +80 -0
  41. package/src/api/middleware/csrf.ts +308 -0
  42. package/src/api/middleware/errorHandler.ts +166 -0
  43. package/src/api/middleware/rateLimit.ts +360 -0
  44. package/src/api/middleware/validation.ts +514 -0
  45. package/src/api/routes/documents.ts +286 -0
  46. package/src/api/routes/profiles.ts +237 -0
  47. package/src/api/routes/search.ts +71 -0
  48. package/src/api/stores/index.ts +58 -0
  49. package/src/config/bootstrap-env.ts +3 -0
  50. package/src/config/env.ts +71 -0
  51. package/src/config/feature-flags.ts +25 -0
  52. package/src/config/index.ts +140 -0
  53. package/src/config/secrets.config.ts +291 -0
  54. package/src/db/client.ts +92 -0
  55. package/src/db/index.ts +73 -0
  56. package/src/db/postgres.ts +72 -0
  57. package/src/db/schema/chunks.schema.ts +31 -0
  58. package/src/db/schema/containers.schema.ts +46 -0
  59. package/src/db/schema/documents.schema.ts +49 -0
  60. package/src/db/schema/embeddings.schema.ts +32 -0
  61. package/src/db/schema/index.ts +11 -0
  62. package/src/db/schema/memories.schema.ts +72 -0
  63. package/src/db/schema/profiles.schema.ts +34 -0
  64. package/src/db/schema/queue.schema.ts +59 -0
  65. package/src/db/schema/relationships.schema.ts +42 -0
  66. package/src/db/schema.ts +223 -0
  67. package/src/db/worker-connection.ts +47 -0
  68. package/src/index.ts +235 -0
  69. package/src/mcp/CLAUDE.md +1 -0
  70. package/src/mcp/index.ts +1380 -0
  71. package/src/mcp/legacyState.ts +22 -0
  72. package/src/mcp/rateLimit.ts +358 -0
  73. package/src/mcp/resources.ts +309 -0
  74. package/src/mcp/results.ts +104 -0
  75. package/src/mcp/tools.ts +401 -0
  76. package/src/queues/config.ts +119 -0
  77. package/src/queues/index.ts +289 -0
  78. package/src/sdk/client.ts +225 -0
  79. package/src/sdk/errors.ts +266 -0
  80. package/src/sdk/http.ts +560 -0
  81. package/src/sdk/index.ts +244 -0
  82. package/src/sdk/resources/base.ts +65 -0
  83. package/src/sdk/resources/connections.ts +204 -0
  84. package/src/sdk/resources/documents.ts +163 -0
  85. package/src/sdk/resources/index.ts +10 -0
  86. package/src/sdk/resources/memories.ts +150 -0
  87. package/src/sdk/resources/search.ts +60 -0
  88. package/src/sdk/resources/settings.ts +36 -0
  89. package/src/sdk/types.ts +674 -0
  90. package/src/services/chunking/index.ts +451 -0
  91. package/src/services/chunking.service.ts +650 -0
  92. package/src/services/csrf.service.ts +252 -0
  93. package/src/services/documents.repository.ts +219 -0
  94. package/src/services/documents.service.ts +191 -0
  95. package/src/services/embedding.service.ts +404 -0
  96. package/src/services/extraction.service.ts +300 -0
  97. package/src/services/extractors/code.extractor.ts +451 -0
  98. package/src/services/extractors/index.ts +9 -0
  99. package/src/services/extractors/markdown.extractor.ts +461 -0
  100. package/src/services/extractors/pdf.extractor.ts +315 -0
  101. package/src/services/extractors/text.extractor.ts +118 -0
  102. package/src/services/extractors/url.extractor.ts +243 -0
  103. package/src/services/index.ts +235 -0
  104. package/src/services/ingestion.service.ts +177 -0
  105. package/src/services/llm/anthropic.ts +400 -0
  106. package/src/services/llm/base.ts +460 -0
  107. package/src/services/llm/contradiction-detector.service.ts +526 -0
  108. package/src/services/llm/heuristics.ts +148 -0
  109. package/src/services/llm/index.ts +309 -0
  110. package/src/services/llm/memory-classifier.service.ts +383 -0
  111. package/src/services/llm/memory-extension-detector.service.ts +523 -0
  112. package/src/services/llm/mock.ts +470 -0
  113. package/src/services/llm/openai.ts +398 -0
  114. package/src/services/llm/prompts.ts +438 -0
  115. package/src/services/llm/types.ts +373 -0
  116. package/src/services/memory.repository.ts +1769 -0
  117. package/src/services/memory.service.ts +1338 -0
  118. package/src/services/memory.types.ts +234 -0
  119. package/src/services/persistence/index.ts +295 -0
  120. package/src/services/pipeline.service.ts +509 -0
  121. package/src/services/profile.repository.ts +436 -0
  122. package/src/services/profile.service.ts +560 -0
  123. package/src/services/profile.types.ts +270 -0
  124. package/src/services/relationships/detector.ts +1128 -0
  125. package/src/services/relationships/index.ts +268 -0
  126. package/src/services/relationships/memory-integration.ts +459 -0
  127. package/src/services/relationships/strategies.ts +132 -0
  128. package/src/services/relationships/types.ts +370 -0
  129. package/src/services/search.service.ts +761 -0
  130. package/src/services/search.types.ts +220 -0
  131. package/src/services/secrets.service.ts +384 -0
  132. package/src/services/vectorstore/base.ts +327 -0
  133. package/src/services/vectorstore/index.ts +444 -0
  134. package/src/services/vectorstore/memory.ts +286 -0
  135. package/src/services/vectorstore/migration.ts +295 -0
  136. package/src/services/vectorstore/mock.ts +403 -0
  137. package/src/services/vectorstore/pgvector.ts +695 -0
  138. package/src/services/vectorstore/types.ts +247 -0
  139. package/src/startup.ts +389 -0
  140. package/src/types/api.types.ts +193 -0
  141. package/src/types/document.types.ts +103 -0
  142. package/src/types/index.ts +241 -0
  143. package/src/types/profile.base.ts +133 -0
  144. package/src/utils/errors.ts +447 -0
  145. package/src/utils/id.ts +15 -0
  146. package/src/utils/index.ts +101 -0
  147. package/src/utils/logger.ts +313 -0
  148. package/src/utils/sanitization.ts +501 -0
  149. package/src/utils/secret-validation.ts +273 -0
  150. package/src/utils/synonyms.ts +188 -0
  151. package/src/utils/validation.ts +581 -0
  152. package/src/workers/chunking.worker.ts +242 -0
  153. package/src/workers/embedding.worker.ts +358 -0
  154. package/src/workers/extraction.worker.ts +346 -0
  155. package/src/workers/indexing.worker.ts +505 -0
  156. package/tsconfig.json +38 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Search Types for Supermemory Clone
3
+ *
4
+ * Type definitions for vector embedding and hybrid search functionality.
5
+ */
6
+
7
+ import type { Memory } from './memory.types.js'
8
+
9
+ // Re-export Memory for convenience
10
+ export type { Memory }
11
+
12
+ /**
13
+ * Search mode determines how results are retrieved
14
+ */
15
+ export type SearchMode = 'vector' | 'memory' | 'fulltext' | 'hybrid'
16
+
17
+ /**
18
+ * Metadata filters for search queries
19
+ */
20
+ export interface MetadataFilter {
21
+ key: string
22
+ value: string | number | boolean
23
+ operator?: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith'
24
+ }
25
+
26
+ /**
27
+ * Date range filter for search
28
+ */
29
+ export interface DateRangeFilter {
30
+ from?: Date
31
+ to?: Date
32
+ }
33
+
34
+ /**
35
+ * Search options configuration
36
+ */
37
+ export interface SearchOptions {
38
+ /** Search mode: vector, memory, fulltext, or hybrid (default: hybrid) */
39
+ searchMode: SearchMode
40
+
41
+ /** Maximum number of results to return (default: 10) */
42
+ limit: number
43
+
44
+ /** Minimum similarity threshold for results (0-1, default: 0.7) */
45
+ threshold: number
46
+
47
+ /** Whether to apply cross-encoder reranking (default: false) */
48
+ rerank: boolean
49
+
50
+ /** Whether to expand/rewrite query for better recall (default: false) */
51
+ rewriteQuery: boolean
52
+
53
+ /** Metadata filters to apply */
54
+ filters?: MetadataFilter[]
55
+
56
+ /** Date range filter */
57
+ dateRange?: DateRangeFilter
58
+
59
+ /** Include chunk content in results */
60
+ includeContent?: boolean
61
+
62
+ /** Include embedding vectors in results (for debugging) */
63
+ includeEmbeddings?: boolean
64
+ }
65
+
66
+ /**
67
+ * Default search options
68
+ */
69
+ export const DEFAULT_SEARCH_OPTIONS: SearchOptions = {
70
+ searchMode: 'hybrid',
71
+ limit: 10,
72
+ threshold: 0.7,
73
+ rerank: false,
74
+ rewriteQuery: false,
75
+ includeContent: true,
76
+ includeEmbeddings: false,
77
+ }
78
+
79
+ /**
80
+ * Document chunk for vector search
81
+ */
82
+ export interface Chunk {
83
+ id: string
84
+ memoryId: string
85
+ content: string
86
+ chunkIndex: number
87
+ embedding?: number[]
88
+ metadata?: Record<string, unknown>
89
+ createdAt: Date
90
+ }
91
+
92
+ /**
93
+ * Search result item
94
+ */
95
+ export interface SearchResult {
96
+ /** Unique identifier */
97
+ id: string
98
+
99
+ /** The memory object if from memory search */
100
+ memory?: Memory
101
+
102
+ /** The chunk object if from vector search */
103
+ chunk?: Chunk
104
+
105
+ /** Cosine similarity score (0-1) */
106
+ similarity: number
107
+
108
+ /** Combined metadata from memory and chunk */
109
+ metadata: Record<string, unknown>
110
+
111
+ /** Last update timestamp */
112
+ updatedAt: Date
113
+
114
+ /** Source of the result */
115
+ source: 'vector' | 'memory' | 'fulltext' | 'hybrid'
116
+
117
+ /** Reranking score if reranking was applied */
118
+ rerankScore?: number
119
+ }
120
+
121
+ /**
122
+ * Hybrid search response
123
+ */
124
+ export interface SearchResponse {
125
+ /** Search results */
126
+ results: SearchResult[]
127
+
128
+ /** Total count of matching items (before limit) */
129
+ totalCount: number
130
+
131
+ /** Query used for search (may be rewritten) */
132
+ query: string
133
+
134
+ /** Original query if rewriting was applied */
135
+ originalQuery?: string
136
+
137
+ /** Time taken for search in milliseconds */
138
+ searchTimeMs: number
139
+
140
+ /** Search options used */
141
+ options: SearchOptions
142
+ }
143
+
144
+ /**
145
+ * Embedding model configuration
146
+ */
147
+ export interface EmbeddingConfig {
148
+ /** Model name (e.g., 'text-embedding-3-small') */
149
+ model: string
150
+
151
+ /** Dimension of the embedding vectors */
152
+ dimensions: number
153
+
154
+ /** Whether this is a local fallback model */
155
+ isLocal: boolean
156
+
157
+ /** Maximum tokens per request */
158
+ maxTokens?: number
159
+
160
+ /** Batch size for batch embedding */
161
+ batchSize?: number
162
+ }
163
+
164
+ /**
165
+ * Embedding provider types
166
+ */
167
+ export type EmbeddingProvider = 'openai' | 'local'
168
+
169
+ /**
170
+ * Vector similarity metrics
171
+ */
172
+ export type SimilarityMetric = 'cosine' | 'euclidean' | 'dotProduct'
173
+
174
+ /**
175
+ * Reranking options
176
+ */
177
+ export interface RerankOptions {
178
+ /** Maximum number of results to rerank */
179
+ topK: number
180
+
181
+ /** Model to use for reranking */
182
+ model?: string
183
+
184
+ /** Whether to return original scores alongside rerank scores */
185
+ returnOriginalScores?: boolean
186
+ }
187
+
188
+ /**
189
+ * Query rewriting options
190
+ */
191
+ export interface QueryRewriteOptions {
192
+ /** Number of query variations to generate */
193
+ numVariations?: number
194
+
195
+ /** Whether to include synonyms */
196
+ includeSynonyms?: boolean
197
+
198
+ /** Whether to expand abbreviations */
199
+ expandAbbreviations?: boolean
200
+
201
+ /** Additional context for query rewriting */
202
+ context?: string
203
+ }
204
+
205
+ /**
206
+ * Vector store entry for similarity search
207
+ */
208
+ export interface VectorEntry {
209
+ id: string
210
+ embedding: number[]
211
+ metadata: Record<string, unknown>
212
+ }
213
+
214
+ /**
215
+ * Similarity search result from vector store
216
+ */
217
+ export interface VectorSearchResult {
218
+ entry: VectorEntry
219
+ similarity: number
220
+ }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Secrets Management Service
3
+ *
4
+ * Handles secure storage, validation, rotation, and lifecycle management of secrets.
5
+ *
6
+ * Security Features:
7
+ * - AES-256-GCM encryption at rest
8
+ * - PBKDF2 key derivation with salt
9
+ * - Secret pattern detection (API keys, tokens, passwords)
10
+ * - Logging sanitization (prevents secret leakage)
11
+ * - Secret rotation with audit trail
12
+ * - Entropy validation for secret strength
13
+ */
14
+
15
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto'
16
+ import { AppError, ErrorCode } from '../utils/errors.js'
17
+ import { logger } from '../utils/logger.js'
18
+ import { calculateEntropy } from '../utils/secret-validation.js'
19
+
20
+ const ENCRYPTION_ALGORITHM = 'aes-256-gcm'
21
+ /** 600,000 iterations as per OWASP 2023 recommendations */
22
+ const PBKDF2_ITERATIONS = 600000
23
+ const PBKDF2_DIGEST = 'sha512'
24
+ const KEY_LENGTH = 32
25
+ const SALT_LENGTH = 16
26
+ const IV_LENGTH = 12
27
+ const MIN_SECRET_ENTROPY = 128
28
+ const REDACTED = '[REDACTED]'
29
+
30
+ /** Patterns for detecting various secret types */
31
+ const SECRET_PATTERNS = {
32
+ apiKey: /(?:api[_-]?key|apikey)[=:\s]+['"]?([a-z0-9_-]{20,})/gi,
33
+ bearerToken: /bearer\s+([a-z0-9_.-]+)/gi,
34
+ awsAccessKey: /AKIA[0-9A-Z]{16}/g,
35
+ awsSecretKey: /aws[_-]?secret[_-]?access[_-]?key[=:\s]+['"]?([a-z0-9/+=]{40})/gi,
36
+ token: /(?:auth[_-]?token|access[_-]?token|refresh[_-]?token)[=:\s]+['"]?([a-z0-9_.-]{20,})/gi,
37
+ privateKey: /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/i,
38
+ databaseUrl: /(?:postgres|mysql|mongodb):\/\/([^:]+):([^@]+)@/gi,
39
+ password: /(?:password|passwd|pwd)[=:\s]+['"]?([^\s'"]{8,})/gi,
40
+ jwt: /eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g,
41
+ secret: /secret[_-]?key[=:\s]+['"]?([a-z0-9_.-]{20,})/gi,
42
+ } as const
43
+
44
+ /** Encrypted secret format */
45
+ export interface EncryptedSecret {
46
+ encrypted: string
47
+ iv: string
48
+ authTag: string
49
+ salt: string
50
+ algorithm: string
51
+ encryptedAt: Date
52
+ }
53
+
54
+ /** Secret metadata for audit trail */
55
+ export interface SecretMetadata {
56
+ id: string
57
+ name: string
58
+ createdAt: Date
59
+ lastRotated?: Date
60
+ rotationCount: number
61
+ type?: string
62
+ }
63
+
64
+ /** Secret validation result */
65
+ export interface SecretValidationResult {
66
+ valid: boolean
67
+ errors: string[]
68
+ warnings: string[]
69
+ entropy?: number
70
+ }
71
+
72
+ export class SecretsService {
73
+ private masterKey: Buffer | null = null
74
+ private readonly secretsCache = new Map<string, EncryptedSecret>()
75
+
76
+ /**
77
+ * Initialize the secrets service with a master encryption key
78
+ * @param masterPassword - Master password for key derivation (from env)
79
+ */
80
+ initialize(masterPassword: string): void {
81
+ if (!masterPassword || masterPassword.length < 16) {
82
+ throw new AppError('Master password must be at least 16 characters', ErrorCode.INVALID_INPUT)
83
+ }
84
+
85
+ // Derive master key from password using PBKDF2
86
+ // In production, this salt should be stored securely (e.g., KMS, secrets manager)
87
+ const salt = process.env.SECRETS_SALT ? Buffer.from(process.env.SECRETS_SALT, 'base64') : randomBytes(SALT_LENGTH)
88
+
89
+ this.masterKey = pbkdf2Sync(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST)
90
+
91
+ logger.info('[Secrets] Service initialized', {
92
+ algorithm: ENCRYPTION_ALGORITHM,
93
+ iterations: PBKDF2_ITERATIONS,
94
+ })
95
+ }
96
+
97
+ /**
98
+ * Load and validate secrets from environment variables
99
+ * @param requiredSecrets - List of required secret names
100
+ * @returns Map of secret names to decrypted values
101
+ */
102
+ loadSecrets(requiredSecrets: string[]): Map<string, string> {
103
+ this.assertInitialized()
104
+
105
+ const secrets = new Map<string, string>()
106
+ const missingSecrets: string[] = []
107
+
108
+ for (const secretName of requiredSecrets) {
109
+ const envValue = process.env[secretName]
110
+
111
+ if (!envValue) {
112
+ missingSecrets.push(secretName)
113
+ continue
114
+ }
115
+
116
+ // Store encrypted if needed, or use plaintext from env
117
+ secrets.set(secretName, envValue)
118
+ }
119
+
120
+ if (missingSecrets.length > 0) {
121
+ throw new AppError(`Missing required secrets: ${missingSecrets.join(', ')}`, ErrorCode.VALIDATION_ERROR, {
122
+ missingSecrets,
123
+ })
124
+ }
125
+
126
+ logger.info('[Secrets] Loaded secrets', {
127
+ count: secrets.size,
128
+ secrets: Array.from(secrets.keys()), // Names only, not values
129
+ })
130
+
131
+ return secrets
132
+ }
133
+
134
+ /**
135
+ * Validate secrets configuration
136
+ * @param secrets - Map of secret names to values
137
+ * @returns Validation results for each secret
138
+ */
139
+ validateSecrets(secrets: Map<string, string>): Map<string, SecretValidationResult> {
140
+ const results = new Map<string, SecretValidationResult>()
141
+
142
+ for (const [name, value] of secrets) {
143
+ const result = this.validateSecret(name, value)
144
+ results.set(name, result)
145
+
146
+ if (!result.valid) {
147
+ logger.warn('[Secrets] Secret validation failed', {
148
+ secret: name,
149
+ errors: result.errors,
150
+ })
151
+ } else if (result.warnings.length > 0) {
152
+ logger.warn('[Secrets] Secret validation warnings', {
153
+ secret: name,
154
+ warnings: result.warnings,
155
+ })
156
+ }
157
+ }
158
+
159
+ return results
160
+ }
161
+
162
+ /**
163
+ * Validate a single secret
164
+ */
165
+ private validateSecret(name: string, value: string): SecretValidationResult {
166
+ const errors: string[] = []
167
+ const warnings: string[] = []
168
+
169
+ if (value.length < 8) {
170
+ errors.push('Secret must be at least 8 characters')
171
+ }
172
+
173
+ const entropy = calculateEntropy(value)
174
+
175
+ if (entropy < MIN_SECRET_ENTROPY) {
176
+ warnings.push(`Low entropy (${entropy.toFixed(0)} bits). Consider using a stronger secret.`)
177
+ }
178
+
179
+ // Check for common weak patterns
180
+ if (/^(password|secret|key|token)$/i.test(value)) {
181
+ errors.push('Secret cannot be a common word')
182
+ }
183
+
184
+ const hasMixedCase = /[a-z]/.test(value) && /[A-Z]/.test(value)
185
+ const hasNumbers = /[0-9]/.test(value)
186
+ const hasSymbols = /[^a-zA-Z0-9]/.test(value)
187
+
188
+ if (!hasMixedCase && !hasNumbers && !hasSymbols) {
189
+ warnings.push('Secret should contain a mix of characters for better security')
190
+ }
191
+
192
+ return {
193
+ valid: errors.length === 0,
194
+ errors,
195
+ warnings,
196
+ entropy,
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Encrypt a secret
202
+ * @param plaintext - Secret to encrypt
203
+ * @returns Encrypted secret object
204
+ */
205
+ encryptSecret(plaintext: string): EncryptedSecret {
206
+ this.assertInitialized()
207
+
208
+ const iv = randomBytes(IV_LENGTH)
209
+ const salt = randomBytes(SALT_LENGTH)
210
+ const key = pbkdf2Sync(this.masterKey!, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST)
211
+ const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv)
212
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
213
+ const authTag = cipher.getAuthTag()
214
+
215
+ return {
216
+ encrypted: encrypted.toString('base64'),
217
+ iv: iv.toString('base64'),
218
+ authTag: authTag.toString('base64'),
219
+ salt: salt.toString('base64'),
220
+ algorithm: ENCRYPTION_ALGORITHM,
221
+ encryptedAt: new Date(),
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Decrypt a secret
227
+ * @param encryptedSecret - Encrypted secret object
228
+ * @returns Decrypted plaintext
229
+ */
230
+ decryptSecret(encryptedSecret: EncryptedSecret): string {
231
+ this.assertInitialized()
232
+
233
+ try {
234
+ const encrypted = Buffer.from(encryptedSecret.encrypted, 'base64')
235
+ const iv = Buffer.from(encryptedSecret.iv, 'base64')
236
+ const authTag = Buffer.from(encryptedSecret.authTag, 'base64')
237
+ const salt = Buffer.from(encryptedSecret.salt, 'base64')
238
+ const key = pbkdf2Sync(this.masterKey!, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST)
239
+ const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv)
240
+ decipher.setAuthTag(authTag)
241
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
242
+
243
+ return decrypted.toString('utf8')
244
+ } catch (error) {
245
+ throw new AppError('Failed to decrypt secret', ErrorCode.INTERNAL_ERROR, {
246
+ error: error instanceof Error ? error.message : String(error),
247
+ })
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Rotate a secret (generate new value)
253
+ * @param oldSecret - Current secret value
254
+ * @param length - Length of new secret (default: 32)
255
+ * @returns New secret value
256
+ */
257
+ rotateSecret(oldSecret: string, length: number = 32): string {
258
+ logger.info('[Secrets] Rotating secret', { oldLength: oldSecret.length, newLength: length })
259
+
260
+ // Generate cryptographically secure random secret
261
+ return randomBytes(length).toString('base64url')
262
+ }
263
+
264
+ /**
265
+ * Sanitize data for logging (remove secrets)
266
+ * @param data - Data to sanitize
267
+ * @returns Sanitized data with secrets redacted
268
+ */
269
+ sanitizeForLogging(data: unknown): unknown {
270
+ if (typeof data === 'string') {
271
+ return this.sanitizeString(data)
272
+ }
273
+
274
+ if (Array.isArray(data)) {
275
+ return data.map((item) => this.sanitizeForLogging(item))
276
+ }
277
+
278
+ if (typeof data === 'object' && data !== null) {
279
+ const sanitized: Record<string, unknown> = {}
280
+ for (const [key, value] of Object.entries(data)) {
281
+ sanitized[key] = this.isSecretKey(key) ? REDACTED : this.sanitizeForLogging(value)
282
+ }
283
+ return sanitized
284
+ }
285
+
286
+ return data
287
+ }
288
+
289
+ /**
290
+ * Detect if a string contains secrets
291
+ * @param text - Text to scan
292
+ * @returns True if secrets detected
293
+ */
294
+ detectSecretInString(text: string): boolean {
295
+ for (const pattern of Object.values(SECRET_PATTERNS)) {
296
+ // Reset regex state to prevent lastIndex pollution across calls
297
+ pattern.lastIndex = 0
298
+ if (pattern.test(text)) {
299
+ return true
300
+ }
301
+ }
302
+ return false
303
+ }
304
+
305
+ /**
306
+ * Get detected secret types in a string
307
+ * @param text - Text to scan
308
+ * @returns Array of detected secret types
309
+ */
310
+ getDetectedSecretTypes(text: string): string[] {
311
+ const detected: string[] = []
312
+
313
+ for (const [type, pattern] of Object.entries(SECRET_PATTERNS)) {
314
+ // Reset regex state to prevent lastIndex pollution across calls
315
+ pattern.lastIndex = 0
316
+ if (pattern.test(text)) {
317
+ detected.push(type)
318
+ }
319
+ }
320
+
321
+ return detected
322
+ }
323
+
324
+ private assertInitialized(): void {
325
+ if (!this.masterKey) {
326
+ throw new AppError('Secrets service not initialized. Call initialize() first.', ErrorCode.INTERNAL_ERROR)
327
+ }
328
+ }
329
+
330
+ private sanitizeString(str: string): string {
331
+ return Object.values(SECRET_PATTERNS).reduce((result, pattern) => {
332
+ // Reset regex state to prevent lastIndex pollution across calls
333
+ pattern.lastIndex = 0
334
+ return result.replace(pattern, REDACTED)
335
+ }, str)
336
+ }
337
+
338
+ private isSecretKey(key: string): boolean {
339
+ const secretKeywords = [
340
+ 'password',
341
+ 'passwd',
342
+ 'pwd',
343
+ 'secret',
344
+ 'token',
345
+ 'key',
346
+ 'apikey',
347
+ 'api_key',
348
+ 'auth',
349
+ 'credential',
350
+ 'private',
351
+ ]
352
+
353
+ const lowerKey = key.toLowerCase()
354
+ return secretKeywords.some((keyword) => lowerKey.includes(keyword))
355
+ }
356
+ }
357
+
358
+ let secretsServiceInstance: SecretsService | null = null
359
+
360
+ /**
361
+ * Get or create the secrets service singleton
362
+ */
363
+ export function getSecretsService(): SecretsService {
364
+ if (!secretsServiceInstance) {
365
+ secretsServiceInstance = new SecretsService()
366
+ }
367
+ return secretsServiceInstance
368
+ }
369
+
370
+ /**
371
+ * Initialize secrets service from environment
372
+ * Should be called during application startup
373
+ */
374
+ export function initializeSecretsService(): SecretsService {
375
+ const service = getSecretsService()
376
+
377
+ const masterPassword = process.env.SECRETS_MASTER_PASSWORD
378
+ if (!masterPassword) {
379
+ throw new AppError('SECRETS_MASTER_PASSWORD environment variable is required', ErrorCode.VALIDATION_ERROR)
380
+ }
381
+
382
+ service.initialize(masterPassword)
383
+ return service
384
+ }