@graphoria/server 0.2.3 → 0.4.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/README.md +29 -20
- package/dist/src/ai/mcp/index.d.ts +9 -2
- package/dist/src/ai/mcp/index.d.ts.map +1 -1
- package/dist/src/ai/tools/core.d.ts +6 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/core.test.d.ts +2 -0
- package/dist/src/ai/tools/core.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
- package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/fragments.d.ts +6 -0
- package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.d.ts +18 -0
- package/dist/src/authentication/capabilities.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.test.d.ts +2 -0
- package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/secrets.d.ts +9 -0
- package/dist/src/authentication/secrets.d.ts.map +1 -0
- package/dist/src/authentication/secrets.test.d.ts +2 -0
- package/dist/src/authentication/secrets.test.d.ts.map +1 -0
- package/dist/src/config/types/auth.d.ts +8 -0
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +4 -0
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/operation.test.d.ts +2 -0
- package/dist/src/config/types/operation.test.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.d.ts +27 -0
- package/dist/src/configuration/crossReferences.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.test.d.ts +2 -0
- package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +70 -6
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +41 -3
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts +7 -5
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +29 -0
- package/dist/src/console/session.d.ts.map +1 -0
- package/dist/src/console/session.test.d.ts +2 -0
- package/dist/src/console/session.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +12 -4
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +7 -3
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +5 -4
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
- package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +30 -7
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/format.d.ts +2 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +17 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/format.d.ts +2 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +26 -7
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +2 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/audit.d.ts +27 -0
- package/dist/src/logging/audit.d.ts.map +1 -0
- package/dist/src/logging/audit.test.d.ts +2 -0
- package/dist/src/logging/audit.test.d.ts.map +1 -0
- package/dist/src/singletons/env.d.ts +27 -4
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queryTimeout.d.ts +3 -0
- package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
- package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
- package/dist/src/subscriptions/types.d.ts +4 -1
- package/dist/src/subscriptions/types.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +97 -12
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +12 -0
- package/dist/src/types/zod/auth.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +28 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts +65 -0
- package/dist/src/utils/rateLimit.d.ts.map +1 -0
- package/dist/src/utils/rateLimit.test.d.ts +2 -0
- package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +6 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/sessionVariables.d.ts +2 -0
- package/dist/src/utils/sessionVariables.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +548 -685
- package/src/ai/mcp/index.ts +19 -14
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -0
- package/src/authentication/capabilities.ts +54 -0
- package/src/authentication/index.ts +3 -3
- package/src/authentication/jwt.ts +30 -25
- package/src/authentication/paseto.ts +36 -26
- package/src/authentication/secrets.ts +30 -0
- package/src/config/types/auth.ts +7 -0
- package/src/config/types/db.ts +7 -0
- package/src/config/types/operation.ts +4 -0
- package/src/configuration/crossReferences.ts +340 -0
- package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
- package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/gqlAuthOperations.ts +26 -0
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +97 -15
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +31 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/generateOpenAPI.ts +61 -11
- package/src/configuration/rest/handleRESTRequestFactory.ts +32 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +102 -16
- package/src/console/session.ts +129 -0
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +13 -4
- package/src/databases/core/function-mapping.ts +5 -1
- package/src/databases/core/procedure-arguments.ts +25 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -17
- package/src/databases/engines/mssql/format.ts +3 -1
- package/src/databases/engines/mssql/query/index.ts +12 -2
- package/src/databases/engines/mysql/connection.ts +40 -20
- package/src/databases/engines/mysql/format.ts +3 -1
- package/src/databases/engines/mysql/query/index.ts +25 -4
- package/src/databases/engines/postgresql/connection.ts +86 -22
- package/src/databases/engines/postgresql/format.ts +3 -1
- package/src/databases/engines/postgresql/query/index.ts +12 -3
- package/src/databases/high-level-operations.ts +13 -2
- package/src/index.ts +127 -19
- package/src/logging/audit.ts +86 -0
- package/src/singletons/queryTimeout.ts +20 -0
- package/src/subscriptions/strategies/database.ts +19 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/subscriptions/utils/polling.ts +6 -3
- package/src/types/env.ts +47 -9
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +16 -0
- package/src/utils/sessionVariables.ts +2 -0
|
@@ -28,6 +28,10 @@ export const getDatabasesStructure = async (
|
|
|
28
28
|
) => {
|
|
29
29
|
const entities: TableResolver[] = [];
|
|
30
30
|
const enhancedStoredProcedures: ProcedureResolver[] = [];
|
|
31
|
+
// Taken before the exclusion and auth-table filters below, because that is the
|
|
32
|
+
// only point where a name the config excludes is still visible. Resolving
|
|
33
|
+
// `schema.excludedTables` against `entities` could never succeed.
|
|
34
|
+
const tableNamesByDatabase: Record<string, string[]> = {};
|
|
31
35
|
|
|
32
36
|
// The auth user table holds the password hash and must never be served via
|
|
33
37
|
// the generated API. Its resolver key matches t.schemaName, which is always
|
|
@@ -39,10 +43,17 @@ export const getDatabasesStructure = async (
|
|
|
39
43
|
for (const db of databases) {
|
|
40
44
|
const { tables, storedProcedures } = await fetchStructure(db);
|
|
41
45
|
|
|
46
|
+
// Every other name comparison in this file folds case. This one used not to,
|
|
47
|
+
// and it is the one where a mismatch fails open: excludedTables is a
|
|
48
|
+
// do-not-serve list, so a name spelled in the wrong case served the table.
|
|
49
|
+
const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
|
|
50
|
+
|
|
51
|
+
tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
|
|
52
|
+
|
|
42
53
|
const tablesToAdd = tables
|
|
43
54
|
.reduce<Tables>((acc, t) => {
|
|
44
55
|
if (authUserKey && t.schemaName === authUserKey) return acc;
|
|
45
|
-
if (
|
|
56
|
+
if (excluded.has(t.schemaName.toLowerCase())) return acc;
|
|
46
57
|
|
|
47
58
|
const tableOverride = db.schema?.database[t.schemaName];
|
|
48
59
|
|
|
@@ -150,7 +161,7 @@ export const getDatabasesStructure = async (
|
|
|
150
161
|
enhancedStoredProcedures.push(...storedProceduresToAdd);
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
return { tables: entities, storedProcedures: enhancedStoredProcedures };
|
|
164
|
+
return { tables: entities, storedProcedures: enhancedStoredProcedures, tableNamesByDatabase };
|
|
154
165
|
};
|
|
155
166
|
|
|
156
167
|
export type EntitiesOfRole = {
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { serve } from "bun";
|
|
|
4
4
|
import { isString } from "es-toolkit";
|
|
5
5
|
|
|
6
6
|
import type { BunRequest } from "bun";
|
|
7
|
+
import type { Capability } from "./authentication/capabilities";
|
|
7
8
|
import type { Configuration } from "./types/configuration";
|
|
8
9
|
import type { Env } from "./types/env";
|
|
10
|
+
import type { SessionContext } from "./utils/sessionVariables";
|
|
9
11
|
|
|
10
12
|
import { createTokenService } from "./authentication";
|
|
11
13
|
import { analyzeConfiguration, loadConfiguration } from "./configuration";
|
|
@@ -14,16 +16,26 @@ import { websocketHandlerFactory } from "./configuration/gql/handleGraphQLSubscr
|
|
|
14
16
|
import { consoleRoutesFactory } from "./console/api";
|
|
15
17
|
import { createAuthTables, verifyAuthTablesExist } from "./databases";
|
|
16
18
|
import { createMCPRoutes } from "./ai";
|
|
19
|
+
import { createCapabilityAuthorizer } from "./authentication/capabilities";
|
|
17
20
|
import { getAgent, instantiateAI } from "./singletons/ai";
|
|
18
21
|
import { getTokenService, setTokenService } from "./singletons/authentication";
|
|
19
22
|
import { instantiateCronJobs } from "./singletons/cron";
|
|
20
23
|
import { disconnectDatabases, instantiateDatabasesConnections } from "./singletons/databases";
|
|
24
|
+
import { getCacheRedisClient } from "./singletons/cache/redisClient";
|
|
21
25
|
import { env } from "./singletons/env";
|
|
26
|
+
import { setQueryTimeoutMs } from "./singletons/queryTimeout";
|
|
22
27
|
import { instantiateQueues } from "./singletons/queues";
|
|
23
28
|
import { ConfigurationZod } from "./types/zod/configuration";
|
|
24
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createMemoryRateLimitStore,
|
|
31
|
+
createRateLimiter,
|
|
32
|
+
createRedisRateLimitStore,
|
|
33
|
+
resolveClientAddress,
|
|
34
|
+
} from "./utils/rateLimit";
|
|
35
|
+
import { S200, S400, S401, S404, S429 } from "./utils/responses";
|
|
25
36
|
import { writeSchema } from "./utils/writeSchema";
|
|
26
37
|
import { logger, configureLogging } from "./logging";
|
|
38
|
+
import { actorFromSession, audit } from "./logging/audit";
|
|
27
39
|
|
|
28
40
|
// Re-export for consumers
|
|
29
41
|
export { configureLogging };
|
|
@@ -73,6 +85,20 @@ const bootAnalyzedConfiguration = async (env: Env) => {
|
|
|
73
85
|
configureLogging(env.logger);
|
|
74
86
|
}
|
|
75
87
|
|
|
88
|
+
if (env.maxQueryDepth === 0) {
|
|
89
|
+
logger("graphoria").warn(
|
|
90
|
+
"MAX_QUERY_DEPTH=0 disables the query depth limit; one deeply nested query can exhaust the server",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setQueryTimeoutMs(env.queryTimeoutMs);
|
|
95
|
+
|
|
96
|
+
if (env.queryTimeoutMs === 0) {
|
|
97
|
+
logger("graphoria").warn(
|
|
98
|
+
"QUERY_TIMEOUT_MS=0 disables the statement timeout; one slow query can hold a connection and its locks indefinitely",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
if (!env.configuration) {
|
|
77
103
|
throw new Error("Configuration is required to create the GraphQL server");
|
|
78
104
|
}
|
|
@@ -212,19 +238,85 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
212
238
|
|
|
213
239
|
const consoleHtml = await renderPlayground("../playgrounds/console/index.html", {});
|
|
214
240
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
241
|
+
const rateLimiter = createRateLimiter({
|
|
242
|
+
settings: env.rateLimit,
|
|
243
|
+
anonymousRole: env.anonymousRole,
|
|
244
|
+
permissions: projectConfiguration.auth?.permissions,
|
|
245
|
+
store: () =>
|
|
246
|
+
env.cache.store === "redis"
|
|
247
|
+
? createRedisRateLimitStore(getCacheRedisClient())
|
|
248
|
+
: createMemoryRateLimitStore(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const authorizeCapability = createCapabilityAuthorizer(env);
|
|
252
|
+
|
|
253
|
+
// Helper to get role-based handlers. A route that names a capability also
|
|
254
|
+
// accepts that capability's scoped credential in the admin-secret header;
|
|
255
|
+
// it stands in for the superadmin role on that route and nowhere else.
|
|
256
|
+
const getRoleHandlers = async (
|
|
257
|
+
req: Request,
|
|
258
|
+
server?: Bun.Server<unknown>,
|
|
259
|
+
capability?: Capability,
|
|
260
|
+
) => {
|
|
261
|
+
const adminSecretHeader = req.headers.get(env.admin.header);
|
|
262
|
+
const grant = capability ? authorizeCapability(adminSecretHeader, capability) : null;
|
|
263
|
+
|
|
264
|
+
const session: SessionContext =
|
|
265
|
+
grant && !grant.superset
|
|
266
|
+
? { sub: capability, role: env.superadmin.role, authMethod: "admin_secret" }
|
|
267
|
+
: await getTokenService().verifyTokenAndGetSession(
|
|
268
|
+
req.headers.get(env.authorizationHeader),
|
|
269
|
+
adminSecretHeader,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const ip = resolveClientAddress(req, server, env.rateLimit.trustProxy);
|
|
273
|
+
|
|
274
|
+
// The limit needs the role, and the role costs a token verification plus a
|
|
275
|
+
// revocation lookup — so it is spent here, once, rather than again inside a
|
|
276
|
+
// wrapper.
|
|
277
|
+
const limit = await rateLimiter?.check(session, ip);
|
|
278
|
+
|
|
279
|
+
const scope: Capability | "all" | undefined = grant
|
|
280
|
+
? grant.superset
|
|
281
|
+
? "all"
|
|
282
|
+
: capability
|
|
283
|
+
: undefined;
|
|
284
|
+
|
|
285
|
+
if (session.authMethod === "admin_secret") {
|
|
286
|
+
audit().emit({
|
|
287
|
+
action: "admin_secret.used",
|
|
288
|
+
actor: { type: "admin_secret", ...(scope ? { scope } : {}), ip },
|
|
289
|
+
target: { kind: "endpoint", method: req.method, path: new URL(req.url).pathname },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
221
293
|
return {
|
|
222
294
|
role: session.role!,
|
|
223
295
|
session,
|
|
296
|
+
scope,
|
|
297
|
+
limit,
|
|
224
298
|
...analyzedConfiguration.roles[session.role!].handlers,
|
|
225
299
|
};
|
|
226
300
|
};
|
|
227
301
|
|
|
302
|
+
/**
|
|
303
|
+
* For the entry points that have no session to key on: the websocket upgrade
|
|
304
|
+
* (the token arrives in `connection_init`, after the upgrade) and MCP. They
|
|
305
|
+
* are keyed by address against the anonymous ceiling.
|
|
306
|
+
*/
|
|
307
|
+
const withRateLimit =
|
|
308
|
+
<T>(handler: (req: BunRequest, server: Bun.Server<unknown>) => T | Promise<T>) =>
|
|
309
|
+
async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
310
|
+
const limit = await rateLimiter?.check(
|
|
311
|
+
null,
|
|
312
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
316
|
+
|
|
317
|
+
return handler(req, server);
|
|
318
|
+
};
|
|
319
|
+
|
|
228
320
|
// Create routes map with all handlers
|
|
229
321
|
const routes: RoutesMap = {};
|
|
230
322
|
|
|
@@ -251,7 +343,8 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
251
343
|
prefixes,
|
|
252
344
|
projectConfiguration,
|
|
253
345
|
analyzedConfiguration,
|
|
254
|
-
|
|
346
|
+
tokenService: getTokenService(),
|
|
347
|
+
rateLimiter,
|
|
255
348
|
}),
|
|
256
349
|
);
|
|
257
350
|
}
|
|
@@ -259,7 +352,7 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
259
352
|
// GraphQL endpoint
|
|
260
353
|
routes[prefixes.graphql] = {
|
|
261
354
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
262
|
-
GET: async (req: Request, server: Bun.Server<unknown>) => {
|
|
355
|
+
GET: withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
|
|
263
356
|
try {
|
|
264
357
|
if (req.headers.get("upgrade") === "websocket") {
|
|
265
358
|
const success = server.upgrade(req, {
|
|
@@ -271,17 +364,19 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
271
364
|
} catch (error) {
|
|
272
365
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
273
366
|
}
|
|
274
|
-
},
|
|
275
|
-
POST: async (req: BunRequest) => {
|
|
367
|
+
}),
|
|
368
|
+
POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
276
369
|
try {
|
|
277
|
-
const { gql, session } = await getRoleHandlers(req);
|
|
370
|
+
const { gql, session, limit } = await getRoleHandlers(req, server);
|
|
371
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
372
|
+
|
|
278
373
|
const { query, variables } = await req.json();
|
|
279
374
|
|
|
280
375
|
if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
|
|
281
376
|
|
|
282
377
|
if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
|
|
283
378
|
|
|
284
|
-
const { hasErrors, validationErrors } = gql.hasErrors(query);
|
|
379
|
+
const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
|
|
285
380
|
|
|
286
381
|
if (hasErrors)
|
|
287
382
|
return new S400({
|
|
@@ -312,23 +407,28 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
312
407
|
|
|
313
408
|
if (mcpEnabled) {
|
|
314
409
|
const mcpPath = `${env.prefix}${env.ai?.mcp?.endpoint ?? projectConfiguration.ai?.endpoint ?? "/ai"}`;
|
|
315
|
-
|
|
410
|
+
const mcpRoutes = createMCPRoutes(analyzedConfiguration, {
|
|
316
411
|
...(env.ai?.mcp ?? {}),
|
|
317
412
|
name: projectConfiguration.name,
|
|
318
413
|
version: projectConfiguration.version,
|
|
319
414
|
maxQueryDepth: env.ai?.mcp?.maxQueryDepth ?? env.maxQueryDepth,
|
|
320
|
-
|
|
415
|
+
authorize: authorizeCapability,
|
|
321
416
|
adminSecretHeader: env.admin.header,
|
|
322
417
|
});
|
|
418
|
+
|
|
419
|
+
routes[mcpPath] = Object.fromEntries(
|
|
420
|
+
Object.entries(mcpRoutes).map(([method, handler]) => [method, withRateLimit(handler)]),
|
|
421
|
+
);
|
|
323
422
|
}
|
|
324
423
|
|
|
325
424
|
if (env.ai?.restEnabled) {
|
|
326
425
|
const aiPath = `${env.prefix}/rest${projectConfiguration.ai.endpoint ?? "/ai"}`;
|
|
327
426
|
routes[aiPath] = {
|
|
328
427
|
...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
|
|
329
|
-
POST: async (req: BunRequest) => {
|
|
428
|
+
POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
330
429
|
try {
|
|
331
|
-
const { role } = await getRoleHandlers(req);
|
|
430
|
+
const { role, session, scope, limit } = await getRoleHandlers(req, server, "ai");
|
|
431
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
332
432
|
if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
|
|
333
433
|
|
|
334
434
|
const { prompt } = await req.json();
|
|
@@ -337,6 +437,13 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
337
437
|
errors: [{ message: "`prompt` (string) is required" }],
|
|
338
438
|
});
|
|
339
439
|
|
|
440
|
+
audit().emit({
|
|
441
|
+
action: "ai.ask",
|
|
442
|
+
actor: { ...actorFromSession(session), ...(scope ? { scope } : {}) },
|
|
443
|
+
target: { kind: "ai", via: "rest" },
|
|
444
|
+
prompt,
|
|
445
|
+
});
|
|
446
|
+
|
|
340
447
|
return new S200({ answer: await getAgent()(prompt) });
|
|
341
448
|
} catch (error) {
|
|
342
449
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
@@ -347,11 +454,12 @@ const createGraphQLServer = async (env: Env) => {
|
|
|
347
454
|
}
|
|
348
455
|
|
|
349
456
|
// REST API endpoint
|
|
350
|
-
routes[`${prefixes.rest}/*`] = async (req: BunRequest) => {
|
|
457
|
+
routes[`${prefixes.rest}/*`] = async (req: BunRequest, server: Bun.Server<unknown>) => {
|
|
351
458
|
if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
|
|
352
459
|
|
|
353
460
|
try {
|
|
354
|
-
const { rest, session } = await getRoleHandlers(req);
|
|
461
|
+
const { rest, session, limit } = await getRoleHandlers(req, server);
|
|
462
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
355
463
|
|
|
356
464
|
const urlParsed = new URL(req.url);
|
|
357
465
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { Capability } from "../authentication/capabilities";
|
|
3
|
+
import type { SessionContext } from "../utils/sessionVariables";
|
|
4
|
+
|
|
5
|
+
import { logger } from "./index";
|
|
6
|
+
|
|
7
|
+
export type AuditActor = {
|
|
8
|
+
type: "admin_secret" | "console" | "token" | "credentials" | "anonymous";
|
|
9
|
+
sub?: string;
|
|
10
|
+
role?: string;
|
|
11
|
+
/** Which credential was presented at a scoped surface: `all` is the admin secret. */
|
|
12
|
+
scope?: Capability | "all";
|
|
13
|
+
ip?: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AuditEvent = {
|
|
17
|
+
action: string;
|
|
18
|
+
actor: AuditActor;
|
|
19
|
+
target: Record<string, unknown>;
|
|
20
|
+
outcome?: "success" | "failure";
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AuditLog = {
|
|
25
|
+
emit(event: AuditEvent): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const REDACTED_KEYS = new Set([
|
|
29
|
+
"password",
|
|
30
|
+
"secret",
|
|
31
|
+
"secrets",
|
|
32
|
+
"token",
|
|
33
|
+
"access_token",
|
|
34
|
+
"refresh_token",
|
|
35
|
+
"authorization",
|
|
36
|
+
"cookie",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const redact = (value: unknown): unknown => {
|
|
40
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
41
|
+
if (typeof value !== "object" || value === null) return value;
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.entries(value).map(([key, entry]) => [
|
|
44
|
+
key,
|
|
45
|
+
REDACTED_KEYS.has(key) ? "[REDACTED]" : redact(entry),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const actorFromSession = (session: SessionContext | undefined): AuditActor => {
|
|
51
|
+
if (!session) return { type: "anonymous" };
|
|
52
|
+
const type =
|
|
53
|
+
session.authMethod === "admin_secret" ? "admin_secret" : session.jti ? "token" : "anonymous";
|
|
54
|
+
return {
|
|
55
|
+
type,
|
|
56
|
+
...(session.sub !== undefined ? { sub: session.sub } : {}),
|
|
57
|
+
...(session.role !== undefined ? { role: session.role } : {}),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const createAuditLog = (base: Logger): AuditLog => {
|
|
62
|
+
// An audit record has to survive LOG_LEVEL=warn: the level is pinned here,
|
|
63
|
+
// independent of whatever the root logger was configured with.
|
|
64
|
+
const log = base.child({});
|
|
65
|
+
log.level = "info";
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
emit: (event) => {
|
|
69
|
+
log.info(redact(event) as object, event.action);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let override: AuditLog | null = null;
|
|
75
|
+
let instance: AuditLog | null = null;
|
|
76
|
+
|
|
77
|
+
/** Test seam: pass `null` to restore the default log. */
|
|
78
|
+
export const setAuditLog = (log: AuditLog | null): void => {
|
|
79
|
+
override = log;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const audit = (): AuditLog => {
|
|
83
|
+
if (override) return override;
|
|
84
|
+
if (!instance) instance = createAuditLog(logger("audit"));
|
|
85
|
+
return instance;
|
|
86
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process-wide statement timeout in milliseconds, resolved from
|
|
3
|
+
* `QUERY_TIMEOUT_MS` at boot.
|
|
4
|
+
*
|
|
5
|
+
* It lives here rather than being read from `singletons/env` because the engine
|
|
6
|
+
* connection modules need it, and that module parses `process.env` the moment it
|
|
7
|
+
* is imported — reaching it from an engine would make every unit test that
|
|
8
|
+
* transitively touches a query builder fail without `ADMIN_SECRET` set.
|
|
9
|
+
*
|
|
10
|
+
* `0` disables the bound. That is also the value before boot sets it, so an
|
|
11
|
+
* engine used outside a booted server behaves exactly as it did before timeouts
|
|
12
|
+
* existed.
|
|
13
|
+
*/
|
|
14
|
+
let queryTimeoutMs = 0;
|
|
15
|
+
|
|
16
|
+
export const setQueryTimeoutMs = (ms: number) => {
|
|
17
|
+
queryTimeoutMs = ms;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const getQueryTimeoutMs = () => queryTimeoutMs;
|
|
@@ -15,10 +15,26 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
|
|
|
15
15
|
source: EntitySource.TABLE,
|
|
16
16
|
|
|
17
17
|
getSubscriptionKey(context) {
|
|
18
|
-
const { analysis } = context;
|
|
18
|
+
const { analysis, field, session, variables } = context;
|
|
19
19
|
const operation = analysis.operations[0];
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
const base = operation.name ?? operation.fields[0]?.name ?? "unknown";
|
|
21
|
+
|
|
22
|
+
// Every part of this is load-bearing. A group shares one poller, and every
|
|
23
|
+
// client joined to it is served that poller's rows — so two callers may
|
|
24
|
+
// only share a key when the query they asked for is the same query,
|
|
25
|
+
// resolved against the same session. The role and the subject carry the row
|
|
26
|
+
// filter; the variables carry everything the caller chose; the selections
|
|
27
|
+
// carry the shape. Two keys that differ where they should not merely cost a
|
|
28
|
+
// second poller, but two that match where they should not is a cross-tenant
|
|
29
|
+
// read.
|
|
30
|
+
return [
|
|
31
|
+
base,
|
|
32
|
+
session.role ?? "",
|
|
33
|
+
session.sub ?? "",
|
|
34
|
+
field.alias ?? "",
|
|
35
|
+
JSON.stringify(field.selections),
|
|
36
|
+
JSON.stringify(variables),
|
|
37
|
+
].join("\u0000");
|
|
22
38
|
},
|
|
23
39
|
|
|
24
40
|
async subscribe(context: SubscriptionContext): Promise<SubscriptionResult> {
|
|
@@ -2,6 +2,7 @@ import type { ServerWebSocket } from "bun";
|
|
|
2
2
|
import type { AnalysisResult, SelectionAnalysis, VariableDefinition } from "../analyzeQuery/types";
|
|
3
3
|
import type { GetSchemaReturn } from "../configuration/getSchemas";
|
|
4
4
|
import type { createQueryEventEmitter } from "../utils/event-emitter";
|
|
5
|
+
import type { SessionContext } from "../utils/sessionVariables";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Type for the query event emitter instance
|
|
@@ -24,7 +25,7 @@ export interface SubscriptionContext {
|
|
|
24
25
|
ws: ServerWebSocket<unknown>;
|
|
25
26
|
/** Unique subscription ID from the client */
|
|
26
27
|
subscriptionId: string;
|
|
27
|
-
/** Analyzed GraphQL query */
|
|
28
|
+
/** Analyzed GraphQL query, with variables and `$session.*` already resolved */
|
|
28
29
|
analysis: AnalysisResult;
|
|
29
30
|
/** The first field being subscribed to */
|
|
30
31
|
field: SelectionAnalysis;
|
|
@@ -34,6 +35,8 @@ export interface SubscriptionContext {
|
|
|
34
35
|
variables: Record<string, unknown>;
|
|
35
36
|
/** Schema and entity information for the current role */
|
|
36
37
|
schemaEntity: GetSchemaReturn;
|
|
38
|
+
/** The caller this subscription belongs to. Load-bearing: it keys the group. */
|
|
39
|
+
session: SessionContext;
|
|
37
40
|
/** Shared event emitter for broadcasting updates */
|
|
38
41
|
eventEmitter: QueryEventEmitter;
|
|
39
42
|
}
|
|
@@ -5,6 +5,7 @@ import type { DatabasePoller, QueryEventEmitter } from "../types";
|
|
|
5
5
|
|
|
6
6
|
import { executeQuery, executeQueryJSON, generateSQL } from "../../databases";
|
|
7
7
|
import { logger } from "../../logging";
|
|
8
|
+
import { env } from "../../singletons/env";
|
|
8
9
|
|
|
9
10
|
export interface DatabasePollerConfig {
|
|
10
11
|
analysis: AnalysisResult;
|
|
@@ -60,9 +61,11 @@ export const createDatabasePoller = async (
|
|
|
60
61
|
const firstFieldName = getFirstFieldName(analysis);
|
|
61
62
|
const db = schemaEntity.queriesMap[firstFieldName!]!.db!;
|
|
62
63
|
|
|
63
|
-
// Generate SQL queries (data query and hash query)
|
|
64
|
-
|
|
65
|
-
const
|
|
64
|
+
// Generate SQL queries (data query and hash query). A subscription query is
|
|
65
|
+
// caller-authored, so it carries the same page bounds an HTTP query does.
|
|
66
|
+
const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
|
|
67
|
+
const [[, queryData]] = generateSQL(schemaEntity, analysis, variables, false, pageLimits);
|
|
68
|
+
const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true, pageLimits);
|
|
66
69
|
|
|
67
70
|
// Get initial hash and send initial data
|
|
68
71
|
let previousHash = await getResultHash(queryHash, db, variableDefinitions, variables);
|
package/src/types/env.ts
CHANGED
|
@@ -3,19 +3,29 @@ import { z } from "zod";
|
|
|
3
3
|
import { version } from "../../package.json";
|
|
4
4
|
import { ConfigurationZod } from "./zod/configuration";
|
|
5
5
|
|
|
6
|
+
// A secret env var is a comma-separated list: the first entry is the one in
|
|
7
|
+
// use, the rest are still accepted so a rotation can overlap instead of cutting
|
|
8
|
+
// over. Blank entries are dropped, so an unset var is an empty list.
|
|
9
|
+
const splitSecrets = (value: string) =>
|
|
10
|
+
value
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((entry) => entry.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
const secretList = z.string().default("").transform(splitSecrets);
|
|
15
|
+
|
|
6
16
|
export const EnvZod = z
|
|
7
17
|
.object({
|
|
8
18
|
PORT: z.coerce.number().default(3000),
|
|
9
19
|
NODE_ENV: z.string().default("DEVELOPMENT"),
|
|
10
|
-
ADMIN_SECRET: z.string(),
|
|
20
|
+
ADMIN_SECRET: z.string().transform(splitSecrets),
|
|
11
21
|
CONFIGURATION: z.union([z.string(), ConfigurationZod]).optional(),
|
|
12
22
|
ANONYMOUS_ROLE: z.string().default("anonymous"),
|
|
13
|
-
JWT_SECRET:
|
|
23
|
+
JWT_SECRET: secretList,
|
|
14
24
|
JWT_EXPIRES_IN: z.string().default("5m"),
|
|
15
25
|
JWT_RT_EXPIRES_IN: z.string().default("7d"),
|
|
16
|
-
PASETO_LOCAL_KEY:
|
|
26
|
+
PASETO_LOCAL_KEY: secretList,
|
|
17
27
|
PASETO_SECRET_KEY: z.string().optional().default(""),
|
|
18
|
-
PASETO_PUBLIC_KEY:
|
|
28
|
+
PASETO_PUBLIC_KEY: secretList,
|
|
19
29
|
AUTH_STRATEGY: z.enum(["jwt", "paseto_local", "paseto_public"]).optional(),
|
|
20
30
|
REDIS_URL: z.string().default("redis://localhost:6379"),
|
|
21
31
|
CACHE_STORE: z.enum(["memory", "redis"]).default("memory"),
|
|
@@ -30,18 +40,31 @@ export const EnvZod = z
|
|
|
30
40
|
// Boolean flags use stringbool, not coerce.boolean: Boolean("false") === true would flip them on
|
|
31
41
|
CONSOLE_ENABLED: z.stringbool().default(false),
|
|
32
42
|
CONSOLE_ENDPOINT: z.string().default("/_console"),
|
|
43
|
+
CONSOLE_SESSION_EXPIRES_IN: z.string().default("1h"),
|
|
44
|
+
CONSOLE_READ_SECRET: secretList,
|
|
45
|
+
CONSOLE_WRITE_SECRET: secretList,
|
|
33
46
|
PREFIX: z.string().default(""),
|
|
34
47
|
CORS_ENABLED: z.stringbool().default(true),
|
|
35
48
|
PRINT_SCHEMAS: z.stringbool().default(false),
|
|
36
49
|
SCHEMAS_OUTPUT_DIR: z.string().default("./.graphoria/schemas"),
|
|
37
50
|
QUERY_ON_RESPONSE: z.stringbool().default(false),
|
|
38
|
-
MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(
|
|
51
|
+
MAX_QUERY_DEPTH: z.coerce.number().int().min(0).default(8),
|
|
52
|
+
DEFAULT_PAGE_SIZE: z.coerce.number().int().min(0).default(100),
|
|
53
|
+
MAX_PAGE_SIZE: z.coerce.number().int().min(0).default(1000),
|
|
54
|
+
QUERY_TIMEOUT_MS: z.coerce.number().int().min(0).default(10000),
|
|
55
|
+
MAX_QUERY_COST: z.coerce.number().int().min(0).default(0),
|
|
56
|
+
RATE_LIMIT_MAX: z.coerce.number().int().min(0).default(0),
|
|
57
|
+
RATE_LIMIT_ANONYMOUS_MAX: z.coerce.number().int().min(0).default(0),
|
|
58
|
+
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60000),
|
|
59
|
+
RATE_LIMIT_TRUST_PROXY: z.stringbool().default(false),
|
|
39
60
|
AI_ENABLED: z.stringbool().optional(),
|
|
40
61
|
AI_GRAPHQL_ENABLED: z.stringbool().default(true),
|
|
41
62
|
AI_REST_ENABLED: z.stringbool().default(true),
|
|
63
|
+
AI_SECRET: secretList,
|
|
42
64
|
AI_MCP_ENABLED: z.stringbool().optional(),
|
|
43
65
|
AI_MCP_ENDPOINT: z.string().default("/mcp"),
|
|
44
66
|
AI_MCP_REQUIRE_ADMIN_SECRET: z.stringbool().default(false),
|
|
67
|
+
AI_MCP_SECRET: secretList,
|
|
45
68
|
AI_MCP_MAX_QUERY_DEPTH: z.coerce.number().int().min(0).optional(),
|
|
46
69
|
AI_MCP_DISABLED_TOOLS: z.string().default(""),
|
|
47
70
|
AI_MCP_DISABLED_RESOURCES: z.string().default(""),
|
|
@@ -66,19 +89,29 @@ export const EnvZod = z
|
|
|
66
89
|
authStrategy: env.AUTH_STRATEGY,
|
|
67
90
|
queryOnResponse: env.QUERY_ON_RESPONSE,
|
|
68
91
|
maxQueryDepth: env.MAX_QUERY_DEPTH,
|
|
92
|
+
defaultPageSize: env.DEFAULT_PAGE_SIZE,
|
|
93
|
+
maxPageSize: env.MAX_PAGE_SIZE,
|
|
94
|
+
queryTimeoutMs: env.QUERY_TIMEOUT_MS,
|
|
95
|
+
maxQueryCost: env.MAX_QUERY_COST,
|
|
69
96
|
admin: {
|
|
70
|
-
|
|
97
|
+
secrets: env.ADMIN_SECRET,
|
|
71
98
|
header: env.ADMIN_SECRET_HEADER,
|
|
72
99
|
},
|
|
73
100
|
jwt: {
|
|
74
|
-
|
|
101
|
+
secrets: env.JWT_SECRET,
|
|
75
102
|
expiresIn: env.JWT_EXPIRES_IN,
|
|
76
103
|
rtExpiresIn: env.JWT_RT_EXPIRES_IN,
|
|
77
104
|
},
|
|
78
105
|
paseto: {
|
|
79
|
-
|
|
106
|
+
localKeys: env.PASETO_LOCAL_KEY,
|
|
80
107
|
secretKey: env.PASETO_SECRET_KEY,
|
|
81
|
-
|
|
108
|
+
publicKeys: env.PASETO_PUBLIC_KEY,
|
|
109
|
+
},
|
|
110
|
+
rateLimit: {
|
|
111
|
+
max: env.RATE_LIMIT_MAX,
|
|
112
|
+
anonymousMax: env.RATE_LIMIT_ANONYMOUS_MAX,
|
|
113
|
+
windowMs: env.RATE_LIMIT_WINDOW_MS,
|
|
114
|
+
trustProxy: env.RATE_LIMIT_TRUST_PROXY,
|
|
82
115
|
},
|
|
83
116
|
cache: {
|
|
84
117
|
store: env.CACHE_STORE,
|
|
@@ -94,15 +127,20 @@ export const EnvZod = z
|
|
|
94
127
|
console: {
|
|
95
128
|
enabled: env.CONSOLE_ENABLED,
|
|
96
129
|
endpoint: env.CONSOLE_ENDPOINT,
|
|
130
|
+
sessionExpiresIn: env.CONSOLE_SESSION_EXPIRES_IN,
|
|
131
|
+
readSecrets: env.CONSOLE_READ_SECRET,
|
|
132
|
+
writeSecrets: env.CONSOLE_WRITE_SECRET,
|
|
97
133
|
},
|
|
98
134
|
ai: {
|
|
99
135
|
enabled: env.AI_ENABLED,
|
|
100
136
|
graphqlEnabled: env.AI_GRAPHQL_ENABLED,
|
|
101
137
|
restEnabled: env.AI_REST_ENABLED,
|
|
138
|
+
secrets: env.AI_SECRET,
|
|
102
139
|
mcp: {
|
|
103
140
|
enabled: env.AI_MCP_ENABLED,
|
|
104
141
|
endpoint: env.AI_MCP_ENDPOINT,
|
|
105
142
|
requireAdminSecret: env.AI_MCP_REQUIRE_ADMIN_SECRET,
|
|
143
|
+
secrets: env.AI_MCP_SECRET,
|
|
106
144
|
maxQueryDepth: env.AI_MCP_MAX_QUERY_DEPTH,
|
|
107
145
|
disabledTools: env.AI_MCP_DISABLED_TOOLS
|
|
108
146
|
? env.AI_MCP_DISABLED_TOOLS.split(",")
|