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.
@@ -16,8 +16,8 @@ module.exports = {
16
16
  "@nocobase/plugin-acl": "2.1.23",
17
17
  "@nocobase/server": "2.1.23",
18
18
  "dayjs": "1.11.13",
19
- "@nocobase/database": "2.1.23",
20
19
  "@nocobase/actions": "2.1.23",
20
+ "@nocobase/database": "2.1.23",
21
21
  "@nocobase/resourcer": "2.1.23",
22
22
  "@nocobase/plugin-ai": "2.1.23"
23
23
  };
@@ -0,0 +1,63 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var ai_api_usage_records_exports = {};
28
+ __export(ai_api_usage_records_exports, {
29
+ default: () => ai_api_usage_records_default
30
+ });
31
+ module.exports = __toCommonJS(ai_api_usage_records_exports);
32
+ var import_database = require("@nocobase/database");
33
+ var ai_api_usage_records_default = (0, import_database.defineCollection)({
34
+ name: "aiApiUsageRecords",
35
+ autoGenId: true,
36
+ fields: [
37
+ { name: "requestId", type: "string", unique: true, index: true },
38
+ { name: "userId", type: "string", index: true },
39
+ { name: "roleName", type: "string", index: true },
40
+ { name: "authType", type: "string", index: true },
41
+ { name: "oauthClientId", type: "string", allowNull: true, index: true },
42
+ { name: "oauthSubject", type: "string", allowNull: true },
43
+ { name: "oauthScopes", type: "json", allowNull: true },
44
+ { name: "endpoint", type: "string" },
45
+ { name: "mode", type: "string", allowNull: true },
46
+ { name: "model", type: "string", allowNull: true, index: true },
47
+ { name: "status", type: "string", index: true },
48
+ { name: "httpStatus", type: "integer", allowNull: true },
49
+ { name: "errorCode", type: "string", allowNull: true },
50
+ { name: "streaming", type: "boolean", defaultValue: false },
51
+ { name: "inputTokens", type: "integer", allowNull: true },
52
+ { name: "outputTokens", type: "integer", allowNull: true },
53
+ { name: "totalTokens", type: "integer", allowNull: true },
54
+ { name: "estimatedCost", type: "decimal", allowNull: true, precision: 20, scale: 8 },
55
+ { name: "currency", type: "string", allowNull: true },
56
+ { name: "providerRequestId", type: "string", allowNull: true },
57
+ { name: "requestMetadata", type: "jsonb", defaultValue: {} },
58
+ { name: "responseMetadata", type: "jsonb", defaultValue: {} },
59
+ { name: "startedAt", type: "datetimeTz", allowNull: true },
60
+ { name: "completedAt", type: "datetimeTz", allowNull: true },
61
+ { name: "durationMs", type: "integer", allowNull: true }
62
+ ]
63
+ });
@@ -27,19 +27,28 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var role_permission_exports = {};
28
28
  __export(role_permission_exports, {
29
29
  checkEmployeeAccess: () => checkEmployeeAccess,
30
- checkRolePermission: () => checkRolePermission
30
+ checkRolePermission: () => checkRolePermission,
31
+ invalidateRolePermissionCache: () => invalidateRolePermissionCache
31
32
  });
32
33
  module.exports = __toCommonJS(role_permission_exports);
33
34
  var import_openai_format = require("../utils/openai-format");
35
+ const PERMISSION_TTL_MS = 15e3;
36
+ const permissionCache = /* @__PURE__ */ new Map();
37
+ function invalidateRolePermissionCache(roleName) {
38
+ if (roleName) permissionCache.delete(roleName);
39
+ else permissionCache.clear();
40
+ }
34
41
  async function checkRolePermission(ctx) {
35
42
  var _a;
36
43
  const roleName = ((_a = ctx.state.currentRoles) == null ? void 0 : _a[0]) || "member";
37
44
  if (roleName === "root" || roleName === "admin") {
38
45
  return true;
39
46
  }
40
- const record = await ctx.db.getRepository("aiApiRolePermissions").findOne({
41
- filter: { roleName }
42
- });
47
+ const cached = permissionCache.get(roleName);
48
+ const record = cached && cached.expiresAt > Date.now() ? cached.record : await ctx.db.getRepository("aiApiRolePermissions").findOne({ filter: { roleName } });
49
+ if (!cached || cached.expiresAt <= Date.now()) {
50
+ permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
51
+ }
43
52
  if (!(record == null ? void 0 : record.enabled)) {
44
53
  ctx.status = 403;
45
54
  ctx.body = (0, import_openai_format.toOpenAIError)(
@@ -62,5 +71,6 @@ function checkEmployeeAccess(ctx, employeeUsername) {
62
71
  // Annotate the CommonJS export names for ESM import in node:
63
72
  0 && (module.exports = {
64
73
  checkEmployeeAccess,
65
- checkRolePermission
74
+ checkRolePermission,
75
+ invalidateRolePermissionCache
66
76
  });
@@ -44,6 +44,7 @@ var import_server = require("@nocobase/server");
44
44
  var import_router = require("./routes/router");
45
45
  var import_ai_api_config = __toESM(require("./resource/ai-api-config"));
46
46
  var import_rate_limiter = require("./utils/rate-limiter");
47
+ var import_role_permission = require("./middleware/role-permission");
47
48
  var import_dayjs = __toESM(require("dayjs"));
48
49
  var import_utc = __toESM(require("dayjs/plugin/utc"));
49
50
  var import_timezone = __toESM(require("dayjs/plugin/timezone"));
@@ -61,8 +62,14 @@ class PluginAiApiServer extends import_server.Plugin {
61
62
  async beforeLoad() {
62
63
  }
63
64
  async load() {
64
- this.app.use((0, import_router.createAiLlmRouter)(this), { before: "resourcer" });
65
+ this.app.use((0, import_router.createAiLlmRouter)(this), { after: "idp-oauth-resource-auth", before: "resourcer" });
65
66
  this.app.resourceManager.define(import_ai_api_config.default);
67
+ this.app.db.on("aiApiRolePermissions.afterSave", (model) => {
68
+ (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
69
+ });
70
+ this.app.db.on("aiApiRolePermissions.afterDestroy", (model) => {
71
+ (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
72
+ });
66
73
  this.app.acl.registerSnippet({
67
74
  name: `pm.${this.name}.configuration`,
68
75
  actions: ["aiApiConfig:*", "aiApiRolePermissions:*"]
@@ -37,7 +37,7 @@ async function authenticateBearer(ctx) {
37
37
  ctx.status = 401;
38
38
  ctx.body = (0, import_openai_format.toOpenAIError)(
39
39
  401,
40
- "Missing or invalid Authorization header. Expected: Bearer <api-key>",
40
+ "Missing or invalid Authorization header. Expected: Bearer <access-token>",
41
41
  "invalid_request_error",
42
42
  "invalid_api_key"
43
43
  );
@@ -50,11 +50,16 @@ async function authenticateBearer(ctx) {
50
50
  return false;
51
51
  }
52
52
  try {
53
- const auth = ctx.app["authManager"];
54
- if (!auth) {
55
- ctx.status = 500;
56
- ctx.body = (0, import_openai_format.toOpenAIError)(500, "Auth system not available", "server_error");
57
- return false;
53
+ if (ctx.state.currentUser) {
54
+ if (!ctx.state.currentRole) {
55
+ const requestedRole = ctx.get("X-Role");
56
+ const rolesRepository2 = ctx.db.getRepository("users.roles", ctx.state.currentUser.id);
57
+ const roles2 = await rolesRepository2.find({ fields: ["name"] });
58
+ const roleNames2 = roles2.map((role) => role.name);
59
+ ctx.state.currentRole = roleNames2.includes(requestedRole) ? requestedRole : roleNames2[0];
60
+ ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames2;
61
+ }
62
+ return true;
58
63
  }
59
64
  const jwt = (_a = ctx.app["authManager"]) == null ? void 0 : _a.jwt;
60
65
  if (!jwt) {
@@ -75,6 +80,16 @@ async function authenticateBearer(ctx) {
75
80
  ctx.body = (0, import_openai_format.toOpenAIError)(401, "Invalid or expired API key", "invalid_request_error", "invalid_api_key");
76
81
  return false;
77
82
  }
83
+ if (!decoded.roleName) {
84
+ ctx.status = 401;
85
+ ctx.body = (0, import_openai_format.toOpenAIError)(
86
+ 401,
87
+ "Token was not resolved by the NocoBase auth middleware",
88
+ "invalid_request_error",
89
+ "invalid_api_key"
90
+ );
91
+ return false;
92
+ }
78
93
  const user = await ctx.db.getRepository("users").findOne({
79
94
  filterByTk: decoded.userId
80
95
  });
@@ -84,7 +99,22 @@ async function authenticateBearer(ctx) {
84
99
  return false;
85
100
  }
86
101
  ctx.state.currentUser = user;
87
- ctx.state.currentRoles = decoded.roleName ? [decoded.roleName] : ["member"];
102
+ const rolesRepository = ctx.db.getRepository("users.roles", user.id);
103
+ const roles = await rolesRepository.find({ fields: ["name"] });
104
+ const roleNames = roles.map((role) => role.name);
105
+ if (!roleNames.includes(decoded.roleName)) {
106
+ ctx.status = 403;
107
+ ctx.body = (0, import_openai_format.toOpenAIError)(
108
+ 403,
109
+ "The API key role is no longer assigned to this user",
110
+ "permission_denied",
111
+ "role_not_permitted"
112
+ );
113
+ return false;
114
+ }
115
+ ctx.state.currentRole = decoded.roleName;
116
+ ctx.state.currentRoles = [decoded.roleName];
117
+ ctx.state.aiApiAuthType = "apiKey";
88
118
  if (!ctx.auth) {
89
119
  ctx.auth = {};
90
120
  }
@@ -49,11 +49,12 @@ var import_embeddings = require("./embeddings");
49
49
  var import_openai_format = require("../utils/openai-format");
50
50
  var import_rate_limit = require("../middleware/rate-limit");
51
51
  var import_role_permission = require("../middleware/role-permission");
52
+ var import_usage = require("../usage");
52
53
  const API_PREFIX = "/api/ai-llm/v1";
53
54
  function createAiLlmRouter(plugin) {
54
55
  const checkRateLimit = (0, import_rate_limit.createRateLimitMiddleware)(plugin.rateLimiter);
55
56
  return async (ctx, next) => {
56
- var _a;
57
+ var _a, _b;
57
58
  const { path, method } = ctx;
58
59
  if (!path.startsWith(API_PREFIX)) {
59
60
  return next();
@@ -100,6 +101,18 @@ function createAiLlmRouter(plugin) {
100
101
  }
101
102
  const model = ((_a = ctx.request.body) == null ? void 0 : _a.model) ?? "-";
102
103
  const t0 = Date.now();
104
+ let usageId;
105
+ try {
106
+ usageId = await (0, import_usage.startUsageRecord)(
107
+ ctx,
108
+ requestId,
109
+ subPath,
110
+ String(model),
111
+ Boolean((_b = ctx.request.body) == null ? void 0 : _b.stream)
112
+ );
113
+ } catch (usageError) {
114
+ ctx.log.error("AI API usage record could not be created:", usageError);
115
+ }
103
116
  try {
104
117
  if (method === "POST" && subPath === "/chat/completions") {
105
118
  const mode = await resolveMode(ctx);
@@ -166,6 +179,14 @@ function createAiLlmRouter(plugin) {
166
179
  ctx.status = 500;
167
180
  ctx.body = (0, import_openai_format.toOpenAIError)(500, err.message || "Internal server error", "server_error");
168
181
  }
182
+ } finally {
183
+ if (usageId !== void 0) {
184
+ try {
185
+ await (0, import_usage.finishUsageRecord)(ctx, usageId, t0, ctx.status >= 200 && ctx.status < 400 ? "succeeded" : "failed");
186
+ } catch (usageError) {
187
+ ctx.log.error("AI API usage record could not be finalized:", usageError);
188
+ }
189
+ }
169
190
  }
170
191
  };
171
192
  }
@@ -0,0 +1,80 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var usage_exports = {};
28
+ __export(usage_exports, {
29
+ finishUsageRecord: () => finishUsageRecord,
30
+ startUsageRecord: () => startUsageRecord
31
+ });
32
+ module.exports = __toCommonJS(usage_exports);
33
+ async function startUsageRecord(ctx, requestId, endpoint, model, streaming) {
34
+ var _a, _b;
35
+ const body = ctx.request.body || {};
36
+ const messages = Array.isArray(body.messages) ? body.messages : void 0;
37
+ const oauth = ctx.state.oauthPrincipal;
38
+ const record = await ctx.db.getRepository("aiApiUsageRecords").create({
39
+ values: {
40
+ requestId,
41
+ userId: String((_a = ctx.state.currentUser) == null ? void 0 : _a.id),
42
+ roleName: ctx.state.currentRole || ((_b = ctx.state.currentRoles) == null ? void 0 : _b[0]) || "unknown",
43
+ authType: ctx.state.aiApiAuthType || (oauth ? "oidc" : "session"),
44
+ oauthClientId: oauth == null ? void 0 : oauth.clientId,
45
+ oauthSubject: oauth == null ? void 0 : oauth.subject,
46
+ oauthScopes: oauth == null ? void 0 : oauth.scopes,
47
+ endpoint,
48
+ mode: ctx.get("X-AI-Mode") || void 0,
49
+ model: model === "-" ? void 0 : model,
50
+ status: "pending",
51
+ streaming,
52
+ startedAt: /* @__PURE__ */ new Date(),
53
+ requestMetadata: { messageCount: messages == null ? void 0 : messages.length, requestedMaxTokens: body.max_tokens }
54
+ }
55
+ });
56
+ return record.id;
57
+ }
58
+ async function finishUsageRecord(ctx, id, startedAt, status) {
59
+ var _a;
60
+ const response = ctx.body || {};
61
+ const usage = response.usage;
62
+ const values = {
63
+ status,
64
+ httpStatus: ctx.status,
65
+ errorCode: (_a = response.error) == null ? void 0 : _a.code,
66
+ inputTokens: usage == null ? void 0 : usage.prompt_tokens,
67
+ outputTokens: usage == null ? void 0 : usage.completion_tokens,
68
+ totalTokens: usage == null ? void 0 : usage.total_tokens,
69
+ providerRequestId: response.id,
70
+ completedAt: /* @__PURE__ */ new Date(),
71
+ durationMs: Date.now() - startedAt,
72
+ responseMetadata: { usageSource: usage ? "response" : "unavailable" }
73
+ };
74
+ await ctx.db.getRepository("aiApiUsageRecords").update({ filterByTk: id, values });
75
+ }
76
+ // Annotate the CommonJS export names for ESM import in node:
77
+ 0 && (module.exports = {
78
+ finishUsageRecord,
79
+ startUsageRecord
80
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -0,0 +1,33 @@
1
+ import { defineCollection } from '@nocobase/database';
2
+
3
+ export default defineCollection({
4
+ name: 'aiApiUsageRecords',
5
+ autoGenId: true,
6
+ fields: [
7
+ { name: 'requestId', type: 'string', unique: true, index: true },
8
+ { name: 'userId', type: 'string', index: true },
9
+ { name: 'roleName', type: 'string', index: true },
10
+ { name: 'authType', type: 'string', index: true },
11
+ { name: 'oauthClientId', type: 'string', allowNull: true, index: true },
12
+ { name: 'oauthSubject', type: 'string', allowNull: true },
13
+ { name: 'oauthScopes', type: 'json', allowNull: true },
14
+ { name: 'endpoint', type: 'string' },
15
+ { name: 'mode', type: 'string', allowNull: true },
16
+ { name: 'model', type: 'string', allowNull: true, index: true },
17
+ { name: 'status', type: 'string', index: true },
18
+ { name: 'httpStatus', type: 'integer', allowNull: true },
19
+ { name: 'errorCode', type: 'string', allowNull: true },
20
+ { name: 'streaming', type: 'boolean', defaultValue: false },
21
+ { name: 'inputTokens', type: 'integer', allowNull: true },
22
+ { name: 'outputTokens', type: 'integer', allowNull: true },
23
+ { name: 'totalTokens', type: 'integer', allowNull: true },
24
+ { name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
25
+ { name: 'currency', type: 'string', allowNull: true },
26
+ { name: 'providerRequestId', type: 'string', allowNull: true },
27
+ { name: 'requestMetadata', type: 'jsonb', defaultValue: {} },
28
+ { name: 'responseMetadata', type: 'jsonb', defaultValue: {} },
29
+ { name: 'startedAt', type: 'datetimeTz', allowNull: true },
30
+ { name: 'completedAt', type: 'datetimeTz', allowNull: true },
31
+ { name: 'durationMs', type: 'integer', allowNull: true },
32
+ ],
33
+ });
@@ -1,66 +1,79 @@
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
- * Check whether the authenticated role is allowed to use the AI API.
15
- * Loads the permission record and stores it in ctx.state.aiApiRolePermission.
16
- *
17
- * Returns true if access is allowed (caller may proceed).
18
- * Returns false if access is denied (403 already written to ctx, caller must return).
19
- *
20
- * The 'root' and 'admin' roles always bypass the check.
21
- */
22
- export async function checkRolePermission(ctx: Context): Promise<boolean> {
23
- const roleName = ctx.state.currentRoles?.[0] || 'member';
24
-
25
- // root / admin always allowed
26
- if (roleName === 'root' || roleName === 'admin') {
27
- return true;
28
- }
29
-
30
- const record = await ctx.db.getRepository('aiApiRolePermissions').findOne({
31
- filter: { roleName },
32
- });
33
-
34
- if (!record?.enabled) {
35
- ctx.status = 403;
36
- ctx.body = toOpenAIError(
37
- 403,
38
- `Role '${roleName}' is not authorized to use the AI API. ` +
39
- `An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
40
- 'permission_denied',
41
- 'role_not_permitted',
42
- );
43
- return false;
44
- }
45
-
46
- // Store for downstream handlers
47
- ctx.state.aiApiRolePermission = record;
48
- return true;
49
- }
50
-
51
- /**
52
- * Check whether the current role is allowed to use a specific AI Employee.
53
- * Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
54
- *
55
- * Returns true when:
56
- * - Role is admin/root (no permission record stored)
57
- * - allowAllEmployees is true
58
- * - The employeeUsername is in the allowedEmployees list
59
- */
60
- export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
61
- const perm = ctx.state.aiApiRolePermission;
62
- // admin/root paths have no record stored → always allowed
63
- if (!perm) return true;
64
- if (perm.allowAllEmployees) return true;
65
- return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
66
- }
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
+ const PERMISSION_TTL_MS = 15_000;
14
+ const permissionCache = new Map<string, { record: any; expiresAt: number }>();
15
+
16
+ export function invalidateRolePermissionCache(roleName?: string): void {
17
+ if (roleName) permissionCache.delete(roleName);
18
+ else permissionCache.clear();
19
+ }
20
+
21
+ /**
22
+ * Check whether the authenticated role is allowed to use the AI API.
23
+ * Loads the permission record and stores it in ctx.state.aiApiRolePermission.
24
+ *
25
+ * Returns true if access is allowed (caller may proceed).
26
+ * Returns false if access is denied (403 already written to ctx, caller must return).
27
+ *
28
+ * The 'root' and 'admin' roles always bypass the check.
29
+ */
30
+ export async function checkRolePermission(ctx: Context): Promise<boolean> {
31
+ const roleName = ctx.state.currentRoles?.[0] || 'member';
32
+
33
+ // root / admin always allowed
34
+ if (roleName === 'root' || roleName === 'admin') {
35
+ return true;
36
+ }
37
+
38
+ const cached = permissionCache.get(roleName);
39
+ const record =
40
+ cached && cached.expiresAt > Date.now()
41
+ ? cached.record
42
+ : await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } });
43
+ if (!cached || cached.expiresAt <= Date.now()) {
44
+ permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
45
+ }
46
+
47
+ if (!record?.enabled) {
48
+ ctx.status = 403;
49
+ ctx.body = toOpenAIError(
50
+ 403,
51
+ `Role '${roleName}' is not authorized to use the AI API. ` +
52
+ `An admin must enable access in Settings Users & Permissions [Role] AI API.`,
53
+ 'permission_denied',
54
+ 'role_not_permitted',
55
+ );
56
+ return false;
57
+ }
58
+
59
+ // Store for downstream handlers
60
+ ctx.state.aiApiRolePermission = record;
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Check whether the current role is allowed to use a specific AI Employee.
66
+ * Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
67
+ *
68
+ * Returns true when:
69
+ * - Role is admin/root (no permission record stored)
70
+ * - allowAllEmployees is true
71
+ * - The employeeUsername is in the allowedEmployees list
72
+ */
73
+ export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
74
+ const perm = ctx.state.aiApiRolePermission;
75
+ // admin/root paths have no record stored → always allowed
76
+ if (!perm) return true;
77
+ if (perm.allowAllEmployees) return true;
78
+ return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
79
+ }