plugin-ai-api 1.0.8 → 1.0.9

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.
@@ -1,89 +1,99 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import { Plugin } from '@nocobase/server';
11
- import { createAiLlmRouter } from './routes/router';
12
- import aiApiConfigResource from './resource/ai-api-config';
13
- import { RateLimiter } from './utils/rate-limiter';
14
-
15
- // Ensure dayjs timezone + utc plugins are loaded.
16
- // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
17
- // extend the 'timezone' plugin, causing utcOffset(value) to behave as a
18
- // getter (returns a number) instead of a setter (returns a dayjs instance).
19
- // That breaks parse-filter.js's utc2unit() "m.startOf is not a function".
20
- // Extending here patches the shared CommonJS dayjs module instance for the
21
- // entire Node.js process before any AIEmployee call is made.
22
- import dayjsLib from 'dayjs';
23
- import utcPlugin from 'dayjs/plugin/utc';
24
- import timezonePlugin from 'dayjs/plugin/timezone';
25
- (dayjsLib as any).extend(utcPlugin);
26
- (dayjsLib as any).extend(timezonePlugin);
27
-
28
- export class PluginAiApiServer extends Plugin {
29
- /**
30
- * Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
31
- * Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
32
- */
33
- rateLimiter = new RateLimiter(60_000);
34
-
35
- private gcInterval: NodeJS.Timeout | null = null;
36
-
37
- async afterAdd() {}
38
-
39
- async beforeLoad() {}
40
-
41
- async load() {
42
- // 1. Register raw Koa middleware for OpenAI-compatible endpoints
43
- // Must run before 'resourcer' so URL paths match OpenAI convention
44
- this.app.use(createAiLlmRouter(this), { before: 'resourcer' });
45
-
46
- // 2. Register admin config resource
47
- this.app.resourceManager.define(aiApiConfigResource);
48
-
49
- // 3. Set ACL permissions for admin config + role permissions management
50
- this.app.acl.registerSnippet({
51
- name: `pm.${this.name}.configuration`,
52
- actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
53
- });
54
-
55
- // 4. GC the rate limiter every 5 minutes to evict stale user entries.
56
- // .unref() prevents this timer from keeping the process alive on shutdown.
57
- this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
58
- this.gcInterval.unref();
59
- }
60
-
61
- async install() {
62
- // Create default config record on first install
63
- const existing = await this.db.getRepository('aiApiConfig').findOne();
64
- if (!existing) {
65
- await this.db.getRepository('aiApiConfig').create({
66
- values: {
67
- defaultAiEmployee: '',
68
- enabledLlmServices: [],
69
- rateLimitPerMinute: 60,
70
- },
71
- });
72
- }
73
- }
74
-
75
- async afterEnable() {}
76
-
77
- async afterDisable() {}
78
-
79
- async remove() {
80
- // Clean up the GC timer so we don't leak resources during hot-reload
81
- if (this.gcInterval) {
82
- clearInterval(this.gcInterval);
83
- this.gcInterval = null;
84
- }
85
- this.rateLimiter.clear();
86
- }
87
- }
88
-
89
- export default PluginAiApiServer;
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { Plugin } from '@nocobase/server';
11
+ import { createAiLlmRouter } from './routes/router';
12
+ import aiApiConfigResource from './resource/ai-api-config';
13
+ import { RateLimiter } from './utils/rate-limiter';
14
+ import { invalidateRolePermissionCache } from './middleware/role-permission';
15
+
16
+ // Ensure dayjs timezone + utc plugins are loaded.
17
+ // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
18
+ // extend the 'timezone' plugin, causing utcOffset(value) to behave as a
19
+ // getter (returns a number) instead of a setter (returns a dayjs instance).
20
+ // That breaks parse-filter.js's utc2unit() "m.startOf is not a function".
21
+ // Extending here patches the shared CommonJS dayjs module instance for the
22
+ // entire Node.js process before any AIEmployee call is made.
23
+ import dayjsLib from 'dayjs';
24
+ import utcPlugin from 'dayjs/plugin/utc';
25
+ import timezonePlugin from 'dayjs/plugin/timezone';
26
+ (dayjsLib as any).extend(utcPlugin);
27
+ (dayjsLib as any).extend(timezonePlugin);
28
+
29
+ export class PluginAiApiServer extends Plugin {
30
+ /**
31
+ * Singleton rate limiter lives for the entire plugin lifetime, shared across all requests.
32
+ * Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
33
+ */
34
+ rateLimiter = new RateLimiter(60_000);
35
+
36
+ private gcInterval: NodeJS.Timeout | null = null;
37
+
38
+ async afterAdd() {}
39
+
40
+ async beforeLoad() {}
41
+
42
+ async load() {
43
+ // 1. Register raw Koa middleware for OpenAI-compatible endpoints
44
+ // Must run before 'resourcer' so URL paths match OpenAI convention
45
+ // OIDC access tokens must first pass through plugin-idp-oauth, which validates
46
+ // issuer/audience/scope and rewrites them to a NocoBase internal token.
47
+ this.app.use(createAiLlmRouter(this), { after: 'idp-oauth-resource-auth', before: 'resourcer' });
48
+
49
+ // 2. Register admin config resource
50
+ this.app.resourceManager.define(aiApiConfigResource);
51
+
52
+ this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
53
+ invalidateRolePermissionCache(model.get('roleName'));
54
+ });
55
+ this.app.db.on('aiApiRolePermissions.afterDestroy', (model) => {
56
+ invalidateRolePermissionCache(model.get('roleName'));
57
+ });
58
+
59
+ // 3. Set ACL permissions for admin config + role permissions management
60
+ this.app.acl.registerSnippet({
61
+ name: `pm.${this.name}.configuration`,
62
+ actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
63
+ });
64
+
65
+ // 4. GC the rate limiter every 5 minutes to evict stale user entries.
66
+ // .unref() prevents this timer from keeping the process alive on shutdown.
67
+ this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
68
+ this.gcInterval.unref();
69
+ }
70
+
71
+ async install() {
72
+ // Create default config record on first install
73
+ const existing = await this.db.getRepository('aiApiConfig').findOne();
74
+ if (!existing) {
75
+ await this.db.getRepository('aiApiConfig').create({
76
+ values: {
77
+ defaultAiEmployee: '',
78
+ enabledLlmServices: [],
79
+ rateLimitPerMinute: 60,
80
+ },
81
+ });
82
+ }
83
+ }
84
+
85
+ async afterEnable() {}
86
+
87
+ async afterDisable() {}
88
+
89
+ async remove() {
90
+ // Clean up the GC timer so we don't leak resources during hot-reload
91
+ if (this.gcInterval) {
92
+ clearInterval(this.gcInterval);
93
+ this.gcInterval = null;
94
+ }
95
+ this.rateLimiter.clear();
96
+ }
97
+ }
98
+
99
+ export default PluginAiApiServer;
@@ -1,111 +1,142 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import { Context, Next } from '@nocobase/actions';
11
- import { toOpenAIError } from '../utils/openai-format';
12
-
13
- /**
14
- * Authentication middleware for /api/ai-llm/v1/* routes.
15
- *
16
- * Validates Bearer token against NocoBase's apiKeys collection.
17
- * Sets ctx.state.currentUser for downstream handlers.
18
- */
19
- export async function authenticateBearer(ctx: Context): Promise<boolean> {
20
- const authHeader = ctx.get('Authorization') || '';
21
- if (!authHeader.startsWith('Bearer ')) {
22
- ctx.status = 401;
23
- ctx.body = toOpenAIError(
24
- 401,
25
- 'Missing or invalid Authorization header. Expected: Bearer <api-key>',
26
- 'invalid_request_error',
27
- 'invalid_api_key',
28
- );
29
- return false;
30
- }
31
-
32
- const token = authHeader.substring(7).trim();
33
- if (!token) {
34
- ctx.status = 401;
35
- ctx.body = toOpenAIError(401, 'API key is empty', 'invalid_request_error', 'invalid_api_key');
36
- return false;
37
- }
38
-
39
- try {
40
- // Use NocoBase's auth system directly to validate the token.
41
- // NocoBase API keys are JWT tokens signed with the app secret.
42
- // We decode them directly via authManager.jwt rather than going through
43
- // the full NocoBase auth middleware stack (which requires being inside
44
- // the resourcer pipeline, but we run before it).
45
- const auth = ctx.app['authManager'];
46
- if (!auth) {
47
- ctx.status = 500;
48
- ctx.body = toOpenAIError(500, 'Auth system not available', 'server_error');
49
- return false;
50
- }
51
-
52
- // Try to authenticate using the token as a NocoBase JWT/API token
53
- // NocoBase API keys are essentially JWT tokens signed with the app secret
54
- const jwt = ctx.app['authManager']?.jwt;
55
- if (!jwt) {
56
- ctx.status = 500;
57
- ctx.body = toOpenAIError(500, 'JWT system not available', 'server_error');
58
- return false;
59
- }
60
-
61
- let decoded: any;
62
- try {
63
- decoded = await jwt.decode(token);
64
- } catch (e) {
65
- ctx.status = 401;
66
- ctx.body = toOpenAIError(401, 'Incorrect API key provided', 'invalid_request_error', 'invalid_api_key');
67
- return false;
68
- }
69
-
70
- if (!decoded || !decoded.userId) {
71
- ctx.status = 401;
72
- ctx.body = toOpenAIError(401, 'Invalid or expired API key', 'invalid_request_error', 'invalid_api_key');
73
- return false;
74
- }
75
-
76
- // Load user
77
- const user = await ctx.db.getRepository('users').findOne({
78
- filterByTk: decoded.userId,
79
- });
80
-
81
- if (!user) {
82
- ctx.status = 401;
83
- ctx.body = toOpenAIError(401, 'User not found for this API key', 'invalid_request_error', 'invalid_api_key');
84
- return false;
85
- }
86
-
87
- // Set user context for downstream handlers
88
- ctx.state.currentUser = user;
89
- ctx.state.currentRoles = decoded.roleName ? [decoded.roleName] : ['member'];
90
-
91
- // Also set ctx.auth for AIEmployee compatibility.
92
- // Only expose non-sensitive fields — exclude password, token, 2FA secrets, etc.
93
- if (!ctx.auth) {
94
- (ctx as any).auth = {};
95
- }
96
- const SAFE_USER_FIELDS = new Set(['id', 'username', 'nickname', 'email', 'createdAt', 'updatedAt']);
97
- const rawUser = user.toJSON?.() ?? {};
98
- const safeUser: Record<string, any> = { id: user.id };
99
- for (const field of SAFE_USER_FIELDS) {
100
- if (rawUser[field] !== undefined) safeUser[field] = rawUser[field];
101
- }
102
- (ctx as any).auth.user = safeUser;
103
-
104
- return true;
105
- } catch (err) {
106
- ctx.log.error('AI API auth error:', err);
107
- ctx.status = 401;
108
- ctx.body = toOpenAIError(401, 'Authentication failed', 'invalid_request_error', 'invalid_api_key');
109
- return false;
110
- }
111
- }
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { Context } from '@nocobase/actions';
11
+ import { toOpenAIError } from '../utils/openai-format';
12
+
13
+ /**
14
+ * Authentication middleware for /api/ai-llm/v1/* routes.
15
+ *
16
+ * Resolve the principal prepared by NocoBase auth/ACL middleware. OIDC resource
17
+ * authentication rewrites the external access token to an internal session token
18
+ * and sets currentUser before this gateway runs. API keys are the one case where
19
+ * we still need to decode the token locally because they are signed directly by
20
+ * NocoBase and carry a fixed roleName.
21
+ */
22
+ export async function authenticateBearer(ctx: Context): Promise<boolean> {
23
+ const authHeader = ctx.get('Authorization') || '';
24
+ if (!authHeader.startsWith('Bearer ')) {
25
+ ctx.status = 401;
26
+ ctx.body = toOpenAIError(
27
+ 401,
28
+ 'Missing or invalid Authorization header. Expected: Bearer <access-token>',
29
+ 'invalid_request_error',
30
+ 'invalid_api_key',
31
+ );
32
+ return false;
33
+ }
34
+
35
+ const token = authHeader.substring(7).trim();
36
+ if (!token) {
37
+ ctx.status = 401;
38
+ ctx.body = toOpenAIError(401, 'API key is empty', 'invalid_request_error', 'invalid_api_key');
39
+ return false;
40
+ }
41
+
42
+ try {
43
+ if (ctx.state.currentUser) {
44
+ if (!ctx.state.currentRole) {
45
+ const requestedRole = ctx.get('X-Role');
46
+ const rolesRepository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id);
47
+ const roles = await rolesRepository.find({ fields: ['name'] });
48
+ const roleNames = roles.map((role: { name: string }) => role.name);
49
+ ctx.state.currentRole = roleNames.includes(requestedRole) ? requestedRole : roleNames[0];
50
+ ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ // Try to authenticate using the token as a NocoBase JWT/API token
56
+ // NocoBase API keys are essentially JWT tokens signed with the app secret
57
+ const jwt = ctx.app['authManager']?.jwt;
58
+ if (!jwt) {
59
+ ctx.status = 500;
60
+ ctx.body = toOpenAIError(500, 'JWT system not available', 'server_error');
61
+ return false;
62
+ }
63
+
64
+ let decoded: { userId?: string | number; roleName?: string; temp?: boolean };
65
+ try {
66
+ decoded = await jwt.decode(token);
67
+ } catch (e) {
68
+ ctx.status = 401;
69
+ ctx.body = toOpenAIError(401, 'Incorrect API key provided', 'invalid_request_error', 'invalid_api_key');
70
+ return false;
71
+ }
72
+
73
+ if (!decoded || !decoded.userId) {
74
+ ctx.status = 401;
75
+ ctx.body = toOpenAIError(401, 'Invalid or expired API key', 'invalid_request_error', 'invalid_api_key');
76
+ return false;
77
+ }
78
+
79
+ // This fallback is for API keys only. A normal login/OIDC token should have
80
+ // been resolved by NocoBase auth middleware and must not be treated as the
81
+ // member role merely because it has no roleName claim.
82
+ if (!decoded.roleName) {
83
+ ctx.status = 401;
84
+ ctx.body = toOpenAIError(
85
+ 401,
86
+ 'Token was not resolved by the NocoBase auth middleware',
87
+ 'invalid_request_error',
88
+ 'invalid_api_key',
89
+ );
90
+ return false;
91
+ }
92
+
93
+ const user = await ctx.db.getRepository('users').findOne({
94
+ filterByTk: decoded.userId,
95
+ });
96
+
97
+ if (!user) {
98
+ ctx.status = 401;
99
+ ctx.body = toOpenAIError(401, 'User not found for this API key', 'invalid_request_error', 'invalid_api_key');
100
+ return false;
101
+ }
102
+
103
+ // Set user context for downstream handlers
104
+ ctx.state.currentUser = user;
105
+ const rolesRepository = ctx.db.getRepository('users.roles', user.id);
106
+ const roles = await rolesRepository.find({ fields: ['name'] });
107
+ const roleNames = roles.map((role: { name: string }) => role.name);
108
+ if (!roleNames.includes(decoded.roleName)) {
109
+ ctx.status = 403;
110
+ ctx.body = toOpenAIError(
111
+ 403,
112
+ 'The API key role is no longer assigned to this user',
113
+ 'permission_denied',
114
+ 'role_not_permitted',
115
+ );
116
+ return false;
117
+ }
118
+ ctx.state.currentRole = decoded.roleName;
119
+ ctx.state.currentRoles = [decoded.roleName];
120
+ ctx.state.aiApiAuthType = 'apiKey';
121
+
122
+ // Also set ctx.auth for AIEmployee compatibility.
123
+ // Only expose non-sensitive fields — exclude password, token, 2FA secrets, etc.
124
+ if (!ctx.auth) {
125
+ (ctx as any).auth = {};
126
+ }
127
+ const SAFE_USER_FIELDS = new Set(['id', 'username', 'nickname', 'email', 'createdAt', 'updatedAt']);
128
+ const rawUser = user.toJSON?.() ?? {};
129
+ const safeUser: Record<string, any> = { id: user.id };
130
+ for (const field of SAFE_USER_FIELDS) {
131
+ if (rawUser[field] !== undefined) safeUser[field] = rawUser[field];
132
+ }
133
+ (ctx as any).auth.user = safeUser;
134
+
135
+ return true;
136
+ } catch (err) {
137
+ ctx.log.error('AI API auth error:', err);
138
+ ctx.status = 401;
139
+ ctx.body = toOpenAIError(401, 'Authentication failed', 'invalid_request_error', 'invalid_api_key');
140
+ return false;
141
+ }
142
+ }