@qwickapps/server 1.5.1 → 1.6.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +43 -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/api-keys/api-keys-plugin.d.ts +46 -0
  23. package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
  24. package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
  25. package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
  26. package/dist/plugins/api-keys/index.d.ts +14 -0
  27. package/dist/plugins/api-keys/index.d.ts.map +1 -0
  28. package/dist/plugins/api-keys/index.js +17 -0
  29. package/dist/plugins/api-keys/index.js.map +1 -0
  30. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
  31. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
  32. package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
  33. package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
  34. package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
  35. package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
  36. package/dist/plugins/api-keys/middleware/index.js +7 -0
  37. package/dist/plugins/api-keys/middleware/index.js.map +1 -0
  38. package/dist/plugins/api-keys/stores/index.d.ts +7 -0
  39. package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
  40. package/dist/plugins/api-keys/stores/index.js +7 -0
  41. package/dist/plugins/api-keys/stores/index.js.map +1 -0
  42. package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
  43. package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
  44. package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
  45. package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
  46. package/dist/plugins/api-keys/types.d.ts +268 -0
  47. package/dist/plugins/api-keys/types.d.ts.map +1 -0
  48. package/dist/plugins/api-keys/types.js +56 -0
  49. package/dist/plugins/api-keys/types.js.map +1 -0
  50. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
  51. package/dist/plugins/auth/auth-plugin.js +17 -1
  52. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  53. package/dist/plugins/auth/auth-plugin.test.js +133 -0
  54. package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
  55. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  56. package/dist/plugins/auth/env-config.js +6 -2
  57. package/dist/plugins/auth/env-config.js.map +1 -1
  58. package/dist/plugins/auth/types.d.ts +10 -0
  59. package/dist/plugins/auth/types.d.ts.map +1 -1
  60. package/dist/plugins/auth/types.js.map +1 -1
  61. package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
  62. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
  63. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  64. package/dist/plugins/frontend-app-plugin.js +21 -4
  65. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  66. package/dist/plugins/index.d.ts +2 -0
  67. package/dist/plugins/index.d.ts.map +1 -1
  68. package/dist/plugins/index.js +2 -0
  69. package/dist/plugins/index.js.map +1 -1
  70. package/dist/plugins/qwickbrain/index.d.ts +25 -0
  71. package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
  72. package/dist/plugins/qwickbrain/index.js +24 -0
  73. package/dist/plugins/qwickbrain/index.js.map +1 -0
  74. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
  75. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
  76. package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
  77. package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
  78. package/dist/plugins/qwickbrain/types.d.ts +131 -0
  79. package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
  80. package/dist/plugins/qwickbrain/types.js +9 -0
  81. package/dist/plugins/qwickbrain/types.js.map +1 -0
  82. package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
  83. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
  84. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  85. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  86. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  87. package/dist/plugins/users/stores/postgres-store.js +59 -1
  88. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  89. package/dist/plugins/users/types.d.ts +22 -0
  90. package/dist/plugins/users/types.d.ts.map +1 -1
  91. package/dist-ui/assets/index-5nX8fM1a.js +469 -0
  92. package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
  93. package/dist-ui/index.html +1 -1
  94. package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
  95. package/dist-ui-lib/components/index.d.ts +2 -1
  96. package/dist-ui-lib/index.js +2642 -2281
  97. package/dist-ui-lib/index.js.map +1 -1
  98. package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
  99. package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
  100. package/package.json +3 -2
  101. package/src/core/control-panel.ts +47 -0
  102. package/src/core/guards.ts +89 -0
  103. package/src/core/health-manager.ts +6 -1
  104. package/src/core/plugin-registry.ts +123 -25
  105. package/src/core/types.ts +2 -0
  106. package/src/index.ts +11 -0
  107. package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
  108. package/src/plugins/api-keys/index.ts +49 -0
  109. package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
  110. package/src/plugins/api-keys/middleware/index.ts +12 -0
  111. package/src/plugins/api-keys/stores/index.ts +7 -0
  112. package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
  113. package/src/plugins/api-keys/types.ts +243 -0
  114. package/src/plugins/auth/auth-plugin.test.ts +167 -0
  115. package/src/plugins/auth/auth-plugin.ts +17 -1
  116. package/src/plugins/auth/env-config.ts +6 -2
  117. package/src/plugins/auth/types.ts +10 -0
  118. package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
  119. package/src/plugins/frontend-app-plugin.ts +24 -4
  120. package/src/plugins/index.ts +15 -0
  121. package/src/plugins/qwickbrain/index.ts +33 -0
  122. package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
  123. package/src/plugins/qwickbrain/types.ts +146 -0
  124. package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
  125. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  126. package/src/plugins/users/stores/postgres-store.ts +69 -0
  127. package/src/plugins/users/types.ts +25 -0
  128. package/ui/src/App.tsx +6 -1
  129. package/ui/src/api/controlPanelApi.ts +206 -37
  130. package/ui/src/components/index.ts +6 -0
  131. package/ui/src/pages/APIKeysPage.tsx +661 -0
  132. package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
  133. package/ui/src/pages/UsersPage.tsx +225 -2
  134. package/dist-ui/assets/index-CynOqPkb.js +0 -469
  135. package/dist-ui/assets/index-CynOqPkb.js.map +0 -1
@@ -0,0 +1,243 @@
1
+ /**
2
+ * API Keys Plugin Types
3
+ *
4
+ * Type definitions for API key authentication and management.
5
+ * Supports PostgreSQL with Row-Level Security (RLS) for data isolation.
6
+ *
7
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
8
+ */
9
+
10
+ import { z } from 'zod';
11
+
12
+ /**
13
+ * API key scope type
14
+ */
15
+ export type ApiKeyScope = 'read' | 'write' | 'admin';
16
+
17
+ /**
18
+ * API key type (M2M = machine-to-machine, PAT = personal access token)
19
+ */
20
+ export type ApiKeyType = 'm2m' | 'pat';
21
+
22
+ /**
23
+ * API key record in the database
24
+ */
25
+ export interface ApiKey {
26
+ /** Primary key - UUID */
27
+ id: string;
28
+ /** User ID (foreign key to users table) */
29
+ user_id: string;
30
+ /** Human-readable name for the key */
31
+ name: string;
32
+ /** Hashed API key (SHA-256) */
33
+ key_hash: string;
34
+ /** Key prefix for identification (e.g., 'qk_live_') - stored in plaintext */
35
+ key_prefix: string;
36
+ /** Key type: m2m (machine-to-machine) or pat (personal access token) */
37
+ key_type: ApiKeyType;
38
+ /** Scopes granted to this key */
39
+ scopes: ApiKeyScope[];
40
+ /** Last time this key was used */
41
+ last_used_at: Date | null;
42
+ /** Expiration date (null = never expires) */
43
+ expires_at: Date | null;
44
+ /** Whether the key is active */
45
+ is_active: boolean;
46
+ /** When the key was created */
47
+ created_at: Date;
48
+ /** When the key was last updated */
49
+ updated_at: Date;
50
+ }
51
+
52
+ /**
53
+ * API key creation parameters
54
+ */
55
+ export interface CreateApiKeyParams {
56
+ /** User ID who owns this key */
57
+ user_id: string;
58
+ /** Human-readable name for the key */
59
+ name: string;
60
+ /** Key type: m2m or pat */
61
+ key_type: ApiKeyType;
62
+ /** Scopes to grant */
63
+ scopes: ApiKeyScope[];
64
+ /** Optional expiration date */
65
+ expires_at?: Date;
66
+ }
67
+
68
+ /**
69
+ * API key update parameters
70
+ */
71
+ export interface UpdateApiKeyParams {
72
+ /** New name (optional) */
73
+ name?: string;
74
+ /** New scopes (optional) */
75
+ scopes?: ApiKeyScope[];
76
+ /** New expiration date (optional) */
77
+ expires_at?: Date;
78
+ /** Activate/deactivate key (optional) */
79
+ is_active?: boolean;
80
+ }
81
+
82
+ /**
83
+ * API key with plaintext key (only returned on creation)
84
+ */
85
+ export interface ApiKeyWithPlaintext extends ApiKey {
86
+ /** Plaintext API key - only available on creation */
87
+ plaintext_key: string;
88
+ }
89
+
90
+ /**
91
+ * API key store interface - all storage backends must implement this
92
+ */
93
+ export interface ApiKeyStore {
94
+ /** Store name (e.g., 'postgres', 'memory') */
95
+ name: string;
96
+
97
+ /**
98
+ * Initialize the store (create tables, RLS policies, etc.)
99
+ */
100
+ initialize(): Promise<void>;
101
+
102
+ /**
103
+ * Create a new API key
104
+ * Returns the key with plaintext value (only time plaintext is accessible)
105
+ */
106
+ create(params: CreateApiKeyParams): Promise<ApiKeyWithPlaintext>;
107
+
108
+ /**
109
+ * Get all API keys for a user
110
+ */
111
+ list(userId: string): Promise<ApiKey[]>;
112
+
113
+ /**
114
+ * Get a specific API key by ID
115
+ * Returns null if key doesn't exist or doesn't belong to user
116
+ */
117
+ get(userId: string, keyId: string): Promise<ApiKey | null>;
118
+
119
+ /**
120
+ * Verify an API key and return the associated key record
121
+ * Returns null if key is invalid, expired, or inactive
122
+ */
123
+ verify(plaintextKey: string): Promise<ApiKey | null>;
124
+
125
+ /**
126
+ * Update an API key
127
+ * Returns the updated key or null if key doesn't exist
128
+ */
129
+ update(userId: string, keyId: string, params: UpdateApiKeyParams): Promise<ApiKey | null>;
130
+
131
+ /**
132
+ * Delete an API key
133
+ * Returns true if key was deleted, false if it didn't exist
134
+ */
135
+ delete(userId: string, keyId: string): Promise<boolean>;
136
+
137
+ /**
138
+ * Record key usage (updates last_used_at timestamp)
139
+ */
140
+ recordUsage(keyId: string): Promise<void>;
141
+
142
+ /**
143
+ * Shutdown the store
144
+ */
145
+ shutdown(): Promise<void>;
146
+ }
147
+
148
+ /**
149
+ * PostgreSQL API key store configuration
150
+ */
151
+ export interface PostgresApiKeyStoreConfig {
152
+ /** PostgreSQL pool instance or a function that returns one (for lazy initialization) */
153
+ pool: unknown | (() => unknown);
154
+ /** Table name (default: 'api_keys') */
155
+ tableName?: string;
156
+ /** Schema name (default: 'public') */
157
+ schema?: string;
158
+ /** Auto-create tables on init (default: true) */
159
+ autoCreateTables?: boolean;
160
+ /** Enable RLS (default: true) */
161
+ enableRLS?: boolean;
162
+ /** Key expiration in days (default: 90, null = never expires) */
163
+ defaultExpirationDays?: number | null;
164
+ /** Environment for key prefix (default: from NODE_ENV, 'test' in non-production, 'live' in production) */
165
+ environment?: 'test' | 'live';
166
+ }
167
+
168
+ /**
169
+ * API keys API configuration
170
+ */
171
+ export interface ApiKeysApiConfig {
172
+ /** API route prefix (default: '/api-keys') */
173
+ prefix?: string;
174
+ /** Enable API endpoints (default: true) */
175
+ enabled?: boolean;
176
+ }
177
+
178
+ /**
179
+ * API keys plugin configuration
180
+ */
181
+ export interface ApiKeysPluginConfig {
182
+ /** API key storage backend */
183
+ store: ApiKeyStore;
184
+ /** API configuration */
185
+ api?: ApiKeysApiConfig;
186
+ /** Enable debug logging */
187
+ debug?: boolean;
188
+ }
189
+
190
+ // ============================================================================
191
+ // Zod Validation Schemas
192
+ // ============================================================================
193
+
194
+ /**
195
+ * Zod schema for API key scope
196
+ */
197
+ export const ApiKeyScopeSchema = z.enum(['read', 'write', 'admin']);
198
+
199
+ /**
200
+ * Zod schema for API key type
201
+ */
202
+ export const ApiKeyTypeSchema = z.enum(['m2m', 'pat']);
203
+
204
+ /**
205
+ * Zod schema for creating an API key
206
+ */
207
+ export const CreateApiKeySchema = z.object({
208
+ name: z.string().min(1).max(255),
209
+ key_type: ApiKeyTypeSchema,
210
+ scopes: z.array(ApiKeyScopeSchema).min(1),
211
+ expires_at: z.coerce.date().optional(),
212
+ });
213
+
214
+ /**
215
+ * Zod schema for updating an API key
216
+ */
217
+ export const UpdateApiKeySchema = z.object({
218
+ name: z.string().min(1).max(255).optional(),
219
+ scopes: z.array(ApiKeyScopeSchema).min(1).optional(),
220
+ expires_at: z.coerce.date().optional(),
221
+ is_active: z.boolean().optional(),
222
+ }).refine(
223
+ (data) => Object.keys(data).length > 0,
224
+ { message: 'At least one field must be provided for update' }
225
+ );
226
+
227
+ /**
228
+ * Zod schema for API key record
229
+ */
230
+ export const ApiKeySchema = z.object({
231
+ id: z.string().uuid(),
232
+ user_id: z.string().uuid(),
233
+ name: z.string(),
234
+ key_hash: z.string(),
235
+ key_prefix: z.string(),
236
+ key_type: ApiKeyTypeSchema,
237
+ scopes: z.array(ApiKeyScopeSchema),
238
+ last_used_at: z.coerce.date().nullable(),
239
+ expires_at: z.coerce.date().nullable(),
240
+ is_active: z.boolean(),
241
+ created_at: z.coerce.date(),
242
+ updated_at: z.coerce.date(),
243
+ });
@@ -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();
@@ -77,7 +78,7 @@ export function createAuthPlugin(config: AuthPluginConfig): Plugin {
77
78
  // Register auth status route
78
79
  registry.addRoute({
79
80
  method: 'get',
80
- path: '/api/auth/status',
81
+ path: '/auth/status',
81
82
  handler: (_req: Request, res: Response) => {
82
83
  const authReq = _req as AuthenticatedRequest;
83
84
  res.json({
@@ -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 });
@@ -323,8 +323,8 @@ type AdapterName = (typeof VALID_ADAPTERS)[number];
323
323
  export function createAuthPluginFromEnv(options?: AuthEnvPluginOptions): Plugin {
324
324
  const adapterName = getEnv('AUTH_ADAPTER')?.toLowerCase();
325
325
 
326
- // No adapter specified - return disabled plugin
327
- if (!adapterName) {
326
+ // No adapter specified OR explicitly disabled - return disabled plugin
327
+ if (!adapterName || adapterName === 'none') {
328
328
  currentStatus = {
329
329
  state: 'disabled',
330
330
  adapter: null,
@@ -403,6 +403,7 @@ export function createAuthPluginFromEnv(options?: AuthEnvPluginOptions): Plugin
403
403
  authRequired,
404
404
  debug,
405
405
  onUnauthorized: options?.onUnauthorized,
406
+ onAuthenticated: options?.onAuthenticated,
406
407
  };
407
408
 
408
409
  // Update status
@@ -418,6 +419,7 @@ export function createAuthPluginFromEnv(options?: AuthEnvPluginOptions): Plugin
418
419
  // Wrap to add config status routes
419
420
  return {
420
421
  ...basePlugin,
422
+ type: 'system' as const, // Explicit system type (auth can handle any path)
421
423
  async onStart(pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
422
424
  // Call base plugin onStart
423
425
  await basePlugin.onStart?.(pluginConfig, registry);
@@ -825,6 +827,7 @@ function createDisabledPlugin(): Plugin {
825
827
  id: 'auth',
826
828
  name: 'Auth Plugin (Disabled)',
827
829
  version: '1.0.0',
830
+ type: 'system' as const,
828
831
 
829
832
  async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
830
833
  const logger = registry.getLogger('auth');
@@ -848,6 +851,7 @@ function createErrorPlugin(error: string): Plugin {
848
851
  id: 'auth',
849
852
  name: 'Auth Plugin (Error)',
850
853
  version: '1.0.0',
854
+ type: 'system' as const,
851
855
 
852
856
  async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
853
857
  const logger = registry.getLogger('auth');
@@ -182,6 +182,11 @@ export interface AuthPluginConfig {
182
182
  authRequired?: boolean;
183
183
  /** Custom unauthorized handler */
184
184
  onUnauthorized?: (req: Request, res: Response) => void;
185
+ /**
186
+ * Callback invoked after successful authentication.
187
+ * Use this to sync users to a local database on first login.
188
+ */
189
+ onAuthenticated?: (user: AuthenticatedUser) => Promise<void>;
185
190
  /** Enable debug logging */
186
191
  debug?: boolean;
187
192
  }
@@ -226,6 +231,11 @@ export interface AuthEnvPluginOptions {
226
231
  debug?: boolean;
227
232
  /** Custom unauthorized handler */
228
233
  onUnauthorized?: (req: Request, res: Response) => void;
234
+ /**
235
+ * Callback invoked after successful authentication.
236
+ * Use this to sync users to a local database on first login.
237
+ */
238
+ onAuthenticated?: (user: AuthenticatedUser) => Promise<void>;
229
239
  }
230
240
 
231
241
  /**
@@ -24,13 +24,15 @@ describe('Token Utilities', () => {
24
24
 
25
25
  it('should generate a 43-character secret after prefix', async () => {
26
26
  const result = await generateDeviceToken('mob');
27
- const secret = result.token.split('_')[1];
27
+ // Use slice to get everything after 'mob_' since base64url can contain '_'
28
+ const secret = result.token.slice(4); // 'mob_'.length = 4
28
29
  expect(secret.length).toBe(43);
29
30
  });
30
31
 
31
32
  it('should generate base64url-safe characters', async () => {
32
33
  const result = await generateDeviceToken('test');
33
- const secret = result.token.split('_')[1];
34
+ // Use slice to get everything after 'test_' since base64url can contain '_'
35
+ const secret = result.token.slice(5); // 'test_'.length = 5
34
36
  expect(secret).toMatch(/^[A-Za-z0-9_-]+$/);
35
37
  });
36
38
 
@@ -50,6 +50,7 @@ export function createFrontendAppPlugin(config: FrontendAppPluginConfig): Plugin
50
50
  id: 'frontend-app',
51
51
  name: 'Frontend App Plugin',
52
52
  version: '1.0.0',
53
+ type: 'system' as const,
53
54
 
54
55
  async onStart(_pluginConfig: PluginConfig, registry: PluginRegistry): Promise<void> {
55
56
  const logger = registry.getLogger('frontend-app');
@@ -80,11 +81,30 @@ export function createFrontendAppPlugin(config: FrontendAppPluginConfig): Plugin
80
81
  // Priority 2: Serve static files
81
82
  if (config.staticPath && existsSync(config.staticPath)) {
82
83
  logger.info(`Frontend app: Serving static files from ${config.staticPath}`);
83
- app.use('/', express.static(config.staticPath));
84
84
 
85
- // SPA fallback
86
- app.get('/', (_req, res) => {
87
- res.sendFile(resolve(config.staticPath!, 'index.html'));
85
+ // Serve static assets first
86
+ app.use(express.static(config.staticPath, { index: false }));
87
+
88
+ // SPA fallback for all non-API routes
89
+ // This must be registered after static files but handles routes that don't match files
90
+ app.get('*', (req, res, next) => {
91
+ // Skip API routes, control panel, auth, and MCP endpoints
92
+ if (
93
+ req.path.startsWith('/api') ||
94
+ req.path.startsWith(config.mountPath || '/cpanel') ||
95
+ req.path.startsWith('/auth') ||
96
+ req.path.startsWith('/mcp')
97
+ ) {
98
+ return next();
99
+ }
100
+
101
+ // Serve index.html for all other routes (SPA routing)
102
+ const indexPath = resolve(config.staticPath!, 'index.html');
103
+ if (existsSync(indexPath)) {
104
+ res.sendFile(indexPath);
105
+ } else {
106
+ next();
107
+ }
88
108
  });
89
109
  return;
90
110
  }
@@ -423,3 +423,18 @@ export type {
423
423
  ConnectionHealth,
424
424
  NotificationsManagerInterface,
425
425
  } from './notifications/index.js';
426
+
427
+ // QwickBrain MCP plugin
428
+ export {
429
+ createQwickBrainPlugin,
430
+ getConnectionStatus,
431
+ isConnected,
432
+ } from './qwickbrain/index.js';
433
+ export type {
434
+ QwickBrainPluginConfig,
435
+ MCPToolDefinition,
436
+ MCPToolCallRequest,
437
+ MCPToolCallResponse,
438
+ QwickBrainConnectionStatus,
439
+ MCPRateLimitConfig,
440
+ } from './qwickbrain/index.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * QwickBrain Plugin
3
+ *
4
+ * MCP proxy plugin for exposing QwickBrain tools to external AI clients.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { createControlPanel } from '@qwickapps/server';
9
+ * import { createQwickBrainPlugin } from '@qwickapps/server/plugins';
10
+ *
11
+ * const panel = await createControlPanel({
12
+ * plugins: [
13
+ * createQwickBrainPlugin({
14
+ * qwickbrainUrl: 'http://macmini.tailnet-xxx.ts.net:8080',
15
+ * exposedTools: '*', // or ['search_codebase', 'get_document', ...]
16
+ * }),
17
+ * ],
18
+ * });
19
+ * ```
20
+ *
21
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
22
+ */
23
+
24
+ export { createQwickBrainPlugin, getConnectionStatus, isConnected } from './qwickbrain-plugin.js';
25
+
26
+ export type {
27
+ QwickBrainPluginConfig,
28
+ MCPToolDefinition,
29
+ MCPToolCallRequest,
30
+ MCPToolCallResponse,
31
+ QwickBrainConnectionStatus,
32
+ MCPRateLimitConfig,
33
+ } from './types.js';