@elliotding/ai-agent-mcp 0.1.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/dist/api/cached-client.d.ts +48 -0
- package/dist/api/cached-client.d.ts.map +1 -0
- package/dist/api/cached-client.js +126 -0
- package/dist/api/cached-client.js.map +1 -0
- package/dist/api/client.d.ts +213 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +326 -0
- package/dist/api/client.js.map +1 -0
- package/dist/auth/index.d.ts +8 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +26 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/middleware.d.ts +36 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +194 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/permissions.d.ts +60 -0
- package/dist/auth/permissions.d.ts.map +1 -0
- package/dist/auth/permissions.js +256 -0
- package/dist/auth/permissions.js.map +1 -0
- package/dist/auth/token-validator.d.ts +52 -0
- package/dist/auth/token-validator.d.ts.map +1 -0
- package/dist/auth/token-validator.js +217 -0
- package/dist/auth/token-validator.js.map +1 -0
- package/dist/cache/cache-manager.d.ts +49 -0
- package/dist/cache/cache-manager.d.ts.map +1 -0
- package/dist/cache/cache-manager.js +191 -0
- package/dist/cache/cache-manager.js.map +1 -0
- package/dist/cache/index.d.ts +6 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +12 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/redis-client.d.ts +45 -0
- package/dist/cache/redis-client.d.ts.map +1 -0
- package/dist/cache/redis-client.js +210 -0
- package/dist/cache/redis-client.js.map +1 -0
- package/dist/config/constants.d.ts +28 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +31 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +168 -0
- package/dist/config/index.js.map +1 -0
- package/dist/filesystem/manager.d.ts +45 -0
- package/dist/filesystem/manager.d.ts.map +1 -0
- package/dist/filesystem/manager.js +246 -0
- package/dist/filesystem/manager.js.map +1 -0
- package/dist/git/multi-source-manager.d.ts +62 -0
- package/dist/git/multi-source-manager.d.ts.map +1 -0
- package/dist/git/multi-source-manager.js +293 -0
- package/dist/git/multi-source-manager.js.map +1 -0
- package/dist/git/operations.d.ts +27 -0
- package/dist/git/operations.d.ts.map +1 -0
- package/dist/git/operations.js +83 -0
- package/dist/git/operations.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/monitoring/health.d.ts +35 -0
- package/dist/monitoring/health.d.ts.map +1 -0
- package/dist/monitoring/health.js +105 -0
- package/dist/monitoring/health.js.map +1 -0
- package/dist/resources/index.d.ts +6 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +10 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/loader.d.ts +87 -0
- package/dist/resources/loader.d.ts.map +1 -0
- package/dist/resources/loader.js +452 -0
- package/dist/resources/loader.js.map +1 -0
- package/dist/server/http.d.ts +57 -0
- package/dist/server/http.d.ts.map +1 -0
- package/dist/server/http.js +336 -0
- package/dist/server/http.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/session/manager.d.ts +91 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +251 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/tools/index.d.ts +11 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +27 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/manage-subscription.d.ts +43 -0
- package/dist/tools/manage-subscription.d.ts.map +1 -0
- package/dist/tools/manage-subscription.js +268 -0
- package/dist/tools/manage-subscription.js.map +1 -0
- package/dist/tools/registry.d.ts +40 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +85 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/search-resources.d.ts +31 -0
- package/dist/tools/search-resources.d.ts.map +1 -0
- package/dist/tools/search-resources.js +154 -0
- package/dist/tools/search-resources.js.map +1 -0
- package/dist/tools/sync-resources.d.ts +41 -0
- package/dist/tools/sync-resources.d.ts.map +1 -0
- package/dist/tools/sync-resources.js +606 -0
- package/dist/tools/sync-resources.js.map +1 -0
- package/dist/tools/uninstall-resource.d.ts +30 -0
- package/dist/tools/uninstall-resource.d.ts.map +1 -0
- package/dist/tools/uninstall-resource.js +259 -0
- package/dist/tools/uninstall-resource.js.map +1 -0
- package/dist/tools/upload-resource.d.ts +77 -0
- package/dist/tools/upload-resource.d.ts.map +1 -0
- package/dist/tools/upload-resource.js +252 -0
- package/dist/tools/upload-resource.js.map +1 -0
- package/dist/transport/sse.d.ts +29 -0
- package/dist/transport/sse.d.ts.map +1 -0
- package/dist/transport/sse.js +271 -0
- package/dist/transport/sse.js.map +1 -0
- package/dist/types/errors.d.ts +60 -0
- package/dist/types/errors.d.ts.map +1 -0
- package/dist/types/errors.js +112 -0
- package/dist/types/errors.js.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +23 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/mcp.d.ts +50 -0
- package/dist/types/mcp.d.ts.map +1 -0
- package/dist/types/mcp.js +6 -0
- package/dist/types/mcp.js.map +1 -0
- package/dist/types/resources.d.ts +109 -0
- package/dist/types/resources.d.ts.map +1 -0
- package/dist/types/resources.js +7 -0
- package/dist/types/resources.js.map +1 -0
- package/dist/types/tools.d.ts +147 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +6 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/utils/cursor-paths.d.ts +49 -0
- package/dist/utils/cursor-paths.d.ts.map +1 -0
- package/dist/utils/cursor-paths.js +116 -0
- package/dist/utils/cursor-paths.js.map +1 -0
- package/dist/utils/log-cleaner.d.ts +18 -0
- package/dist/utils/log-cleaner.d.ts.map +1 -0
- package/dist/utils/log-cleaner.js +112 -0
- package/dist/utils/log-cleaner.js.map +1 -0
- package/dist/utils/logger.d.ts +59 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +292 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/validation.d.ts +58 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +214 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +58 -0
- package/src/api/cached-client.ts +144 -0
- package/src/api/client.ts +578 -0
- package/src/auth/index.ts +11 -0
- package/src/auth/middleware.ts +244 -0
- package/src/auth/permissions.ts +317 -0
- package/src/auth/token-validator.ts +294 -0
- package/src/cache/cache-manager.ts +243 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/redis-client.ts +249 -0
- package/src/config/constants.ts +33 -0
- package/src/config/index.ts +228 -0
- package/src/filesystem/manager.ts +235 -0
- package/src/git/multi-source-manager.ts +333 -0
- package/src/git/operations.ts +93 -0
- package/src/index.ts +139 -0
- package/src/monitoring/health.ts +132 -0
- package/src/resources/index.ts +13 -0
- package/src/resources/loader.ts +530 -0
- package/src/server/http.ts +427 -0
- package/src/server.ts +191 -0
- package/src/session/manager.ts +296 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/manage-subscription.ts +332 -0
- package/src/tools/registry.ts +97 -0
- package/src/tools/search-resources.ts +177 -0
- package/src/tools/sync-resources.ts +662 -0
- package/src/tools/uninstall-resource.ts +248 -0
- package/src/tools/upload-resource.ts +258 -0
- package/src/transport/sse.ts +308 -0
- package/src/types/errors.ts +146 -0
- package/src/types/index.ts +7 -0
- package/src/types/mcp.ts +61 -0
- package/src/types/resources.ts +141 -0
- package/src/types/tools.ts +175 -0
- package/src/utils/cursor-paths.ts +83 -0
- package/src/utils/log-cleaner.ts +92 -0
- package/src/utils/logger.ts +333 -0
- package/src/utils/validation.ts +262 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Validation via CSP API
|
|
3
|
+
* Validates tokens by calling CSP /user/permissions endpoint
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { apiClient } from '../api/client';
|
|
7
|
+
import { logger, logError, logAuthAttempt } from '../utils/logger';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Token validation payload structure
|
|
11
|
+
*/
|
|
12
|
+
export interface TokenPayload {
|
|
13
|
+
userId: string;
|
|
14
|
+
email: string;
|
|
15
|
+
groups: string[]; // Changed from 'roles' to 'groups' to match API response
|
|
16
|
+
// Additional fields for compatibility
|
|
17
|
+
roles?: string[]; // Alias for groups
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CSP API response for /user/permissions
|
|
23
|
+
*/
|
|
24
|
+
interface PermissionsResponse {
|
|
25
|
+
code: number;
|
|
26
|
+
data?: {
|
|
27
|
+
user_id: string;
|
|
28
|
+
email: string;
|
|
29
|
+
groups: string[];
|
|
30
|
+
};
|
|
31
|
+
message?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Token validation cache (in-memory, 5 minute TTL)
|
|
36
|
+
*/
|
|
37
|
+
const tokenCache = new Map<string, { payload: TokenPayload; expireAt: number }>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cache cleanup interval reference (for cleanup on shutdown)
|
|
41
|
+
*/
|
|
42
|
+
let cacheCleanupInterval: NodeJS.Timeout | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Clean expired cache entries
|
|
46
|
+
*/
|
|
47
|
+
function cleanExpiredCache(): void {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
let cleaned = 0;
|
|
50
|
+
|
|
51
|
+
for (const [token, entry] of tokenCache.entries()) {
|
|
52
|
+
if (entry.expireAt < now) {
|
|
53
|
+
tokenCache.delete(token);
|
|
54
|
+
cleaned++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cleaned > 0) {
|
|
59
|
+
logger.debug(
|
|
60
|
+
{ type: 'cache_cleanup', cleaned, remaining: tokenCache.size },
|
|
61
|
+
`Cleaned ${cleaned} expired token(s) from cache`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start cache cleanup interval
|
|
68
|
+
*/
|
|
69
|
+
export function startCacheCleanup(): void {
|
|
70
|
+
if (cacheCleanupInterval) {
|
|
71
|
+
logger.warn('Cache cleanup interval already running');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Clean cache every minute
|
|
76
|
+
cacheCleanupInterval = setInterval(cleanExpiredCache, 60000);
|
|
77
|
+
logger.info('Token cache cleanup interval started (60s)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop cache cleanup interval
|
|
82
|
+
*/
|
|
83
|
+
export function stopCacheCleanup(): void {
|
|
84
|
+
if (cacheCleanupInterval) {
|
|
85
|
+
clearInterval(cacheCleanupInterval);
|
|
86
|
+
cacheCleanupInterval = null;
|
|
87
|
+
logger.info('Token cache cleanup interval stopped');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Start cleanup on module load
|
|
92
|
+
startCacheCleanup();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify token by calling CSP API /user/permissions
|
|
96
|
+
* @param token - The JWT token to verify
|
|
97
|
+
* @returns Token payload if valid, null otherwise
|
|
98
|
+
*/
|
|
99
|
+
export async function verifyTokenViaAPI(token: string): Promise<TokenPayload | null> {
|
|
100
|
+
const tokenPreview = token.substring(0, 10) + '...' + token.substring(token.length - 10);
|
|
101
|
+
const startTime = Date.now();
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
logger.debug(
|
|
105
|
+
{
|
|
106
|
+
type: 'auth',
|
|
107
|
+
operation: 'verify_token_api',
|
|
108
|
+
tokenPreview,
|
|
109
|
+
timestamp: new Date().toISOString()
|
|
110
|
+
},
|
|
111
|
+
'Calling CSP API /user/permissions to validate token'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Call CSP API to validate token and get permissions
|
|
115
|
+
// Note: apiClient already adds Authorization header with CSP_API_TOKEN
|
|
116
|
+
// But for SSE connection, we might use a different token from the client
|
|
117
|
+
const response = await apiClient.get<PermissionsResponse>(
|
|
118
|
+
'/csp/api/user/permissions',
|
|
119
|
+
{
|
|
120
|
+
headers: {
|
|
121
|
+
'Authorization': `Bearer ${token}`,
|
|
122
|
+
},
|
|
123
|
+
timeout: 5000, // 5 second timeout for auth check
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const duration = Date.now() - startTime;
|
|
128
|
+
|
|
129
|
+
// Check response code (2000 means success)
|
|
130
|
+
if (response.code === 2000 && response.data) {
|
|
131
|
+
const payload: TokenPayload = {
|
|
132
|
+
userId: response.data.user_id,
|
|
133
|
+
email: response.data.email,
|
|
134
|
+
groups: response.data.groups || [],
|
|
135
|
+
roles: response.data.groups || [], // Alias for backward compatibility
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
logger.info(
|
|
139
|
+
{
|
|
140
|
+
type: 'auth',
|
|
141
|
+
operation: 'verify_token_api',
|
|
142
|
+
userId: payload.userId,
|
|
143
|
+
email: payload.email,
|
|
144
|
+
groups: payload.groups,
|
|
145
|
+
duration,
|
|
146
|
+
timestamp: new Date().toISOString()
|
|
147
|
+
},
|
|
148
|
+
`Token validated successfully for user ${payload.userId}`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
logAuthAttempt('token_validation', true, {
|
|
152
|
+
userId: payload.userId,
|
|
153
|
+
email: payload.email,
|
|
154
|
+
groups: payload.groups,
|
|
155
|
+
duration
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger.warn(
|
|
162
|
+
{
|
|
163
|
+
type: 'auth',
|
|
164
|
+
operation: 'verify_token_api',
|
|
165
|
+
code: response.code,
|
|
166
|
+
message: response.message,
|
|
167
|
+
tokenPreview,
|
|
168
|
+
duration,
|
|
169
|
+
timestamp: new Date().toISOString()
|
|
170
|
+
},
|
|
171
|
+
'Token validation failed - invalid or expired token'
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
logAuthAttempt('token_validation', false, {
|
|
175
|
+
code: response.code,
|
|
176
|
+
message: response.message,
|
|
177
|
+
duration
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const duration = Date.now() - startTime;
|
|
183
|
+
|
|
184
|
+
logError(error as Error, {
|
|
185
|
+
type: 'auth',
|
|
186
|
+
operation: 'verify_token_api',
|
|
187
|
+
tokenPreview,
|
|
188
|
+
duration,
|
|
189
|
+
timestamp: new Date().toISOString()
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
logAuthAttempt('token_validation', false, {
|
|
193
|
+
error: error instanceof Error ? error.message : String(error),
|
|
194
|
+
duration
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Verify token with caching
|
|
203
|
+
* Uses cached result if available to reduce API calls
|
|
204
|
+
* @param token - The token to verify
|
|
205
|
+
* @returns Token payload if valid, null otherwise
|
|
206
|
+
*/
|
|
207
|
+
export async function verifyToken(token: string): Promise<TokenPayload | null> {
|
|
208
|
+
const tokenPreview = token.substring(0, 10) + '...' + token.substring(token.length - 10);
|
|
209
|
+
|
|
210
|
+
// Check cache first
|
|
211
|
+
const cached = tokenCache.get(token);
|
|
212
|
+
if (cached && cached.expireAt > Date.now()) {
|
|
213
|
+
logger.debug(
|
|
214
|
+
{
|
|
215
|
+
type: 'auth',
|
|
216
|
+
operation: 'verify_token',
|
|
217
|
+
userId: cached.payload.userId,
|
|
218
|
+
email: cached.payload.email,
|
|
219
|
+
cacheHit: true,
|
|
220
|
+
tokenPreview,
|
|
221
|
+
timestamp: new Date().toISOString()
|
|
222
|
+
},
|
|
223
|
+
'Token validation cache hit'
|
|
224
|
+
);
|
|
225
|
+
return cached.payload;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
logger.debug(
|
|
229
|
+
{
|
|
230
|
+
type: 'auth',
|
|
231
|
+
operation: 'verify_token',
|
|
232
|
+
cacheHit: false,
|
|
233
|
+
tokenPreview,
|
|
234
|
+
timestamp: new Date().toISOString()
|
|
235
|
+
},
|
|
236
|
+
'Token validation cache miss, calling API'
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Validate via API
|
|
240
|
+
const payload = await verifyTokenViaAPI(token);
|
|
241
|
+
|
|
242
|
+
// Cache the result if valid (5 minute TTL)
|
|
243
|
+
if (payload) {
|
|
244
|
+
const expireAt = Date.now() + 5 * 60 * 1000; // 5 minutes
|
|
245
|
+
tokenCache.set(token, { payload, expireAt });
|
|
246
|
+
logger.debug(
|
|
247
|
+
{
|
|
248
|
+
type: 'auth',
|
|
249
|
+
operation: 'verify_token',
|
|
250
|
+
userId: payload.userId,
|
|
251
|
+
email: payload.email,
|
|
252
|
+
cacheTTL: '5min',
|
|
253
|
+
timestamp: new Date().toISOString()
|
|
254
|
+
},
|
|
255
|
+
'Token validation result cached (5 min TTL)'
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return payload;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Clear token from cache (e.g., after logout)
|
|
264
|
+
* @param token - The token to invalidate
|
|
265
|
+
*/
|
|
266
|
+
export function invalidateToken(token: string): void {
|
|
267
|
+
tokenCache.delete(token);
|
|
268
|
+
logger.debug(
|
|
269
|
+
{ type: 'auth', operation: 'invalidate_token' },
|
|
270
|
+
'Token removed from cache'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Clear all cached tokens
|
|
276
|
+
*/
|
|
277
|
+
export function clearTokenCache(): void {
|
|
278
|
+
tokenCache.clear();
|
|
279
|
+
logger.info(
|
|
280
|
+
{ type: 'auth', operation: 'clear_cache' },
|
|
281
|
+
'All cached tokens cleared'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get cache statistics
|
|
287
|
+
*/
|
|
288
|
+
export function getTokenCacheStats() {
|
|
289
|
+
cleanExpiredCache();
|
|
290
|
+
return {
|
|
291
|
+
size: tokenCache.size,
|
|
292
|
+
tokens: Array.from(tokenCache.keys()).map(t => t.substring(0, 10) + '...'),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Layer Cache Manager
|
|
3
|
+
* L1: In-memory LRU cache
|
|
4
|
+
* L2: Redis persistent cache
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { LRUCache } from 'lru-cache';
|
|
8
|
+
import { config } from '../config';
|
|
9
|
+
import { logger } from '../utils/logger';
|
|
10
|
+
import { redisClient } from './redis-client';
|
|
11
|
+
|
|
12
|
+
const CACHE_KEY_PREFIX = 'csp:cache';
|
|
13
|
+
|
|
14
|
+
/** JSON-serializable cache value type (satisfies LRUCache V extends {}) */
|
|
15
|
+
type CacheValue = object | string | number | boolean;
|
|
16
|
+
|
|
17
|
+
export interface CacheStats {
|
|
18
|
+
l1Hits: number;
|
|
19
|
+
l2Hits: number;
|
|
20
|
+
misses: number;
|
|
21
|
+
hitRate: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class CacheManager {
|
|
25
|
+
private static instance: CacheManager | null = null;
|
|
26
|
+
private readonly l1: LRUCache<string, CacheValue>;
|
|
27
|
+
private readonly l2: typeof redisClient;
|
|
28
|
+
private readonly defaultTtlSeconds: number;
|
|
29
|
+
private readonly defaultNamespace: string;
|
|
30
|
+
|
|
31
|
+
private l1Hits = 0;
|
|
32
|
+
private l2Hits = 0;
|
|
33
|
+
private misses = 0;
|
|
34
|
+
|
|
35
|
+
private constructor(options?: { namespace?: string }) {
|
|
36
|
+
const ttlMs = (config.cache.redis?.ttl ?? 900) * 1000;
|
|
37
|
+
this.defaultTtlSeconds = config.cache.redis?.ttl ?? 900;
|
|
38
|
+
this.defaultNamespace = options?.namespace ?? 'default';
|
|
39
|
+
|
|
40
|
+
this.l1 = new LRUCache<string, CacheValue>({
|
|
41
|
+
max: 100,
|
|
42
|
+
ttl: ttlMs,
|
|
43
|
+
ttlAutopurge: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.l2 = redisClient;
|
|
47
|
+
|
|
48
|
+
logger.info(
|
|
49
|
+
{
|
|
50
|
+
type: 'cache',
|
|
51
|
+
message: 'CacheManager initialized',
|
|
52
|
+
l1Max: 100,
|
|
53
|
+
ttlSeconds: this.defaultTtlSeconds,
|
|
54
|
+
namespace: this.defaultNamespace,
|
|
55
|
+
},
|
|
56
|
+
'Multi-layer cache initialized'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static getInstance(options?: { namespace?: string }): CacheManager {
|
|
61
|
+
if (CacheManager.instance === null) {
|
|
62
|
+
CacheManager.instance = new CacheManager(options);
|
|
63
|
+
}
|
|
64
|
+
return CacheManager.instance;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static async resetInstance(): Promise<void> {
|
|
68
|
+
if (CacheManager.instance) {
|
|
69
|
+
await CacheManager.instance.clear();
|
|
70
|
+
await CacheManager.instance.l2.disconnect();
|
|
71
|
+
CacheManager.instance = null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private buildKey(key: string, namespace?: string): string {
|
|
76
|
+
const ns = namespace ?? this.defaultNamespace;
|
|
77
|
+
return `${CACHE_KEY_PREFIX}:${ns}:${key}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private getRedisPattern(namespace?: string): string {
|
|
81
|
+
const ns = namespace ?? this.defaultNamespace;
|
|
82
|
+
return `${CACHE_KEY_PREFIX}:${ns}:*`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async connect(): Promise<void> {
|
|
86
|
+
await this.l2.connect();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get value from cache. Checks L1 first, then L2.
|
|
91
|
+
* On L2 hit, promotes value to L1.
|
|
92
|
+
*/
|
|
93
|
+
async get(key: string, namespace?: string): Promise<unknown | null> {
|
|
94
|
+
const fullKey = this.buildKey(key, namespace);
|
|
95
|
+
|
|
96
|
+
const l1Value = this.l1.get(fullKey);
|
|
97
|
+
if (l1Value !== undefined) {
|
|
98
|
+
this.l1Hits++;
|
|
99
|
+
logger.debug({ type: 'cache', key: fullKey, layer: 'L1' }, 'Cache L1 hit');
|
|
100
|
+
return l1Value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let l2Value: string | null = null;
|
|
104
|
+
try {
|
|
105
|
+
l2Value = await this.l2.get(fullKey);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.debug(
|
|
108
|
+
{ type: 'cache', key: fullKey, error: (error as Error).message },
|
|
109
|
+
'L2 get failed, Redis may be unavailable'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (l2Value !== null) {
|
|
114
|
+
this.l2Hits++;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(l2Value) as unknown;
|
|
117
|
+
if (parsed !== null && parsed !== undefined) {
|
|
118
|
+
this.l1.set(fullKey, parsed as CacheValue);
|
|
119
|
+
}
|
|
120
|
+
logger.debug({ type: 'cache', key: fullKey, layer: 'L2' }, 'Cache L2 hit, promoted to L1');
|
|
121
|
+
return parsed;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logger.warn(
|
|
124
|
+
{ type: 'cache', key: fullKey, error: (error as Error).message },
|
|
125
|
+
'Failed to parse L2 cache value'
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
await this.l2.del(fullKey);
|
|
129
|
+
} catch {
|
|
130
|
+
/* Redis may be unavailable */
|
|
131
|
+
}
|
|
132
|
+
this.misses++;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.misses++;
|
|
138
|
+
logger.debug({ type: 'cache', key: fullKey }, 'Cache miss');
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set value in both L1 and L2 caches.
|
|
144
|
+
*/
|
|
145
|
+
async set(
|
|
146
|
+
key: string,
|
|
147
|
+
value: unknown,
|
|
148
|
+
ttl?: number,
|
|
149
|
+
namespace?: string
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const fullKey = this.buildKey(key, namespace);
|
|
152
|
+
const ttlSeconds = ttl ?? this.defaultTtlSeconds;
|
|
153
|
+
const ttlMs = ttlSeconds * 1000;
|
|
154
|
+
|
|
155
|
+
if (value !== null && value !== undefined) {
|
|
156
|
+
this.l1.set(fullKey, value as CacheValue, { ttl: ttlMs });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const serialized = JSON.stringify(value);
|
|
160
|
+
try {
|
|
161
|
+
await this.l2.set(fullKey, serialized, ttlSeconds);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger.debug(
|
|
164
|
+
{ type: 'cache', key: fullKey, error: (error as Error).message },
|
|
165
|
+
'L2 set failed, Redis may be unavailable'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logger.debug(
|
|
170
|
+
{ type: 'cache', key: fullKey, ttlSeconds },
|
|
171
|
+
'Cache set'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete key from both caches.
|
|
177
|
+
*/
|
|
178
|
+
async del(key: string, namespace?: string): Promise<void> {
|
|
179
|
+
const fullKey = this.buildKey(key, namespace);
|
|
180
|
+
this.l1.delete(fullKey);
|
|
181
|
+
try {
|
|
182
|
+
await this.l2.del(fullKey);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logger.debug(
|
|
185
|
+
{ type: 'cache', key: fullKey, error: (error as Error).message },
|
|
186
|
+
'L2 del failed, Redis may be unavailable'
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
logger.debug({ type: 'cache', key: fullKey }, 'Cache del');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clear all cache layers. If namespace provided, clear only that namespace.
|
|
194
|
+
*/
|
|
195
|
+
async clear(namespace?: string): Promise<void> {
|
|
196
|
+
if (namespace !== undefined) {
|
|
197
|
+
const pattern = this.getRedisPattern(namespace);
|
|
198
|
+
const prefix = `${CACHE_KEY_PREFIX}:${namespace}:`;
|
|
199
|
+
for (const k of this.l1.keys()) {
|
|
200
|
+
if (k.startsWith(prefix)) {
|
|
201
|
+
this.l1.delete(k);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
await this.l2.clear(pattern);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger.debug(
|
|
208
|
+
{ type: 'cache', namespace, error: (error as Error).message },
|
|
209
|
+
'L2 clear failed, Redis may be unavailable'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
logger.info({ type: 'cache', namespace }, 'Cache cleared for namespace');
|
|
213
|
+
} else {
|
|
214
|
+
this.l1.clear();
|
|
215
|
+
try {
|
|
216
|
+
await this.l2.clear(`${CACHE_KEY_PREFIX}:*`);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.debug(
|
|
219
|
+
{ type: 'cache', error: (error as Error).message },
|
|
220
|
+
'L2 clear failed, Redis may be unavailable'
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
logger.info({ type: 'cache' }, 'Cache cleared');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getStats(): CacheStats {
|
|
228
|
+
const total = this.l1Hits + this.l2Hits + this.misses;
|
|
229
|
+
const hitRate = total > 0 ? (this.l1Hits + this.l2Hits) / total : 0;
|
|
230
|
+
return {
|
|
231
|
+
l1Hits: this.l1Hits,
|
|
232
|
+
l2Hits: this.l2Hits,
|
|
233
|
+
misses: this.misses,
|
|
234
|
+
hitRate,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
resetStats(): void {
|
|
239
|
+
this.l1Hits = 0;
|
|
240
|
+
this.l2Hits = 0;
|
|
241
|
+
this.misses = 0;
|
|
242
|
+
}
|
|
243
|
+
}
|