@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.
Files changed (183) hide show
  1. package/README.md +29 -20
  2. package/dist/src/ai/mcp/index.d.ts +9 -2
  3. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.d.ts +6 -1
  5. package/dist/src/ai/tools/core.d.ts.map +1 -1
  6. package/dist/src/ai/tools/core.test.d.ts +2 -0
  7. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  9. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  10. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  11. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  12. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  13. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  14. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  15. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  16. package/dist/src/authentication/capabilities.d.ts +18 -0
  17. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  18. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  19. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  20. package/dist/src/authentication/jwt.d.ts.map +1 -1
  21. package/dist/src/authentication/paseto.d.ts.map +1 -1
  22. package/dist/src/authentication/secrets.d.ts +9 -0
  23. package/dist/src/authentication/secrets.d.ts.map +1 -0
  24. package/dist/src/authentication/secrets.test.d.ts +2 -0
  25. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  26. package/dist/src/config/types/auth.d.ts +8 -0
  27. package/dist/src/config/types/auth.d.ts.map +1 -1
  28. package/dist/src/config/types/db.d.ts +2 -0
  29. package/dist/src/config/types/db.d.ts.map +1 -1
  30. package/dist/src/config/types/operation.d.ts +4 -0
  31. package/dist/src/config/types/operation.d.ts.map +1 -1
  32. package/dist/src/config/types/operation.test.d.ts +2 -0
  33. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  34. package/dist/src/configuration/crossReferences.d.ts +27 -0
  35. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  36. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  37. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  38. package/dist/src/configuration/getSchemas/index.d.ts +70 -6
  39. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  40. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -1
  41. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  42. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  43. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  44. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  45. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  46. package/dist/src/configuration/index.d.ts +41 -3
  47. package/dist/src/configuration/index.d.ts.map +1 -1
  48. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  49. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  50. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  51. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  52. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  53. package/dist/src/console/api.d.ts +7 -5
  54. package/dist/src/console/api.d.ts.map +1 -1
  55. package/dist/src/console/session.d.ts +29 -0
  56. package/dist/src/console/session.d.ts.map +1 -0
  57. package/dist/src/console/session.test.d.ts +2 -0
  58. package/dist/src/console/session.test.d.ts.map +1 -0
  59. package/dist/src/databases/common.d.ts +12 -4
  60. package/dist/src/databases/common.d.ts.map +1 -1
  61. package/dist/src/databases/core/executor.d.ts +7 -3
  62. package/dist/src/databases/core/executor.d.ts.map +1 -1
  63. package/dist/src/databases/core/function-mapping.d.ts +5 -4
  64. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  65. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  66. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  67. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  68. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  69. package/dist/src/databases/core/query-builder.d.ts +7 -1
  70. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  71. package/dist/src/databases/engines/mssql/connection.d.ts +30 -7
  72. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  73. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  74. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  75. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  76. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  77. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  78. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  79. package/dist/src/databases/engines/mysql/connection.d.ts +17 -1
  80. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  81. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  82. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  83. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  84. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  85. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  86. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  87. package/dist/src/databases/engines/postgresql/connection.d.ts +26 -7
  88. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  89. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  90. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  91. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  92. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  93. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  94. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  95. package/dist/src/databases/high-level-operations.d.ts +2 -0
  96. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  97. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  98. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  99. package/dist/src/index.d.ts.map +1 -1
  100. package/dist/src/logging/audit.d.ts +27 -0
  101. package/dist/src/logging/audit.d.ts.map +1 -0
  102. package/dist/src/logging/audit.test.d.ts +2 -0
  103. package/dist/src/logging/audit.test.d.ts.map +1 -0
  104. package/dist/src/singletons/env.d.ts +27 -4
  105. package/dist/src/singletons/env.d.ts.map +1 -1
  106. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  107. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  108. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  109. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  110. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  111. package/dist/src/subscriptions/types.d.ts +4 -1
  112. package/dist/src/subscriptions/types.d.ts.map +1 -1
  113. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  114. package/dist/src/types/env.d.ts +97 -12
  115. package/dist/src/types/env.d.ts.map +1 -1
  116. package/dist/src/types/zod/auth.d.ts +12 -0
  117. package/dist/src/types/zod/auth.d.ts.map +1 -1
  118. package/dist/src/types/zod/configuration.d.ts +28 -0
  119. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  120. package/dist/src/utils/rateLimit.d.ts +65 -0
  121. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  122. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  123. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  124. package/dist/src/utils/responses.d.ts +6 -0
  125. package/dist/src/utils/responses.d.ts.map +1 -1
  126. package/dist/src/utils/sessionVariables.d.ts +2 -0
  127. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  128. package/dist/tsconfig.tsbuildinfo +1 -1
  129. package/package.json +5 -5
  130. package/playgrounds/console/index.html +7 -7
  131. package/playgrounds/graphiql/index.html +548 -685
  132. package/src/ai/mcp/index.ts +19 -14
  133. package/src/ai/tools/core.ts +30 -6
  134. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  135. package/src/analyzeQuery/costLimit.ts +236 -0
  136. package/src/analyzeQuery/depthLimit.ts +4 -23
  137. package/src/analyzeQuery/fragments.ts +18 -0
  138. package/src/authentication/capabilities.ts +54 -0
  139. package/src/authentication/index.ts +3 -3
  140. package/src/authentication/jwt.ts +30 -25
  141. package/src/authentication/paseto.ts +36 -26
  142. package/src/authentication/secrets.ts +30 -0
  143. package/src/config/types/auth.ts +7 -0
  144. package/src/config/types/db.ts +7 -0
  145. package/src/config/types/operation.ts +4 -0
  146. package/src/configuration/crossReferences.ts +340 -0
  147. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  148. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  149. package/src/configuration/gql/buildExecute.ts +1 -1
  150. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  151. package/src/configuration/gql/handleGraphQLRequestFactory.ts +97 -15
  152. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +31 -2
  153. package/src/configuration/index.ts +24 -6
  154. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  155. package/src/configuration/rest/handleRESTRequestFactory.ts +32 -4
  156. package/src/configuration/rest/index.ts +11 -2
  157. package/src/console/api.ts +102 -16
  158. package/src/console/session.ts +129 -0
  159. package/src/databases/common.ts +55 -10
  160. package/src/databases/core/executor.ts +13 -4
  161. package/src/databases/core/function-mapping.ts +5 -1
  162. package/src/databases/core/procedure-arguments.ts +25 -0
  163. package/src/databases/core/query-builder.ts +7 -0
  164. package/src/databases/engines/mssql/connection.ts +66 -17
  165. package/src/databases/engines/mssql/format.ts +3 -1
  166. package/src/databases/engines/mssql/query/index.ts +12 -2
  167. package/src/databases/engines/mysql/connection.ts +40 -20
  168. package/src/databases/engines/mysql/format.ts +3 -1
  169. package/src/databases/engines/mysql/query/index.ts +25 -4
  170. package/src/databases/engines/postgresql/connection.ts +86 -22
  171. package/src/databases/engines/postgresql/format.ts +3 -1
  172. package/src/databases/engines/postgresql/query/index.ts +12 -3
  173. package/src/databases/high-level-operations.ts +13 -2
  174. package/src/index.ts +127 -19
  175. package/src/logging/audit.ts +86 -0
  176. package/src/singletons/queryTimeout.ts +20 -0
  177. package/src/subscriptions/strategies/database.ts +19 -3
  178. package/src/subscriptions/types.ts +4 -1
  179. package/src/subscriptions/utils/polling.ts +6 -3
  180. package/src/types/env.ts +47 -9
  181. package/src/utils/rateLimit.ts +209 -0
  182. package/src/utils/responses.ts +16 -0
  183. 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 (db.schema?.excludedTables.includes(t.schemaName)) return acc;
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 { S200, S400, S401, S404 } from "./utils/responses";
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
- // Helper to get role-based handlers
216
- const getRoleHandlers = async (req: Request) => {
217
- const session = await getTokenService().verifyTokenAndGetSession(
218
- req.headers.get(env.authorizationHeader),
219
- req.headers.get(env.admin.header),
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
- getRoleHandlers,
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
- routes[mcpPath] = createMCPRoutes(analyzedConfiguration, {
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
- adminSecret: env.admin.secret,
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
- // Use operation name as the base key (existing behavior)
21
- return operation.name ?? operation.fields[0]?.name ?? "unknown";
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
- const [[, queryData]] = generateSQL(schemaEntity, analysis, variables);
65
- const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true);
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: z.string().optional().default(""),
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: z.string().optional().default(""),
26
+ PASETO_LOCAL_KEY: secretList,
17
27
  PASETO_SECRET_KEY: z.string().optional().default(""),
18
- PASETO_PUBLIC_KEY: z.string().optional().default(""),
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(0),
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
- secret: env.ADMIN_SECRET,
97
+ secrets: env.ADMIN_SECRET,
71
98
  header: env.ADMIN_SECRET_HEADER,
72
99
  },
73
100
  jwt: {
74
- secret: env.JWT_SECRET,
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
- localKey: env.PASETO_LOCAL_KEY,
106
+ localKeys: env.PASETO_LOCAL_KEY,
80
107
  secretKey: env.PASETO_SECRET_KEY,
81
- publicKey: env.PASETO_PUBLIC_KEY,
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(",")