@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
@@ -11,11 +11,13 @@ import type { Auth } from "../../types/configuration";
11
11
  import type { SessionContext } from "../../utils/sessionVariables";
12
12
 
13
13
  import { analyzeQuery } from "../../analyzeQuery";
14
+ import { checkQueryCost } from "../../analyzeQuery/costLimit";
14
15
  import { depthLimitRule } from "../../analyzeQuery/depthLimit";
15
16
  import { resolveVariableRef, resolveVariables } from "../../analyzeQuery/resolveVariables";
16
17
  import { callStoredProcedure, executeQueryJSON, generateSQL } from "../../databases";
17
18
  import { proxyRemoteField } from "../../remoteSchemas/proxy";
18
19
  import { getAgent } from "../../singletons/ai";
20
+ import { actorFromSession, audit } from "../../logging/audit";
19
21
  import { databasesConnections, repositoryMap } from "../../singletons/databases";
20
22
  import { env } from "../../singletons/env";
21
23
  import { queueManager } from "../../singletons/queues";
@@ -43,7 +45,7 @@ export const handleGraphQLRequestFactory = (
43
45
  ) => Promise<{ data: object }>
44
46
  >
45
47
  > = {
46
- [EntitySource.QUEUE_PUBLISHER]: async (field, variables) => {
48
+ [EntitySource.QUEUE_PUBLISHER]: async (field, variables, _queryAnalysis, _req, session) => {
47
49
  const data = resolveVariableRef(variables, field.arguments?.data);
48
50
 
49
51
  const publisher = entities.queuesMap[field.name];
@@ -52,6 +54,12 @@ export const handleGraphQLRequestFactory = (
52
54
  throw new Error(`Queue publisher not found: ${field.name}`);
53
55
  }
54
56
 
57
+ audit().emit({
58
+ action: "queue.publish",
59
+ actor: actorFromSession(session),
60
+ target: { kind: "publisher", name: publisher.resolverName },
61
+ });
62
+
55
63
  return {
56
64
  data: {
57
65
  [field.alias || field.name]: await queueManager?.sendMessage(
@@ -82,7 +90,7 @@ export const handleGraphQLRequestFactory = (
82
90
  const result = await operation.handler(
83
91
  {
84
92
  databases: databasesConnections,
85
- gqlQuery: gql.handler,
93
+ gqlQuery: operatorQuery,
86
94
  queues: queueManager,
87
95
  repository: repositoryMap,
88
96
  },
@@ -99,7 +107,7 @@ export const handleGraphQLRequestFactory = (
99
107
  };
100
108
  },
101
109
 
102
- [EntitySource.STORED_PROCEDURE]: async (field, variables, queryAnalysis) => {
110
+ [EntitySource.STORED_PROCEDURE]: async (field, variables) => {
103
111
  const sp = entities.mutationsMap[field.name];
104
112
 
105
113
  if (!sp) {
@@ -115,7 +123,6 @@ export const handleGraphQLRequestFactory = (
115
123
 
116
124
  const result = await callStoredProcedure(
117
125
  sp,
118
- queryAnalysis.operations[0].variables ?? [],
119
126
  argumentsReplaced as Record<string, string | number | boolean | null>,
120
127
  );
121
128
 
@@ -162,6 +169,8 @@ export const handleGraphQLRequestFactory = (
162
169
 
163
170
  const queryCache = new LRUCache<string, CachedQuery>({ max: 1000 });
164
171
 
172
+ const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
173
+
165
174
  // undefined = unparseable query (never cached)
166
175
  const getCacheEntry = (query: string): CachedQuery | undefined => {
167
176
  const hit = queryCache.get(query);
@@ -207,20 +216,54 @@ export const handleGraphQLRequestFactory = (
207
216
  // Return the introspection result for clients like GraphiQL or Apollo Client
208
217
  introspectionResult: { data: gqlEntities.introspection },
209
218
  noDataResult: { data: { _no_data: "No data available" } },
210
- hasErrors: (query: string) => {
219
+ // `enforceDepthLimit: false` and `enforceCostLimit: false` are for
220
+ // operator-authored queries (REST operations), which are config, not
221
+ // attacker input. The depth verdict is never cached under them: the cache is
222
+ // keyed on query text alone, so storing a depth-free verdict would serve a
223
+ // caller sending the same text over the wire.
224
+ //
225
+ // Two flags rather than one because they are exempted for the same reason
226
+ // but enforced in different places — the depth rule inside the memoised
227
+ // `validate`, the cost check after it. Overloading one flag would leave the
228
+ // REST call sites reading as if they only exempted depth.
229
+ hasErrors: (
230
+ query: string,
231
+ options?: {
232
+ enforceDepthLimit?: boolean;
233
+ enforceCostLimit?: boolean;
234
+ variables?: Record<string, unknown>;
235
+ },
236
+ ) => {
237
+ const enforceDepthLimit = options?.enforceDepthLimit ?? true;
238
+ const enforceCostLimit = options?.enforceCostLimit ?? true;
211
239
  const entry = getCacheEntry(query);
212
240
  // Unparseable queries aren't cached; let parse surface the syntax error
213
241
  const document = entry?.document ?? parse(query);
214
242
 
215
- let validationErrors = entry?.validationErrors;
243
+ let validationErrors = enforceDepthLimit ? entry?.validationErrors : undefined;
216
244
  if (!validationErrors) {
217
- const rules =
218
- env.maxQueryDepth > 0
219
- ? [...specifiedRules, depthLimitRule(env.maxQueryDepth)]
220
- : undefined; // undefined = use default specifiedRules
245
+ const maxDepth = enforceDepthLimit ? env.maxQueryDepth : 0;
246
+ const rules = maxDepth > 0 ? [...specifiedRules, depthLimitRule(maxDepth)] : undefined; // undefined = use default specifiedRules
221
247
 
222
248
  validationErrors = validate(gqlEntities.schema, document, rules);
223
- if (entry) entry.validationErrors = validationErrors;
249
+ if (entry && enforceDepthLimit) entry.validationErrors = validationErrors;
250
+ }
251
+
252
+ // Outside the block above, and never cached: the estimate depends on the
253
+ // request's variable values, so the same query text has different verdicts
254
+ // for different callers. Only for a query that already typechecks — an
255
+ // estimate drawn from a document that does not resolve against the schema
256
+ // would bury the error that actually explains the rejection.
257
+ if (enforceCostLimit && env.maxQueryCost > 0 && validationErrors.length === 0) {
258
+ const costError = checkQueryCost(
259
+ document,
260
+ gqlEntities.schema,
261
+ options?.variables ?? {},
262
+ pageLimits,
263
+ env.maxQueryCost,
264
+ );
265
+
266
+ if (costError) validationErrors = [costError];
224
267
  }
225
268
 
226
269
  return {
@@ -234,6 +277,18 @@ export const handleGraphQLRequestFactory = (
234
277
  variables: Record<string, unknown> = {},
235
278
  req?: BunRequest,
236
279
  session?: SessionContext,
280
+ // `enforcePageLimits: false` is for operator-authored queries (REST
281
+ // operations, cron jobs, the `gqlQuery` handed to operation hooks). They
282
+ // are configuration, not caller input, so a page cap could only reject
283
+ // the operator's own intent. Use `gql.operatorQuery` rather than passing
284
+ // this by hand.
285
+ //
286
+ // `timeoutMs` is the per-operation override only. Left undefined, each
287
+ // engine stays on its own default — the pool's on Postgres and SQL
288
+ // Server, QUERY_TIMEOUT_MS on MySQL, which has no pool-level route — so
289
+ // an ordinary caller query is bounded without the factory resolving
290
+ // anything.
291
+ options?: { enforcePageLimits?: boolean; timeoutMs?: number },
237
292
  // oxlint-disable-next-line typescript/no-explicit-any
238
293
  ): Promise<{ data: any }> => {
239
294
  const log = logger("graphql").child({ role: session?.role });
@@ -315,8 +370,14 @@ export const handleGraphQLRequestFactory = (
315
370
  let aiData: Record<string, unknown> = {};
316
371
  for (const field of aiFields) {
317
372
  const alias = field.alias || field.name;
318
- const prompt = field.arguments?.prompt;
319
- aiData[alias] = await getAgent()(String(prompt ?? ""));
373
+ const prompt = String(field.arguments?.prompt ?? "");
374
+ audit().emit({
375
+ action: "ai.ask",
376
+ actor: actorFromSession(session),
377
+ target: { kind: "ai", via: "graphql" },
378
+ prompt,
379
+ });
380
+ aiData[alias] = await getAgent()(prompt);
320
381
  }
321
382
 
322
383
  // Skip SQL generation if there are no table fields
@@ -339,7 +400,14 @@ export const handleGraphQLRequestFactory = (
339
400
  ],
340
401
  };
341
402
 
342
- const sqlQueries = generateSQL(entities, tableQueryAnalysis, resolved.allVariables);
403
+ const sqlQueries = generateSQL(
404
+ entities,
405
+ tableQueryAnalysis,
406
+ resolved.allVariables,
407
+ false,
408
+ (options?.enforcePageLimits ?? true) ? pageLimits : null,
409
+ options?.timeoutMs,
410
+ );
343
411
 
344
412
  const data = await Promise.all<object>(
345
413
  sqlQueries.map(([db, query]) =>
@@ -348,6 +416,7 @@ export const handleGraphQLRequestFactory = (
348
416
  db,
349
417
  resolved.variables,
350
418
  resolved.allVariables as Record<string, string | number | boolean | null>,
419
+ options?.timeoutMs,
351
420
  ),
352
421
  ),
353
422
  );
@@ -409,7 +478,20 @@ export const handleGraphQLRequestFactory = (
409
478
  },
410
479
  };
411
480
 
412
- return gql;
481
+ /**
482
+ * `handler` for operator-authored queries — REST operations, cron jobs, and
483
+ * the `gqlQuery` handed to operation hooks and handlers. Their text comes from
484
+ * configuration, never from a caller, so page limits do not apply.
485
+ */
486
+ const operatorQuery = (
487
+ query: Parameters<typeof gql.handler>[0],
488
+ variables?: Parameters<typeof gql.handler>[1],
489
+ req?: Parameters<typeof gql.handler>[2],
490
+ session?: Parameters<typeof gql.handler>[3],
491
+ timeoutMs?: number,
492
+ ) => gql.handler(query, variables, req, session, { enforcePageLimits: false, timeoutMs });
493
+
494
+ return { ...gql, operatorQuery };
413
495
  };
414
496
 
415
497
  export type HandleGraphQLRequest = ReturnType<typeof handleGraphQLRequestFactory>;
@@ -1,4 +1,5 @@
1
1
  import type { ServerWebSocket, WebSocketHandler } from "bun";
2
+ import type { AnalysisResult } from "../../analyzeQuery/types";
2
3
  import type { AnalyzedConfiguration } from "../../configuration";
3
4
  import type { SubscriptionContext } from "../../subscriptions";
4
5
  import type { SessionContext } from "../../utils/sessionVariables";
@@ -9,6 +10,7 @@ import { getTokenService } from "../../singletons/authentication";
9
10
  import { createSubscriptionStrategyRegistry, getStrategyForSource } from "../../subscriptions";
10
11
  import { createQueryEventEmitter } from "../../utils/event-emitter";
11
12
  import { logger } from "../../logging";
13
+ import { audit } from "../../logging/audit";
12
14
 
13
15
  export const queryEventEmitter = createQueryEventEmitter();
14
16
 
@@ -59,6 +61,14 @@ const handleGraphQLSubscriptionFactory = (
59
61
 
60
62
  subscriptionMapping.set(ws, session);
61
63
 
64
+ if (session.authMethod === "admin_secret") {
65
+ audit().emit({
66
+ action: "admin_secret.used",
67
+ actor: { type: "admin_secret", ip: ws.remoteAddress },
68
+ target: { kind: "websocket" },
69
+ });
70
+ }
71
+
62
72
  logger("subscriptions").debug({ role: session.role }, "client connected");
63
73
 
64
74
  ws.send(
@@ -96,7 +106,9 @@ const handleGraphQLSubscriptionFactory = (
96
106
  const schemaEntity = roles[session.role!];
97
107
 
98
108
  // Validate query
99
- const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query);
109
+ const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query, {
110
+ variables,
111
+ });
100
112
 
101
113
  if (hasErrors) {
102
114
  queryEventEmitter.sendErrorToSingleClient(ws, id, validationErrors);
@@ -146,15 +158,32 @@ const handleGraphQLSubscriptionFactory = (
146
158
  return;
147
159
  }
148
160
 
161
+ // The SQL builders read `$n` from a definition's index in the
162
+ // operation's own variable list, so the analysis handed to them has to
163
+ // be the resolved one — the raw analysis knows nothing of the statics
164
+ // that binding a role filter mints. Same construction as the HTTP path.
165
+ const resolvedAnalysis: AnalysisResult = {
166
+ ...analysis,
167
+ operations: [
168
+ {
169
+ ...operation,
170
+ fields: resolved.fields,
171
+ variables: resolved.variables,
172
+ },
173
+ ...analysis.operations.slice(1),
174
+ ],
175
+ };
176
+
149
177
  // Build context for the strategy
150
178
  const context: SubscriptionContext = {
151
179
  ws,
152
180
  subscriptionId: id,
153
- analysis,
181
+ analysis: resolvedAnalysis,
154
182
  field: resolved.fields[0],
155
183
  variableDefinitions: resolved.variables,
156
184
  variables: resolved.allVariables,
157
185
  schemaEntity,
186
+ session,
158
187
  eventEmitter: queryEventEmitter,
159
188
  };
160
189
 
@@ -19,6 +19,7 @@ import {
19
19
  virtualColumnExpression,
20
20
  virtualColumnFunction,
21
21
  } from "../types/configuration";
22
+ import { collectCrossReferenceErrors } from "./crossReferences";
22
23
  import { getSchema, getSchemas } from "./getSchemas";
23
24
  import { generateOpenAPI } from "./rest/generateOpenAPI";
24
25
 
@@ -60,12 +61,29 @@ export const analyzeConfiguration = async (configuration: Configuration, options
60
61
  // Use pre-calculated enabledDatabases from configuration parsing
61
62
  const enabledDatabases = configuration.enabledDatabases;
62
63
 
63
- const [{ tables, storedProcedures }, resolvedRemoteSchemas, resolvedRemoteREST] =
64
- await Promise.all([
65
- getDatabasesStructure(enabledDatabases, configuration.auth),
66
- resolveRemoteSchemas(configuration.remoteSchemas ?? []),
67
- resolveRemoteRESTApis(configuration.remoteREST ?? []),
68
- ]);
64
+ const [
65
+ { tables, storedProcedures, tableNamesByDatabase },
66
+ resolvedRemoteSchemas,
67
+ resolvedRemoteREST,
68
+ ] = await Promise.all([
69
+ getDatabasesStructure(enabledDatabases, configuration.auth),
70
+ resolveRemoteSchemas(configuration.remoteSchemas ?? []),
71
+ resolveRemoteRESTApis(configuration.remoteREST ?? []),
72
+ ]);
73
+
74
+ const crossReferenceErrors = collectCrossReferenceErrors(configuration, {
75
+ tables,
76
+ storedProcedures,
77
+ tableNamesByDatabase,
78
+ });
79
+
80
+ if (crossReferenceErrors.length) {
81
+ throw new Error(
82
+ `Configuration references ${crossReferenceErrors.length} name${
83
+ crossReferenceErrors.length === 1 ? "" : "s"
84
+ } that do not exist:\n${crossReferenceErrors.map((error) => ` - ${error}`).join("\n")}`,
85
+ );
86
+ }
69
87
 
70
88
  const operationsWithRestEndpoints = Object.fromEntries(
71
89
  Object.entries(configuration.operations).filter(([, operationConfig]) => operationConfig.rest),
@@ -1,38 +1,77 @@
1
+ import { GraphQLObjectType } from "graphql";
2
+
3
+ import type { GraphQLType } from "graphql";
1
4
  import type { OpenAPIV3_1 } from "openapi-types";
2
5
  import type { SelectionAnalysis } from "../../analyzeQuery/types";
3
6
  import type { GetSchemaReturn } from "../../configuration/getSchemas";
4
7
  import type { RemoteRESTResolved } from "../../remoteREST/types";
5
8
  import type { Env } from "../../types/env";
6
9
 
10
+ import { getFieldType, unwrapType } from "../../analyzeQuery/typeUtils";
7
11
  import { convertFromZod, errors } from "./openApiErrors";
8
12
 
13
+ // Deliberately coarse: Int and Float both map to "number", so a column keeps
14
+ // the OpenAPI type it had when types were read off the SQL data type.
15
+ const openApiTypeOf = (namedType: GraphQLType | undefined) => {
16
+ const name = namedType && "name" in namedType ? namedType.name : undefined;
17
+
18
+ switch (name) {
19
+ case "Int":
20
+ case "Float":
21
+ return "number";
22
+ case "Boolean":
23
+ return "boolean";
24
+ default:
25
+ return "string";
26
+ }
27
+ };
28
+
29
+ const resolveFieldType = (parentType: GraphQLObjectType | undefined, fieldName: string) => {
30
+ const fieldType = parentType && getFieldType(parentType, fieldName);
31
+ const namedType = fieldType ? unwrapType(fieldType).type : undefined;
32
+
33
+ return {
34
+ namedType,
35
+ objectType: namedType instanceof GraphQLObjectType ? namedType : undefined,
36
+ };
37
+ };
38
+
39
+ /**
40
+ * Resolves selections against the GraphQL schema rather than against a table
41
+ * name. `<table>_aggregate` returns a GroupBy wrapper whose fields — `key`,
42
+ * `count`, `items`, `min`/`max`/`sum`/`avg` — are not tables, so looking a
43
+ * selection's parent name up in the entity map typed everything under an
44
+ * aggregate as a string. Walking the schema also makes sanitised column names
45
+ * resolve, since the schema is what the client actually queries.
46
+ */
9
47
  const processSelections = (
10
- schema: GetSchemaReturn,
11
- name: string,
12
- selections: SelectionAnalysis[] = [],
48
+ parentType: GraphQLObjectType | undefined,
49
+ fieldParent: SelectionAnalysis,
13
50
  ) =>
14
- selections.reduce<Record<string, unknown>>((acc, field) => {
15
- const type = schema.getColumnTypeForOpenApi(name, field.name);
51
+ fieldParent.selections?.reduce<Record<string, unknown>>((acc, field) => {
52
+ const { namedType, objectType } = resolveFieldType(parentType, field.name);
16
53
 
17
54
  if (field.isArray) {
18
55
  acc[field.alias || field.name] = {
19
56
  type: "array",
20
57
  items: {
21
58
  type: "object",
22
- properties: processSelections(schema, field.name, field.selections),
59
+ properties: processSelections(objectType, field),
23
60
  required: (field.selections ?? [])
24
61
  .filter((f) => f.isRequired)
25
62
  .map((f) => f.alias || f.name),
63
+ nullable: !field.isRequired,
26
64
  },
27
65
  };
28
66
  } else {
29
67
  acc[field.alias || field.name] = field.selections
30
68
  ? {
31
69
  type: "object",
32
- properties: processSelections(schema, field.name, field.selections),
70
+ properties: processSelections(objectType, field),
33
71
  required: field.selections.filter((f) => f.isRequired).map((f) => f.alias || f.name),
72
+ nullable: !field.isRequired,
34
73
  }
35
- : { type };
74
+ : { type: openApiTypeOf(namedType), nullable: !field.isRequired };
36
75
  }
37
76
 
38
77
  return acc;
@@ -137,7 +176,12 @@ export const generateOpenAPI = ({
137
176
  const responseDataSchema = value.output
138
177
  ? convertFromZod(value.output)
139
178
  : (() => {
140
- const fields = value.queryStructure!.operations[0].fields;
179
+ const operation = value.queryStructure!.operations[0];
180
+ const fields = operation.fields;
181
+ const rootType =
182
+ (operation.operation === "mutation"
183
+ ? schema.schema.getMutationType()
184
+ : schema.schema.getQueryType()) ?? undefined;
141
185
  return {
142
186
  type: "object" as const,
143
187
  properties: fields.reduce<Record<string, unknown>>((acc, field) => {
@@ -146,7 +190,10 @@ export const generateOpenAPI = ({
146
190
  type: "array",
147
191
  items: {
148
192
  type: "object",
149
- properties: processSelections(schema, field.name, field.selections!),
193
+ properties: processSelections(
194
+ resolveFieldType(rootType, field.name).objectType,
195
+ field,
196
+ ),
150
197
  required: (field.selections ?? [])
151
198
  .filter((f) => f.isRequired)
152
199
  .map((f) => f.alias || f.name),
@@ -155,7 +202,10 @@ export const generateOpenAPI = ({
155
202
  } else {
156
203
  acc[field.alias || field.name] = {
157
204
  type: "object",
158
- properties: processSelections(schema, field.name, field.selections!),
205
+ properties: processSelections(
206
+ resolveFieldType(rootType, field.name).objectType,
207
+ field,
208
+ ),
159
209
  required: (field.selections ?? [])
160
210
  .filter((f) => f.isRequired)
161
211
  .map((f) => f.alias || f.name),
@@ -12,6 +12,7 @@ import type { HandleGraphQLRequest } from "../gql/handleGraphQLRequestFactory";
12
12
  import { analyzeQuery } from "../../analyzeQuery";
13
13
  import { checkUserCredentials } from "../../databases";
14
14
  import { proxyRemoteRESTRequest } from "../../remoteREST/proxy";
15
+ import { actorFromSession, audit } from "../../logging/audit";
15
16
  import { getTokenService } from "../../singletons/authentication";
16
17
  import { getCache } from "../../singletons/cache";
17
18
  import { databasesConnections, repositoryMap } from "../../singletons/databases";
@@ -108,7 +109,7 @@ export const handleRESTRequestFactory = (
108
109
 
109
110
  if (routesInitDataPromises[route.routeKey] === undefined) {
110
111
  routesInitDataPromises[route.routeKey] = await route.hooks?.init?.({
111
- gqlQuery: gqlSuperadminHandler?.handler ?? gql.handler,
112
+ gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
112
113
  databases: databasesConnections,
113
114
  queues: queueManager,
114
115
  repository: repositoryMap,
@@ -174,9 +175,23 @@ export const handleRESTRequestFactory = (
174
175
 
175
176
  const result = await checkUserCredentials(auth, username, password);
176
177
 
178
+ const actor = { type: "credentials", sub: username } as const;
177
179
  if (!result.valid) {
180
+ audit().emit({
181
+ action: "auth.login",
182
+ outcome: "failure",
183
+ actor,
184
+ target: { kind: "auth", via: "rest" },
185
+ reason: "Invalid username or password",
186
+ });
178
187
  return new S401({ errors: ["Invalid username or password"] });
179
188
  }
189
+ audit().emit({
190
+ action: "auth.login",
191
+ outcome: "success",
192
+ actor,
193
+ target: { kind: "auth", via: "rest", role: result.role },
194
+ });
180
195
 
181
196
  const data = await getTokenService().createTokenPair({
182
197
  sub: username,
@@ -229,9 +244,11 @@ export const handleRESTRequestFactory = (
229
244
 
230
245
  if (route.authOperation === "logout") {
231
246
  const tokenService = getTokenService();
247
+ const revoked: string[] = [];
232
248
 
233
249
  if (session?.jti) {
234
250
  await tokenService.revoke(session.jti);
251
+ revoked.push(session.jti);
235
252
  }
236
253
 
237
254
  const refreshCookie = req?.cookies?.get("refresh_token");
@@ -241,6 +258,7 @@ export const handleRESTRequestFactory = (
241
258
  audience: "refresh",
242
259
  });
243
260
  await tokenService.revoke(refreshPayload.jti);
261
+ revoked.push(refreshPayload.jti);
244
262
  } catch {
245
263
  // tampered or expired cookie — nothing to revoke
246
264
  }
@@ -250,6 +268,14 @@ export const handleRESTRequestFactory = (
250
268
  req.cookies.delete("refresh_token");
251
269
  }
252
270
 
271
+ if (revoked.length > 0) {
272
+ audit().emit({
273
+ action: "auth.logout",
274
+ actor: actorFromSession(session),
275
+ target: { kind: "auth", via: "rest", revoked },
276
+ });
277
+ }
278
+
253
279
  return new S200({ data: true });
254
280
  }
255
281
 
@@ -269,7 +295,7 @@ export const handleRESTRequestFactory = (
269
295
  try {
270
296
  const result = await route.handler(
271
297
  {
272
- gqlQuery: gqlSuperadminHandler?.handler ?? gql.handler,
298
+ gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
273
299
  databases: databasesConnections,
274
300
  queues: queueManager,
275
301
  repository: repositoryMap,
@@ -330,7 +356,7 @@ export const handleRESTRequestFactory = (
330
356
  // Execute the GraphQL request, then transform via afterRequest before
331
357
  // caching so hits and misses return the same shape.
332
358
  const result = await applyAfterRequest(
333
- await gql.handler(queryAnalysis!, variables, req, session),
359
+ await gql.operatorQuery(queryAnalysis!, variables, req, session, route.timeout),
334
360
  );
335
361
 
336
362
  // Cache the (already transformed) result
@@ -344,7 +370,9 @@ export const handleRESTRequestFactory = (
344
370
  } else if (queryAnalysis) {
345
371
  // No caching for this route, execute normally
346
372
  return new S200(
347
- await applyAfterRequest(await gql.handler(queryAnalysis, variables, req, session)),
373
+ await applyAfterRequest(
374
+ await gql.operatorQuery(queryAnalysis, variables, req, session, route.timeout),
375
+ ),
348
376
  );
349
377
  }
350
378
 
@@ -42,11 +42,20 @@ export const buildApiRoutes = (
42
42
  let hasError = false;
43
43
 
44
44
  if (routeConfig.query) {
45
- const validationErrors = gql.hasErrors(routeConfig.query);
45
+ // Operation queries come from the configuration, not from a caller, so the
46
+ // depth and cost limits do not apply to them — they would turn an
47
+ // operator's own deep or wide query into a dead route.
48
+ const validationErrors = gql.hasErrors(routeConfig.query, {
49
+ enforceDepthLimit: false,
50
+ enforceCostLimit: false,
51
+ });
46
52
 
47
53
  if (validationErrors.hasErrors && gqlSuperadminHandler) {
48
54
  // Re-validate with superadmin handler if available
49
- const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query);
55
+ const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query, {
56
+ enforceDepthLimit: false,
57
+ enforceCostLimit: false,
58
+ });
50
59
 
51
60
  if (superadminErrors.hasErrors) {
52
61
  throw new Error(`Endpoint ${routeKey} has invalid query.`);