@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +41 -0
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/guards.d.ts.map +1 -1
  6. package/dist/core/guards.js +77 -0
  7. package/dist/core/guards.js.map +1 -1
  8. package/dist/core/health-manager.d.ts +4 -0
  9. package/dist/core/health-manager.d.ts.map +1 -1
  10. package/dist/core/health-manager.js +6 -1
  11. package/dist/core/health-manager.js.map +1 -1
  12. package/dist/core/plugin-registry.d.ts +55 -5
  13. package/dist/core/plugin-registry.d.ts.map +1 -1
  14. package/dist/core/plugin-registry.js +57 -19
  15. package/dist/core/plugin-registry.js.map +1 -1
  16. package/dist/core/types.d.ts +2 -0
  17. package/dist/core/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
  23. package/dist/plugins/auth/auth-plugin.js +16 -0
  24. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  25. package/dist/plugins/auth/auth-plugin.test.js +133 -0
  26. package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
  27. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  28. package/dist/plugins/auth/env-config.js +4 -0
  29. package/dist/plugins/auth/env-config.js.map +1 -1
  30. package/dist/plugins/auth/types.d.ts +10 -0
  31. package/dist/plugins/auth/types.d.ts.map +1 -1
  32. package/dist/plugins/auth/types.js.map +1 -1
  33. package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
  34. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
  35. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  36. package/dist/plugins/frontend-app-plugin.js +18 -4
  37. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  38. package/dist/plugins/index.d.ts +2 -0
  39. package/dist/plugins/index.d.ts.map +1 -1
  40. package/dist/plugins/index.js +2 -0
  41. package/dist/plugins/index.js.map +1 -1
  42. package/dist/plugins/qwickbrain/index.d.ts +25 -0
  43. package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
  44. package/dist/plugins/qwickbrain/index.js +24 -0
  45. package/dist/plugins/qwickbrain/index.js.map +1 -0
  46. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
  47. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
  48. package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
  49. package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
  50. package/dist/plugins/qwickbrain/types.d.ts +131 -0
  51. package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
  52. package/dist/plugins/qwickbrain/types.js +9 -0
  53. package/dist/plugins/qwickbrain/types.js.map +1 -0
  54. package/dist-ui/assets/{index-CynOqPkb.js → index-BfC7mG5L.js} +2 -2
  55. package/dist-ui/assets/{index-CynOqPkb.js.map → index-BfC7mG5L.js.map} +1 -1
  56. package/dist-ui/index.html +1 -1
  57. package/dist-ui-lib/api/controlPanelApi.d.ts +6 -0
  58. package/dist-ui-lib/index.js +277 -266
  59. package/dist-ui-lib/index.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/core/control-panel.ts +47 -0
  62. package/src/core/guards.ts +89 -0
  63. package/src/core/health-manager.ts +6 -1
  64. package/src/core/plugin-registry.ts +123 -25
  65. package/src/core/types.ts +2 -0
  66. package/src/index.ts +11 -0
  67. package/src/plugins/auth/auth-plugin.test.ts +167 -0
  68. package/src/plugins/auth/auth-plugin.ts +16 -0
  69. package/src/plugins/auth/env-config.ts +4 -0
  70. package/src/plugins/auth/types.ts +10 -0
  71. package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
  72. package/src/plugins/frontend-app-plugin.ts +19 -4
  73. package/src/plugins/index.ts +15 -0
  74. package/src/plugins/qwickbrain/index.ts +33 -0
  75. package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
  76. package/src/plugins/qwickbrain/types.ts +146 -0
  77. package/ui/src/api/controlPanelApi.ts +49 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qwickapps/server",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Plugin-based application server framework for building websites, APIs, admin dashboards, and full-stack products",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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}`);
@@ -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
- if (unhealthyCount > 0) return 'unhealthy';
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
- /** Plugin ID that contributed this */
164
- pluginId: string;
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.routes.push(route);
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
- this.logger.debug(`Route registered: ${route.method.toUpperCase()} ${route.path} by ${route.pluginId}`);
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
@@ -24,6 +24,8 @@ export interface BasicAuthGuardConfig {
24
24
  realm?: string;
25
25
  /** Paths to exclude from authentication (e.g., ['/health']) */
26
26
  excludePaths?: string[];
27
+ /** Session cookie duration in hours (default: 8) */
28
+ sessionDurationHours?: number;
27
29
  }
28
30
 
29
31
  /**
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 });