@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
@@ -50,6 +50,11 @@ export const instantiateDatabasesConnections = async (databases: Database[]) =>
50
50
  return { databasesConnections, repositoryMap };
51
51
  };
52
52
 
53
+ export const pingConnection = (connection: SQL | ConnectionPool, type: string) =>
54
+ type === "mssql"
55
+ ? (connection as ConnectionPool).query("SELECT 1")
56
+ : (connection as SQL).unsafe("SELECT 1");
57
+
53
58
  /**
54
59
  * Close every open database connection and clear the singleton maps. Bun's `SQL`
55
60
  * and mssql's `ConnectionPool` both expose `close()`. Used by
@@ -6,7 +6,11 @@ import { startKafkaConnections } from "../queues/kafka";
6
6
  import { startRabbitMQConnections } from "../queues/rabbitmq";
7
7
  import { logger } from "../logging";
8
8
 
9
- export type QueueConnectionStatus = { type: "rabbitmq" | "kafka"; connected: boolean };
9
+ export type QueueConnectionStatus = {
10
+ type: "rabbitmq" | "kafka";
11
+ name: string;
12
+ connected: boolean;
13
+ };
10
14
 
11
15
  export type QueueManager = {
12
16
  publisherMap: () => Record<string, RabbitMQPublisher | KafkaPublisher>;
@@ -40,8 +44,9 @@ export const instantiateQueues = async (queues: QueueConfig[]) => {
40
44
  publisherMap: rabbitMQManager.publisherMap,
41
45
  sendMessage: rabbitMQManager.sendMessage,
42
46
  connections: () =>
43
- rabbitMQManager.managers.map((manager) => ({
47
+ rabbitMQManager.managers.map((manager, index) => ({
44
48
  type: "rabbitmq" as const,
49
+ name: rabbitMQQueues[index]!.name,
45
50
  connected: manager.isConnected(),
46
51
  })),
47
52
  });
@@ -55,8 +60,9 @@ export const instantiateQueues = async (queues: QueueConfig[]) => {
55
60
  publisherMap: kafkaManager.publisherMap,
56
61
  sendMessage: kafkaManager.sendMessage,
57
62
  connections: () =>
58
- kafkaManager.managers.map((manager) => ({
63
+ kafkaManager.managers.map((manager, index) => ({
59
64
  type: "kafka" as const,
65
+ name: kafkaQueues[index]!.name,
60
66
  connected: manager.isConnected(),
61
67
  })),
62
68
  cleanup: kafkaManager.cleanup,
@@ -46,6 +46,7 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
46
46
  variables,
47
47
  schemaEntity,
48
48
  eventEmitter,
49
+ session,
49
50
  } = context;
50
51
 
51
52
  const subscriptionKey = this.getSubscriptionKey(context);
@@ -69,6 +70,7 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
69
70
  subscriptionKey,
70
71
  eventEmitter,
71
72
  pollIntervalMs: 1000,
73
+ role: session.role,
72
74
  });
73
75
 
74
76
  // Store poll reference for cleanup by event emitter
@@ -1,10 +1,12 @@
1
1
  import type { AnalysisResult, VariableDefinition } from "../../analyzeQuery/types";
2
2
  import type { GetSchemaReturn } from "../../configuration/getSchemas";
3
+ import type { QuerySource } from "../../logging/slowQuery";
3
4
  import type { Database } from "../../types/configuration";
4
5
  import type { DatabasePoller, QueryEventEmitter } from "../types";
5
6
 
6
7
  import { executeQuery, executeQueryJSON, generateSQL } from "../../databases";
7
8
  import { logger } from "../../logging";
9
+ import { withSpan } from "../../observability/tracing";
8
10
  import { env } from "../../singletons/env";
9
11
 
10
12
  export interface DatabasePollerConfig {
@@ -15,6 +17,7 @@ export interface DatabasePollerConfig {
15
17
  subscriptionKey: string;
16
18
  eventEmitter: QueryEventEmitter;
17
19
  pollIntervalMs?: number;
20
+ role?: string | undefined;
18
21
  }
19
22
 
20
23
  /**
@@ -32,12 +35,15 @@ const getResultHash = async (
32
35
  db: Database,
33
36
  variableDefinitions: VariableDefinition[],
34
37
  variables: Record<string, unknown>,
38
+ source: QuerySource,
35
39
  ): Promise<string> => {
36
40
  const hashResult = await executeQuery<{ ResultHash: string }>(
37
41
  queryHash,
38
42
  db,
39
43
  variableDefinitions,
40
44
  variables,
45
+ undefined,
46
+ source,
41
47
  );
42
48
  return Buffer.from(hashResult[0].ResultHash)?.toString("hex");
43
49
  };
@@ -56,6 +62,7 @@ export const createDatabasePoller = async (
56
62
  subscriptionKey,
57
63
  eventEmitter,
58
64
  pollIntervalMs = 1000,
65
+ role,
59
66
  } = config;
60
67
 
61
68
  const firstFieldName = getFirstFieldName(analysis);
@@ -67,35 +74,87 @@ export const createDatabasePoller = async (
67
74
  const [[, queryData]] = generateSQL(schemaEntity, analysis, variables, false, pageLimits);
68
75
  const [[, queryHash]] = generateSQL(schemaEntity, analysis, variables, true, pageLimits);
69
76
 
70
- // Get initial hash and send initial data
71
- let previousHash = await getResultHash(queryHash, db, variableDefinitions, variables);
77
+ const operation = analysis.operations[0];
78
+ const source: QuerySource = {
79
+ operation: {
80
+ type: operation.operation,
81
+ name: operation.name,
82
+ fields: operation.fields.map((field) => field.name),
83
+ },
84
+ role,
85
+ };
86
+
87
+ // A poll is not inside a request, so each one roots its own trace. The span is
88
+ // entered, which is what puts the statements it runs underneath it. An empty
89
+ // poll still gets a span — an empty poll that is slow is worth seeing.
90
+ const pollSpan = <T>(run: () => Promise<T>) =>
91
+ withSpan(
92
+ "subscription.poll",
93
+ {
94
+ parent: null,
95
+ attributes: {
96
+ "graphql.operation.name": source.operation.name ?? undefined,
97
+ "graphql.operation.type": source.operation.type,
98
+ "graphoria.role": source.role,
99
+ },
100
+ },
101
+ run,
102
+ );
72
103
 
73
- eventEmitter.sendDataUpdate(subscriptionKey, {
74
- data: await executeQueryJSON(queryData, db, variableDefinitions, variables),
104
+ // Get initial hash and send initial data
105
+ let previousHash = "";
106
+
107
+ await pollSpan(async () => {
108
+ previousHash = await getResultHash(queryHash, db, variableDefinitions, variables, source);
109
+
110
+ eventEmitter.sendDataUpdate(subscriptionKey, {
111
+ data: await executeQueryJSON(
112
+ queryData,
113
+ db,
114
+ variableDefinitions,
115
+ variables,
116
+ undefined,
117
+ source,
118
+ ),
119
+ });
75
120
  });
76
121
 
77
122
  // Poll function that checks for changes
78
123
  const log = logger("polling").child({ subscription: subscriptionKey });
79
- const poll = async () => {
80
- try {
81
- const currentHash = await getResultHash(queryHash, db, variableDefinitions, variables);
82
-
83
- if (currentHash !== previousHash) {
84
- log.info(
85
- { changed: !!previousHash, operation: analysis.operations[0].name },
86
- previousHash ? "data changed" : "initial fetch",
124
+ const poll = () =>
125
+ pollSpan(async () => {
126
+ try {
127
+ const currentHash = await getResultHash(
128
+ queryHash,
129
+ db,
130
+ variableDefinitions,
131
+ variables,
132
+ source,
87
133
  );
88
134
 
89
- previousHash = currentHash;
90
-
91
- eventEmitter.sendDataUpdate(subscriptionKey, {
92
- data: await executeQueryJSON(queryData, db, variableDefinitions, variables),
93
- });
135
+ if (currentHash !== previousHash) {
136
+ log.info(
137
+ { changed: !!previousHash, operation: analysis.operations[0].name },
138
+ previousHash ? "data changed" : "initial fetch",
139
+ );
140
+
141
+ previousHash = currentHash;
142
+
143
+ eventEmitter.sendDataUpdate(subscriptionKey, {
144
+ data: await executeQueryJSON(
145
+ queryData,
146
+ db,
147
+ variableDefinitions,
148
+ variables,
149
+ undefined,
150
+ source,
151
+ ),
152
+ });
153
+ }
154
+ } catch (error) {
155
+ log.error({ err: error }, "polling failed");
94
156
  }
95
- } catch (error) {
96
- log.error({ err: error }, "polling failed");
97
- }
98
- };
157
+ });
99
158
 
100
159
  // Poller control
101
160
  let stopped = false;
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"),
@@ -31,6 +41,8 @@ export const EnvZod = z
31
41
  CONSOLE_ENABLED: z.stringbool().default(false),
32
42
  CONSOLE_ENDPOINT: z.string().default("/_console"),
33
43
  CONSOLE_SESSION_EXPIRES_IN: z.string().default("1h"),
44
+ CONSOLE_READ_SECRET: secretList,
45
+ CONSOLE_WRITE_SECRET: secretList,
34
46
  PREFIX: z.string().default(""),
35
47
  CORS_ENABLED: z.stringbool().default(true),
36
48
  PRINT_SCHEMAS: z.stringbool().default(false),
@@ -40,6 +52,19 @@ export const EnvZod = z
40
52
  DEFAULT_PAGE_SIZE: z.coerce.number().int().min(0).default(100),
41
53
  MAX_PAGE_SIZE: z.coerce.number().int().min(0).default(1000),
42
54
  QUERY_TIMEOUT_MS: z.coerce.number().int().min(0).default(10000),
55
+ SLOW_QUERY_MS: z.coerce.number().int().min(0).default(1000),
56
+ METRICS_ENABLED: z.stringbool().default(false),
57
+ METRICS_ENDPOINT: z.string().default("/metrics"),
58
+ METRICS_SECRET: secretList,
59
+ METRICS_MAX_OPERATION_LABELS: z.coerce.number().int().min(1).default(200),
60
+ // Only the gate is graphoria's own. The rest are the standard OpenTelemetry
61
+ // names, so a cluster that already injects them into its pods needs to set
62
+ // nothing but TRACING_ENABLED.
63
+ TRACING_ENABLED: z.stringbool().default(false),
64
+ OTEL_EXPORTER_OTLP_ENDPOINT: z.string().default("http://localhost:4318"),
65
+ OTEL_EXPORTER_OTLP_HEADERS: z.string().default(""),
66
+ OTEL_SERVICE_NAME: z.string().default("graphoria"),
67
+ OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1),
43
68
  MAX_QUERY_COST: z.coerce.number().int().min(0).default(0),
44
69
  RATE_LIMIT_MAX: z.coerce.number().int().min(0).default(0),
45
70
  RATE_LIMIT_ANONYMOUS_MAX: z.coerce.number().int().min(0).default(0),
@@ -48,9 +73,11 @@ export const EnvZod = z
48
73
  AI_ENABLED: z.stringbool().optional(),
49
74
  AI_GRAPHQL_ENABLED: z.stringbool().default(true),
50
75
  AI_REST_ENABLED: z.stringbool().default(true),
76
+ AI_SECRET: secretList,
51
77
  AI_MCP_ENABLED: z.stringbool().optional(),
52
78
  AI_MCP_ENDPOINT: z.string().default("/mcp"),
53
79
  AI_MCP_REQUIRE_ADMIN_SECRET: z.stringbool().default(false),
80
+ AI_MCP_SECRET: secretList,
54
81
  AI_MCP_MAX_QUERY_DEPTH: z.coerce.number().int().min(0).optional(),
55
82
  AI_MCP_DISABLED_TOOLS: z.string().default(""),
56
83
  AI_MCP_DISABLED_RESOURCES: z.string().default(""),
@@ -78,20 +105,21 @@ export const EnvZod = z
78
105
  defaultPageSize: env.DEFAULT_PAGE_SIZE,
79
106
  maxPageSize: env.MAX_PAGE_SIZE,
80
107
  queryTimeoutMs: env.QUERY_TIMEOUT_MS,
108
+ slowQueryMs: env.SLOW_QUERY_MS,
81
109
  maxQueryCost: env.MAX_QUERY_COST,
82
110
  admin: {
83
- secret: env.ADMIN_SECRET,
111
+ secrets: env.ADMIN_SECRET,
84
112
  header: env.ADMIN_SECRET_HEADER,
85
113
  },
86
114
  jwt: {
87
- secret: env.JWT_SECRET,
115
+ secrets: env.JWT_SECRET,
88
116
  expiresIn: env.JWT_EXPIRES_IN,
89
117
  rtExpiresIn: env.JWT_RT_EXPIRES_IN,
90
118
  },
91
119
  paseto: {
92
- localKey: env.PASETO_LOCAL_KEY,
120
+ localKeys: env.PASETO_LOCAL_KEY,
93
121
  secretKey: env.PASETO_SECRET_KEY,
94
- publicKey: env.PASETO_PUBLIC_KEY,
122
+ publicKeys: env.PASETO_PUBLIC_KEY,
95
123
  },
96
124
  rateLimit: {
97
125
  max: env.RATE_LIMIT_MAX,
@@ -110,19 +138,36 @@ export const EnvZod = z
110
138
  superadmin: {
111
139
  role: env.SUPERADMIN_ROLE,
112
140
  },
141
+ metrics: {
142
+ enabled: env.METRICS_ENABLED,
143
+ endpoint: env.METRICS_ENDPOINT,
144
+ secrets: env.METRICS_SECRET,
145
+ maxOperationLabels: env.METRICS_MAX_OPERATION_LABELS,
146
+ },
147
+ tracing: {
148
+ enabled: env.TRACING_ENABLED,
149
+ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT,
150
+ headers: env.OTEL_EXPORTER_OTLP_HEADERS,
151
+ serviceName: env.OTEL_SERVICE_NAME,
152
+ sampleRatio: env.OTEL_TRACES_SAMPLER_ARG,
153
+ },
113
154
  console: {
114
155
  enabled: env.CONSOLE_ENABLED,
115
156
  endpoint: env.CONSOLE_ENDPOINT,
116
157
  sessionExpiresIn: env.CONSOLE_SESSION_EXPIRES_IN,
158
+ readSecrets: env.CONSOLE_READ_SECRET,
159
+ writeSecrets: env.CONSOLE_WRITE_SECRET,
117
160
  },
118
161
  ai: {
119
162
  enabled: env.AI_ENABLED,
120
163
  graphqlEnabled: env.AI_GRAPHQL_ENABLED,
121
164
  restEnabled: env.AI_REST_ENABLED,
165
+ secrets: env.AI_SECRET,
122
166
  mcp: {
123
167
  enabled: env.AI_MCP_ENABLED,
124
168
  endpoint: env.AI_MCP_ENDPOINT,
125
169
  requireAdminSecret: env.AI_MCP_REQUIRE_ADMIN_SECRET,
170
+ secrets: env.AI_MCP_SECRET,
126
171
  maxQueryDepth: env.AI_MCP_MAX_QUERY_DEPTH,
127
172
  disabledTools: env.AI_MCP_DISABLED_TOOLS
128
173
  ? env.AI_MCP_DISABLED_TOOLS.split(",")
@@ -1,6 +1,7 @@
1
1
  import { LRUCache } from "lru-cache";
2
2
 
3
3
  import { logger } from "../logging";
4
+ import { incMetric } from "../observability/metrics";
4
5
 
5
6
  export type ConsumeResult = { allowed: boolean; retryAfterMs: number };
6
7
 
@@ -203,7 +204,11 @@ export const createRateLimiter = ({
203
204
  const key =
204
205
  sub && sub !== "anonymous" ? `rl:${role}:${sub}` : `rl:${role}:ip:${address ?? "unknown"}`;
205
206
 
206
- return resolvedStore.consume(key, max, max / windowMs, windowMs);
207
+ const verdict = await resolvedStore.consume(key, max, max / windowMs, windowMs);
208
+
209
+ if (!verdict.allowed) incMetric("graphoria_rate_limit_rejections_total", { role });
210
+
211
+ return verdict;
207
212
  },
208
213
  };
209
214
  };
@@ -0,0 +1,71 @@
1
+ import { RedisClient as ValkeyClient } from "bun";
2
+
3
+ import { logger } from "../logging";
4
+
5
+ const RECONNECT_INITIAL_DELAY = 1000;
6
+ const RECONNECT_MAX_DELAY = 30000;
7
+ const RECONNECT_MULTIPLIER = 2;
8
+
9
+ export type RevivableRedisClient = {
10
+ onclose: ((error: Error) => void) | null;
11
+ connect(): Promise<void>;
12
+ };
13
+
14
+ export type KeepRedisConnectedOptions = {
15
+ setTimeout?: typeof setTimeout;
16
+ };
17
+
18
+ /**
19
+ * Bun's client reconnects on its own only until `maxRetries` runs out; then it
20
+ * fires `onclose` and fails every later command with "Connection has failed",
21
+ * even once the server is back. Only an explicit `connect()` revives it, so
22
+ * that is what `onclose` schedules here, backing off while the server stays down.
23
+ */
24
+ export const keepRedisConnected = (
25
+ client: RevivableRedisClient,
26
+ options: KeepRedisConnectedOptions = {},
27
+ ) => {
28
+ const log = logger("redis");
29
+ const setTimeoutFn = options.setTimeout ?? setTimeout;
30
+
31
+ let attempts = 0;
32
+ let reconnecting = false;
33
+
34
+ const scheduleReconnect = () => {
35
+ if (reconnecting) return;
36
+ reconnecting = true;
37
+
38
+ const delay = Math.min(
39
+ RECONNECT_INITIAL_DELAY * Math.pow(RECONNECT_MULTIPLIER, attempts),
40
+ RECONNECT_MAX_DELAY,
41
+ );
42
+ attempts++;
43
+
44
+ setTimeoutFn(() => {
45
+ client.connect().then(
46
+ () => {
47
+ reconnecting = false;
48
+ attempts = 0;
49
+ log.info("reconnected");
50
+ },
51
+ (error: unknown) => {
52
+ reconnecting = false;
53
+ log.warn({ err: error, attempt: attempts }, "reconnect attempt failed");
54
+ scheduleReconnect();
55
+ },
56
+ );
57
+ }, delay).unref();
58
+ };
59
+
60
+ client.onclose = (error) => {
61
+ if (reconnecting) return;
62
+ log.warn({ err: error }, "connection lost, reconnecting");
63
+ scheduleReconnect();
64
+ };
65
+ };
66
+
67
+ export const createRedisClient = (url: string) => {
68
+ const client = new ValkeyClient(url);
69
+ keepRedisConnected(client);
70
+ return client;
71
+ };
@@ -29,6 +29,12 @@ export class S401 extends ClientResponse {
29
29
  }
30
30
  }
31
31
 
32
+ export class S403 extends ClientResponse {
33
+ constructor(body?: object | null, init?: ResponseInit) {
34
+ super(body, { ...init, status: 403 });
35
+ }
36
+ }
37
+
32
38
  export class S404 extends ClientResponse {
33
39
  constructor(body?: object | null, init?: ResponseInit) {
34
40
  super(body, { ...init, status: 404 });
@@ -50,3 +56,9 @@ export class S500 extends ClientResponse {
50
56
  super(body, { ...init, status: 500 });
51
57
  }
52
58
  }
59
+
60
+ export class S503 extends ClientResponse {
61
+ constructor(body?: object | null, init?: ResponseInit) {
62
+ super(body, { ...init, status: 503 });
63
+ }
64
+ }
@@ -13,6 +13,8 @@ export type SessionContext = {
13
13
  iat?: number;
14
14
  exp?: number;
15
15
  nbf?: number;
16
+ /** Set when the admin secret, not a token, produced this session. */
17
+ authMethod?: "admin_secret";
16
18
  [key: string]: unknown;
17
19
  };
18
20