@coffeerunhobby/mcp-ha-connect 0.7.0 → 0.8.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/docs/CHANGELOG.md CHANGED
@@ -1,4 +1,16 @@
1
- ### 0.7.0 (January 2026)
1
+ ### 0.8.0
2
+ - **Bearer Token Authentication**: Secure HTTP endpoints with bearer tokens
3
+ - `MCP_AUTH_METHOD`: Set to `bearer` to enable authentication
4
+ - `MCP_AUTH_TOKEN`: Comma-separated tokens for multiple clients
5
+ - `WWW-Authenticate` header on 401 responses
6
+ - Health check and OpenAPI endpoints skip authentication
7
+ - **Server-Timing Header**: Response timing on all HTTP responses
8
+ - `Server-Timing: total;dur=X` header (integer milliseconds)
9
+ - Visible in browser DevTools Network tab
10
+ - **Modular Auth Middleware**: Clean separation in `src/server/auth.ts`
11
+ - All 648 tests passing
12
+
13
+ ### 0.7.0
2
14
  - **Calendar Tools**: Access Home Assistant calendar entities
3
15
  - `listCalendars`: Get all calendar entities
4
16
  - `getCalendarEvents`: Get events from one or all calendars with date filtering
@@ -8,7 +20,7 @@
8
20
  - Returns onsite/away counts and linked device trackers
9
21
  - Total tools increased from 34 to 37
10
22
 
11
- ### 0.6.0 (January 2026)
23
+ ### 0.6.0
12
24
  - **Mobile App Notifications**: Full support for Home Assistant mobile app notifications
13
25
  - `sendNotification`: Enhanced with action buttons, priority, images, videos
14
26
  - `listNotificationTargets`: Discover available mobile app notification targets
@@ -27,7 +39,7 @@
27
39
  - Switched to NodeNext module resolution for TypeScript
28
40
  - All 310 tests passing
29
41
 
30
- ### 0.5.0 (January 2026)
42
+ ### 0.5.0
31
43
  - **Real-time SSE Event Subscription**: Subscribe to Home Assistant events via Server-Sent Events
32
44
  - Filter by domain, entity_id, or event types
33
45
  - Automatic WebSocket connection to Home Assistant
@@ -60,7 +72,7 @@
60
72
  - `MCP_RATE_LIMIT_MAX_REQUESTS`: Max requests per window
61
73
  - Total tools increased from 16 to 33
62
74
 
63
- ### 0.4.0 (January 2026)
75
+ ### 0.4.0
64
76
  - **Extensible AI Provider System**: Refactored AI client to support multiple providers
65
77
  - New `src/localAI/` folder with provider-based architecture
66
78
  - Supports Ollama (native API) and OpenAI-compatible APIs (LocalAI, LM Studio, vLLM)
@@ -80,7 +92,7 @@
80
92
  - No changes to public API or tools - fully backward compatible
81
93
  - All 325 tests pass
82
94
 
83
- ### 0.3.0 (January 2026)
95
+ ### 0.3.0
84
96
  - Added 8 new tools: `getVersion`, `entityAction`, `listEntities`, `getDomainSummary`, `listAutomations`, `restartHomeAssistant`, `getSystemLog`, `checkUpdates`
85
97
  - Added MCP Resources support with 5 URI-based endpoints (`hass://entities`, etc.)
86
98
  - Added `entityAction` tool for simplified turn_on/turn_off/toggle operations
@@ -93,7 +105,7 @@
93
105
  - Total tools increased from 8 to 16
94
106
  - Enhanced type definitions for Automation, DomainSummary, HaVersion, LogbookEntry, UpdateInfo
95
107
 
96
- ### 0.2.0 (January 2026)
108
+ ### 0.2.0
97
109
  - Added AI-powered sensor analysis with Ollama
98
110
  - Added `getAllSensors` tool
99
111
  - Added `analyzeSensors` tool
@@ -108,7 +120,7 @@
108
120
  - Added Docker CLI workflow import method
109
121
  - Enhanced troubleshooting with network/firewall diagnostics
110
122
 
111
- ### 0.1.0 (Initial Release)
123
+ ### 0.1.0
112
124
  - Basic Home Assistant integration
113
125
  - 5 core tools (getStates, getState, callService, getEntitiesByDomain, searchEntities)
114
126
  - Multiple transport modes (stdio, SSE, HTTP streaming)
package/docs/SSE_API.md CHANGED
@@ -273,7 +273,7 @@ Response:
273
273
  ```json
274
274
  {
275
275
  "status": "healthy",
276
- "version": "0.5.0",
276
+ "version": "0.8.0",
277
277
  "sseEventsEnabled": true,
278
278
  "sseEventsPath": "/subscribe_events",
279
279
  "sseConnectedClients": 3
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Home Assistant MCP Wrapper",
5
- "version": "0.7.0",
5
+ "version": "0.8.0",
6
6
  "description": "REST endpoints that wrap MCP tools for Home Assistant integration. Raw JSON-RPC passthrough available at POST / for n8n compatibility."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeerunhobby/mcp-ha-connect",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Smart home MCP server for Home Assistant plus Local AI integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/config.ts CHANGED
@@ -71,6 +71,10 @@ const envSchema = z
71
71
  rateLimitEnabled: createBooleanStringSchema(true),
72
72
  rateLimitWindowMs: numericStringSchema,
73
73
  rateLimitMaxRequests: numericStringSchema,
74
+
75
+ // Authentication Configuration
76
+ authMethod: z.enum(['none', 'bearer']).optional().default('none'),
77
+ authToken: listStringSchema,
74
78
  })
75
79
  .refine(
76
80
  (data) => {
@@ -104,6 +108,19 @@ const envSchema = z
104
108
  path: ['httpAllowedOrigins'],
105
109
  };
106
110
  }
111
+ )
112
+ .refine(
113
+ (data) => {
114
+ // If bearer auth is enabled, token must be provided
115
+ if (data.authMethod === 'bearer' && !data.authToken) {
116
+ return false;
117
+ }
118
+ return true;
119
+ },
120
+ {
121
+ message: 'MCP_AUTH_TOKEN is required when MCP_AUTH_METHOD is "bearer"',
122
+ path: ['authToken'],
123
+ }
107
124
  );
108
125
 
109
126
  export interface EnvironmentConfig {
@@ -144,6 +161,10 @@ export interface EnvironmentConfig {
144
161
  rateLimitEnabled: boolean;
145
162
  rateLimitWindowMs: number;
146
163
  rateLimitMaxRequests: number;
164
+
165
+ // Authentication Configuration
166
+ authMethod: 'none' | 'bearer';
167
+ authToken?: Set<string>;
147
168
  }
148
169
 
149
170
  export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentConfig {
@@ -185,6 +206,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
185
206
  rateLimitEnabled: env.MCP_RATE_LIMIT_ENABLED,
186
207
  rateLimitWindowMs: env.MCP_RATE_LIMIT_WINDOW_MS,
187
208
  rateLimitMaxRequests: env.MCP_RATE_LIMIT_MAX_REQUESTS,
209
+
210
+ // Authentication Configuration
211
+ authMethod: env.MCP_AUTH_METHOD,
212
+ authToken: env.MCP_AUTH_TOKEN,
188
213
  });
189
214
 
190
215
  if (!parsed.success) {
@@ -244,6 +269,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
244
269
  rateLimitEnabled: parsed.data.rateLimitEnabled,
245
270
  rateLimitWindowMs: parsed.data.rateLimitWindowMs ?? 60000,
246
271
  rateLimitMaxRequests: parsed.data.rateLimitMaxRequests ?? 100,
272
+
273
+ // Authentication Configuration
274
+ authMethod: parsed.data.authMethod,
275
+ authToken: parsed.data.authToken ? new Set(parsed.data.authToken) : undefined,
247
276
  };
248
277
  }
249
278
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Authentication middleware for HTTP server
3
+ * Supports bearer token authentication with multi-token support
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ export type AuthMethod = 'none' | 'bearer';
10
+
11
+ export interface AuthConfig {
12
+ method: AuthMethod;
13
+ tokens?: Set<string>;
14
+ skipPaths?: string[];
15
+ }
16
+
17
+ export interface AuthResult {
18
+ authenticated: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ /**
23
+ * Validate bearer token from Authorization header
24
+ */
25
+ function validateBearerToken(req: IncomingMessage, validTokens: Set<string>): AuthResult {
26
+ const authHeader = req.headers.authorization;
27
+
28
+ if (!authHeader) {
29
+ return { authenticated: false, error: 'Missing Authorization header' };
30
+ }
31
+
32
+ if (!authHeader.startsWith('Bearer ')) {
33
+ return { authenticated: false, error: 'Invalid Authorization header format. Expected: Bearer <token>' };
34
+ }
35
+
36
+ const token = authHeader.slice(7);
37
+ if (!validTokens.has(token)) {
38
+ return { authenticated: false, error: 'Invalid token' };
39
+ }
40
+
41
+ return { authenticated: true };
42
+ }
43
+
44
+ /**
45
+ * Authentication middleware
46
+ * Returns true if request should proceed, false if blocked
47
+ */
48
+ export function createAuthMiddleware(config: AuthConfig): (req: IncomingMessage, res: ServerResponse) => boolean {
49
+ const { method, tokens, skipPaths = [] } = config;
50
+
51
+ return (req: IncomingMessage, res: ServerResponse): boolean => {
52
+ // No auth required
53
+ if (method === 'none') {
54
+ return true;
55
+ }
56
+
57
+ // Check if path should skip auth
58
+ const url = req.url ?? '/';
59
+ const urlPath = url.split('?')[0];
60
+
61
+ if (skipPaths.some((path) => urlPath === path || urlPath.startsWith(path + '/'))) {
62
+ return true;
63
+ }
64
+
65
+ // Bearer auth
66
+ if (method === 'bearer') {
67
+ if (!tokens?.size) {
68
+ logger.error('Bearer auth enabled but no token configured');
69
+ res.statusCode = 500;
70
+ res.setHeader('Content-Type', 'application/json');
71
+ res.end(JSON.stringify({ error: 'Server misconfiguration: auth token not set' }));
72
+ return false;
73
+ }
74
+
75
+ const result = validateBearerToken(req, tokens);
76
+
77
+ if (!result.authenticated) {
78
+ logger.warn('Authentication failed', {
79
+ method: req.method,
80
+ url: urlPath,
81
+ error: result.error,
82
+ ip: req.socket.remoteAddress,
83
+ });
84
+
85
+ res.statusCode = 401;
86
+ res.setHeader('Content-Type', 'application/json');
87
+ res.setHeader('WWW-Authenticate', result.error === 'Invalid token' ? 'Bearer error="invalid_token"' : 'Bearer');
88
+ res.end(JSON.stringify({ error: 'Unauthorized', message: result.error }));
89
+ return false;
90
+ }
91
+
92
+ logger.debug('Authentication successful', { method: req.method, url: urlPath });
93
+ return true;
94
+ }
95
+
96
+ // Unknown auth method
97
+ logger.error('Unknown auth method', { method });
98
+ res.statusCode = 500;
99
+ res.setHeader('Content-Type', 'application/json');
100
+ res.end(JSON.stringify({ error: 'Server misconfiguration: unknown auth method' }));
101
+ return false;
102
+ };
103
+ }
@@ -9,13 +9,14 @@ import type { LocalAIClient } from '../localAI/index.js';
9
9
  import { registerAllTools } from '../tools/index.js';
10
10
  import { registerAllResources } from '../resources/index.js';
11
11
  import { logger } from '../utils/logger.js';
12
+ import { VERSION } from '../version.js';
12
13
 
13
14
  export function createServer(client: HaClient, aiClient?: LocalAIClient): McpServer {
14
15
  logger.debug('Creating MCP server instance');
15
16
 
16
17
  const server = new McpServer({
17
18
  name: 'mcp-ha-connect',
18
- version: '0.5.0',
19
+ version: VERSION,
19
20
  });
20
21
 
21
22
  // Register all Home Assistant tools
@@ -13,10 +13,45 @@ import { handleSseConnection, handleSseMessage, getSseMessagePath, type SSETrans
13
13
  import { handleStreamRequest, type StreamTransportState } from './stream.js';
14
14
  import { handleEventSubscription, getClientCount } from './eventSubscription.js';
15
15
  import { RateLimiter } from './rateLimiter.js';
16
+ import { createAuthMiddleware } from './auth.js';
17
+ import { VERSION } from '../version.js';
16
18
 
17
19
  // Session storage for stateful mode
18
20
  const sessions = new Map<string, SSETransportState | StreamTransportState>();
19
21
 
22
+ /**
23
+ * Wrap response to add Server-Timing header with request duration
24
+ * The timing is calculated and header set just before res.end() is called
25
+ */
26
+ function wrapResponseWithTiming(res: ServerResponse): { res: ServerResponse; startTime: number } {
27
+ const startTime = performance.now();
28
+ const originalEnd = res.end.bind(res);
29
+
30
+ // Override res.end to set timing header before sending
31
+ res.end = function (
32
+ chunk?: unknown,
33
+ encodingOrCallback?: BufferEncoding | (() => void),
34
+ callback?: () => void
35
+ ): ServerResponse {
36
+ // Set Server-Timing header if headers haven't been sent
37
+ if (!res.headersSent) {
38
+ const durationMs = Math.round(performance.now() - startTime);
39
+ res.setHeader('Server-Timing', `total;dur=${durationMs}`);
40
+ }
41
+
42
+ // Call original end with proper overload handling
43
+ if (typeof encodingOrCallback === 'function') {
44
+ return originalEnd(chunk, encodingOrCallback);
45
+ }
46
+ if (encodingOrCallback !== undefined) {
47
+ return originalEnd(chunk, encodingOrCallback, callback);
48
+ }
49
+ return originalEnd(chunk, callback);
50
+ } as typeof res.end;
51
+
52
+ return { res, startTime };
53
+ }
54
+
20
55
  /**
21
56
  * Parse JSON body from request
22
57
  */
@@ -43,7 +78,8 @@ function parseBody(req: IncomingMessage): Promise<unknown> {
43
78
  * Send JSON response
44
79
  */
45
80
  function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
46
- res.writeHead(statusCode, { 'Content-Type': 'application/json' });
81
+ res.statusCode = statusCode;
82
+ res.setHeader('Content-Type', 'application/json');
47
83
  res.end(JSON.stringify(data));
48
84
  }
49
85
 
@@ -53,9 +89,10 @@ function sendJson(res: ServerResponse, statusCode: number, data: unknown): void
53
89
  function handleHealthCheck(res: ServerResponse, config: EnvironmentConfig, aiEnabled: boolean, sseEnabled: boolean): void {
54
90
  sendJson(res, 200, {
55
91
  status: 'healthy',
56
- version: '0.5.0',
92
+ version: VERSION,
57
93
  transport: config.httpTransport,
58
94
  stateful: config.stateful,
95
+ authMethod: config.authMethod,
59
96
  aiEnabled,
60
97
  aiProvider: aiEnabled ? config.aiProvider : undefined,
61
98
  aiUrl: aiEnabled ? config.aiUrl : undefined,
@@ -197,7 +234,7 @@ function getOpenApiSpec(baseUrl: string): object {
197
234
  info: {
198
235
  title: 'Home Assistant MCP Tools',
199
236
  description: 'MCP server for Home Assistant - exposes entity states and service calls',
200
- version: '0.2.0',
237
+ version: VERSION,
201
238
  },
202
239
  servers: [{ url: baseUrl }],
203
240
  paths: {
@@ -361,6 +398,13 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
361
398
  })
362
399
  : null;
363
400
 
401
+ // Initialize auth middleware
402
+ const authMiddleware = createAuthMiddleware({
403
+ method: config.authMethod,
404
+ tokens: config.authToken,
405
+ skipPaths: [healthPath, '/openapi.json'],
406
+ });
407
+
364
408
  // Initialize event subscriber if SSE events are enabled
365
409
  let eventSubscriber: EventSubscriber | null = null;
366
410
  if (config.sseEventsEnabled) {
@@ -394,7 +438,10 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
394
438
  sseEventsEnabled: config.sseEventsEnabled && !!eventSubscriber,
395
439
  });
396
440
 
397
- const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
441
+ const server = createHttpServer(async (req: IncomingMessage, originalRes: ServerResponse) => {
442
+ // Wrap response to add Server-Timing header
443
+ const { res } = wrapResponseWithTiming(originalRes);
444
+
398
445
  const url = req.url ?? '/';
399
446
  const urlPath = url.split('?')[0];
400
447
 
@@ -418,6 +465,11 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
418
465
  }
419
466
  }
420
467
 
468
+ // Apply authentication (skip health checks and openapi.json)
469
+ if (!authMiddleware(req, res)) {
470
+ return; // Response already sent by auth middleware
471
+ }
472
+
421
473
  // Add CORS headers for MCP endpoints
422
474
  handleCors(req, res, config);
423
475
 
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package version
3
+ * Uses npm_package_version when available (npm scripts), falls back to package.json
4
+ */
5
+
6
+ export const VERSION: string = process.env.npm_package_version ?? (() => {
7
+ // Fallback: read from package.json at runtime (for direct node execution)
8
+ // Uses process.cwd() to avoid dist folder path issues
9
+ try {
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ const pkg = require(`${process.cwd()}/package.json`) as { version: string };
12
+ return pkg.version;
13
+ } catch {
14
+ return 'dev';
15
+ }
16
+ })();
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Authentication middleware tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import type { IncomingMessage, ServerResponse } from 'node:http';
7
+ import { createAuthMiddleware } from '../../src/server/auth.js';
8
+
9
+ vi.mock('../../src/utils/logger.js');
10
+
11
+ describe('Auth Middleware', () => {
12
+ let mockReq: Partial<IncomingMessage>;
13
+ let mockRes: Partial<ServerResponse>;
14
+ let responseData: { statusCode?: number; headers: Record<string, string>; body?: string };
15
+
16
+ beforeEach(() => {
17
+ responseData = { headers: {} };
18
+
19
+ mockReq = {
20
+ url: '/api/states',
21
+ method: 'GET',
22
+ headers: {},
23
+ socket: { remoteAddress: '127.0.0.1' } as any,
24
+ };
25
+
26
+ mockRes = {
27
+ statusCode: 200,
28
+ setHeader: vi.fn((key: string, value: string) => {
29
+ responseData.headers[key] = value;
30
+ return mockRes as ServerResponse;
31
+ }),
32
+ end: vi.fn((body?: string) => {
33
+ responseData.body = body;
34
+ return mockRes as ServerResponse;
35
+ }) as unknown as ServerResponse['end'],
36
+ };
37
+
38
+ Object.defineProperty(mockRes, 'statusCode', {
39
+ get: () => responseData.statusCode,
40
+ set: (val) => { responseData.statusCode = val; },
41
+ });
42
+ });
43
+
44
+ describe('No Auth', () => {
45
+ it('should allow all requests when method is none', () => {
46
+ const middleware = createAuthMiddleware({ method: 'none' });
47
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
48
+
49
+ expect(result).toBe(true);
50
+ expect(mockRes.end).not.toHaveBeenCalled();
51
+ });
52
+ });
53
+
54
+ describe('Bearer Auth', () => {
55
+ const validTokens = new Set(['test-token-123', 'another-token']);
56
+
57
+ it('should allow request with valid token', () => {
58
+ mockReq.headers = { authorization: 'Bearer test-token-123' };
59
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
60
+
61
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
62
+
63
+ expect(result).toBe(true);
64
+ expect(mockRes.end).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should allow request with any valid token from set', () => {
68
+ mockReq.headers = { authorization: 'Bearer another-token' };
69
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
70
+
71
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
72
+
73
+ expect(result).toBe(true);
74
+ });
75
+
76
+ it('should reject request without Authorization header', () => {
77
+ mockReq.headers = {};
78
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
79
+
80
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
81
+
82
+ expect(result).toBe(false);
83
+ expect(responseData.statusCode).toBe(401);
84
+ expect(responseData.headers['WWW-Authenticate']).toBe('Bearer');
85
+ expect(responseData.body).toContain('Missing Authorization header');
86
+ });
87
+
88
+ it('should reject request with invalid token', () => {
89
+ mockReq.headers = { authorization: 'Bearer wrong-token' };
90
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
91
+
92
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
93
+
94
+ expect(result).toBe(false);
95
+ expect(responseData.statusCode).toBe(401);
96
+ expect(responseData.headers['WWW-Authenticate']).toBe('Bearer error="invalid_token"');
97
+ expect(responseData.body).toContain('Invalid token');
98
+ });
99
+
100
+ it('should reject request with malformed Authorization header', () => {
101
+ mockReq.headers = { authorization: 'Basic dXNlcjpwYXNz' };
102
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
103
+
104
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
105
+
106
+ expect(result).toBe(false);
107
+ expect(responseData.statusCode).toBe(401);
108
+ expect(responseData.body).toContain('Invalid Authorization header format');
109
+ });
110
+
111
+ it('should return 500 if bearer auth enabled but no tokens configured', () => {
112
+ mockReq.headers = { authorization: 'Bearer test-token' };
113
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens: undefined });
114
+
115
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
116
+
117
+ expect(result).toBe(false);
118
+ expect(responseData.statusCode).toBe(500);
119
+ expect(responseData.body).toContain('auth token not set');
120
+ });
121
+ });
122
+
123
+ describe('Skip Paths', () => {
124
+ const validTokens = new Set(['secret-token']);
125
+
126
+ it('should skip auth for exact path match', () => {
127
+ mockReq.url = '/health';
128
+ mockReq.headers = {}; // No auth header
129
+ const middleware = createAuthMiddleware({
130
+ method: 'bearer',
131
+ tokens: validTokens,
132
+ skipPaths: ['/health'],
133
+ });
134
+
135
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
136
+
137
+ expect(result).toBe(true);
138
+ });
139
+
140
+ it('should skip auth for path prefix', () => {
141
+ mockReq.url = '/health/detailed';
142
+ mockReq.headers = {};
143
+ const middleware = createAuthMiddleware({
144
+ method: 'bearer',
145
+ tokens: validTokens,
146
+ skipPaths: ['/health'],
147
+ });
148
+
149
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
150
+
151
+ expect(result).toBe(true);
152
+ });
153
+
154
+ it('should require auth for non-skipped paths', () => {
155
+ mockReq.url = '/api/states';
156
+ mockReq.headers = {};
157
+ const middleware = createAuthMiddleware({
158
+ method: 'bearer',
159
+ tokens: validTokens,
160
+ skipPaths: ['/health'],
161
+ });
162
+
163
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
164
+
165
+ expect(result).toBe(false);
166
+ expect(responseData.statusCode).toBe(401);
167
+ });
168
+
169
+ it('should handle query strings in URL', () => {
170
+ mockReq.url = '/health?check=true';
171
+ mockReq.headers = {};
172
+ const middleware = createAuthMiddleware({
173
+ method: 'bearer',
174
+ tokens: validTokens,
175
+ skipPaths: ['/health'],
176
+ });
177
+
178
+ const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
179
+
180
+ expect(result).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe('Multi-token Support', () => {
185
+ it('should support multiple tokens for different clients', () => {
186
+ const tokens = new Set(['n8n-token', 'openwebui-token', 'cli-token']);
187
+ const middleware = createAuthMiddleware({ method: 'bearer', tokens });
188
+
189
+ // n8n client
190
+ mockReq.headers = { authorization: 'Bearer n8n-token' };
191
+ expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
192
+
193
+ // Open WebUI client
194
+ mockReq.headers = { authorization: 'Bearer openwebui-token' };
195
+ expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
196
+
197
+ // CLI client
198
+ mockReq.headers = { authorization: 'Bearer cli-token' };
199
+ expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
200
+
201
+ // Invalid client
202
+ mockReq.headers = { authorization: 'Bearer unknown-token' };
203
+ expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(false);
204
+ });
205
+ });
206
+ });