@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.
- package/CHANGELOG.md +43 -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/api-keys/api-keys-plugin.d.ts +46 -0
- package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
- package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
- package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
- package/dist/plugins/api-keys/index.d.ts +14 -0
- package/dist/plugins/api-keys/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/index.js +17 -0
- package/dist/plugins/api-keys/index.js.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
- package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
- package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/index.js +7 -0
- package/dist/plugins/api-keys/middleware/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/index.d.ts +7 -0
- package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/index.js +7 -0
- package/dist/plugins/api-keys/stores/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
- package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
- package/dist/plugins/api-keys/types.d.ts +268 -0
- package/dist/plugins/api-keys/types.d.ts.map +1 -0
- package/dist/plugins/api-keys/types.js +56 -0
- package/dist/plugins/api-keys/types.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/plugins/auth/auth-plugin.js +17 -1
- 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 +6 -2
- 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 +21 -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/plugins/users/__tests__/postgres-store.test.js +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
- package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
- package/dist/plugins/users/stores/postgres-store.js +59 -1
- package/dist/plugins/users/stores/postgres-store.js.map +1 -1
- package/dist/plugins/users/types.d.ts +22 -0
- package/dist/plugins/users/types.d.ts.map +1 -1
- package/dist-ui/assets/index-5nX8fM1a.js +469 -0
- package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
- package/dist-ui-lib/components/index.d.ts +2 -1
- package/dist-ui-lib/index.js +2642 -2281
- package/dist-ui-lib/index.js.map +1 -1
- package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
- package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
- package/package.json +3 -2
- 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/api-keys/api-keys-plugin.ts +397 -0
- package/src/plugins/api-keys/index.ts +49 -0
- package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
- package/src/plugins/api-keys/middleware/index.ts +12 -0
- package/src/plugins/api-keys/stores/index.ts +7 -0
- package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
- package/src/plugins/api-keys/types.ts +243 -0
- package/src/plugins/auth/auth-plugin.test.ts +167 -0
- package/src/plugins/auth/auth-plugin.ts +17 -1
- package/src/plugins/auth/env-config.ts +6 -2
- 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 +24 -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/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
- package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
- package/src/plugins/users/stores/postgres-store.ts +69 -0
- package/src/plugins/users/types.ts +25 -0
- package/ui/src/App.tsx +6 -1
- package/ui/src/api/controlPanelApi.ts +206 -37
- package/ui/src/components/index.ts +6 -0
- package/ui/src/pages/APIKeysPage.tsx +661 -0
- package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
- package/ui/src/pages/UsersPage.tsx +225 -2
- package/dist-ui/assets/index-CynOqPkb.js +0 -469
- 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: '/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
app.
|
|
87
|
-
|
|
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
|
}
|
package/src/plugins/index.ts
CHANGED
|
@@ -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';
|