@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.
- package/.env.example +57 -0
- package/README.md +374 -0
- package/dist/index.js +189 -0
- package/dist/mcp/index.js +1132 -0
- package/docker-compose.prod.yml +91 -0
- package/docker-compose.yml +358 -0
- package/drizzle/0000_dapper_the_professor.sql +159 -0
- package/drizzle/0001_api_keys.sql +51 -0
- package/drizzle/meta/0000_snapshot.json +1532 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +20 -0
- package/package.json +114 -0
- package/scripts/add-extraction-job.ts +122 -0
- package/scripts/benchmark-pgvector.ts +122 -0
- package/scripts/bootstrap.sh +209 -0
- package/scripts/check-runtime-pack.ts +111 -0
- package/scripts/claude-mcp-config.ts +336 -0
- package/scripts/docker-entrypoint.sh +183 -0
- package/scripts/doctor.ts +377 -0
- package/scripts/init-db.sql +33 -0
- package/scripts/install.sh +1110 -0
- package/scripts/mcp-setup.ts +271 -0
- package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
- package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
- package/scripts/migrations/003_create_hnsw_index.sql +94 -0
- package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
- package/scripts/migrations/005_create_chunks_table.sql +95 -0
- package/scripts/migrations/006_create_processing_queue.sql +45 -0
- package/scripts/migrations/generate_test_data.sql +42 -0
- package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
- package/scripts/migrations/run_migrations.sh +286 -0
- package/scripts/migrations/test_hnsw_index.sql +255 -0
- package/scripts/pre-commit-secrets +282 -0
- package/scripts/run-extraction-worker.ts +46 -0
- package/scripts/run-phase1-tests.sh +291 -0
- package/scripts/setup.ts +222 -0
- package/scripts/smoke-install.sh +12 -0
- package/scripts/test-health-endpoint.sh +328 -0
- package/src/api/index.ts +2 -0
- package/src/api/middleware/auth.ts +80 -0
- package/src/api/middleware/csrf.ts +308 -0
- package/src/api/middleware/errorHandler.ts +166 -0
- package/src/api/middleware/rateLimit.ts +360 -0
- package/src/api/middleware/validation.ts +514 -0
- package/src/api/routes/documents.ts +286 -0
- package/src/api/routes/profiles.ts +237 -0
- package/src/api/routes/search.ts +71 -0
- package/src/api/stores/index.ts +58 -0
- package/src/config/bootstrap-env.ts +3 -0
- package/src/config/env.ts +71 -0
- package/src/config/feature-flags.ts +25 -0
- package/src/config/index.ts +140 -0
- package/src/config/secrets.config.ts +291 -0
- package/src/db/client.ts +92 -0
- package/src/db/index.ts +73 -0
- package/src/db/postgres.ts +72 -0
- package/src/db/schema/chunks.schema.ts +31 -0
- package/src/db/schema/containers.schema.ts +46 -0
- package/src/db/schema/documents.schema.ts +49 -0
- package/src/db/schema/embeddings.schema.ts +32 -0
- package/src/db/schema/index.ts +11 -0
- package/src/db/schema/memories.schema.ts +72 -0
- package/src/db/schema/profiles.schema.ts +34 -0
- package/src/db/schema/queue.schema.ts +59 -0
- package/src/db/schema/relationships.schema.ts +42 -0
- package/src/db/schema.ts +223 -0
- package/src/db/worker-connection.ts +47 -0
- package/src/index.ts +235 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/index.ts +1380 -0
- package/src/mcp/legacyState.ts +22 -0
- package/src/mcp/rateLimit.ts +358 -0
- package/src/mcp/resources.ts +309 -0
- package/src/mcp/results.ts +104 -0
- package/src/mcp/tools.ts +401 -0
- package/src/queues/config.ts +119 -0
- package/src/queues/index.ts +289 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/errors.ts +266 -0
- package/src/sdk/http.ts +560 -0
- package/src/sdk/index.ts +244 -0
- package/src/sdk/resources/base.ts +65 -0
- package/src/sdk/resources/connections.ts +204 -0
- package/src/sdk/resources/documents.ts +163 -0
- package/src/sdk/resources/index.ts +10 -0
- package/src/sdk/resources/memories.ts +150 -0
- package/src/sdk/resources/search.ts +60 -0
- package/src/sdk/resources/settings.ts +36 -0
- package/src/sdk/types.ts +674 -0
- package/src/services/chunking/index.ts +451 -0
- package/src/services/chunking.service.ts +650 -0
- package/src/services/csrf.service.ts +252 -0
- package/src/services/documents.repository.ts +219 -0
- package/src/services/documents.service.ts +191 -0
- package/src/services/embedding.service.ts +404 -0
- package/src/services/extraction.service.ts +300 -0
- package/src/services/extractors/code.extractor.ts +451 -0
- package/src/services/extractors/index.ts +9 -0
- package/src/services/extractors/markdown.extractor.ts +461 -0
- package/src/services/extractors/pdf.extractor.ts +315 -0
- package/src/services/extractors/text.extractor.ts +118 -0
- package/src/services/extractors/url.extractor.ts +243 -0
- package/src/services/index.ts +235 -0
- package/src/services/ingestion.service.ts +177 -0
- package/src/services/llm/anthropic.ts +400 -0
- package/src/services/llm/base.ts +460 -0
- package/src/services/llm/contradiction-detector.service.ts +526 -0
- package/src/services/llm/heuristics.ts +148 -0
- package/src/services/llm/index.ts +309 -0
- package/src/services/llm/memory-classifier.service.ts +383 -0
- package/src/services/llm/memory-extension-detector.service.ts +523 -0
- package/src/services/llm/mock.ts +470 -0
- package/src/services/llm/openai.ts +398 -0
- package/src/services/llm/prompts.ts +438 -0
- package/src/services/llm/types.ts +373 -0
- package/src/services/memory.repository.ts +1769 -0
- package/src/services/memory.service.ts +1338 -0
- package/src/services/memory.types.ts +234 -0
- package/src/services/persistence/index.ts +295 -0
- package/src/services/pipeline.service.ts +509 -0
- package/src/services/profile.repository.ts +436 -0
- package/src/services/profile.service.ts +560 -0
- package/src/services/profile.types.ts +270 -0
- package/src/services/relationships/detector.ts +1128 -0
- package/src/services/relationships/index.ts +268 -0
- package/src/services/relationships/memory-integration.ts +459 -0
- package/src/services/relationships/strategies.ts +132 -0
- package/src/services/relationships/types.ts +370 -0
- package/src/services/search.service.ts +761 -0
- package/src/services/search.types.ts +220 -0
- package/src/services/secrets.service.ts +384 -0
- package/src/services/vectorstore/base.ts +327 -0
- package/src/services/vectorstore/index.ts +444 -0
- package/src/services/vectorstore/memory.ts +286 -0
- package/src/services/vectorstore/migration.ts +295 -0
- package/src/services/vectorstore/mock.ts +403 -0
- package/src/services/vectorstore/pgvector.ts +695 -0
- package/src/services/vectorstore/types.ts +247 -0
- package/src/startup.ts +389 -0
- package/src/types/api.types.ts +193 -0
- package/src/types/document.types.ts +103 -0
- package/src/types/index.ts +241 -0
- package/src/types/profile.base.ts +133 -0
- package/src/utils/errors.ts +447 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/index.ts +101 -0
- package/src/utils/logger.ts +313 -0
- package/src/utils/sanitization.ts +501 -0
- package/src/utils/secret-validation.ts +273 -0
- package/src/utils/synonyms.ts +188 -0
- package/src/utils/validation.ts +581 -0
- package/src/workers/chunking.worker.ts +242 -0
- package/src/workers/embedding.worker.ts +358 -0
- package/src/workers/extraction.worker.ts +346 -0
- package/src/workers/indexing.worker.ts +505 -0
- 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
|
+
}
|