@qwickapps/server 1.0.0 → 1.1.6

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.
@@ -23,7 +23,6 @@ import type { ControlPanelConfig, ControlPanelPlugin, Logger } from './types.js'
23
23
  import { createControlPanel } from './control-panel.js';
24
24
  import { initializeLogging, getControlPanelLogger, type LoggingConfig } from './logging.js';
25
25
  import { createProxyMiddleware, type Options } from 'http-proxy-middleware';
26
- import { randomBytes } from 'crypto';
27
26
  import express from 'express';
28
27
  import { existsSync } from 'fs';
29
28
  import { resolve } from 'path';
@@ -56,31 +55,47 @@ export interface GatewayConfig {
56
55
  /** Quick links for the control panel */
57
56
  links?: ControlPanelConfig['links'];
58
57
 
59
- /** Path to custom React UI dist folder */
58
+ /** Path to custom React UI dist folder for the control panel */
60
59
  customUiPath?: string;
61
60
 
62
61
  /**
63
- * API paths to proxy to the internal service.
64
- * Defaults to ['/api/v1'] if not specified.
65
- * The gateway always proxies /health to the internal service.
62
+ * Mount path for the control panel.
63
+ * Defaults to '/cpanel'.
66
64
  */
67
- proxyPaths?: string[];
65
+ controlPanelPath?: string;
68
66
 
69
67
  /**
70
- * Authentication mode for the control panel (not the API).
71
- * - 'none': No authentication (not recommended for production)
72
- * - 'basic': HTTP Basic Auth with username/password
73
- * - 'auto': Auto-generate password on startup (default)
68
+ * Route guard for the control panel.
69
+ * Defaults to auto-generated basic auth.
74
70
  */
75
- authMode?: 'none' | 'basic' | 'auto';
71
+ controlPanelGuard?: ControlPanelConfig['guard'];
76
72
 
77
- /** Basic auth username (defaults to 'admin') */
78
- basicAuthUser?: string;
73
+ /**
74
+ * Frontend app configuration for the root path (/).
75
+ * If not provided, root path is not handled by the gateway.
76
+ */
77
+ frontendApp?: {
78
+ /** Redirect to another URL */
79
+ redirectUrl?: string;
80
+ /** Path to static files to serve */
81
+ staticPath?: string;
82
+ /** Landing page configuration */
83
+ landingPage?: {
84
+ title: string;
85
+ heading?: string;
86
+ description?: string;
87
+ links?: Array<{ label: string; url: string }>;
88
+ };
89
+ };
79
90
 
80
- /** Basic auth password (required if authMode is 'basic') */
81
- basicAuthPassword?: string;
91
+ /**
92
+ * API paths to proxy to the internal service.
93
+ * Defaults to ['/api/v1'] if not specified.
94
+ * The gateway always proxies /health to the internal service.
95
+ */
96
+ proxyPaths?: string[];
82
97
 
83
- /** Logger instance (deprecated: use logging config instead) */
98
+ /** Logger instance */
84
99
  logger?: Logger;
85
100
 
86
101
  /** Logging configuration */
@@ -131,63 +146,106 @@ export interface GatewayInstance {
131
146
 
132
147
 
133
148
  /**
134
- * Basic auth middleware for gateway protection (control panel only)
135
- * - Skips localhost requests
136
- * - Skips API routes (/api/v1/*) - they have their own service auth
137
- * - Skips health endpoints - these should be public
138
- * - Requires valid credentials for non-localhost control panel access
149
+ * Generate landing page HTML for the frontend app
139
150
  */
140
- function createBasicAuthMiddleware(
141
- username: string,
142
- password: string,
143
- apiPaths: string[]
144
- ) {
145
- const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
146
-
147
- return (req: Request, res: Response, next: NextFunction) => {
148
- const path = req.path;
149
-
150
- // Skip auth for API routes - they use their own authentication
151
- for (const apiPath of apiPaths) {
152
- if (path.startsWith(apiPath)) {
153
- return next();
154
- }
151
+ function generateLandingPageHtml(
152
+ config: NonNullable<GatewayConfig['frontendApp']>['landingPage'],
153
+ controlPanelPath: string
154
+ ): string {
155
+ if (!config) return '';
156
+
157
+ const primaryColor = '#6366f1';
158
+
159
+ const links = config.links || [
160
+ { label: 'Control Panel', url: controlPanelPath },
161
+ ];
162
+
163
+ const linksHtml = links
164
+ .map(
165
+ (link) =>
166
+ `<a href="${link.url}" class="link">${link.label}</a>`
167
+ )
168
+ .join('');
169
+
170
+ return `<!DOCTYPE html>
171
+ <html lang="en">
172
+ <head>
173
+ <meta charset="UTF-8">
174
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
175
+ <title>${config.title}</title>
176
+ <style>
177
+ * { margin: 0; padding: 0; box-sizing: border-box; }
178
+ body {
179
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
180
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
181
+ color: #e2e8f0;
182
+ min-height: 100vh;
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
155
186
  }
156
-
157
- // Skip auth for health endpoints - these should be publicly accessible
158
- if (path === '/health' || path === '/api/health') {
159
- return next();
187
+ .container {
188
+ text-align: center;
189
+ max-width: 600px;
190
+ padding: 2rem;
160
191
  }
161
-
162
- // Allow localhost without auth
163
- const remoteAddress = req.ip || req.socket?.remoteAddress || '';
164
- const host = req.hostname || req.headers.host || '';
165
- const isLocalhost =
166
- host === 'localhost' ||
167
- host === '127.0.0.1' ||
168
- host.startsWith('localhost:') ||
169
- host.startsWith('127.0.0.1:') ||
170
- remoteAddress === '127.0.0.1' ||
171
- remoteAddress === '::1' ||
172
- remoteAddress === '::ffff:127.0.0.1';
173
-
174
- if (isLocalhost) {
175
- return next();
192
+ h1 {
193
+ font-size: 2.5rem;
194
+ color: ${primaryColor};
195
+ margin-bottom: 1rem;
176
196
  }
177
-
178
- // Check for valid basic auth
179
- const authHeader = req.headers.authorization;
180
- if (authHeader === expectedAuth) {
181
- return next();
197
+ p {
198
+ font-size: 1.125rem;
199
+ color: #94a3b8;
200
+ margin-bottom: 2rem;
201
+ line-height: 1.6;
182
202
  }
183
-
184
- // Request authentication
185
- res.setHeader('WWW-Authenticate', 'Basic realm="Control Panel"');
186
- res.status(401).json({
187
- error: 'Unauthorized',
188
- message: 'Authentication required.',
189
- });
190
- };
203
+ .links {
204
+ display: flex;
205
+ flex-wrap: wrap;
206
+ gap: 1rem;
207
+ justify-content: center;
208
+ }
209
+ .link {
210
+ display: inline-block;
211
+ padding: 0.875rem 2rem;
212
+ background: ${primaryColor};
213
+ color: white;
214
+ text-decoration: none;
215
+ border-radius: 0.5rem;
216
+ font-weight: 500;
217
+ transition: all 0.2s;
218
+ }
219
+ .link:hover {
220
+ transform: translateY(-2px);
221
+ box-shadow: 0 10px 20px rgba(0,0,0,0.3);
222
+ }
223
+ .footer {
224
+ position: fixed;
225
+ bottom: 1rem;
226
+ left: 0;
227
+ right: 0;
228
+ text-align: center;
229
+ color: #64748b;
230
+ font-size: 0.875rem;
231
+ }
232
+ .footer a {
233
+ color: ${primaryColor};
234
+ text-decoration: none;
235
+ }
236
+ </style>
237
+ </head>
238
+ <body>
239
+ <div class="container">
240
+ <h1>${config.heading || config.title}</h1>
241
+ ${config.description ? `<p>${config.description}</p>` : ''}
242
+ ${linksHtml ? `<div class="links">${linksHtml}</div>` : ''}
243
+ </div>
244
+ <div class="footer">
245
+ Powered by <a href="https://qwickapps.com" target="_blank">QwickApps</a>
246
+ </div>
247
+ </body>
248
+ </html>`;
191
249
  }
192
250
 
193
251
  /**
@@ -238,12 +296,11 @@ export function createGateway(
238
296
  const servicePort = config.servicePort || parseInt(process.env.SERVICE_PORT || '3100', 10);
239
297
  const nodeEnv = process.env.NODE_ENV || 'development';
240
298
 
241
- // Auth configuration
242
- const authMode = config.authMode || 'auto';
243
- const basicAuthUser = config.basicAuthUser || process.env.BASIC_AUTH_USER || 'admin';
244
- const providedPassword = config.basicAuthPassword || process.env.BASIC_AUTH_PASSWORD;
245
- const basicAuthPassword = providedPassword || (authMode === 'auto' ? randomBytes(16).toString('base64url') : '');
246
- const isPasswordAutoGenerated = !providedPassword && authMode === 'auto';
299
+ // Control panel mount path (defaults to /cpanel)
300
+ const controlPanelPath = config.controlPanelPath || '/cpanel';
301
+
302
+ // Guard configuration for control panel
303
+ const guardConfig = config.controlPanelGuard;
247
304
 
248
305
  // API paths to proxy
249
306
  const proxyPaths = config.proxyPaths || ['/api/v1'];
@@ -260,19 +317,18 @@ export function createGateway(
260
317
  cors: config.corsOrigins ? { origins: config.corsOrigins } : undefined,
261
318
  // Skip body parsing for proxied paths
262
319
  skipBodyParserPaths: [...proxyPaths, '/health'],
263
- // Disable built-in dashboard if custom UI is provided
264
- disableDashboard: !!config.customUiPath,
320
+ // Mount path for control panel
321
+ mountPath: controlPanelPath,
322
+ // Route guard
323
+ guard: guardConfig,
324
+ // Custom UI path
325
+ customUiPath: config.customUiPath,
265
326
  links: config.links,
266
327
  },
267
328
  plugins: config.plugins || [],
268
329
  logger,
269
330
  });
270
331
 
271
- // Add basic auth middleware if enabled
272
- if (authMode === 'basic' || authMode === 'auto') {
273
- controlPanel.app.use(createBasicAuthMiddleware(basicAuthUser, basicAuthPassword, proxyPaths));
274
- }
275
-
276
332
  // Setup proxy middleware for API paths
277
333
  const setupProxyMiddleware = () => {
278
334
  const target = `http://localhost:${servicePort}`;
@@ -325,18 +381,41 @@ export function createGateway(
325
381
  controlPanel.app.use(createProxyMiddleware(healthProxyOptions));
326
382
  };
327
383
 
328
- // Serve custom React UI if provided
329
- const setupCustomUI = () => {
330
- if (config.customUiPath && existsSync(config.customUiPath)) {
331
- logger.info(`Serving custom UI from ${config.customUiPath}`);
332
- controlPanel.app.use(express.static(config.customUiPath));
333
-
334
- // SPA fallback
335
- controlPanel.app.get('*', (req, res, next) => {
336
- if (req.path.startsWith('/api/') || req.path === '/api') {
337
- return next();
338
- }
339
- res.sendFile(resolve(config.customUiPath!, 'index.html'));
384
+ // Setup frontend app at root path
385
+ const setupFrontendApp = () => {
386
+ if (!config.frontendApp) {
387
+ return;
388
+ }
389
+
390
+ const { redirectUrl, staticPath, landingPage } = config.frontendApp;
391
+
392
+ // Priority 1: Redirect
393
+ if (redirectUrl) {
394
+ logger.info(`Frontend app: Redirecting / to ${redirectUrl}`);
395
+ controlPanel.app.get('/', (_req, res) => {
396
+ res.redirect(redirectUrl);
397
+ });
398
+ return;
399
+ }
400
+
401
+ // Priority 2: Serve static files
402
+ if (staticPath && existsSync(staticPath)) {
403
+ logger.info(`Frontend app: Serving static files from ${staticPath}`);
404
+ controlPanel.app.use('/', express.static(staticPath));
405
+
406
+ // SPA fallback for root
407
+ controlPanel.app.get('/', (_req, res) => {
408
+ res.sendFile(resolve(staticPath, 'index.html'));
409
+ });
410
+ return;
411
+ }
412
+
413
+ // Priority 3: Landing page
414
+ if (landingPage) {
415
+ logger.info(`Frontend app: Serving landing page`);
416
+ controlPanel.app.get('/', (_req, res) => {
417
+ const html = generateLandingPageHtml(landingPage, controlPanelPath);
418
+ res.type('html').send(html);
340
419
  });
341
420
  }
342
421
  };
@@ -352,12 +431,15 @@ export function createGateway(
352
431
  // 2. Setup proxy middleware (after service is started)
353
432
  setupProxyMiddleware();
354
433
 
355
- // 3. Setup custom UI (after proxy middleware)
356
- setupCustomUI();
434
+ // 3. Setup frontend app at root path
435
+ setupFrontendApp();
357
436
 
358
437
  // 4. Start control panel gateway
359
438
  await controlPanel.start();
360
439
 
440
+ // Calculate API base path
441
+ const apiBasePath = controlPanelPath === '/' ? '/api' : `${controlPanelPath}/api`;
442
+
361
443
  // Log startup info
362
444
  logger.info('');
363
445
  logger.info('========================================');
@@ -368,25 +450,24 @@ export function createGateway(
368
450
  logger.info(` Service Port: ${servicePort} (internal)`);
369
451
  logger.info('');
370
452
 
371
- if (authMode === 'basic' || authMode === 'auto') {
453
+ if (guardConfig && guardConfig.type === 'basic') {
372
454
  logger.info(' Control Panel Auth: HTTP Basic Auth');
373
455
  logger.info(' ----------------------------------------');
374
- logger.info(` Username: ${basicAuthUser}`);
375
- if (isPasswordAutoGenerated) {
376
- logger.info(` Password: ${basicAuthPassword}`);
377
- logger.info(' (auto-generated, set BASIC_AUTH_PASSWORD to use a fixed password)');
378
- } else {
379
- logger.info(' Password: ********** (from environment)');
380
- }
456
+ logger.info(` Username: ${guardConfig.username}`);
381
457
  logger.info(' ----------------------------------------');
458
+ } else if (guardConfig && guardConfig.type !== 'none') {
459
+ logger.info(` Control Panel Auth: ${guardConfig.type}`);
382
460
  } else {
383
461
  logger.info(' Control Panel Auth: None (not recommended)');
384
462
  }
385
463
 
386
464
  logger.info('');
387
465
  logger.info(' Endpoints:');
388
- logger.info(` GET / - Control Panel UI`);
389
- logger.info(` GET /api/health - Gateway health`);
466
+ if (config.frontendApp) {
467
+ logger.info(` GET / - Frontend App`);
468
+ }
469
+ logger.info(` GET ${controlPanelPath.padEnd(20)} - Control Panel UI`);
470
+ logger.info(` GET ${apiBasePath}/health - Gateway health`);
390
471
  logger.info(` GET /health - Service health (proxied)`);
391
472
  for (const apiPath of proxyPaths) {
392
473
  logger.info(` * ${apiPath}/* - Service API (proxied)`);
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Route Guards for @qwickapps/server
3
+ *
4
+ * Provides authentication middleware for protecting routes.
5
+ *
6
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
7
+ */
8
+
9
+ import type { Request, Response, NextFunction, RequestHandler } from 'express';
10
+ import type {
11
+ RouteGuardConfig,
12
+ BasicAuthGuardConfig,
13
+ SupabaseAuthGuardConfig,
14
+ Auth0GuardConfig,
15
+ } from './types.js';
16
+
17
+ /**
18
+ * Create a route guard middleware from configuration
19
+ */
20
+ export function createRouteGuard(config: RouteGuardConfig): RequestHandler {
21
+ switch (config.type) {
22
+ case 'none':
23
+ return (_req, _res, next) => next();
24
+ case 'basic':
25
+ return createBasicAuthGuard(config);
26
+ case 'supabase':
27
+ return createSupabaseGuard(config);
28
+ case 'auth0':
29
+ return createAuth0Guard(config);
30
+ default:
31
+ throw new Error(`Unknown guard type: ${(config as any).type}`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Create basic auth guard middleware
37
+ */
38
+ function createBasicAuthGuard(config: BasicAuthGuardConfig): RequestHandler {
39
+ const expectedAuth = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
40
+ const realm = config.realm || 'Protected';
41
+ const excludePaths = config.excludePaths || [];
42
+
43
+ return (req: Request, res: Response, next: NextFunction) => {
44
+ // Check if path is excluded
45
+ if (excludePaths.some(path => req.path.startsWith(path))) {
46
+ return next();
47
+ }
48
+
49
+ const authHeader = req.headers.authorization;
50
+ if (authHeader === expectedAuth) {
51
+ return next();
52
+ }
53
+
54
+ res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
55
+ res.status(401).json({
56
+ error: 'Unauthorized',
57
+ message: 'Authentication required.',
58
+ });
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Create Supabase auth guard middleware
64
+ *
65
+ * Validates JWT tokens from Supabase Auth
66
+ */
67
+ function createSupabaseGuard(config: SupabaseAuthGuardConfig): RequestHandler {
68
+ const excludePaths = config.excludePaths || [];
69
+
70
+ return async (req: Request, res: Response, next: NextFunction) => {
71
+ // Check if path is excluded
72
+ if (excludePaths.some(path => req.path.startsWith(path))) {
73
+ return next();
74
+ }
75
+
76
+ const authHeader = req.headers.authorization;
77
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
78
+ return res.status(401).json({
79
+ error: 'Unauthorized',
80
+ message: 'Missing or invalid authorization header. Expected: Bearer <token>',
81
+ });
82
+ }
83
+
84
+ const token = authHeader.substring(7);
85
+
86
+ try {
87
+ // Validate the JWT with Supabase
88
+ const response = await fetch(`${config.supabaseUrl}/auth/v1/user`, {
89
+ headers: {
90
+ Authorization: `Bearer ${token}`,
91
+ apikey: config.supabaseAnonKey,
92
+ },
93
+ });
94
+
95
+ if (!response.ok) {
96
+ return res.status(401).json({
97
+ error: 'Unauthorized',
98
+ message: 'Invalid or expired token.',
99
+ });
100
+ }
101
+
102
+ const user = await response.json();
103
+ (req as any).user = user;
104
+ next();
105
+ } catch (error) {
106
+ return res.status(401).json({
107
+ error: 'Unauthorized',
108
+ message: 'Failed to validate token.',
109
+ });
110
+ }
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Create Auth0 guard middleware
116
+ *
117
+ * Uses express-openid-connect for Auth0 authentication
118
+ */
119
+ function createAuth0Guard(config: Auth0GuardConfig): RequestHandler {
120
+ // Lazy-load express-openid-connect to avoid requiring it when not used
121
+ let authMiddleware: RequestHandler | null = null;
122
+
123
+ return async (req: Request, res: Response, next: NextFunction) => {
124
+ // Lazy initialize the middleware
125
+ if (!authMiddleware) {
126
+ try {
127
+ const { auth } = await import('express-openid-connect');
128
+ authMiddleware = auth({
129
+ authRequired: true,
130
+ auth0Logout: true,
131
+ secret: config.secret,
132
+ baseURL: config.baseUrl,
133
+ clientID: config.clientId,
134
+ issuerBaseURL: `https://${config.domain}`,
135
+ clientSecret: config.clientSecret,
136
+ idpLogout: true,
137
+ routes: {
138
+ login: config.routes?.login || '/login',
139
+ logout: config.routes?.logout || '/logout',
140
+ callback: config.routes?.callback || '/callback',
141
+ },
142
+ });
143
+ } catch (error) {
144
+ return res.status(500).json({
145
+ error: 'Configuration Error',
146
+ message: 'Auth0 is not properly configured. Install express-openid-connect package.',
147
+ });
148
+ }
149
+ }
150
+
151
+ // Check if path is excluded
152
+ const excludePaths = config.excludePaths || [];
153
+ if (excludePaths.some(path => req.path.startsWith(path))) {
154
+ return next();
155
+ }
156
+
157
+ // Apply Auth0 middleware
158
+ authMiddleware!(req, res, next);
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Helper to check if a request is authenticated (for use in handlers)
164
+ */
165
+ export function isAuthenticated(req: Request): boolean {
166
+ // Check for Auth0 session
167
+ if ((req as any).oidc?.isAuthenticated?.()) {
168
+ return true;
169
+ }
170
+ // Check for Supabase user
171
+ if ((req as any).user) {
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ /**
178
+ * Get the authenticated user from the request
179
+ */
180
+ export function getAuthenticatedUser(req: Request): any | null {
181
+ // Check for Auth0 user
182
+ if ((req as any).oidc?.user) {
183
+ return (req as any).oidc.user;
184
+ }
185
+ // Check for Supabase user
186
+ if ((req as any).user) {
187
+ return (req as any).user;
188
+ }
189
+ return null;
190
+ }