@graphoria/server 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +29 -20
  2. package/dist/src/ai/mcp/index.d.ts +9 -2
  3. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.d.ts +6 -1
  5. package/dist/src/ai/tools/core.d.ts.map +1 -1
  6. package/dist/src/ai/tools/core.test.d.ts +2 -0
  7. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  9. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  10. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  11. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  12. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  13. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  14. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  15. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  16. package/dist/src/authentication/capabilities.d.ts +18 -0
  17. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  18. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  19. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  20. package/dist/src/authentication/jwt.d.ts.map +1 -1
  21. package/dist/src/authentication/paseto.d.ts.map +1 -1
  22. package/dist/src/authentication/secrets.d.ts +9 -0
  23. package/dist/src/authentication/secrets.d.ts.map +1 -0
  24. package/dist/src/authentication/secrets.test.d.ts +2 -0
  25. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  26. package/dist/src/config/types/auth.d.ts +8 -0
  27. package/dist/src/config/types/auth.d.ts.map +1 -1
  28. package/dist/src/config/types/db.d.ts +2 -0
  29. package/dist/src/config/types/db.d.ts.map +1 -1
  30. package/dist/src/config/types/operation.d.ts +4 -0
  31. package/dist/src/config/types/operation.d.ts.map +1 -1
  32. package/dist/src/config/types/operation.test.d.ts +2 -0
  33. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  34. package/dist/src/configuration/crossReferences.d.ts +27 -0
  35. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  36. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  37. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  38. package/dist/src/configuration/getSchemas/index.d.ts +70 -6
  39. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  40. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -1
  41. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  42. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  43. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  44. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  45. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  46. package/dist/src/configuration/index.d.ts +41 -3
  47. package/dist/src/configuration/index.d.ts.map +1 -1
  48. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  49. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  50. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  51. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  52. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  53. package/dist/src/console/api.d.ts +7 -5
  54. package/dist/src/console/api.d.ts.map +1 -1
  55. package/dist/src/console/session.d.ts +29 -0
  56. package/dist/src/console/session.d.ts.map +1 -0
  57. package/dist/src/console/session.test.d.ts +2 -0
  58. package/dist/src/console/session.test.d.ts.map +1 -0
  59. package/dist/src/databases/common.d.ts +12 -4
  60. package/dist/src/databases/common.d.ts.map +1 -1
  61. package/dist/src/databases/core/executor.d.ts +7 -3
  62. package/dist/src/databases/core/executor.d.ts.map +1 -1
  63. package/dist/src/databases/core/function-mapping.d.ts +5 -4
  64. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  65. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  66. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  67. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  68. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  69. package/dist/src/databases/core/query-builder.d.ts +7 -1
  70. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  71. package/dist/src/databases/engines/mssql/connection.d.ts +30 -7
  72. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  73. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  74. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  75. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  76. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  77. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  78. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  79. package/dist/src/databases/engines/mysql/connection.d.ts +17 -1
  80. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  81. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  82. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  83. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  84. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  85. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  86. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  87. package/dist/src/databases/engines/postgresql/connection.d.ts +26 -7
  88. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  89. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  90. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  91. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  92. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  93. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  94. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  95. package/dist/src/databases/high-level-operations.d.ts +2 -0
  96. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  97. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  98. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  99. package/dist/src/index.d.ts.map +1 -1
  100. package/dist/src/logging/audit.d.ts +27 -0
  101. package/dist/src/logging/audit.d.ts.map +1 -0
  102. package/dist/src/logging/audit.test.d.ts +2 -0
  103. package/dist/src/logging/audit.test.d.ts.map +1 -0
  104. package/dist/src/singletons/env.d.ts +27 -4
  105. package/dist/src/singletons/env.d.ts.map +1 -1
  106. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  107. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  108. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  109. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  110. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  111. package/dist/src/subscriptions/types.d.ts +4 -1
  112. package/dist/src/subscriptions/types.d.ts.map +1 -1
  113. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  114. package/dist/src/types/env.d.ts +97 -12
  115. package/dist/src/types/env.d.ts.map +1 -1
  116. package/dist/src/types/zod/auth.d.ts +12 -0
  117. package/dist/src/types/zod/auth.d.ts.map +1 -1
  118. package/dist/src/types/zod/configuration.d.ts +28 -0
  119. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  120. package/dist/src/utils/rateLimit.d.ts +65 -0
  121. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  122. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  123. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  124. package/dist/src/utils/responses.d.ts +6 -0
  125. package/dist/src/utils/responses.d.ts.map +1 -1
  126. package/dist/src/utils/sessionVariables.d.ts +2 -0
  127. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  128. package/dist/tsconfig.tsbuildinfo +1 -1
  129. package/package.json +5 -5
  130. package/playgrounds/console/index.html +7 -7
  131. package/playgrounds/graphiql/index.html +548 -685
  132. package/src/ai/mcp/index.ts +19 -14
  133. package/src/ai/tools/core.ts +30 -6
  134. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  135. package/src/analyzeQuery/costLimit.ts +236 -0
  136. package/src/analyzeQuery/depthLimit.ts +4 -23
  137. package/src/analyzeQuery/fragments.ts +18 -0
  138. package/src/authentication/capabilities.ts +54 -0
  139. package/src/authentication/index.ts +3 -3
  140. package/src/authentication/jwt.ts +30 -25
  141. package/src/authentication/paseto.ts +36 -26
  142. package/src/authentication/secrets.ts +30 -0
  143. package/src/config/types/auth.ts +7 -0
  144. package/src/config/types/db.ts +7 -0
  145. package/src/config/types/operation.ts +4 -0
  146. package/src/configuration/crossReferences.ts +340 -0
  147. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  148. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  149. package/src/configuration/gql/buildExecute.ts +1 -1
  150. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  151. package/src/configuration/gql/handleGraphQLRequestFactory.ts +97 -15
  152. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +31 -2
  153. package/src/configuration/index.ts +24 -6
  154. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  155. package/src/configuration/rest/handleRESTRequestFactory.ts +32 -4
  156. package/src/configuration/rest/index.ts +11 -2
  157. package/src/console/api.ts +102 -16
  158. package/src/console/session.ts +129 -0
  159. package/src/databases/common.ts +55 -10
  160. package/src/databases/core/executor.ts +13 -4
  161. package/src/databases/core/function-mapping.ts +5 -1
  162. package/src/databases/core/procedure-arguments.ts +25 -0
  163. package/src/databases/core/query-builder.ts +7 -0
  164. package/src/databases/engines/mssql/connection.ts +66 -17
  165. package/src/databases/engines/mssql/format.ts +3 -1
  166. package/src/databases/engines/mssql/query/index.ts +12 -2
  167. package/src/databases/engines/mysql/connection.ts +40 -20
  168. package/src/databases/engines/mysql/format.ts +3 -1
  169. package/src/databases/engines/mysql/query/index.ts +25 -4
  170. package/src/databases/engines/postgresql/connection.ts +86 -22
  171. package/src/databases/engines/postgresql/format.ts +3 -1
  172. package/src/databases/engines/postgresql/query/index.ts +12 -3
  173. package/src/databases/high-level-operations.ts +13 -2
  174. package/src/index.ts +127 -19
  175. package/src/logging/audit.ts +86 -0
  176. package/src/singletons/queryTimeout.ts +20 -0
  177. package/src/subscriptions/strategies/database.ts +19 -3
  178. package/src/subscriptions/types.ts +4 -1
  179. package/src/subscriptions/utils/polling.ts +6 -3
  180. package/src/types/env.ts +47 -9
  181. package/src/utils/rateLimit.ts +209 -0
  182. package/src/utils/responses.ts +16 -0
  183. package/src/utils/sessionVariables.ts +2 -0
@@ -1,14 +1,20 @@
1
+ import type { ConsoleScope } from "./session";
2
+ import type { TokenService } from "../authentication/types";
1
3
  import type { AnalyzedConfiguration } from "../configuration";
2
4
  import type { Configuration } from "../types/configuration";
3
5
  import type { Env } from "../types/env";
4
- import type { SQL } from "bun";
6
+ import type { RateLimiter } from "../utils/rateLimit";
7
+ import type { BunRequest, SQL } from "bun";
5
8
  import type { ConnectionPool } from "mssql";
6
9
 
10
+ import { createConsoleSessions } from "./session";
7
11
  import { getTags } from "../configuration/rest/generateOpenAPI";
12
+ import { audit } from "../logging/audit";
8
13
  import { getCronJobs } from "../singletons/cron";
9
14
  import { databasesConnections } from "../singletons/databases";
10
15
  import { queueManager } from "../singletons/queues";
11
- import { S200, S400, S404 } from "../utils/responses";
16
+ import { resolveClientAddress } from "../utils/rateLimit";
17
+ import { S200, S400, S401, S403, S404, S429 } from "../utils/responses";
12
18
 
13
19
  type ConsoleRoutesFactoryOptions = {
14
20
  env: Env;
@@ -16,10 +22,16 @@ type ConsoleRoutesFactoryOptions = {
16
22
  prefixes: Record<string, string>;
17
23
  projectConfiguration: Configuration;
18
24
  analyzedConfiguration: AnalyzedConfiguration;
19
- getRoleHandlers: (req: Request) => Promise<{ role: string }>;
25
+ tokenService: TokenService;
26
+ rateLimiter?: RateLimiter | undefined;
20
27
  };
21
28
 
22
- type ConsoleRouteHandler = (req: Request) => Response | Promise<Response>;
29
+ // Bun hands a route handler the server as its second argument; the parameter is
30
+ // optional only because the type is also used for handlers that ignore it.
31
+ type ConsoleRouteHandler = (
32
+ req: BunRequest,
33
+ server?: Bun.Server<unknown>,
34
+ ) => Response | Promise<Response>;
23
35
 
24
36
  const PING_TIMEOUT_MS = 2000;
25
37
 
@@ -52,15 +64,30 @@ export const consoleRoutesFactory = ({
52
64
  prefixes,
53
65
  projectConfiguration,
54
66
  analyzedConfiguration,
55
- getRoleHandlers,
67
+ tokenService,
68
+ rateLimiter,
56
69
  }: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
70
+ const sessions = createConsoleSessions({ env, consolePath, tokenService });
71
+
72
+ const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
73
+ resolveClientAddress(req, server, env.rateLimit.trustProxy);
74
+
57
75
  const guarded =
58
- (handler: (req: Request) => object | Promise<object>): ConsoleRouteHandler =>
59
- async (req: Request) => {
76
+ (
77
+ handler: (
78
+ req: BunRequest,
79
+ server: Bun.Server<unknown> | undefined,
80
+ scope: ConsoleScope,
81
+ ) => object | Promise<object>,
82
+ required: ConsoleScope = "read",
83
+ ): ConsoleRouteHandler =>
84
+ async (req: BunRequest, server?: Bun.Server<unknown>) => {
60
85
  try {
61
- const { role } = await getRoleHandlers(req);
62
- if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
63
- return new S200(await handler(req));
86
+ const scope = await sessions.authorize(req);
87
+ if (!scope) return new S404({ error: "Not Found" });
88
+ if (required === "write" && scope !== "write")
89
+ return new S403({ errors: [{ message: "Console session is read-only" }] });
90
+ return new S200(await handler(req, server, scope));
64
91
  } catch (error) {
65
92
  return new S400({ errors: [{ message: (error as Error)?.message }] });
66
93
  }
@@ -78,10 +105,58 @@ export const consoleRoutesFactory = ({
78
105
  new S200({
79
106
  name: projectConfiguration.name,
80
107
  version: projectConfiguration.version,
81
- adminSecretHeader: env.admin.header,
82
108
  }),
83
109
  },
84
110
 
111
+ [`${base}/login`]: {
112
+ ...cors,
113
+ POST: async (req, server) => {
114
+ // Keyed by address against the anonymous ceiling: there is no session
115
+ // yet, and the attempt is charged before the secret is compared, so a
116
+ // guesser cannot spend attempts for free.
117
+ const ip = clientAddress(req, server);
118
+ const limit = await rateLimiter?.check(null, ip);
119
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
120
+
121
+ const actor = { type: "admin_secret", ip } as const;
122
+ try {
123
+ const { expiresIn, scope, superset } = await sessions.login(req);
124
+ audit().emit({
125
+ action: "console.login",
126
+ outcome: "success",
127
+ actor: { ...actor, scope: superset ? "all" : `console:${scope}` },
128
+ target: { kind: "console" },
129
+ });
130
+ return new S200({ expiresIn, scope });
131
+ } catch (error) {
132
+ audit().emit({
133
+ action: "console.login",
134
+ outcome: "failure",
135
+ actor,
136
+ target: { kind: "console" },
137
+ reason: (error as Error)?.message,
138
+ });
139
+ // One message for a bad secret and for a malformed body alike: the
140
+ // caller learns nothing about which it was.
141
+ return new S401({ errors: [{ message: "Invalid admin secret" }] });
142
+ }
143
+ },
144
+ },
145
+
146
+ [`${base}/logout`]: {
147
+ ...cors,
148
+ POST: async (req, server) => {
149
+ if (await sessions.logout(req)) {
150
+ audit().emit({
151
+ action: "console.logout",
152
+ actor: { type: "console", ip: clientAddress(req, server) },
153
+ target: { kind: "console" },
154
+ });
155
+ }
156
+ return new S200({ ok: true });
157
+ },
158
+ },
159
+
85
160
  [`${base}/tables`]: {
86
161
  ...cors,
87
162
  GET: guarded(() => ({
@@ -223,7 +298,7 @@ export const consoleRoutesFactory = ({
223
298
 
224
299
  [`${base}/config`]: {
225
300
  ...cors,
226
- GET: guarded(() => ({
301
+ GET: guarded((_req, _server, scope) => ({
227
302
  name: projectConfiguration.name,
228
303
  version: projectConfiguration.version,
229
304
  prefixes,
@@ -233,12 +308,13 @@ export const consoleRoutesFactory = ({
233
308
  mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
234
309
  cors: env.enableCors,
235
310
  },
311
+ session: { scope },
236
312
  })),
237
313
  },
238
314
 
239
315
  [`${base}/queues/publish`]: {
240
316
  ...cors,
241
- POST: guarded(async (req) => {
317
+ POST: guarded(async (req, server) => {
242
318
  const { publisher, message, key } = (await req.json()) as {
243
319
  publisher?: string;
244
320
  message?: string | object;
@@ -247,13 +323,18 @@ export const consoleRoutesFactory = ({
247
323
  if (!publisher || message == null) throw new Error("publisher and message are required");
248
324
  if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
249
325
  throw new Error(`Unknown publisher "${publisher}"`);
326
+ audit().emit({
327
+ action: "console.queue.publish",
328
+ actor: { type: "console", ip: clientAddress(req, server) },
329
+ target: { kind: "publisher", name: publisher, key },
330
+ });
250
331
  return { ok: await queueManager!.sendMessage(publisher, message, key) };
251
- }),
332
+ }, "write"),
252
333
  },
253
334
 
254
335
  [`${base}/cron`]: {
255
336
  ...cors,
256
- POST: guarded(async (req) => {
337
+ POST: guarded(async (req, server) => {
257
338
  const { name, action } = (await req.json()) as { name?: string; action?: string };
258
339
  const cron = getCronJobs();
259
340
  if (!cron) throw new Error("Cron is not enabled");
@@ -261,10 +342,15 @@ export const consoleRoutesFactory = ({
261
342
  if (action !== "trigger" && action !== "pause" && action !== "resume")
262
343
  throw new Error(`Unknown action "${action}"`);
263
344
  if (!cron.getJob(name)) throw new Error(`Unknown job "${name}"`);
345
+ audit().emit({
346
+ action: `console.cron.${action}`,
347
+ actor: { type: "console", ip: clientAddress(req, server) },
348
+ target: { kind: "cron", name },
349
+ });
264
350
  if (action === "trigger") await cron.trigger(name);
265
351
  else cron[action](name);
266
352
  return { ok: true };
267
- }),
353
+ }, "write"),
268
354
  },
269
355
  };
270
356
  };
@@ -0,0 +1,129 @@
1
+ import type { BunRequest } from "bun";
2
+ import type { TokenService } from "../authentication/types";
3
+ import type { Env } from "../types/env";
4
+
5
+ import { createCapabilityAuthorizer } from "../authentication/capabilities";
6
+ import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
7
+ import { logger } from "../logging";
8
+
9
+ export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
10
+
11
+ // Separates a console session from an access or refresh token signed with the
12
+ // same key: neither can be presented where the other is expected.
13
+ const CONSOLE_AUDIENCE = "console";
14
+ const CONSOLE_SUBJECT = "console";
15
+
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";
20
+
21
+ export type ConsoleSessions = {
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>;
31
+ };
32
+
33
+ type ConsoleSessionsOptions = {
34
+ env: Env;
35
+ consolePath: string;
36
+ tokenService: TokenService;
37
+ };
38
+
39
+ export const createConsoleSessions = ({
40
+ env,
41
+ consolePath,
42
+ tokenService,
43
+ }: ConsoleSessionsOptions): ConsoleSessions => {
44
+ const log = logger("console");
45
+ const authorizeCapability = createCapabilityAuthorizer(env);
46
+ const expiresIn = env.console.sessionExpiresIn;
47
+ const sessionMs = parseDurationToMs(expiresIn);
48
+
49
+ // Revoked JTIs, held in this process only: the console mints its own tokens
50
+ // and never writes to the Redis JTI store, so a logout is honoured by the
51
+ // worker that served it and by no other. An entry is dropped once the token
52
+ // it revokes would have expired on its own.
53
+ const revoked = new Map<string, number>();
54
+
55
+ const prune = () => {
56
+ const now = Date.now();
57
+ for (const [jti, expiresAt] of revoked) if (expiresAt <= now) revoked.delete(jti);
58
+ };
59
+
60
+ const cookieOptions = {
61
+ httpOnly: true,
62
+ secure: true,
63
+ sameSite: "strict",
64
+ path: consolePath,
65
+ } as const;
66
+
67
+ const readSession = async (req: BunRequest) => {
68
+ const cookie = req.cookies?.get(CONSOLE_SESSION_COOKIE);
69
+ if (!cookie) return null;
70
+
71
+ try {
72
+ const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
73
+ if (payload.role !== env.superadmin.role) return null;
74
+ const scope = payload.claims?.scope;
75
+ if (!isScope(scope)) return null;
76
+ return { ...payload, scope };
77
+ } catch {
78
+ return null;
79
+ }
80
+ };
81
+
82
+ return {
83
+ login: async (req) => {
84
+ const { secret: submitted } = (await req.json()) as { secret?: string };
85
+
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";
91
+
92
+ const token = await tokenService.createToken(
93
+ { sub: CONSOLE_SUBJECT, role: env.superadmin.role, claims: { scope } },
94
+ { audience: CONSOLE_AUDIENCE, expiresIn },
95
+ );
96
+
97
+ req.cookies.set(CONSOLE_SESSION_COOKIE, token, {
98
+ ...cookieOptions,
99
+ maxAge: parseDurationToSeconds(expiresIn),
100
+ });
101
+
102
+ log.info({ scope }, "console session issued");
103
+
104
+ return { expiresIn: parseDurationToSeconds(expiresIn), scope, superset: grant.superset };
105
+ },
106
+
107
+ authorize: async (req) => {
108
+ const payload = await readSession(req);
109
+ if (!payload) return null;
110
+
111
+ prune();
112
+ return revoked.has(payload.jti) ? null : payload.scope;
113
+ },
114
+
115
+ logout: async (req) => {
116
+ const payload = await readSession(req);
117
+ if (payload) {
118
+ // The token stays valid until it expires, so the revocation record has
119
+ // to outlive it: one full session lifetime is the upper bound.
120
+ revoked.set(payload.jti, Date.now() + sessionMs);
121
+ log.info("console session revoked");
122
+ }
123
+
124
+ req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
125
+
126
+ return payload !== null;
127
+ },
128
+ };
129
+ };
@@ -826,37 +826,82 @@ const toPositionalPlaceholder = (
826
826
  return null;
827
827
  };
828
828
 
829
+ /**
830
+ * Page bounds applied to a list field. `null` at a call site means the query is
831
+ * operator-authored and exempt; `0` on either field opts that half out.
832
+ */
833
+ export type PageLimits = {
834
+ defaultPageSize: number;
835
+ maxPageSize: number;
836
+ };
837
+
838
+ // Resolve the requested limit to a number, following a $ref into the runtime
839
+ // values. Returns undefined when there is no limit or it cannot be resolved.
840
+ const resolveLimitValue = (
841
+ value: unknown,
842
+ variables: Record<string, unknown>,
843
+ ): number | undefined => {
844
+ const resolved = isString(value) && value.startsWith("$") ? variables[value.substring(1)] : value;
845
+
846
+ return typeof resolved === "number" ? resolved : undefined;
847
+ };
848
+
829
849
  // Factory function for pagination clause builders
830
850
  export const buildPaginationClauseFp =
831
851
  (dbType: DatabaseType = "pg") =>
832
- (field: SelectionAnalysis, variablesDefinition: VariableDefinition[] = []): string => {
852
+ (
853
+ field: SelectionAnalysis,
854
+ variablesDefinition: VariableDefinition[] = [],
855
+ variables: Record<string, unknown> = {},
856
+ pageLimits: PageLimits | null = null,
857
+ ): string => {
833
858
  const args = field.arguments;
834
859
 
835
- if (!args || args["limit"] === undefined) {
836
- return "";
860
+ const requested = resolveLimitValue(args?.["limit"], variables);
861
+
862
+ if (pageLimits && pageLimits.maxPageSize > 0 && requested !== undefined) {
863
+ if (requested > pageLimits.maxPageSize) {
864
+ throw new Error(
865
+ `Requested page size of ${requested} exceeds the maximum allowed page size of ${pageLimits.maxPageSize} (field: "${field.alias || field.name}"). Raise MAX_PAGE_SIZE to allow larger pages.`,
866
+ );
867
+ }
837
868
  }
838
869
 
839
870
  // Convert limit variable reference to positional placeholder
840
- const limitPlaceholder = toPositionalPlaceholder(args["limit"], variablesDefinition, dbType);
841
- if (!limitPlaceholder) {
842
- return ""; // Limit must be a variable reference
871
+ const limitPlaceholder =
872
+ args?.["limit"] === undefined
873
+ ? null
874
+ : toPositionalPlaceholder(args["limit"], variablesDefinition, dbType);
875
+
876
+ // An unbounded list field reads the whole table. Supply the default page
877
+ // size as a literal: minting a variable here would have to push onto the
878
+ // same definition list the driver binds positionally from, and that order
879
+ // is load-bearing. The value is an env-validated integer, never caller text.
880
+ const limitClause =
881
+ limitPlaceholder ??
882
+ (field.isArray && pageLimits && pageLimits.defaultPageSize > 0
883
+ ? String(pageLimits.defaultPageSize)
884
+ : null);
885
+
886
+ if (!limitClause) {
887
+ return "";
843
888
  }
844
889
 
845
890
  // Convert offset variable reference to positional placeholder (default to 0 if not provided)
846
- const offsetPlaceholder = args["offset"]
891
+ const offsetPlaceholder = args?.["offset"]
847
892
  ? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
848
893
  : "0";
849
894
 
850
895
  if (dbType !== "mssql") {
851
- return `LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
896
+ return `LIMIT ${limitClause} OFFSET ${offsetPlaceholder}`;
852
897
  }
853
898
 
854
899
  // T-SQL only accepts OFFSET/FETCH after an ORDER BY. Dropping the limit when
855
900
  // the query has none returned the whole table instead, so supply the
856
901
  // order-agnostic ORDER BY that T-SQL accepts for exactly this case.
857
- const orderByFallback = args["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
902
+ const orderByFallback = args?.["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
858
903
 
859
- return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
904
+ return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitClause} ROWS ONLY`;
860
905
  };
861
906
 
862
907
  export const buildPaginationClausePG = buildPaginationClauseFp("pg");
@@ -14,6 +14,8 @@ export const executeQuery = async <T>(
14
14
  db: Database,
15
15
  variablesDefinition: VariableDefinition[],
16
16
  variables: Record<string, unknown>,
17
+ /** Per-operation override. `undefined` leaves the engine on its own default. */
18
+ timeoutMs?: number,
17
19
  ) => {
18
20
  const adapter = databaseAdapters[db?.type];
19
21
  if (!adapter) {
@@ -24,7 +26,7 @@ export const executeQuery = async <T>(
24
26
  const startTime = Bun.nanoseconds();
25
27
 
26
28
  try {
27
- const result = await adapter.execute<T>(query, db, variablesDefinition, variables);
29
+ const result = await adapter.execute<T>(query, db, variablesDefinition, variables, timeoutMs);
28
30
  log.debug(
29
31
  { durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
30
32
  "query executed",
@@ -41,6 +43,8 @@ export const executeQueryJSON = async <T>(
41
43
  db: Database,
42
44
  variablesDefinition: VariableDefinition[],
43
45
  variables: Record<string, unknown>,
46
+ /** Per-operation override. `undefined` leaves the engine on its own default. */
47
+ timeoutMs?: number,
44
48
  ) => {
45
49
  const adapter = databaseAdapters[db.type];
46
50
  if (!adapter) {
@@ -51,7 +55,13 @@ export const executeQueryJSON = async <T>(
51
55
  const startTime = Bun.nanoseconds();
52
56
 
53
57
  try {
54
- const result = await adapter.executeJson<T>(query, db, variablesDefinition, variables);
58
+ const result = await adapter.executeJson<T>(
59
+ query,
60
+ db,
61
+ variablesDefinition,
62
+ variables,
63
+ timeoutMs,
64
+ );
55
65
  log.debug(
56
66
  { durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
57
67
  "query executed (json)",
@@ -68,7 +78,6 @@ export const executeQueryJSON = async <T>(
68
78
 
69
79
  export const callStoredProcedure = async (
70
80
  sp: ProcedureResolver,
71
- variablesDefinition: VariableDefinition[],
72
81
  variables: Record<string, unknown> = {},
73
82
  ) => {
74
83
  const adapter = databaseAdapters[sp.db!.type];
@@ -84,7 +93,7 @@ export const callStoredProcedure = async (
84
93
  const startTime = Bun.nanoseconds();
85
94
 
86
95
  try {
87
- const result = await adapter.callStoredProcedure(sp, variablesDefinition, variables);
96
+ const result = await adapter.callStoredProcedure(sp, variables);
88
97
  log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
89
98
  return result;
90
99
  } catch (error) {
@@ -3,6 +3,7 @@ import type { MergedEntities } from "../../configuration/getSchemas/mergeEntitie
3
3
  import type { Auth, Database, DatabaseType } from "../../types/configuration";
4
4
  import type { DatabaseStructure, ProcedureResolver, TableResolver, View } from "../../types/db";
5
5
  import type { GenerateCreateTableSQLParameters } from "../schemaBuilder/generateCreateTable";
6
+ import type { PageLimits } from "../common";
6
7
 
7
8
  import {
8
9
  checkUserCredentials as checkUserCredentialsMSSQL,
@@ -85,22 +86,25 @@ type DatabaseFunctions = {
85
86
  operation: OperationAnalysis,
86
87
  variables: Record<string, unknown>,
87
88
  forHashMethod: boolean,
89
+ pageLimits: PageLimits | null,
90
+ timeoutMs: number | undefined,
88
91
  ) => string;
89
92
  execute: <T>(
90
93
  query: string,
91
94
  db: Database,
92
95
  variablesDefinition: VariableDefinition[],
93
96
  values: Record<string, unknown>,
97
+ timeoutMs: number | undefined,
94
98
  ) => Promise<T[]>;
95
99
  executeJson: <T>(
96
100
  query: string,
97
101
  db: Database,
98
102
  variablesDefinition: VariableDefinition[],
99
103
  values: Record<string, unknown>,
104
+ timeoutMs: number | undefined,
100
105
  ) => Promise<T>;
101
106
  callStoredProcedure: (
102
107
  sp: ProcedureResolver,
103
- variablesDefinition: VariableDefinition[],
104
108
  parameters: Record<string, unknown>,
105
109
  // oxlint-disable-next-line typescript/no-explicit-any
106
110
  ) => Promise<any>;
@@ -0,0 +1,25 @@
1
+ import type { VariableDefinition } from "../../analyzeQuery/types";
2
+ import type { ProcedureResolver } from "../../types/db";
3
+
4
+ /**
5
+ * Orders a procedure call's arguments for the engines that bind positionally.
6
+ *
7
+ * `$1, $2, …` are numbered by one list and filled from another, so the two have
8
+ * to be the same list or the values land on the wrong parameters. Signature
9
+ * order comes from the introspected metadata; the caller's argument object is
10
+ * unordered and reflects the GraphQL query text.
11
+ *
12
+ * Arguments the metadata doesn't account for keep their supplied order and
13
+ * follow: an unnamed procedure parameter is introspected under a synthesized
14
+ * name that no caller can match, and dropping the value there would be worse
15
+ * than binding it where it was already going.
16
+ */
17
+ export const orderProcedureArguments = (
18
+ sp: ProcedureResolver,
19
+ variables: Record<string, unknown>,
20
+ ): VariableDefinition[] => {
21
+ const declared = sp.parameters.map((p) => p.name).filter((name) => name in variables);
22
+ const undeclared = Object.keys(variables).filter((name) => !declared.includes(name));
23
+
24
+ return [...declared, ...undeclared].map((name) => ({ name, type: "", required: false }));
25
+ };
@@ -1,6 +1,7 @@
1
1
  import type { AnalysisResult, SelectionAnalysis } from "../../analyzeQuery/types";
2
2
  import type { MergedEntities } from "../../configuration/getSchemas/mergeEntities";
3
3
  import type { Database } from "../../types/configuration";
4
+ import type { PageLimits } from "../common";
4
5
 
5
6
  import { databaseAdapters } from "./function-mapping";
6
7
 
@@ -9,6 +10,10 @@ export const generateSQL = (
9
10
  analysisResult: AnalysisResult,
10
11
  variables: Record<string, unknown> = {},
11
12
  forHashMethod: boolean = false,
13
+ /** `null` exempts the query — operator-authored queries are configuration, not caller input. */
14
+ pageLimits: PageLimits | null = null,
15
+ /** Per-operation override. `undefined` leaves the engine on its own default. */
16
+ timeoutMs?: number,
12
17
  ) => {
13
18
  const operation = analysisResult.operations[0];
14
19
 
@@ -40,6 +45,8 @@ export const generateSQL = (
40
45
  },
41
46
  variables,
42
47
  forHashMethod,
48
+ pageLimits,
49
+ timeoutMs,
43
50
  );
44
51
 
45
52
  return [db, query] as const;