@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
@@ -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";
@@ -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
 
@@ -1,3 +1,4 @@
1
+ import type { ConsoleScope } from "./session";
1
2
  import type { TokenService } from "../authentication/types";
2
3
  import type { AnalyzedConfiguration } from "../configuration";
3
4
  import type { Configuration } from "../types/configuration";
@@ -8,11 +9,12 @@ import type { ConnectionPool } from "mssql";
8
9
 
9
10
  import { createConsoleSessions } from "./session";
10
11
  import { getTags } from "../configuration/rest/generateOpenAPI";
12
+ import { audit } from "../logging/audit";
11
13
  import { getCronJobs } from "../singletons/cron";
12
- import { databasesConnections } from "../singletons/databases";
14
+ import { databasesConnections, pingConnection } from "../singletons/databases";
13
15
  import { queueManager } from "../singletons/queues";
14
16
  import { resolveClientAddress } from "../utils/rateLimit";
15
- import { S200, S400, S401, S404, S429 } from "../utils/responses";
17
+ import { S200, S400, S401, S403, S404, S429 } from "../utils/responses";
16
18
 
17
19
  type ConsoleRoutesFactoryOptions = {
18
20
  env: Env;
@@ -33,11 +35,6 @@ type ConsoleRouteHandler = (
33
35
 
34
36
  const PING_TIMEOUT_MS = 2000;
35
37
 
36
- const pingConnection = (connection: SQL | ConnectionPool, type: string) =>
37
- type === "mssql"
38
- ? (connection as ConnectionPool).query("SELECT 1")
39
- : (connection as SQL).unsafe("SELECT 1");
40
-
41
38
  const measureLatency = async (connection: SQL | ConnectionPool, type: string) => {
42
39
  const start = performance.now();
43
40
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -67,12 +64,25 @@ export const consoleRoutesFactory = ({
67
64
  }: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
68
65
  const sessions = createConsoleSessions({ env, consolePath, tokenService });
69
66
 
67
+ const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
68
+ resolveClientAddress(req, server, env.rateLimit.trustProxy);
69
+
70
70
  const guarded =
71
- (handler: (req: BunRequest) => object | Promise<object>): ConsoleRouteHandler =>
72
- async (req: BunRequest) => {
71
+ (
72
+ handler: (
73
+ req: BunRequest,
74
+ server: Bun.Server<unknown> | undefined,
75
+ scope: ConsoleScope,
76
+ ) => object | Promise<object>,
77
+ required: ConsoleScope = "read",
78
+ ): ConsoleRouteHandler =>
79
+ async (req: BunRequest, server?: Bun.Server<unknown>) => {
73
80
  try {
74
- if (!(await sessions.authorize(req))) return new S404({ error: "Not Found" });
75
- return new S200(await handler(req));
81
+ const scope = await sessions.authorize(req);
82
+ if (!scope) return new S404({ error: "Not Found" });
83
+ if (required === "write" && scope !== "write")
84
+ return new S403({ errors: [{ message: "Console session is read-only" }] });
85
+ return new S200(await handler(req, server, scope));
76
86
  } catch (error) {
77
87
  return new S400({ errors: [{ message: (error as Error)?.message }] });
78
88
  }
@@ -99,15 +109,28 @@ export const consoleRoutesFactory = ({
99
109
  // Keyed by address against the anonymous ceiling: there is no session
100
110
  // yet, and the attempt is charged before the secret is compared, so a
101
111
  // guesser cannot spend attempts for free.
102
- const limit = await rateLimiter?.check(
103
- null,
104
- resolveClientAddress(req, server, env.rateLimit.trustProxy),
105
- );
112
+ const ip = clientAddress(req, server);
113
+ const limit = await rateLimiter?.check(null, ip);
106
114
  if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
107
115
 
116
+ const actor = { type: "admin_secret", ip } as const;
108
117
  try {
109
- return new S200(await sessions.login(req));
110
- } catch {
118
+ const { expiresIn, scope, superset } = await sessions.login(req);
119
+ audit().emit({
120
+ action: "console.login",
121
+ outcome: "success",
122
+ actor: { ...actor, scope: superset ? "all" : `console:${scope}` },
123
+ target: { kind: "console" },
124
+ });
125
+ return new S200({ expiresIn, scope });
126
+ } catch (error) {
127
+ audit().emit({
128
+ action: "console.login",
129
+ outcome: "failure",
130
+ actor,
131
+ target: { kind: "console" },
132
+ reason: (error as Error)?.message,
133
+ });
111
134
  // One message for a bad secret and for a malformed body alike: the
112
135
  // caller learns nothing about which it was.
113
136
  return new S401({ errors: [{ message: "Invalid admin secret" }] });
@@ -117,8 +140,14 @@ export const consoleRoutesFactory = ({
117
140
 
118
141
  [`${base}/logout`]: {
119
142
  ...cors,
120
- POST: async (req) => {
121
- await sessions.logout(req);
143
+ POST: async (req, server) => {
144
+ if (await sessions.logout(req)) {
145
+ audit().emit({
146
+ action: "console.logout",
147
+ actor: { type: "console", ip: clientAddress(req, server) },
148
+ target: { kind: "console" },
149
+ });
150
+ }
122
151
  return new S200({ ok: true });
123
152
  },
124
153
  },
@@ -264,7 +293,7 @@ export const consoleRoutesFactory = ({
264
293
 
265
294
  [`${base}/config`]: {
266
295
  ...cors,
267
- GET: guarded(() => ({
296
+ GET: guarded((_req, _server, scope) => ({
268
297
  name: projectConfiguration.name,
269
298
  version: projectConfiguration.version,
270
299
  prefixes,
@@ -274,12 +303,13 @@ export const consoleRoutesFactory = ({
274
303
  mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
275
304
  cors: env.enableCors,
276
305
  },
306
+ session: { scope },
277
307
  })),
278
308
  },
279
309
 
280
310
  [`${base}/queues/publish`]: {
281
311
  ...cors,
282
- POST: guarded(async (req) => {
312
+ POST: guarded(async (req, server) => {
283
313
  const { publisher, message, key } = (await req.json()) as {
284
314
  publisher?: string;
285
315
  message?: string | object;
@@ -288,13 +318,18 @@ export const consoleRoutesFactory = ({
288
318
  if (!publisher || message == null) throw new Error("publisher and message are required");
289
319
  if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
290
320
  throw new Error(`Unknown publisher "${publisher}"`);
321
+ audit().emit({
322
+ action: "console.queue.publish",
323
+ actor: { type: "console", ip: clientAddress(req, server) },
324
+ target: { kind: "publisher", name: publisher, key },
325
+ });
291
326
  return { ok: await queueManager!.sendMessage(publisher, message, key) };
292
- }),
327
+ }, "write"),
293
328
  },
294
329
 
295
330
  [`${base}/cron`]: {
296
331
  ...cors,
297
- POST: guarded(async (req) => {
332
+ POST: guarded(async (req, server) => {
298
333
  const { name, action } = (await req.json()) as { name?: string; action?: string };
299
334
  const cron = getCronJobs();
300
335
  if (!cron) throw new Error("Cron is not enabled");
@@ -302,10 +337,15 @@ export const consoleRoutesFactory = ({
302
337
  if (action !== "trigger" && action !== "pause" && action !== "resume")
303
338
  throw new Error(`Unknown action "${action}"`);
304
339
  if (!cron.getJob(name)) throw new Error(`Unknown job "${name}"`);
340
+ audit().emit({
341
+ action: `console.cron.${action}`,
342
+ actor: { type: "console", ip: clientAddress(req, server) },
343
+ target: { kind: "cron", name },
344
+ });
305
345
  if (action === "trigger") await cron.trigger(name);
306
346
  else cron[action](name);
307
347
  return { ok: true };
308
- }),
348
+ }, "write"),
309
349
  },
310
350
  };
311
351
  };
@@ -1,9 +1,8 @@
1
- import { timingSafeEqual } from "crypto";
2
-
3
1
  import type { BunRequest } from "bun";
4
2
  import type { TokenService } from "../authentication/types";
5
3
  import type { Env } from "../types/env";
6
4
 
5
+ import { createCapabilityAuthorizer } from "../authentication/capabilities";
7
6
  import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
8
7
  import { logger } from "../logging";
9
8
 
@@ -14,23 +13,21 @@ export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
14
13
  const CONSOLE_AUDIENCE = "console";
15
14
  const CONSOLE_SUBJECT = "console";
16
15
 
17
- const safeCompare = (a: string, b: string): boolean => {
18
- try {
19
- const bufA = Buffer.from(a);
20
- const bufB = Buffer.from(b);
21
- if (bufA.length !== bufB.length) return false;
22
- return timingSafeEqual(bufA, bufB);
23
- } catch {
24
- return false;
25
- }
26
- };
16
+ /** `read` sees state; `write` may also publish to queues and control cron. */
17
+ export type ConsoleScope = "read" | "write";
18
+
19
+ const isScope = (value: unknown): value is ConsoleScope => value === "read" || value === "write";
27
20
 
28
21
  export type ConsoleSessions = {
29
- /** Exchange the admin secret for a session cookie. Throws if it does not match. */
30
- login(req: BunRequest): Promise<{ expiresIn: number }>;
31
- /** True when the request carries a live, unrevoked console session cookie. */
32
- authorize(req: BunRequest): Promise<boolean>;
33
- logout(req: BunRequest): Promise<void>;
22
+ /**
23
+ * Exchange a secret for a session cookie. Throws if none matches. `superset`
24
+ * is true when it was the admin secret rather than a console credential.
25
+ */
26
+ login(req: BunRequest): Promise<{ expiresIn: number; scope: ConsoleScope; superset: boolean }>;
27
+ /** The scope of a live, unrevoked console session cookie; null when there is none. */
28
+ authorize(req: BunRequest): Promise<ConsoleScope | null>;
29
+ /** Resolves true when a live session was revoked; the cookie is cleared either way. */
30
+ logout(req: BunRequest): Promise<boolean>;
34
31
  };
35
32
 
36
33
  type ConsoleSessionsOptions = {
@@ -45,6 +42,7 @@ export const createConsoleSessions = ({
45
42
  tokenService,
46
43
  }: ConsoleSessionsOptions): ConsoleSessions => {
47
44
  const log = logger("console");
45
+ const authorizeCapability = createCapabilityAuthorizer(env);
48
46
  const expiresIn = env.console.sessionExpiresIn;
49
47
  const sessionMs = parseDurationToMs(expiresIn);
50
48
 
@@ -73,7 +71,9 @@ export const createConsoleSessions = ({
73
71
  try {
74
72
  const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
75
73
  if (payload.role !== env.superadmin.role) return null;
76
- return payload;
74
+ const scope = payload.claims?.scope;
75
+ if (!isScope(scope)) return null;
76
+ return { ...payload, scope };
77
77
  } catch {
78
78
  return null;
79
79
  }
@@ -83,12 +83,14 @@ export const createConsoleSessions = ({
83
83
  login: async (req) => {
84
84
  const { secret: submitted } = (await req.json()) as { secret?: string };
85
85
 
86
- if (!env.admin.secret || !submitted || !safeCompare(submitted, env.admin.secret)) {
87
- throw new Error("Invalid admin secret");
88
- }
86
+ const candidate = submitted ?? null;
87
+ const write = authorizeCapability(candidate, "console:write");
88
+ const grant = write ?? authorizeCapability(candidate, "console:read");
89
+ if (!grant) throw new Error("Invalid admin secret");
90
+ const scope: ConsoleScope = write ? "write" : "read";
89
91
 
90
92
  const token = await tokenService.createToken(
91
- { sub: CONSOLE_SUBJECT, role: env.superadmin.role },
93
+ { sub: CONSOLE_SUBJECT, role: env.superadmin.role, claims: { scope } },
92
94
  { audience: CONSOLE_AUDIENCE, expiresIn },
93
95
  );
94
96
 
@@ -97,17 +99,17 @@ export const createConsoleSessions = ({
97
99
  maxAge: parseDurationToSeconds(expiresIn),
98
100
  });
99
101
 
100
- log.info("console session issued");
102
+ log.info({ scope }, "console session issued");
101
103
 
102
- return { expiresIn: parseDurationToSeconds(expiresIn) };
104
+ return { expiresIn: parseDurationToSeconds(expiresIn), scope, superset: grant.superset };
103
105
  },
104
106
 
105
107
  authorize: async (req) => {
106
108
  const payload = await readSession(req);
107
- if (!payload) return false;
109
+ if (!payload) return null;
108
110
 
109
111
  prune();
110
- return !revoked.has(payload.jti);
112
+ return revoked.has(payload.jti) ? null : payload.scope;
111
113
  },
112
114
 
113
115
  logout: async (req) => {
@@ -120,6 +122,8 @@ export const createConsoleSessions = ({
120
122
  }
121
123
 
122
124
  req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
125
+
126
+ return payload !== null;
123
127
  },
124
128
  };
125
129
  };
package/src/cron/index.ts CHANGED
@@ -7,6 +7,8 @@ import type { TickContext } from "../types/zod/cron";
7
7
  import { databasesConnections, repositoryMap } from "../singletons/databases";
8
8
  import { queueManager } from "../singletons/queues";
9
9
  import { logger } from "../logging";
10
+ import { incMetric, observeMetric } from "../observability/metrics";
11
+ import { startSpan, withActiveSpan } from "../observability/tracing";
10
12
 
11
13
  /**
12
14
  * Interface for a scheduled cron job instance
@@ -58,42 +60,63 @@ const createScheduledJob = (config: CronJob, gqlQuery: GqlQueryFn<true>): Schedu
58
60
  context: config.context,
59
61
  },
60
62
  async (self) => {
61
- try {
62
- log.info("executing");
63
-
64
- executionCount++;
65
-
66
- // Execute query if provided
67
- const response = config.query
68
- ? await gqlQuery(config.query, config.variables ?? {})
69
- : undefined;
70
-
71
- const tickContext: TickContext = {
72
- name: config.name,
73
- pattern: config.pattern,
74
- variables: config.variables || {},
75
- executionCount,
76
- nextRun: self.nextRun(),
77
- previousRun: self.previousRun(),
78
- };
79
-
80
- // Call the onTick callback if provided, passing options object
81
- await config.onTick?.(
82
- {
83
- gqlQuery,
84
- databases: databasesConnections,
85
- queues: queueManager,
86
- repository: repositoryMap,
87
- },
88
- tickContext,
89
- response,
90
- );
91
-
92
- log.info({ executionCount, nextRun: self.nextRun() }, "completed");
93
- } catch (error) {
94
- log.error({ err: error }, "failed");
95
- throw error;
96
- }
63
+ const startTime = Bun.nanoseconds();
64
+ let outcome: "success" | "error" = "success";
65
+ // A tick is not inside a request, so it roots its own trace. The span is
66
+ // entered so anything `onTick` runs through `gqlQuery` lands under it.
67
+ const span = startSpan("cron.tick", {
68
+ parent: null,
69
+ attributes: { "graphoria.cron.job": config.name },
70
+ });
71
+
72
+ return withActiveSpan(span, async () => {
73
+ try {
74
+ log.info("executing");
75
+
76
+ executionCount++;
77
+
78
+ // Execute query if provided
79
+ const response = config.query
80
+ ? await gqlQuery(config.query, config.variables ?? {})
81
+ : undefined;
82
+
83
+ const tickContext: TickContext = {
84
+ name: config.name,
85
+ pattern: config.pattern,
86
+ variables: config.variables || {},
87
+ executionCount,
88
+ nextRun: self.nextRun(),
89
+ previousRun: self.previousRun(),
90
+ };
91
+
92
+ // Call the onTick callback if provided, passing options object
93
+ await config.onTick?.(
94
+ {
95
+ gqlQuery,
96
+ databases: databasesConnections,
97
+ queues: queueManager,
98
+ repository: repositoryMap,
99
+ },
100
+ tickContext,
101
+ response,
102
+ );
103
+
104
+ log.info({ executionCount, nextRun: self.nextRun() }, "completed");
105
+ } catch (error) {
106
+ outcome = "error";
107
+ log.error({ err: error }, "failed");
108
+ span?.recordError(error);
109
+ throw error;
110
+ } finally {
111
+ incMetric("graphoria_cron_runs_total", { job: config.name, outcome });
112
+ observeMetric(
113
+ "graphoria_cron_run_duration_seconds",
114
+ { job: config.name },
115
+ (Bun.nanoseconds() - startTime) / 1e9,
116
+ );
117
+ span?.end();
118
+ }
119
+ });
97
120
  },
98
121
  );
99
122