@qwickapps/server 1.5.1 → 1.5.2
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/CHANGELOG.md +21 -0
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +41 -0
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/guards.d.ts.map +1 -1
- package/dist/core/guards.js +77 -0
- package/dist/core/guards.js.map +1 -1
- package/dist/core/health-manager.d.ts +4 -0
- package/dist/core/health-manager.d.ts.map +1 -1
- package/dist/core/health-manager.js +6 -1
- package/dist/core/health-manager.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +55 -5
- package/dist/core/plugin-registry.d.ts.map +1 -1
- package/dist/core/plugin-registry.js +57 -19
- package/dist/core/plugin-registry.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/plugins/auth/auth-plugin.js +16 -0
- package/dist/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/plugins/auth/auth-plugin.test.js +133 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
- package/dist/plugins/auth/env-config.d.ts.map +1 -1
- package/dist/plugins/auth/env-config.js +4 -0
- package/dist/plugins/auth/env-config.js.map +1 -1
- package/dist/plugins/auth/types.d.ts +10 -0
- package/dist/plugins/auth/types.d.ts.map +1 -1
- package/dist/plugins/auth/types.js.map +1 -1
- package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
- package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +18 -4
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/qwickbrain/index.d.ts +25 -0
- package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/index.js +24 -0
- package/dist/plugins/qwickbrain/index.js.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
- package/dist/plugins/qwickbrain/types.d.ts +131 -0
- package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/types.js +9 -0
- package/dist/plugins/qwickbrain/types.js.map +1 -0
- package/dist-ui/assets/{index-CynOqPkb.js → index-BfC7mG5L.js} +2 -2
- package/dist-ui/assets/{index-CynOqPkb.js.map → index-BfC7mG5L.js.map} +1 -1
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +6 -0
- package/dist-ui-lib/index.js +277 -266
- package/dist-ui-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/core/control-panel.ts +47 -0
- package/src/core/guards.ts +89 -0
- package/src/core/health-manager.ts +6 -1
- package/src/core/plugin-registry.ts +123 -25
- package/src/core/types.ts +2 -0
- package/src/index.ts +11 -0
- package/src/plugins/auth/auth-plugin.test.ts +167 -0
- package/src/plugins/auth/auth-plugin.ts +16 -0
- package/src/plugins/auth/env-config.ts +4 -0
- package/src/plugins/auth/types.ts +10 -0
- package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
- package/src/plugins/frontend-app-plugin.ts +19 -4
- package/src/plugins/index.ts +15 -0
- package/src/plugins/qwickbrain/index.ts +33 -0
- package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
- package/src/plugins/qwickbrain/types.ts +146 -0
- package/ui/src/api/controlPanelApi.ts +49 -37
package/package.json
CHANGED
|
@@ -378,6 +378,53 @@ export function createControlPanel(options: CreateControlPanelOptions): ControlP
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// Register all routes with automatic ordering by path specificity
|
|
382
|
+
const routes = pluginRegistry.getRoutes();
|
|
383
|
+
|
|
384
|
+
// Sort routes by path specificity (longest first, wildcards last)
|
|
385
|
+
routes.sort((a, b) => {
|
|
386
|
+
// Wildcards last
|
|
387
|
+
const aWild = a.path.includes('*') ? 1 : 0;
|
|
388
|
+
const bWild = b.path.includes('*') ? 1 : 0;
|
|
389
|
+
if (aWild !== bWild) return aWild - bWild;
|
|
390
|
+
|
|
391
|
+
// Longer paths first (more specific)
|
|
392
|
+
return b.path.length - a.path.length;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Register routes with Express
|
|
396
|
+
logger.debug(`Registering ${routes.length} routes in priority order:`);
|
|
397
|
+
for (const route of routes) {
|
|
398
|
+
// TODO: Add auth middleware if route.auth?.required
|
|
399
|
+
// For now, just register the handler
|
|
400
|
+
// Auth will be handled by auth plugin middleware globally
|
|
401
|
+
|
|
402
|
+
const handlers = [route.handler];
|
|
403
|
+
|
|
404
|
+
switch (route.method) {
|
|
405
|
+
case 'get':
|
|
406
|
+
app.get(route.path, ...handlers);
|
|
407
|
+
break;
|
|
408
|
+
case 'post':
|
|
409
|
+
app.post(route.path, ...handlers);
|
|
410
|
+
break;
|
|
411
|
+
case 'put':
|
|
412
|
+
app.put(route.path, ...handlers);
|
|
413
|
+
break;
|
|
414
|
+
case 'delete':
|
|
415
|
+
app.delete(route.path, ...handlers);
|
|
416
|
+
break;
|
|
417
|
+
case 'patch':
|
|
418
|
+
app.patch(route.path, ...handlers);
|
|
419
|
+
break;
|
|
420
|
+
case 'use':
|
|
421
|
+
app.use(route.path, ...handlers);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
logger.debug(` ${route.method.toUpperCase()} ${route.path}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
381
428
|
return new Promise((resolve) => {
|
|
382
429
|
server = app.listen(config.port, () => {
|
|
383
430
|
logger.debug(`Control panel listening on port ${config.port}`);
|
package/src/core/guards.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
9
10
|
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
10
11
|
import type {
|
|
11
12
|
RouteGuardConfig,
|
|
@@ -14,6 +15,68 @@ import type {
|
|
|
14
15
|
Auth0GuardConfig,
|
|
15
16
|
} from './types.js';
|
|
16
17
|
|
|
18
|
+
// Session cookie configuration
|
|
19
|
+
const SESSION_COOKIE_NAME = 'cpanel_session';
|
|
20
|
+
const DEFAULT_SESSION_DURATION_HOURS = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a signed session token with expiration
|
|
24
|
+
*/
|
|
25
|
+
function createSessionToken(secret: string, durationHours: number): string {
|
|
26
|
+
const expiresAt = Date.now() + durationHours * 60 * 60 * 1000;
|
|
27
|
+
const payload = `cpanel:${expiresAt}`;
|
|
28
|
+
const signature = createHmac('sha256', secret).update(payload).digest('hex');
|
|
29
|
+
return `${payload}:${signature}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Verify a session token and check expiration
|
|
34
|
+
*/
|
|
35
|
+
function verifySessionToken(token: string, secret: string): boolean {
|
|
36
|
+
const parts = token.split(':');
|
|
37
|
+
if (parts.length !== 3 || parts[0] !== 'cpanel') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [prefix, expiresAt, signature] = parts;
|
|
42
|
+
const payload = `${prefix}:${expiresAt}`;
|
|
43
|
+
|
|
44
|
+
// Verify signature
|
|
45
|
+
const expectedSignature = createHmac('sha256', secret).update(payload).digest('hex');
|
|
46
|
+
try {
|
|
47
|
+
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check expiration
|
|
55
|
+
const expires = parseInt(expiresAt, 10);
|
|
56
|
+
if (isNaN(expires) || Date.now() > expires) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse cookies from Cookie header
|
|
65
|
+
*/
|
|
66
|
+
function parseCookies(cookieHeader: string | undefined): Record<string, string> {
|
|
67
|
+
const cookies: Record<string, string> = {};
|
|
68
|
+
if (!cookieHeader) return cookies;
|
|
69
|
+
|
|
70
|
+
cookieHeader.split(';').forEach((cookie) => {
|
|
71
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
72
|
+
if (name && rest.length > 0) {
|
|
73
|
+
cookies[name] = rest.join('=');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return cookies;
|
|
78
|
+
}
|
|
79
|
+
|
|
17
80
|
/**
|
|
18
81
|
* Create a route guard middleware from configuration
|
|
19
82
|
*/
|
|
@@ -34,11 +97,18 @@ export function createRouteGuard(config: RouteGuardConfig): RequestHandler {
|
|
|
34
97
|
|
|
35
98
|
/**
|
|
36
99
|
* Create basic auth guard middleware
|
|
100
|
+
*
|
|
101
|
+
* This guard supports session cookies to prevent repeated login prompts.
|
|
102
|
+
* After successful Basic Auth, a signed session cookie is set that allows
|
|
103
|
+
* subsequent requests to proceed without re-prompting for credentials.
|
|
37
104
|
*/
|
|
38
105
|
function createBasicAuthGuard(config: BasicAuthGuardConfig): RequestHandler {
|
|
39
106
|
const expectedAuth = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
|
|
40
107
|
const realm = config.realm || 'Protected';
|
|
41
108
|
const excludePaths = config.excludePaths || [];
|
|
109
|
+
const sessionDurationHours = config.sessionDurationHours ?? DEFAULT_SESSION_DURATION_HOURS;
|
|
110
|
+
// Use password as HMAC secret for session tokens
|
|
111
|
+
const sessionSecret = config.password;
|
|
42
112
|
|
|
43
113
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
44
114
|
// Check if path is excluded
|
|
@@ -46,11 +116,30 @@ function createBasicAuthGuard(config: BasicAuthGuardConfig): RequestHandler {
|
|
|
46
116
|
return next();
|
|
47
117
|
}
|
|
48
118
|
|
|
119
|
+
// Check for valid session cookie first
|
|
120
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
121
|
+
const sessionToken = cookies[SESSION_COOKIE_NAME];
|
|
122
|
+
if (sessionToken && verifySessionToken(sessionToken, sessionSecret)) {
|
|
123
|
+
return next();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check Authorization header
|
|
49
127
|
const authHeader = req.headers.authorization;
|
|
50
128
|
if (authHeader === expectedAuth) {
|
|
129
|
+
// Set session cookie on successful auth
|
|
130
|
+
const token = createSessionToken(sessionSecret, sessionDurationHours);
|
|
131
|
+
const maxAge = sessionDurationHours * 60 * 60; // seconds
|
|
132
|
+
// Add Secure flag when running over HTTPS
|
|
133
|
+
const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
|
|
134
|
+
const secureFlag = isSecure ? ' Secure;' : '';
|
|
135
|
+
res.setHeader(
|
|
136
|
+
'Set-Cookie',
|
|
137
|
+
`${SESSION_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict;${secureFlag} Max-Age=${maxAge}`
|
|
138
|
+
);
|
|
51
139
|
return next();
|
|
52
140
|
}
|
|
53
141
|
|
|
142
|
+
// No valid session or auth header - prompt for credentials
|
|
54
143
|
res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
|
|
55
144
|
res.status(401).json({
|
|
56
145
|
error: 'Unauthorized',
|
|
@@ -182,6 +182,10 @@ export class HealthManager {
|
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
184
|
* Get aggregated status
|
|
185
|
+
*
|
|
186
|
+
* Returns 'degraded' instead of 'unhealthy' when subsystems fail,
|
|
187
|
+
* allowing the service to remain available even when dependencies are down.
|
|
188
|
+
* This ensures the control panel and other features remain accessible.
|
|
185
189
|
*/
|
|
186
190
|
getAggregatedStatus(): HealthStatus {
|
|
187
191
|
const results = Array.from(this.results.values());
|
|
@@ -191,7 +195,8 @@ export class HealthManager {
|
|
|
191
195
|
const unhealthyCount = results.filter((r) => r.status === 'unhealthy').length;
|
|
192
196
|
const degradedCount = results.filter((r) => r.status === 'degraded').length;
|
|
193
197
|
|
|
194
|
-
|
|
198
|
+
// Return 'degraded' instead of 'unhealthy' to keep service available (HTTP 200)
|
|
199
|
+
if (unhealthyCount > 0) return 'degraded';
|
|
195
200
|
if (degradedCount > 0) return 'degraded';
|
|
196
201
|
|
|
197
202
|
const hasUnknown = results.some((r) => r.status === 'unknown');
|
|
@@ -51,10 +51,19 @@ export interface PluginInfo {
|
|
|
51
51
|
id: string;
|
|
52
52
|
name: string;
|
|
53
53
|
version?: string;
|
|
54
|
+
type: PluginType;
|
|
55
|
+
slug?: string; // Current slug (may be customized)
|
|
54
56
|
status: 'starting' | 'active' | 'stopped' | 'error';
|
|
55
57
|
error?: string;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Plugin type determines routing capabilities
|
|
62
|
+
* - regular: Can only handle routes under their slug prefix (e.g., /mcp/*)
|
|
63
|
+
* - system: Can handle any path (e.g., /*, /auth/*)
|
|
64
|
+
*/
|
|
65
|
+
export type PluginType = 'regular' | 'system';
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
68
|
* The Plugin interface - simple lifecycle with event handling
|
|
60
69
|
*/
|
|
@@ -68,6 +77,30 @@ export interface Plugin {
|
|
|
68
77
|
/** Plugin version (semver) */
|
|
69
78
|
version?: string;
|
|
70
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Plugin type determines routing capabilities
|
|
82
|
+
* - regular: Must use slug prefix for all routes
|
|
83
|
+
* - system: Can register routes at any path
|
|
84
|
+
* Default: 'regular'
|
|
85
|
+
*/
|
|
86
|
+
type?: PluginType;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Default slug for regular plugins (path prefix for routes)
|
|
90
|
+
* Example: 'mcp' makes routes available under /mcp/*
|
|
91
|
+
* Can be overridden via plugin config
|
|
92
|
+
* Ignored for system plugins
|
|
93
|
+
*/
|
|
94
|
+
slug?: string;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Configuration options for this plugin
|
|
98
|
+
*/
|
|
99
|
+
configurable?: {
|
|
100
|
+
/** Allow users to customize the slug via UI */
|
|
101
|
+
slug?: boolean;
|
|
102
|
+
};
|
|
103
|
+
|
|
71
104
|
/**
|
|
72
105
|
* Called when the plugin starts.
|
|
73
106
|
* Initialize resources, register routes/UI contributions here.
|
|
@@ -150,18 +183,34 @@ export interface WidgetContribution {
|
|
|
150
183
|
pluginId: string;
|
|
151
184
|
}
|
|
152
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Auth configuration for routes
|
|
188
|
+
*/
|
|
189
|
+
export interface RouteAuthConfig {
|
|
190
|
+
/** Whether authentication is required */
|
|
191
|
+
required: boolean;
|
|
192
|
+
/** Allowed roles (if auth required) */
|
|
193
|
+
roles?: string[];
|
|
194
|
+
/** Paths to exclude from auth (for middleware routes using 'use' method) */
|
|
195
|
+
excludePaths?: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
153
198
|
/**
|
|
154
199
|
* Route definition for API routes
|
|
155
200
|
*/
|
|
156
201
|
export interface RouteDefinition {
|
|
157
|
-
/** HTTP method */
|
|
158
|
-
method: 'get' | 'post' | 'put' | 'delete' | 'patch';
|
|
159
|
-
/** Route path */
|
|
202
|
+
/** HTTP method (including 'use' for middleware) */
|
|
203
|
+
method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'use';
|
|
204
|
+
/** Route path (will be auto-prefixed with slug for regular plugins) */
|
|
160
205
|
path: string;
|
|
161
206
|
/** Request handler */
|
|
162
207
|
handler: RequestHandler;
|
|
163
|
-
/**
|
|
164
|
-
|
|
208
|
+
/** Authentication configuration */
|
|
209
|
+
auth?: RouteAuthConfig;
|
|
210
|
+
/** Plugin ID that contributed this (set automatically) */
|
|
211
|
+
pluginId?: string;
|
|
212
|
+
/** Original path before slug prefixing (set automatically) */
|
|
213
|
+
originalPath?: string;
|
|
165
214
|
}
|
|
166
215
|
|
|
167
216
|
/**
|
|
@@ -321,6 +370,8 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
321
370
|
private pluginStatus = new Map<string, PluginInfo['status']>();
|
|
322
371
|
private pluginErrors = new Map<string, string>();
|
|
323
372
|
private pluginConfigs = new Map<string, PluginConfig>();
|
|
373
|
+
private pluginSlugs = new Map<string, string>(); // pluginId -> slug
|
|
374
|
+
private currentPlugin: string | null = null; // Track plugin during onStart
|
|
324
375
|
|
|
325
376
|
private routes: RouteDefinition[] = [];
|
|
326
377
|
private menuItems: MenuContribution[] = [];
|
|
@@ -371,6 +422,8 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
371
422
|
id: plugin.id,
|
|
372
423
|
name: plugin.name,
|
|
373
424
|
version: plugin.version,
|
|
425
|
+
type: plugin.type || 'regular',
|
|
426
|
+
slug: this.pluginSlugs.get(plugin.id),
|
|
374
427
|
status: this.pluginStatus.get(plugin.id) || 'stopped',
|
|
375
428
|
error: this.pluginErrors.get(plugin.id),
|
|
376
429
|
}));
|
|
@@ -381,28 +434,34 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
381
434
|
// ---------------------------------------------------------------------------
|
|
382
435
|
|
|
383
436
|
addRoute(route: RouteDefinition): void {
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
// Register with Express router
|
|
387
|
-
switch (route.method) {
|
|
388
|
-
case 'get':
|
|
389
|
-
this.router.get(route.path, route.handler);
|
|
390
|
-
break;
|
|
391
|
-
case 'post':
|
|
392
|
-
this.router.post(route.path, route.handler);
|
|
393
|
-
break;
|
|
394
|
-
case 'put':
|
|
395
|
-
this.router.put(route.path, route.handler);
|
|
396
|
-
break;
|
|
397
|
-
case 'delete':
|
|
398
|
-
this.router.delete(route.path, route.handler);
|
|
399
|
-
break;
|
|
400
|
-
case 'patch':
|
|
401
|
-
this.router.patch(route.path, route.handler);
|
|
402
|
-
break;
|
|
437
|
+
if (!this.currentPlugin) {
|
|
438
|
+
throw new Error('addRoute can only be called during plugin.onStart()');
|
|
403
439
|
}
|
|
404
440
|
|
|
405
|
-
|
|
441
|
+
const plugin = this.plugins.get(this.currentPlugin)!;
|
|
442
|
+
const pluginType = plugin.type || 'regular';
|
|
443
|
+
const originalPath = route.path;
|
|
444
|
+
let fullPath = route.path;
|
|
445
|
+
|
|
446
|
+
// Auto-prefix for regular plugins
|
|
447
|
+
if (pluginType === 'regular') {
|
|
448
|
+
const slug = this.pluginSlugs.get(this.currentPlugin)!;
|
|
449
|
+
fullPath = `/${slug}${route.path}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const routeWithMetadata: RouteDefinition = {
|
|
453
|
+
...route,
|
|
454
|
+
path: fullPath,
|
|
455
|
+
pluginId: this.currentPlugin,
|
|
456
|
+
originalPath,
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
this.routes.push(routeWithMetadata);
|
|
460
|
+
|
|
461
|
+
this.logger.debug(
|
|
462
|
+
`Route registered: ${route.method.toUpperCase()} ${fullPath} by ${this.currentPlugin}` +
|
|
463
|
+
(pluginType === 'regular' ? ` (original: ${originalPath})` : '')
|
|
464
|
+
);
|
|
406
465
|
}
|
|
407
466
|
|
|
408
467
|
addMenuItem(menu: MenuContribution): void {
|
|
@@ -572,6 +631,22 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
572
631
|
return this.healthManager;
|
|
573
632
|
}
|
|
574
633
|
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Slug management (internal)
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Check if a slug is available
|
|
640
|
+
*/
|
|
641
|
+
private isSlugAvailable(slug: string, excludePluginId?: string): boolean {
|
|
642
|
+
for (const [pluginId, existingSlug] of this.pluginSlugs.entries()) {
|
|
643
|
+
if (pluginId !== excludePluginId && existingSlug === slug) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
|
|
575
650
|
// ---------------------------------------------------------------------------
|
|
576
651
|
// Plugin lifecycle management (internal)
|
|
577
652
|
// ---------------------------------------------------------------------------
|
|
@@ -583,6 +658,27 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
583
658
|
this.plugins.set(plugin.id, plugin);
|
|
584
659
|
this.pluginConfigs.set(plugin.id, config);
|
|
585
660
|
this.pluginStatus.set(plugin.id, 'starting');
|
|
661
|
+
this.currentPlugin = plugin.id;
|
|
662
|
+
|
|
663
|
+
const pluginType = plugin.type || 'regular';
|
|
664
|
+
|
|
665
|
+
// Handle slug for regular plugins
|
|
666
|
+
if (pluginType === 'regular') {
|
|
667
|
+
const slug = (config.slug as string | undefined) || plugin.slug || plugin.id;
|
|
668
|
+
|
|
669
|
+
// Validate slug uniqueness
|
|
670
|
+
if (!this.isSlugAvailable(slug, plugin.id)) {
|
|
671
|
+
this.currentPlugin = null;
|
|
672
|
+
const errorMessage = `Slug conflict: "${slug}" already in use`;
|
|
673
|
+
this.pluginStatus.set(plugin.id, 'error');
|
|
674
|
+
this.pluginErrors.set(plugin.id, errorMessage);
|
|
675
|
+
this.logger.error(`Plugin ${plugin.id} failed to start: ${errorMessage}`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
this.pluginSlugs.set(plugin.id, slug);
|
|
680
|
+
this.logger.debug(`Plugin ${plugin.id} registered with slug: ${slug}`);
|
|
681
|
+
}
|
|
586
682
|
|
|
587
683
|
try {
|
|
588
684
|
await Promise.race([
|
|
@@ -592,6 +688,7 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
592
688
|
|
|
593
689
|
this.pluginStatus.set(plugin.id, 'active');
|
|
594
690
|
this.pluginErrors.delete(plugin.id);
|
|
691
|
+
this.currentPlugin = null;
|
|
595
692
|
|
|
596
693
|
this.emit({
|
|
597
694
|
type: 'plugin:started',
|
|
@@ -605,6 +702,7 @@ export class PluginRegistryImpl implements PluginRegistry {
|
|
|
605
702
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
606
703
|
this.pluginStatus.set(plugin.id, 'error');
|
|
607
704
|
this.pluginErrors.set(plugin.id, errorMessage);
|
|
705
|
+
this.currentPlugin = null;
|
|
608
706
|
|
|
609
707
|
this.emit({
|
|
610
708
|
type: 'plugin:error',
|
package/src/core/types.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -262,6 +262,10 @@ export {
|
|
|
262
262
|
getActivityLog,
|
|
263
263
|
postgresParentalStore,
|
|
264
264
|
kidsAdapter,
|
|
265
|
+
// QwickBrain plugin
|
|
266
|
+
createQwickBrainPlugin,
|
|
267
|
+
getConnectionStatus,
|
|
268
|
+
isConnected,
|
|
265
269
|
} from './plugins/index.js';
|
|
266
270
|
export type {
|
|
267
271
|
HealthPluginConfig,
|
|
@@ -414,4 +418,11 @@ export type {
|
|
|
414
418
|
ParentalApiConfig,
|
|
415
419
|
PostgresParentalStoreConfig,
|
|
416
420
|
KidsAdapterConfig,
|
|
421
|
+
// QwickBrain plugin types
|
|
422
|
+
QwickBrainPluginConfig,
|
|
423
|
+
MCPToolDefinition,
|
|
424
|
+
MCPToolCallRequest,
|
|
425
|
+
MCPToolCallResponse,
|
|
426
|
+
QwickBrainConnectionStatus,
|
|
427
|
+
MCPRateLimitConfig,
|
|
417
428
|
} from './plugins/index.js';
|
|
@@ -174,3 +174,170 @@ describe('Auth Plugin helpers', () => {
|
|
|
174
174
|
expect(authModule.requireAnyRole).toBeDefined();
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
|
+
|
|
178
|
+
describe('onAuthenticated callback', () => {
|
|
179
|
+
// Mock adapter that always authenticates
|
|
180
|
+
function createMockAdapter(user: AuthenticatedUser | null): ReturnType<typeof basicAdapter> {
|
|
181
|
+
return {
|
|
182
|
+
name: 'mock',
|
|
183
|
+
initialize: () => (_req: Request, _res: Response, next: NextFunction) => next(),
|
|
184
|
+
isAuthenticated: () => user !== null,
|
|
185
|
+
getUser: () => user,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
it('should call onAuthenticated callback after successful authentication', async () => {
|
|
190
|
+
const { createAuthPlugin } = await import('./auth-plugin.js');
|
|
191
|
+
const mockUser: AuthenticatedUser = {
|
|
192
|
+
id: 'user-123',
|
|
193
|
+
email: 'test@example.com',
|
|
194
|
+
name: 'Test User',
|
|
195
|
+
};
|
|
196
|
+
const onAuthenticated = vi.fn().mockResolvedValue(undefined);
|
|
197
|
+
|
|
198
|
+
const plugin = createAuthPlugin({
|
|
199
|
+
adapter: createMockAdapter(mockUser),
|
|
200
|
+
authRequired: false,
|
|
201
|
+
onAuthenticated,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Create mock registry
|
|
205
|
+
const mockApp = {
|
|
206
|
+
use: vi.fn(),
|
|
207
|
+
};
|
|
208
|
+
const mockRegistry = {
|
|
209
|
+
getApp: () => mockApp,
|
|
210
|
+
addRoute: vi.fn(),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Start the plugin to register middleware
|
|
214
|
+
await plugin.onStart?.({} as never, mockRegistry as never);
|
|
215
|
+
|
|
216
|
+
// Get the auth middleware (last middleware added)
|
|
217
|
+
const authMiddleware = mockApp.use.mock.calls[mockApp.use.mock.calls.length - 1][0];
|
|
218
|
+
|
|
219
|
+
// Call middleware
|
|
220
|
+
const req = createMockRequest();
|
|
221
|
+
const res = createMockResponse();
|
|
222
|
+
const next = vi.fn();
|
|
223
|
+
|
|
224
|
+
await authMiddleware(req, res, next);
|
|
225
|
+
|
|
226
|
+
expect(onAuthenticated).toHaveBeenCalledWith(mockUser);
|
|
227
|
+
expect(next).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should not call onAuthenticated when authentication fails', async () => {
|
|
231
|
+
const { createAuthPlugin } = await import('./auth-plugin.js');
|
|
232
|
+
const onAuthenticated = vi.fn().mockResolvedValue(undefined);
|
|
233
|
+
|
|
234
|
+
const plugin = createAuthPlugin({
|
|
235
|
+
adapter: createMockAdapter(null), // null user = not authenticated
|
|
236
|
+
authRequired: false,
|
|
237
|
+
onAuthenticated,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const mockApp = {
|
|
241
|
+
use: vi.fn(),
|
|
242
|
+
};
|
|
243
|
+
const mockRegistry = {
|
|
244
|
+
getApp: () => mockApp,
|
|
245
|
+
addRoute: vi.fn(),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await plugin.onStart?.({} as never, mockRegistry as never);
|
|
249
|
+
|
|
250
|
+
const authMiddleware = mockApp.use.mock.calls[mockApp.use.mock.calls.length - 1][0];
|
|
251
|
+
|
|
252
|
+
const req = createMockRequest();
|
|
253
|
+
const res = createMockResponse();
|
|
254
|
+
const next = vi.fn();
|
|
255
|
+
|
|
256
|
+
await authMiddleware(req, res, next);
|
|
257
|
+
|
|
258
|
+
expect(onAuthenticated).not.toHaveBeenCalled();
|
|
259
|
+
expect(next).toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should not fail authentication when onAuthenticated throws an error', async () => {
|
|
263
|
+
const { createAuthPlugin } = await import('./auth-plugin.js');
|
|
264
|
+
const mockUser: AuthenticatedUser = {
|
|
265
|
+
id: 'user-123',
|
|
266
|
+
email: 'test@example.com',
|
|
267
|
+
name: 'Test User',
|
|
268
|
+
};
|
|
269
|
+
const onAuthenticated = vi.fn().mockRejectedValue(new Error('Sync failed'));
|
|
270
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
271
|
+
|
|
272
|
+
const plugin = createAuthPlugin({
|
|
273
|
+
adapter: createMockAdapter(mockUser),
|
|
274
|
+
authRequired: false,
|
|
275
|
+
onAuthenticated,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const mockApp = {
|
|
279
|
+
use: vi.fn(),
|
|
280
|
+
};
|
|
281
|
+
const mockRegistry = {
|
|
282
|
+
getApp: () => mockApp,
|
|
283
|
+
addRoute: vi.fn(),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await plugin.onStart?.({} as never, mockRegistry as never);
|
|
287
|
+
|
|
288
|
+
const authMiddleware = mockApp.use.mock.calls[mockApp.use.mock.calls.length - 1][0];
|
|
289
|
+
|
|
290
|
+
const req = createMockRequest();
|
|
291
|
+
const res = createMockResponse();
|
|
292
|
+
const next = vi.fn();
|
|
293
|
+
|
|
294
|
+
await authMiddleware(req, res, next);
|
|
295
|
+
|
|
296
|
+
// Callback was called
|
|
297
|
+
expect(onAuthenticated).toHaveBeenCalledWith(mockUser);
|
|
298
|
+
// Error was logged
|
|
299
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
300
|
+
// Request still proceeds
|
|
301
|
+
expect(next).toHaveBeenCalled();
|
|
302
|
+
// Auth info is still set correctly
|
|
303
|
+
expect((req as unknown as { auth: { isAuthenticated: boolean } }).auth.isAuthenticated).toBe(true);
|
|
304
|
+
|
|
305
|
+
consoleSpy.mockRestore();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should not call onAuthenticated when callback is not provided', async () => {
|
|
309
|
+
const { createAuthPlugin } = await import('./auth-plugin.js');
|
|
310
|
+
const mockUser: AuthenticatedUser = {
|
|
311
|
+
id: 'user-123',
|
|
312
|
+
email: 'test@example.com',
|
|
313
|
+
name: 'Test User',
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const plugin = createAuthPlugin({
|
|
317
|
+
adapter: createMockAdapter(mockUser),
|
|
318
|
+
authRequired: false,
|
|
319
|
+
// No onAuthenticated callback
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const mockApp = {
|
|
323
|
+
use: vi.fn(),
|
|
324
|
+
};
|
|
325
|
+
const mockRegistry = {
|
|
326
|
+
getApp: () => mockApp,
|
|
327
|
+
addRoute: vi.fn(),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
await plugin.onStart?.({} as never, mockRegistry as never);
|
|
331
|
+
|
|
332
|
+
const authMiddleware = mockApp.use.mock.calls[mockApp.use.mock.calls.length - 1][0];
|
|
333
|
+
|
|
334
|
+
const req = createMockRequest();
|
|
335
|
+
const res = createMockResponse();
|
|
336
|
+
const next = vi.fn();
|
|
337
|
+
|
|
338
|
+
// Should not throw
|
|
339
|
+
await authMiddleware(req, res, next);
|
|
340
|
+
|
|
341
|
+
expect(next).toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -38,6 +38,7 @@ export function createAuthPlugin(config: AuthPluginConfig): Plugin {
|
|
|
38
38
|
id: 'auth',
|
|
39
39
|
name: 'Auth Plugin',
|
|
40
40
|
version: '1.0.0',
|
|
41
|
+
type: 'system' as const,
|
|
41
42
|
|
|
42
43
|
async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
|
|
43
44
|
const app = registry.getApp();
|
|
@@ -181,6 +182,21 @@ export function createAuthPlugin(config: AuthPluginConfig): Plugin {
|
|
|
181
182
|
accessToken: activeAdapter.getAccessToken?.(req) || undefined,
|
|
182
183
|
};
|
|
183
184
|
|
|
185
|
+
// Call onAuthenticated callback if provided and user is authenticated
|
|
186
|
+
if (authenticated && user && config.onAuthenticated) {
|
|
187
|
+
try {
|
|
188
|
+
await config.onAuthenticated(user);
|
|
189
|
+
log('onAuthenticated callback completed', { userId: user.id, email: user.email });
|
|
190
|
+
} catch (error) {
|
|
191
|
+
// Log error but don't fail the request - auth succeeded, sync is optional
|
|
192
|
+
console.error('[AuthPlugin] onAuthenticated callback error:', {
|
|
193
|
+
userId: user.id,
|
|
194
|
+
email: user.email,
|
|
195
|
+
error: error instanceof Error ? error.message : String(error),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
184
200
|
// Check if auth is required but user is not authenticated
|
|
185
201
|
if (authRequired && !authenticated) {
|
|
186
202
|
log('Auth required but not authenticated', { path: req.path });
|