@sylphx/lens-server 2.3.2 → 2.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.
@@ -139,12 +139,12 @@ describe("createHTTPHandler", () => {
139
139
  expect(data.data.id).toBe("456");
140
140
  });
141
141
 
142
- it("handles CORS preflight", async () => {
142
+ it("handles CORS preflight in development mode", async () => {
143
143
  const app = createApp({
144
144
  queries: { getUser },
145
145
  mutations: { createUser },
146
146
  });
147
- const handler = createHTTPHandler(app);
147
+ const handler = createHTTPHandler(app, { errors: { development: true } });
148
148
 
149
149
  const request = new Request("http://localhost/", {
150
150
  method: "OPTIONS",
@@ -155,6 +155,39 @@ describe("createHTTPHandler", () => {
155
155
  expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
156
156
  });
157
157
 
158
+ it("does not allow CORS by default in production mode", async () => {
159
+ const app = createApp({
160
+ queries: { getUser },
161
+ mutations: { createUser },
162
+ });
163
+ const handler = createHTTPHandler(app); // Production mode is default
164
+
165
+ const request = new Request("http://localhost/", {
166
+ method: "OPTIONS",
167
+ });
168
+
169
+ const response = await handler(request);
170
+ expect(response.status).toBe(204);
171
+ // No Access-Control-Allow-Origin header in production without explicit config
172
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
173
+ });
174
+
175
+ it("allows CORS with explicit origin configuration", async () => {
176
+ const app = createApp({
177
+ queries: { getUser },
178
+ mutations: { createUser },
179
+ });
180
+ const handler = createHTTPHandler(app, { cors: { origin: "https://example.com" } });
181
+
182
+ const request = new Request("http://localhost/", {
183
+ method: "OPTIONS",
184
+ });
185
+
186
+ const response = await handler(request);
187
+ expect(response.status).toBe(204);
188
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://example.com");
189
+ });
190
+
158
191
  it("returns 404 for unknown paths", async () => {
159
192
  const app = createApp({
160
193
  queries: { getUser },
@@ -212,4 +245,196 @@ describe("createHTTPHandler", () => {
212
245
  const data = await response.json();
213
246
  expect(data.error).toContain("not found");
214
247
  });
248
+
249
+ describe("health check", () => {
250
+ it("returns health status on GET /__lens/health", async () => {
251
+ const app = createApp({
252
+ queries: { getUser },
253
+ mutations: { createUser },
254
+ });
255
+ const handler = createHTTPHandler(app);
256
+
257
+ const request = new Request("http://localhost/__lens/health", {
258
+ method: "GET",
259
+ });
260
+
261
+ const response = await handler(request);
262
+ expect(response.status).toBe(200);
263
+
264
+ const data = await response.json();
265
+ expect(data.status).toBe("healthy");
266
+ expect(data.service).toBe("lens-server");
267
+ expect(data.version).toBeDefined();
268
+ expect(data.uptime).toBeGreaterThanOrEqual(0);
269
+ expect(data.timestamp).toBeDefined();
270
+ });
271
+
272
+ it("supports path prefix for health endpoint", async () => {
273
+ const app = createApp({
274
+ queries: { getUser },
275
+ mutations: { createUser },
276
+ });
277
+ const handler = createHTTPHandler(app, { pathPrefix: "/api" });
278
+
279
+ const request = new Request("http://localhost/api/__lens/health", {
280
+ method: "GET",
281
+ });
282
+
283
+ const response = await handler(request);
284
+ expect(response.status).toBe(200);
285
+
286
+ const data = await response.json();
287
+ expect(data.status).toBe("healthy");
288
+ });
289
+
290
+ it("supports custom health check path", async () => {
291
+ const app = createApp({
292
+ queries: { getUser },
293
+ mutations: { createUser },
294
+ });
295
+ const handler = createHTTPHandler(app, {
296
+ health: { path: "/healthz" },
297
+ });
298
+
299
+ const request = new Request("http://localhost/healthz", {
300
+ method: "GET",
301
+ });
302
+
303
+ const response = await handler(request);
304
+ expect(response.status).toBe(200);
305
+ });
306
+
307
+ it("can be disabled", async () => {
308
+ const app = createApp({
309
+ queries: { getUser },
310
+ mutations: { createUser },
311
+ });
312
+ const handler = createHTTPHandler(app, {
313
+ health: { enabled: false },
314
+ });
315
+
316
+ const request = new Request("http://localhost/__lens/health", {
317
+ method: "GET",
318
+ });
319
+
320
+ const response = await handler(request);
321
+ expect(response.status).toBe(404);
322
+ });
323
+
324
+ it("includes custom health checks", async () => {
325
+ const app = createApp({
326
+ queries: { getUser },
327
+ mutations: { createUser },
328
+ });
329
+ const handler = createHTTPHandler(app, {
330
+ health: {
331
+ checks: () => ({
332
+ database: { status: "pass", message: "Connected" },
333
+ cache: { status: "pass" },
334
+ }),
335
+ },
336
+ });
337
+
338
+ const request = new Request("http://localhost/__lens/health", {
339
+ method: "GET",
340
+ });
341
+
342
+ const response = await handler(request);
343
+ expect(response.status).toBe(200);
344
+
345
+ const data = await response.json();
346
+ expect(data.checks?.database.status).toBe("pass");
347
+ expect(data.checks?.cache.status).toBe("pass");
348
+ });
349
+
350
+ it("returns 503 when custom health check fails", async () => {
351
+ const app = createApp({
352
+ queries: { getUser },
353
+ mutations: { createUser },
354
+ });
355
+ const handler = createHTTPHandler(app, {
356
+ health: {
357
+ checks: () => ({
358
+ database: { status: "fail", message: "Connection refused" },
359
+ }),
360
+ },
361
+ });
362
+
363
+ const request = new Request("http://localhost/__lens/health", {
364
+ method: "GET",
365
+ });
366
+
367
+ const response = await handler(request);
368
+ expect(response.status).toBe(503);
369
+
370
+ const data = await response.json();
371
+ expect(data.status).toBe("degraded");
372
+ expect(data.checks?.database.status).toBe("fail");
373
+ });
374
+
375
+ it("handles async health checks", async () => {
376
+ const app = createApp({
377
+ queries: { getUser },
378
+ mutations: { createUser },
379
+ });
380
+ const handler = createHTTPHandler(app, {
381
+ health: {
382
+ checks: async () => {
383
+ await new Promise((r) => setTimeout(r, 10));
384
+ return { async: { status: "pass" } };
385
+ },
386
+ },
387
+ });
388
+
389
+ const request = new Request("http://localhost/__lens/health", {
390
+ method: "GET",
391
+ });
392
+
393
+ const response = await handler(request);
394
+ expect(response.status).toBe(200);
395
+
396
+ const data = await response.json();
397
+ expect(data.checks?.async.status).toBe("pass");
398
+ });
399
+
400
+ it("handles health check errors gracefully", async () => {
401
+ const app = createApp({
402
+ queries: { getUser },
403
+ mutations: { createUser },
404
+ });
405
+ const handler = createHTTPHandler(app, {
406
+ health: {
407
+ checks: () => {
408
+ throw new Error("Check failed");
409
+ },
410
+ },
411
+ });
412
+
413
+ const request = new Request("http://localhost/__lens/health", {
414
+ method: "GET",
415
+ });
416
+
417
+ const response = await handler(request);
418
+ expect(response.status).toBe(503);
419
+
420
+ const data = await response.json();
421
+ expect(data.status).toBe("degraded");
422
+ expect(data.checks?.healthCheck.status).toBe("fail");
423
+ });
424
+
425
+ it("includes Cache-Control header", async () => {
426
+ const app = createApp({
427
+ queries: { getUser },
428
+ mutations: { createUser },
429
+ });
430
+ const handler = createHTTPHandler(app);
431
+
432
+ const request = new Request("http://localhost/__lens/health", {
433
+ method: "GET",
434
+ });
435
+
436
+ const response = await handler(request);
437
+ expect(response.headers.get("Cache-Control")).toBe("no-cache, no-store, must-revalidate");
438
+ });
439
+ });
215
440
  });
@@ -12,6 +12,64 @@ import type { LensServer } from "../server/create.js";
12
12
  // Types
13
13
  // =============================================================================
14
14
 
15
+ /** Error sanitization options */
16
+ export interface ErrorSanitizationOptions {
17
+ /**
18
+ * Enable development mode - shows full error messages.
19
+ * Default: false (production mode - sanitized errors only)
20
+ */
21
+ development?: boolean;
22
+
23
+ /**
24
+ * Custom error sanitizer function.
25
+ * Return a safe error message to send to the client.
26
+ */
27
+ sanitize?: (error: Error) => string;
28
+ }
29
+
30
+ /**
31
+ * Health check response data
32
+ */
33
+ export interface HealthCheckResponse {
34
+ /** Overall status */
35
+ status: "healthy" | "degraded" | "unhealthy";
36
+ /** Service name */
37
+ service: string;
38
+ /** Server version */
39
+ version: string;
40
+ /** Uptime in seconds */
41
+ uptime: number;
42
+ /** Timestamp of health check */
43
+ timestamp: string;
44
+ /** Optional checks with their status */
45
+ checks?: Record<string, { status: "pass" | "fail"; message?: string }>;
46
+ }
47
+
48
+ /**
49
+ * Health check options
50
+ */
51
+ export interface HealthCheckOptions {
52
+ /**
53
+ * Enable health check endpoint.
54
+ * Default: true
55
+ */
56
+ enabled?: boolean;
57
+
58
+ /**
59
+ * Custom health check path.
60
+ * Default: "/__lens/health"
61
+ */
62
+ path?: string;
63
+
64
+ /**
65
+ * Custom health check function.
66
+ * Return additional checks to include in the response.
67
+ */
68
+ checks?: () =>
69
+ | Promise<Record<string, { status: "pass" | "fail"; message?: string }>>
70
+ | Record<string, { status: "pass" | "fail"; message?: string }>;
71
+ }
72
+
15
73
  export interface HTTPHandlerOptions {
16
74
  /**
17
75
  * Path prefix for Lens endpoints.
@@ -29,13 +87,25 @@ export interface HTTPHandlerOptions {
29
87
 
30
88
  /**
31
89
  * Custom CORS headers.
32
- * Default: Allow all origins
90
+ * Default: Allow all origins in development, strict in production
33
91
  */
34
92
  cors?: {
35
93
  origin?: string | string[];
36
94
  methods?: string[];
37
95
  headers?: string[];
38
96
  };
97
+
98
+ /**
99
+ * Error sanitization options.
100
+ * Controls what error information is exposed to clients.
101
+ */
102
+ errors?: ErrorSanitizationOptions;
103
+
104
+ /**
105
+ * Health check endpoint configuration.
106
+ * Enabled by default at /__lens/health
107
+ */
108
+ health?: HealthCheckOptions;
39
109
  }
40
110
 
41
111
  export interface HTTPHandler {
@@ -75,23 +145,100 @@ export interface HTTPHandler {
75
145
  * export default { fetch: handler }
76
146
  * ```
77
147
  */
148
+ /**
149
+ * Default error sanitizer - removes sensitive information from errors.
150
+ * Safe error messages are preserved, internal details are hidden.
151
+ */
152
+ function sanitizeError(error: Error, isDevelopment: boolean): string {
153
+ if (isDevelopment) {
154
+ return error.message;
155
+ }
156
+
157
+ const message = error.message;
158
+
159
+ // Known safe error patterns (validation errors, business logic errors)
160
+ const safePatterns = [
161
+ /^Invalid input:/,
162
+ /^Missing operation/,
163
+ /^Not found/,
164
+ /^Unauthorized/,
165
+ /^Forbidden/,
166
+ /^Bad request/,
167
+ /^Validation failed/,
168
+ ];
169
+
170
+ if (safePatterns.some((pattern) => pattern.test(message))) {
171
+ return message;
172
+ }
173
+
174
+ // Check for sensitive patterns
175
+ const sensitivePatterns = [
176
+ /\/[^\s]+\.(ts|js|json)/, // file paths
177
+ /at\s+[^\s]+\s+\(/, // stack traces
178
+ /ENOENT|EACCES|ECONNREFUSED/, // system errors
179
+ /SELECT|INSERT|UPDATE|DELETE|FROM|WHERE/i, // SQL
180
+ /password|secret|token|key|auth/i, // credentials
181
+ ];
182
+
183
+ if (sensitivePatterns.some((pattern) => pattern.test(message))) {
184
+ return "An internal error occurred";
185
+ }
186
+
187
+ // Allow short, simple messages through
188
+ if (message.length < 100 && !message.includes("\n")) {
189
+ return message;
190
+ }
191
+
192
+ return "An internal error occurred";
193
+ }
194
+
78
195
  export function createHTTPHandler(
79
196
  server: LensServer,
80
197
  options: HTTPHandlerOptions = {},
81
198
  ): HTTPHandler {
82
- const { pathPrefix = "", cors } = options;
199
+ const { pathPrefix = "", cors, errors, health } = options;
200
+ const isDevelopment = errors?.development ?? false;
201
+
202
+ // Health check configuration
203
+ const healthEnabled = health?.enabled !== false; // Enabled by default
204
+ const healthPath = health?.path ?? "/__lens/health";
205
+ const startTime = Date.now();
206
+
207
+ // Error sanitization function
208
+ const sanitize = (error: Error): string => {
209
+ if (errors?.sanitize) {
210
+ return errors.sanitize(error);
211
+ }
212
+ return sanitizeError(error, isDevelopment);
213
+ };
83
214
 
84
215
  // Build CORS headers
85
- const corsHeaders: Record<string, string> = {
86
- "Access-Control-Allow-Origin": cors?.origin
87
- ? Array.isArray(cors.origin)
88
- ? cors.origin.join(", ")
89
- : cors.origin
90
- : "*",
216
+ // In production, require explicit origin configuration for security
217
+ // In development, allow all origins for convenience
218
+ const allowedOrigin = cors?.origin
219
+ ? Array.isArray(cors.origin)
220
+ ? cors.origin.join(", ")
221
+ : cors.origin
222
+ : isDevelopment
223
+ ? "*"
224
+ : ""; // No cross-origin allowed by default in production
225
+
226
+ // Base headers including security headers
227
+ const baseHeaders: Record<string, string> = {
228
+ "Content-Type": "application/json",
229
+ // Security headers
230
+ "X-Content-Type-Options": "nosniff",
231
+ "X-Frame-Options": "DENY",
232
+ // CORS headers
91
233
  "Access-Control-Allow-Methods": cors?.methods?.join(", ") ?? "GET, POST, OPTIONS",
92
234
  "Access-Control-Allow-Headers": cors?.headers?.join(", ") ?? "Content-Type, Authorization",
93
235
  };
94
236
 
237
+ // Only add Access-Control-Allow-Origin if there's an allowed origin
238
+ if (allowedOrigin) {
239
+ baseHeaders["Access-Control-Allow-Origin"] = allowedOrigin;
240
+ }
241
+
95
242
  const handler = async (request: Request): Promise<Response> => {
96
243
  const url = new URL(request.url);
97
244
  const pathname = url.pathname;
@@ -100,7 +247,52 @@ export function createHTTPHandler(
100
247
  if (request.method === "OPTIONS") {
101
248
  return new Response(null, {
102
249
  status: 204,
103
- headers: corsHeaders,
250
+ headers: baseHeaders,
251
+ });
252
+ }
253
+
254
+ // Health check endpoint: GET /__lens/health
255
+ const fullHealthPath = `${pathPrefix}${healthPath}`;
256
+ if (healthEnabled && request.method === "GET" && pathname === fullHealthPath) {
257
+ const metadata = server.getMetadata();
258
+ const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
259
+
260
+ // Run custom health checks if provided
261
+ let customChecks: Record<string, { status: "pass" | "fail"; message?: string }> = {};
262
+ let hasFailure = false;
263
+
264
+ if (health?.checks) {
265
+ try {
266
+ customChecks = await health.checks();
267
+ hasFailure = Object.values(customChecks).some((c) => c.status === "fail");
268
+ } catch (error) {
269
+ customChecks.healthCheck = {
270
+ status: "fail",
271
+ message: error instanceof Error ? error.message : "Health check failed",
272
+ };
273
+ hasFailure = true;
274
+ }
275
+ }
276
+
277
+ const response: HealthCheckResponse = {
278
+ status: hasFailure ? "degraded" : "healthy",
279
+ service: "lens-server",
280
+ version: metadata.version,
281
+ uptime: uptimeSeconds,
282
+ timestamp: new Date().toISOString(),
283
+ };
284
+
285
+ if (Object.keys(customChecks).length > 0) {
286
+ response.checks = customChecks;
287
+ }
288
+
289
+ return new Response(JSON.stringify(response), {
290
+ status: hasFailure ? 503 : 200,
291
+ headers: {
292
+ "Content-Type": "application/json",
293
+ "Cache-Control": "no-cache, no-store, must-revalidate",
294
+ ...baseHeaders,
295
+ },
104
296
  });
105
297
  }
106
298
 
@@ -110,7 +302,7 @@ export function createHTTPHandler(
110
302
  return new Response(JSON.stringify(server.getMetadata()), {
111
303
  headers: {
112
304
  "Content-Type": "application/json",
113
- ...corsHeaders,
305
+ ...baseHeaders,
114
306
  },
115
307
  });
116
308
  }
@@ -121,13 +313,21 @@ export function createHTTPHandler(
121
313
  request.method === "POST" &&
122
314
  (pathname === operationPath || pathname === `${pathPrefix}/`)
123
315
  ) {
316
+ // Parse JSON body with proper error handling
317
+ let body: { operation?: string; path?: string; input?: unknown };
124
318
  try {
125
- const body = (await request.json()) as {
126
- operation?: string;
127
- path?: string;
128
- input?: unknown;
129
- };
319
+ body = (await request.json()) as typeof body;
320
+ } catch {
321
+ return new Response(JSON.stringify({ error: "Invalid JSON in request body" }), {
322
+ status: 400,
323
+ headers: {
324
+ "Content-Type": "application/json",
325
+ ...baseHeaders,
326
+ },
327
+ });
328
+ }
130
329
 
330
+ try {
131
331
  // Support both 'operation' and 'path' for backwards compatibility
132
332
  const operationPath = body.operation ?? body.path;
133
333
  if (!operationPath) {
@@ -135,7 +335,7 @@ export function createHTTPHandler(
135
335
  status: 400,
136
336
  headers: {
137
337
  "Content-Type": "application/json",
138
- ...corsHeaders,
338
+ ...baseHeaders,
139
339
  },
140
340
  });
141
341
  }
@@ -148,11 +348,11 @@ export function createHTTPHandler(
148
348
  );
149
349
 
150
350
  if (result.error) {
151
- return new Response(JSON.stringify({ error: result.error.message }), {
351
+ return new Response(JSON.stringify({ error: sanitize(result.error) }), {
152
352
  status: 500,
153
353
  headers: {
154
354
  "Content-Type": "application/json",
155
- ...corsHeaders,
355
+ ...baseHeaders,
156
356
  },
157
357
  });
158
358
  }
@@ -160,15 +360,16 @@ export function createHTTPHandler(
160
360
  return new Response(JSON.stringify({ data: result.data }), {
161
361
  headers: {
162
362
  "Content-Type": "application/json",
163
- ...corsHeaders,
363
+ ...baseHeaders,
164
364
  },
165
365
  });
166
366
  } catch (error) {
167
- return new Response(JSON.stringify({ error: String(error) }), {
367
+ const err = error instanceof Error ? error : new Error(String(error));
368
+ return new Response(JSON.stringify({ error: sanitize(err) }), {
168
369
  status: 500,
169
370
  headers: {
170
371
  "Content-Type": "application/json",
171
- ...corsHeaders,
372
+ ...baseHeaders,
172
373
  },
173
374
  });
174
375
  }
@@ -179,7 +380,7 @@ export function createHTTPHandler(
179
380
  status: 404,
180
381
  headers: {
181
382
  "Content-Type": "application/json",
182
- ...corsHeaders,
383
+ ...baseHeaders,
183
384
  },
184
385
  });
185
386
  };
@@ -40,6 +40,8 @@ export {
40
40
 
41
41
  export {
42
42
  createHTTPHandler,
43
+ type HealthCheckOptions,
44
+ type HealthCheckResponse,
43
45
  type HTTPHandler,
44
46
  type HTTPHandlerOptions,
45
47
  } from "./http.js";
@@ -20,6 +20,45 @@ export interface WSHandlerOptions {
20
20
  warn?: (message: string, ...args: unknown[]) => void;
21
21
  error?: (message: string, ...args: unknown[]) => void;
22
22
  };
23
+
24
+ /**
25
+ * Maximum message size in bytes.
26
+ * Messages larger than this will be rejected.
27
+ * Default: 1MB (1024 * 1024)
28
+ */
29
+ maxMessageSize?: number;
30
+
31
+ /**
32
+ * Maximum subscriptions per client.
33
+ * Prevents resource exhaustion from malicious clients.
34
+ * Default: 100
35
+ */
36
+ maxSubscriptionsPerClient?: number;
37
+
38
+ /**
39
+ * Maximum connections total.
40
+ * Prevents server overload.
41
+ * Default: 10000
42
+ */
43
+ maxConnections?: number;
44
+
45
+ /**
46
+ * Rate limiting configuration.
47
+ * Uses token bucket algorithm per client.
48
+ */
49
+ rateLimit?: {
50
+ /**
51
+ * Maximum messages per window.
52
+ * Default: 100
53
+ */
54
+ maxMessages?: number;
55
+
56
+ /**
57
+ * Time window in milliseconds.
58
+ * Default: 1000 (1 second)
59
+ */
60
+ windowMs?: number;
61
+ };
23
62
  }
24
63
 
25
64
  /**