@graphoria/server 0.3.0 → 0.5.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 (203) hide show
  1. package/README.md +28 -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/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  5. package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
  6. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  7. package/dist/src/authentication/capabilities.d.ts +18 -0
  8. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  9. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  10. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  11. package/dist/src/authentication/jwt.d.ts.map +1 -1
  12. package/dist/src/authentication/paseto.d.ts.map +1 -1
  13. package/dist/src/authentication/secrets.d.ts +9 -0
  14. package/dist/src/authentication/secrets.d.ts.map +1 -0
  15. package/dist/src/authentication/secrets.test.d.ts +2 -0
  16. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  17. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  18. package/dist/src/config/types/db.d.ts +25 -0
  19. package/dist/src/config/types/db.d.ts.map +1 -1
  20. package/dist/src/configuration/getSchemas/index.d.ts +40 -2
  21. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  22. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -1
  23. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  24. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  25. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  26. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  27. package/dist/src/configuration/index.d.ts +30 -1
  28. package/dist/src/configuration/index.d.ts.map +1 -1
  29. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  30. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  31. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  32. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  33. package/dist/src/console/api.d.ts.map +1 -1
  34. package/dist/src/console/session.d.ts +12 -4
  35. package/dist/src/console/session.d.ts.map +1 -1
  36. package/dist/src/cron/index.d.ts.map +1 -1
  37. package/dist/src/databases/common.d.ts +20 -6
  38. package/dist/src/databases/common.d.ts.map +1 -1
  39. package/dist/src/databases/core/executor.d.ts +4 -6
  40. package/dist/src/databases/core/executor.d.ts.map +1 -1
  41. package/dist/src/databases/core/function-mapping.d.ts +2 -3
  42. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  43. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  44. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  45. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  46. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  47. package/dist/src/databases/core/query-builder.d.ts +5 -0
  48. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  49. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  51. package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
  52. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  53. package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
  54. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  55. package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
  56. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  57. package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
  58. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  59. package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
  60. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  61. package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
  62. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  63. package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
  64. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  65. package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
  66. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  67. package/dist/src/databases/high-level-operations.d.ts +5 -0
  68. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  69. package/dist/src/databases/metadata/structure.d.ts +5 -0
  70. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  71. package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
  72. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  73. package/dist/src/index.d.ts +4 -0
  74. package/dist/src/index.d.ts.map +1 -1
  75. package/dist/src/logging/audit.d.ts +27 -0
  76. package/dist/src/logging/audit.d.ts.map +1 -0
  77. package/dist/src/logging/audit.test.d.ts +2 -0
  78. package/dist/src/logging/audit.test.d.ts.map +1 -0
  79. package/dist/src/logging/slowQuery.d.ts +29 -0
  80. package/dist/src/logging/slowQuery.d.ts.map +1 -0
  81. package/dist/src/logging/slowQuery.test.d.ts +2 -0
  82. package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
  83. package/dist/src/observability/health.d.ts +23 -0
  84. package/dist/src/observability/health.d.ts.map +1 -0
  85. package/dist/src/observability/health.test.d.ts +2 -0
  86. package/dist/src/observability/health.test.d.ts.map +1 -0
  87. package/dist/src/observability/httpMetrics.d.ts +7 -0
  88. package/dist/src/observability/httpMetrics.d.ts.map +1 -0
  89. package/dist/src/observability/httpMetrics.test.d.ts +2 -0
  90. package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
  91. package/dist/src/observability/httpTracing.d.ts +11 -0
  92. package/dist/src/observability/httpTracing.d.ts.map +1 -0
  93. package/dist/src/observability/httpTracing.test.d.ts +2 -0
  94. package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
  95. package/dist/src/observability/metrics.d.ts +81 -0
  96. package/dist/src/observability/metrics.d.ts.map +1 -0
  97. package/dist/src/observability/metrics.perf.test.d.ts +2 -0
  98. package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
  99. package/dist/src/observability/metrics.test.d.ts +2 -0
  100. package/dist/src/observability/metrics.test.d.ts.map +1 -0
  101. package/dist/src/observability/metricsRoute.d.ts +19 -0
  102. package/dist/src/observability/metricsRoute.d.ts.map +1 -0
  103. package/dist/src/observability/metricsRoute.test.d.ts +2 -0
  104. package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
  105. package/dist/src/observability/tracing.d.ts +92 -0
  106. package/dist/src/observability/tracing.d.ts.map +1 -0
  107. package/dist/src/observability/tracing.perf.test.d.ts +2 -0
  108. package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
  109. package/dist/src/observability/tracing.test.d.ts +2 -0
  110. package/dist/src/observability/tracing.test.d.ts.map +1 -0
  111. package/dist/src/queues/kafka.d.ts +6 -0
  112. package/dist/src/queues/kafka.d.ts.map +1 -1
  113. package/dist/src/queues/kafka.test.d.ts +2 -0
  114. package/dist/src/queues/kafka.test.d.ts.map +1 -0
  115. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  116. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  117. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  118. package/dist/src/singletons/cache/redisClient.d.ts +2 -2
  119. package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
  120. package/dist/src/singletons/databases.d.ts +1 -0
  121. package/dist/src/singletons/databases.d.ts.map +1 -1
  122. package/dist/src/singletons/env.d.ts +37 -4
  123. package/dist/src/singletons/env.d.ts.map +1 -1
  124. package/dist/src/singletons/queues.d.ts +1 -0
  125. package/dist/src/singletons/queues.d.ts.map +1 -1
  126. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  127. package/dist/src/subscriptions/utils/polling.d.ts +1 -0
  128. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  129. package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
  130. package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
  131. package/dist/src/types/env.d.ts +113 -12
  132. package/dist/src/types/env.d.ts.map +1 -1
  133. package/dist/src/types/zod/configuration.d.ts +25 -0
  134. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  135. package/dist/src/types/zod/db.d.ts +85 -0
  136. package/dist/src/types/zod/db.d.ts.map +1 -1
  137. package/dist/src/utils/rateLimit.d.ts.map +1 -1
  138. package/dist/src/utils/redis.d.ts +17 -0
  139. package/dist/src/utils/redis.d.ts.map +1 -0
  140. package/dist/src/utils/redis.test.d.ts +2 -0
  141. package/dist/src/utils/redis.test.d.ts.map +1 -0
  142. package/dist/src/utils/responses.d.ts +6 -0
  143. package/dist/src/utils/responses.d.ts.map +1 -1
  144. package/dist/src/utils/sessionVariables.d.ts +2 -0
  145. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  146. package/dist/tsconfig.tsbuildinfo +1 -1
  147. package/package.json +8 -5
  148. package/playgrounds/console/index.html +9 -9
  149. package/playgrounds/graphiql/index.html +549 -686
  150. package/playgrounds/scalar/index.html +1 -1
  151. package/src/ai/mcp/index.ts +19 -14
  152. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  153. package/src/analyzeQuery/depthLimit.ts +15 -5
  154. package/src/authentication/capabilities.ts +56 -0
  155. package/src/authentication/index.ts +3 -3
  156. package/src/authentication/jwt.ts +30 -25
  157. package/src/authentication/paseto.ts +36 -26
  158. package/src/authentication/secrets.ts +30 -0
  159. package/src/authentication/tokenRepository.ts +2 -3
  160. package/src/config/types/db.ts +16 -2
  161. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  162. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  163. package/src/configuration/gql/handleGraphQLRequestFactory.ts +241 -148
  164. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
  165. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  166. package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
  167. package/src/console/api.ts +64 -24
  168. package/src/console/session.ts +30 -26
  169. package/src/cron/index.ts +59 -36
  170. package/src/databases/common.ts +109 -10
  171. package/src/databases/core/executor.ts +97 -20
  172. package/src/databases/core/function-mapping.ts +1 -2
  173. package/src/databases/core/procedure-arguments.ts +25 -0
  174. package/src/databases/engines/mssql/connection.ts +0 -3
  175. package/src/databases/engines/mssql/query/index.ts +4 -1
  176. package/src/databases/engines/mysql/connection.ts +10 -7
  177. package/src/databases/engines/mysql/query/index.ts +5 -2
  178. package/src/databases/engines/postgresql/connection.ts +10 -7
  179. package/src/databases/engines/postgresql/query/index.ts +5 -2
  180. package/src/databases/high-level-operations.ts +44 -2
  181. package/src/index.ts +200 -76
  182. package/src/logging/audit.ts +86 -0
  183. package/src/logging/slowQuery.ts +59 -0
  184. package/src/observability/health.ts +67 -0
  185. package/src/observability/httpMetrics.ts +37 -0
  186. package/src/observability/httpTracing.ts +48 -0
  187. package/src/observability/metrics.ts +243 -0
  188. package/src/observability/metricsRoute.ts +33 -0
  189. package/src/observability/tracing.ts +421 -0
  190. package/src/queues/kafka.ts +45 -2
  191. package/src/queues/rabbitmq.ts +37 -1
  192. package/src/remoteREST/proxy.ts +17 -0
  193. package/src/remoteSchemas/proxy.ts +17 -0
  194. package/src/singletons/cache/redisClient.ts +4 -5
  195. package/src/singletons/databases.ts +5 -0
  196. package/src/singletons/queues.ts +9 -3
  197. package/src/subscriptions/strategies/database.ts +2 -0
  198. package/src/subscriptions/utils/polling.ts +80 -21
  199. package/src/types/env.ts +53 -8
  200. package/src/utils/rateLimit.ts +6 -1
  201. package/src/utils/redis.ts +71 -0
  202. package/src/utils/responses.ts +12 -0
  203. package/src/utils/sessionVariables.ts +2 -0
@@ -48,6 +48,44 @@ export const getDatabasesStructure = async (
48
48
  // do-not-serve list, so a name spelled in the wrong case served the table.
49
49
  const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
50
50
 
51
+ // Normalising the ancestor to introspected casing here is what lets the query
52
+ // builder match it with a plain === against the entity it resolves at runtime.
53
+ // Resolution is per-database by construction, so a cross-database ancestor
54
+ // cannot resolve — which is correct, nothing joins across connections.
55
+ const resolveAncestor = (ancestor: { schema: string; name: string; column: string }) => {
56
+ const ancestorTable = tables.find(
57
+ (a) =>
58
+ `${ancestor.schema}_${ancestor.name}`.toLowerCase() ===
59
+ `${a.schema}_${a.name}`.toLowerCase(),
60
+ );
61
+
62
+ if (!ancestorTable) {
63
+ throw new Error(
64
+ `Ancestor table ${ancestor.schema}_${ancestor.name} not found in database ${db.name}`,
65
+ );
66
+ }
67
+
68
+ // An excluded table can never appear in a query path, so the condition could
69
+ // only ever throw at request time. Fail at boot instead.
70
+ if (excluded.has(ancestorTable.schemaName.toLowerCase())) {
71
+ throw new Error(
72
+ `Ancestor table ${ancestor.schema}_${ancestor.name} is in excludedTables and can never be in a query path`,
73
+ );
74
+ }
75
+
76
+ const column = ancestorTable.columns.find(
77
+ (c) => c.name.toLowerCase() === ancestor.column.toLowerCase(),
78
+ );
79
+
80
+ if (!column) {
81
+ throw new Error(
82
+ `Ancestor column ${ancestor.column} not found in table ${ancestorTable.name}`,
83
+ );
84
+ }
85
+
86
+ return { schema: ancestorTable.schema, name: ancestorTable.name, column: column.name };
87
+ };
88
+
51
89
  tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
52
90
 
53
91
  const tablesToAdd = tables
@@ -117,6 +155,10 @@ export const getDatabasesStructure = async (
117
155
  };
118
156
  }),
119
157
  conditions: (fk.conditions ?? []).map((cond) => {
158
+ const ancestor = cond.ancestor
159
+ ? { ancestor: resolveAncestor(cond.ancestor) }
160
+ : undefined;
161
+
120
162
  if (cond.source !== undefined) {
121
163
  const sourceColumn = t.columns.find(
122
164
  (col) => col.name.toLowerCase() === cond.source!.toLowerCase(),
@@ -126,7 +168,7 @@ export const getDatabasesStructure = async (
126
168
  throw new Error(`Condition column ${cond.source} not found in table ${t.name}`);
127
169
  }
128
170
 
129
- return { ...cond, source: sourceColumn.name };
171
+ return { ...cond, ...ancestor, source: sourceColumn.name };
130
172
  }
131
173
 
132
174
  const targetColumn = toTable.columns.find(
@@ -139,7 +181,7 @@ export const getDatabasesStructure = async (
139
181
  );
140
182
  }
141
183
 
142
- return { ...cond, target: targetColumn.name };
184
+ return { ...cond, ...ancestor, target: targetColumn.name };
143
185
  }),
144
186
  };
145
187
  });
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,14 +16,21 @@ 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
- import { disconnectDatabases, instantiateDatabasesConnections } from "./singletons/databases";
23
+ import {
24
+ databasesConnections,
25
+ disconnectDatabases,
26
+ instantiateDatabasesConnections,
27
+ pingConnection,
28
+ } from "./singletons/databases";
21
29
  import { getCacheRedisClient } from "./singletons/cache/redisClient";
22
30
  import { env } from "./singletons/env";
23
31
  import { setQueryTimeoutMs } from "./singletons/queryTimeout";
24
- import { instantiateQueues } from "./singletons/queues";
32
+ import { setSlowQueryMs } from "./logging/slowQuery";
33
+ import { instantiateQueues, queueManager } from "./singletons/queues";
25
34
  import { ConfigurationZod } from "./types/zod/configuration";
26
35
  import {
27
36
  createMemoryRateLimitStore,
@@ -32,6 +41,13 @@ import {
32
41
  import { S200, S400, S401, S404, S429 } from "./utils/responses";
33
42
  import { writeSchema } from "./utils/writeSchema";
34
43
  import { logger, configureLogging } from "./logging";
44
+ import { actorFromSession, audit } from "./logging/audit";
45
+ import { createHealthRoutes } from "./observability/health";
46
+ import { configureMetrics, renderMetrics } from "./observability/metrics";
47
+ import { configureTracing } from "./observability/tracing";
48
+ import { withHttpMetrics } from "./observability/httpMetrics";
49
+ import { withHttpTracing } from "./observability/httpTracing";
50
+ import { createMetricsRoute } from "./observability/metricsRoute";
35
51
 
36
52
  // Re-export for consumers
37
53
  export { configureLogging };
@@ -42,6 +58,8 @@ type RouteHandler =
42
58
 
43
59
  type RoutesMap = Record<string, RouteHandler | Record<string, RouteHandler>>;
44
60
 
61
+ const HEALTH_CHECK_TIMEOUT_MS = 2000;
62
+
45
63
  const renderPlayground = async (filepath: string, replacements: Record<string, string>) => {
46
64
  const path = join(import.meta.dir, filepath);
47
65
  const content = await Bun.file(path).text();
@@ -67,6 +85,8 @@ const generatePrefixes = (options: Env) => ({
67
85
  rest: options.prefix + options.restApiPrefix,
68
86
  openapi: options.prefix + options.openApiEndpoint,
69
87
  console: options.prefix + options.console.endpoint,
88
+ health: options.prefix + "/health",
89
+ metrics: options.prefix + options.metrics.endpoint,
70
90
  });
71
91
 
72
92
  /**
@@ -88,6 +108,18 @@ const bootAnalyzedConfiguration = async (env: Env) => {
88
108
  }
89
109
 
90
110
  setQueryTimeoutMs(env.queryTimeoutMs);
111
+ setSlowQueryMs(env.slowQueryMs);
112
+ configureMetrics({
113
+ enabled: env.metrics.enabled,
114
+ maxOperationLabels: env.metrics.maxOperationLabels,
115
+ });
116
+ configureTracing({
117
+ enabled: env.tracing.enabled,
118
+ endpoint: env.tracing.endpoint,
119
+ headers: env.tracing.headers,
120
+ serviceName: env.tracing.serviceName,
121
+ sampleRatio: env.tracing.sampleRatio,
122
+ });
91
123
 
92
124
  if (env.queryTimeoutMs === 0) {
93
125
  logger("graphoria").warn(
@@ -244,24 +276,52 @@ const createGraphQLServer = async (env: Env) => {
244
276
  : createMemoryRateLimitStore(),
245
277
  });
246
278
 
247
- // Helper to get role-based handlers
248
- const getRoleHandlers = async (req: Request, server?: Bun.Server<unknown>) => {
249
- const session = await getTokenService().verifyTokenAndGetSession(
250
- req.headers.get(env.authorizationHeader),
251
- req.headers.get(env.admin.header),
252
- );
279
+ const authorizeCapability = createCapabilityAuthorizer(env);
280
+
281
+ // Helper to get role-based handlers. A route that names a capability also
282
+ // accepts that capability's scoped credential in the admin-secret header;
283
+ // it stands in for the superadmin role on that route and nowhere else.
284
+ const getRoleHandlers = async (
285
+ req: Request,
286
+ server?: Bun.Server<unknown>,
287
+ capability?: Capability,
288
+ ) => {
289
+ const adminSecretHeader = req.headers.get(env.admin.header);
290
+ const grant = capability ? authorizeCapability(adminSecretHeader, capability) : null;
291
+
292
+ const session: SessionContext =
293
+ grant && !grant.superset
294
+ ? { sub: capability, role: env.superadmin.role, authMethod: "admin_secret" }
295
+ : await getTokenService().verifyTokenAndGetSession(
296
+ req.headers.get(env.authorizationHeader),
297
+ adminSecretHeader,
298
+ );
299
+
300
+ const ip = resolveClientAddress(req, server, env.rateLimit.trustProxy);
253
301
 
254
302
  // The limit needs the role, and the role costs a token verification plus a
255
303
  // revocation lookup — so it is spent here, once, rather than again inside a
256
304
  // wrapper.
257
- const limit = await rateLimiter?.check(
258
- session,
259
- resolveClientAddress(req, server, env.rateLimit.trustProxy),
260
- );
305
+ const limit = await rateLimiter?.check(session, ip);
306
+
307
+ const scope: Capability | "all" | undefined = grant
308
+ ? grant.superset
309
+ ? "all"
310
+ : capability
311
+ : undefined;
312
+
313
+ if (session.authMethod === "admin_secret") {
314
+ audit().emit({
315
+ action: "admin_secret.used",
316
+ actor: { type: "admin_secret", ...(scope ? { scope } : {}), ip },
317
+ target: { kind: "endpoint", method: req.method, path: new URL(req.url).pathname },
318
+ });
319
+ }
261
320
 
262
321
  return {
263
322
  role: session.role!,
264
323
  session,
324
+ scope,
265
325
  limit,
266
326
  ...analyzedConfiguration.roles[session.role!].handlers,
267
327
  };
@@ -298,6 +358,51 @@ const createGraphQLServer = async (env: Env) => {
298
358
  routes[prefixes.graphiql] = () => html(graphiqlFile);
299
359
  routes[prefixes.scalar] = () => html(scalarFile);
300
360
 
361
+ // Probes for an orchestrator: no auth, no rate limit. Redis is a dependency
362
+ // only where something reads it — the token repository or the redis cache.
363
+ const redisInUse = projectConfiguration.auth?.enabled || env.cache.store === "redis";
364
+ Object.assign(
365
+ routes,
366
+ createHealthRoutes({
367
+ basePath: prefixes.health,
368
+ timeoutMs: HEALTH_CHECK_TIMEOUT_MS,
369
+ checks: () => [
370
+ ...analyzedConfiguration.databases.map((database) => ({
371
+ kind: "database",
372
+ name: database.name,
373
+ probe: async () => {
374
+ const connection = databasesConnections[database.name];
375
+ if (!connection) return false;
376
+ await pingConnection(connection, database.type);
377
+ return true;
378
+ },
379
+ })),
380
+ ...(redisInUse
381
+ ? [{ kind: "redis", probe: async () => (await getCacheRedisClient().ping()) === "PONG" }]
382
+ : []),
383
+ ...(queueManager?.connections() ?? []).map(({ type, name, connected }) => ({
384
+ kind: type,
385
+ name,
386
+ probe: () => connected,
387
+ })),
388
+ ],
389
+ }),
390
+ );
391
+
392
+ // Prometheus exposition, opt-in via METRICS_ENABLED. Gated: the series name
393
+ // operations, roles and databases.
394
+ if (env.metrics.enabled) {
395
+ Object.assign(
396
+ routes,
397
+ createMetricsRoute({
398
+ path: prefixes.metrics,
399
+ secretHeader: env.admin.header,
400
+ authorize: (candidate) => authorizeCapability(candidate, "metrics"),
401
+ render: renderMetrics,
402
+ }),
403
+ );
404
+ }
405
+
301
406
  // Console (admin UI + status APIs), opt-in via CONSOLE_ENABLED
302
407
  if (env.console.enabled) {
303
408
  const consoleHandler = () => html(consoleHtml);
@@ -320,51 +425,60 @@ const createGraphQLServer = async (env: Env) => {
320
425
  // GraphQL endpoint
321
426
  routes[prefixes.graphql] = {
322
427
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
323
- GET: withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
324
- try {
325
- if (req.headers.get("upgrade") === "websocket") {
326
- const success = server.upgrade(req, {
327
- data: {},
328
- });
329
- return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
330
- }
331
- return new S404({ error: "Not Found" });
332
- } catch (error) {
333
- return new S400({ errors: [{ message: (error as Error)?.message }] });
334
- }
335
- }),
336
- POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
337
- try {
338
- const { gql, session, limit } = await getRoleHandlers(req, server);
339
- if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
340
-
341
- const { query, variables } = await req.json();
342
-
343
- if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
344
-
345
- if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
346
-
347
- const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
348
-
349
- if (hasErrors)
350
- return new S400({
351
- errors: validationErrors.map((error) => ({
352
- message: error.message,
353
- locations: error.locations,
354
- })),
355
- });
356
-
357
- return new S200(await gql.handler(query, variables, req, session));
358
- } catch (error) {
359
- const message = (error as Error)?.message;
360
-
361
- if (message === "Invalid username or password") {
362
- return new S401({ errors: [{ message }] });
363
- } else {
364
- return new S400({ errors: [{ message }] });
428
+ GET: withHttpTracing(
429
+ "graphql",
430
+ withHttpMetrics(
431
+ "graphql",
432
+ withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
433
+ try {
434
+ if (req.headers.get("upgrade") === "websocket") {
435
+ const success = server.upgrade(req, {
436
+ data: {},
437
+ });
438
+ return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
439
+ }
440
+ return new S404({ error: "Not Found" });
441
+ } catch (error) {
442
+ return new S400({ errors: [{ message: (error as Error)?.message }] });
443
+ }
444
+ }),
445
+ ),
446
+ ),
447
+ POST: withHttpTracing(
448
+ "graphql",
449
+ withHttpMetrics("graphql", async (req: BunRequest, server: Bun.Server<unknown>) => {
450
+ try {
451
+ const { gql, session, limit } = await getRoleHandlers(req, server);
452
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
453
+
454
+ const { query, variables } = await req.json();
455
+
456
+ if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
457
+
458
+ if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
459
+
460
+ const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
461
+
462
+ if (hasErrors)
463
+ return new S400({
464
+ errors: validationErrors.map((error) => ({
465
+ message: error.message,
466
+ locations: error.locations,
467
+ })),
468
+ });
469
+
470
+ return new S200(await gql.handler(query, variables, req, session));
471
+ } catch (error) {
472
+ const message = (error as Error)?.message;
473
+
474
+ if (message === "Invalid username or password") {
475
+ return new S401({ errors: [{ message }] });
476
+ } else {
477
+ return new S400({ errors: [{ message }] });
478
+ }
365
479
  }
366
- }
367
- },
480
+ }),
481
+ ),
368
482
  };
369
483
 
370
484
  const aiEnabled = projectConfiguration.ai?.enabled ?? false;
@@ -380,7 +494,7 @@ const createGraphQLServer = async (env: Env) => {
380
494
  name: projectConfiguration.name,
381
495
  version: projectConfiguration.version,
382
496
  maxQueryDepth: env.ai?.mcp?.maxQueryDepth ?? env.maxQueryDepth,
383
- adminSecret: env.admin.secret,
497
+ authorize: authorizeCapability,
384
498
  adminSecretHeader: env.admin.header,
385
499
  });
386
500
 
@@ -395,7 +509,7 @@ const createGraphQLServer = async (env: Env) => {
395
509
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
396
510
  POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
397
511
  try {
398
- const { role, limit } = await getRoleHandlers(req, server);
512
+ const { role, session, scope, limit } = await getRoleHandlers(req, server, "ai");
399
513
  if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
400
514
  if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
401
515
 
@@ -405,6 +519,13 @@ const createGraphQLServer = async (env: Env) => {
405
519
  errors: [{ message: "`prompt` (string) is required" }],
406
520
  });
407
521
 
522
+ audit().emit({
523
+ action: "ai.ask",
524
+ actor: { ...actorFromSession(session), ...(scope ? { scope } : {}) },
525
+ target: { kind: "ai", via: "rest" },
526
+ prompt,
527
+ });
528
+
408
529
  return new S200({ answer: await getAgent()(prompt) });
409
530
  } catch (error) {
410
531
  return new S400({ errors: [{ message: (error as Error)?.message }] });
@@ -415,26 +536,29 @@ const createGraphQLServer = async (env: Env) => {
415
536
  }
416
537
 
417
538
  // REST API endpoint
418
- routes[`${prefixes.rest}/*`] = async (req: BunRequest, server: Bun.Server<unknown>) => {
419
- if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
539
+ routes[`${prefixes.rest}/*`] = withHttpTracing(
540
+ "rest",
541
+ withHttpMetrics("rest", async (req: BunRequest, server: Bun.Server<unknown>) => {
542
+ if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
420
543
 
421
- try {
422
- const { rest, session, limit } = await getRoleHandlers(req, server);
423
- if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
424
-
425
- const urlParsed = new URL(req.url);
544
+ try {
545
+ const { rest, session, limit } = await getRoleHandlers(req, server);
546
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
426
547
 
427
- return await rest.handler(
428
- urlParsed,
429
- urlParsed.pathname.replace(prefixes.rest, ""),
430
- req.method,
431
- req,
432
- session,
433
- );
434
- } catch {
435
- return new S400({ errors: [{ message: "Bad request" }] });
436
- }
437
- };
548
+ const urlParsed = new URL(req.url);
549
+
550
+ return await rest.handler(
551
+ urlParsed,
552
+ urlParsed.pathname.replace(prefixes.rest, ""),
553
+ req.method,
554
+ req,
555
+ session,
556
+ );
557
+ } catch {
558
+ return new S400({ errors: [{ message: "Bad request" }] });
559
+ }
560
+ }),
561
+ );
438
562
 
439
563
  // Create WebSocket handler
440
564
  const websocketHandler = websocketHandlerFactory(analyzedConfiguration.roles);
@@ -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,59 @@
1
+ import type { Logger } from "pino";
2
+
3
+ import { logger } from "./index";
4
+
5
+ /** The GraphQL operation a statement was generated for. Names only, never values. */
6
+ export type QuerySource = {
7
+ operation: {
8
+ type: "query" | "mutation" | "subscription";
9
+ name: string | null;
10
+ fields: string[];
11
+ };
12
+ role?: string | undefined;
13
+ };
14
+
15
+ export type SlowQueryRecord = {
16
+ durationMs: number;
17
+ dbType: string;
18
+ dbName: string;
19
+ sql?: string;
20
+ procedure?: string;
21
+ outcome: "success" | "error";
22
+ } & Partial<QuerySource>;
23
+
24
+ export type SlowQueryLog = {
25
+ emit(record: SlowQueryRecord & { thresholdMs: number }): void;
26
+ };
27
+
28
+ /**
29
+ * Resolved from `SLOW_QUERY_MS` at boot. `0` — also the value before boot sets
30
+ * it — reports nothing, so an executor used outside a booted server stays quiet.
31
+ */
32
+ let thresholdMs = 0;
33
+
34
+ export const setSlowQueryMs = (ms: number): void => {
35
+ thresholdMs = ms;
36
+ };
37
+
38
+ export const createSlowQueryLog = (base: Logger): SlowQueryLog => ({
39
+ emit: (record) => base.warn(record, "slow query"),
40
+ });
41
+
42
+ let override: SlowQueryLog | null = null;
43
+ let instance: SlowQueryLog | null = null;
44
+
45
+ /** Test seam: pass `null` to restore the default log. */
46
+ export const setSlowQueryLog = (log: SlowQueryLog | null): void => {
47
+ override = log;
48
+ };
49
+
50
+ const slowQueryLog = (): SlowQueryLog => {
51
+ if (override) return override;
52
+ if (!instance) instance = createSlowQueryLog(logger("slow-query"));
53
+ return instance;
54
+ };
55
+
56
+ export const reportQueryDuration = (record: SlowQueryRecord): void => {
57
+ if (thresholdMs === 0 || record.durationMs <= thresholdMs) return;
58
+ slowQueryLog().emit({ ...record, thresholdMs });
59
+ };
@@ -0,0 +1,67 @@
1
+ import { logger } from "../logging";
2
+ import { S200, S503 } from "../utils/responses";
3
+
4
+ export type HealthCheck = {
5
+ kind: string;
6
+ name?: string;
7
+ probe: () => Promise<boolean> | boolean;
8
+ };
9
+
10
+ export type HealthRoutesOptions = {
11
+ basePath: string;
12
+ /** Read on every readiness request, so a check can follow state that changes after boot. */
13
+ checks: () => HealthCheck[];
14
+ timeoutMs: number;
15
+ };
16
+
17
+ const runCheck = async ({ probe }: HealthCheck, timeoutMs: number) => {
18
+ let timer: ReturnType<typeof setTimeout> | undefined;
19
+ try {
20
+ return await Promise.race([
21
+ Promise.resolve().then(probe),
22
+ new Promise<never>((_, reject) => {
23
+ timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs}ms`)), timeoutMs);
24
+ }),
25
+ ]);
26
+ } finally {
27
+ clearTimeout(timer);
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Unauthenticated probes for an orchestrator. Liveness touches nothing, so a
33
+ * dependency outage never gets the process restarted; readiness pings every
34
+ * dependency and answers 503 while one is down. The body names each check and
35
+ * whether it passed — the reason stays in the log, because a driver error can
36
+ * carry a host and port.
37
+ */
38
+ export const createHealthRoutes = ({ basePath, checks, timeoutMs }: HealthRoutesOptions) => {
39
+ const log = logger("health");
40
+
41
+ const live = () => new S200({ status: "ok" });
42
+
43
+ const ready = async () => {
44
+ const results = await Promise.all(
45
+ checks().map(async (check) => {
46
+ let ok = false;
47
+ try {
48
+ ok = (await runCheck(check, timeoutMs)) === true;
49
+ if (!ok) log.warn({ kind: check.kind, name: check.name }, "readiness check failed");
50
+ } catch (error) {
51
+ log.warn({ kind: check.kind, name: check.name, err: error }, "readiness check failed");
52
+ }
53
+ return { kind: check.kind, ...(check.name ? { name: check.name } : {}), ok };
54
+ }),
55
+ );
56
+
57
+ const healthy = results.every((result) => result.ok);
58
+ const body = { status: healthy ? "ok" : "unavailable", checks: results };
59
+
60
+ return healthy ? new S200(body) : new S503(body);
61
+ };
62
+
63
+ return {
64
+ [`${basePath}/live`]: live,
65
+ [`${basePath}/ready`]: ready,
66
+ };
67
+ };