@graphoria/server 0.3.0 → 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 (87) hide show
  1. package/README.md +24 -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/authentication/capabilities.d.ts +18 -0
  6. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  7. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  8. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  9. package/dist/src/authentication/jwt.d.ts.map +1 -1
  10. package/dist/src/authentication/paseto.d.ts.map +1 -1
  11. package/dist/src/authentication/secrets.d.ts +9 -0
  12. package/dist/src/authentication/secrets.d.ts.map +1 -0
  13. package/dist/src/authentication/secrets.test.d.ts +2 -0
  14. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  15. package/dist/src/configuration/getSchemas/index.d.ts +0 -2
  16. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  17. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +0 -1
  18. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  19. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  20. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  21. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  22. package/dist/src/configuration/index.d.ts +0 -1
  23. package/dist/src/configuration/index.d.ts.map +1 -1
  24. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  25. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  26. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  27. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  28. package/dist/src/console/api.d.ts.map +1 -1
  29. package/dist/src/console/session.d.ts +12 -4
  30. package/dist/src/console/session.d.ts.map +1 -1
  31. package/dist/src/databases/core/executor.d.ts +1 -1
  32. package/dist/src/databases/core/executor.d.ts.map +1 -1
  33. package/dist/src/databases/core/function-mapping.d.ts +1 -1
  34. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  35. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  36. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  37. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  38. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  39. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  40. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  41. package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
  42. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  43. package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
  44. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/logging/audit.d.ts +27 -0
  47. package/dist/src/logging/audit.d.ts.map +1 -0
  48. package/dist/src/logging/audit.test.d.ts +2 -0
  49. package/dist/src/logging/audit.test.d.ts.map +1 -0
  50. package/dist/src/singletons/env.d.ts +8 -4
  51. package/dist/src/singletons/env.d.ts.map +1 -1
  52. package/dist/src/types/env.d.ts +24 -12
  53. package/dist/src/types/env.d.ts.map +1 -1
  54. package/dist/src/utils/responses.d.ts +3 -0
  55. package/dist/src/utils/responses.d.ts.map +1 -1
  56. package/dist/src/utils/sessionVariables.d.ts +2 -0
  57. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +5 -5
  60. package/playgrounds/console/index.html +7 -7
  61. package/playgrounds/graphiql/index.html +548 -685
  62. package/src/ai/mcp/index.ts +19 -14
  63. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  64. package/src/authentication/capabilities.ts +54 -0
  65. package/src/authentication/index.ts +3 -3
  66. package/src/authentication/jwt.ts +30 -25
  67. package/src/authentication/paseto.ts +36 -26
  68. package/src/authentication/secrets.ts +30 -0
  69. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  70. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  71. package/src/configuration/gql/handleGraphQLRequestFactory.ts +17 -5
  72. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
  73. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  74. package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
  75. package/src/console/api.ts +63 -18
  76. package/src/console/session.ts +30 -26
  77. package/src/databases/core/executor.ts +1 -2
  78. package/src/databases/core/function-mapping.ts +0 -1
  79. package/src/databases/core/procedure-arguments.ts +25 -0
  80. package/src/databases/engines/mssql/connection.ts +0 -3
  81. package/src/databases/engines/mysql/connection.ts +10 -7
  82. package/src/databases/engines/postgresql/connection.ts +10 -7
  83. package/src/index.ts +51 -12
  84. package/src/logging/audit.ts +86 -0
  85. package/src/types/env.ts +26 -8
  86. package/src/utils/responses.ts +6 -0
  87. package/src/utils/sessionVariables.ts +2 -0
@@ -1,38 +1,77 @@
1
+ import { GraphQLObjectType } from "graphql";
2
+
3
+ import type { GraphQLType } from "graphql";
1
4
  import type { OpenAPIV3_1 } from "openapi-types";
2
5
  import type { SelectionAnalysis } from "../../analyzeQuery/types";
3
6
  import type { GetSchemaReturn } from "../../configuration/getSchemas";
4
7
  import type { RemoteRESTResolved } from "../../remoteREST/types";
5
8
  import type { Env } from "../../types/env";
6
9
 
10
+ import { getFieldType, unwrapType } from "../../analyzeQuery/typeUtils";
7
11
  import { convertFromZod, errors } from "./openApiErrors";
8
12
 
13
+ // Deliberately coarse: Int and Float both map to "number", so a column keeps
14
+ // the OpenAPI type it had when types were read off the SQL data type.
15
+ const openApiTypeOf = (namedType: GraphQLType | undefined) => {
16
+ const name = namedType && "name" in namedType ? namedType.name : undefined;
17
+
18
+ switch (name) {
19
+ case "Int":
20
+ case "Float":
21
+ return "number";
22
+ case "Boolean":
23
+ return "boolean";
24
+ default:
25
+ return "string";
26
+ }
27
+ };
28
+
29
+ const resolveFieldType = (parentType: GraphQLObjectType | undefined, fieldName: string) => {
30
+ const fieldType = parentType && getFieldType(parentType, fieldName);
31
+ const namedType = fieldType ? unwrapType(fieldType).type : undefined;
32
+
33
+ return {
34
+ namedType,
35
+ objectType: namedType instanceof GraphQLObjectType ? namedType : undefined,
36
+ };
37
+ };
38
+
39
+ /**
40
+ * Resolves selections against the GraphQL schema rather than against a table
41
+ * name. `<table>_aggregate` returns a GroupBy wrapper whose fields — `key`,
42
+ * `count`, `items`, `min`/`max`/`sum`/`avg` — are not tables, so looking a
43
+ * selection's parent name up in the entity map typed everything under an
44
+ * aggregate as a string. Walking the schema also makes sanitised column names
45
+ * resolve, since the schema is what the client actually queries.
46
+ */
9
47
  const processSelections = (
10
- schema: GetSchemaReturn,
11
- name: string,
12
- selections: SelectionAnalysis[] = [],
48
+ parentType: GraphQLObjectType | undefined,
49
+ fieldParent: SelectionAnalysis,
13
50
  ) =>
14
- selections.reduce<Record<string, unknown>>((acc, field) => {
15
- const type = schema.getColumnTypeForOpenApi(name, field.name);
51
+ fieldParent.selections?.reduce<Record<string, unknown>>((acc, field) => {
52
+ const { namedType, objectType } = resolveFieldType(parentType, field.name);
16
53
 
17
54
  if (field.isArray) {
18
55
  acc[field.alias || field.name] = {
19
56
  type: "array",
20
57
  items: {
21
58
  type: "object",
22
- properties: processSelections(schema, field.name, field.selections),
59
+ properties: processSelections(objectType, field),
23
60
  required: (field.selections ?? [])
24
61
  .filter((f) => f.isRequired)
25
62
  .map((f) => f.alias || f.name),
63
+ nullable: !field.isRequired,
26
64
  },
27
65
  };
28
66
  } else {
29
67
  acc[field.alias || field.name] = field.selections
30
68
  ? {
31
69
  type: "object",
32
- properties: processSelections(schema, field.name, field.selections),
70
+ properties: processSelections(objectType, field),
33
71
  required: field.selections.filter((f) => f.isRequired).map((f) => f.alias || f.name),
72
+ nullable: !field.isRequired,
34
73
  }
35
- : { type };
74
+ : { type: openApiTypeOf(namedType), nullable: !field.isRequired };
36
75
  }
37
76
 
38
77
  return acc;
@@ -137,7 +176,12 @@ export const generateOpenAPI = ({
137
176
  const responseDataSchema = value.output
138
177
  ? convertFromZod(value.output)
139
178
  : (() => {
140
- const fields = value.queryStructure!.operations[0].fields;
179
+ const operation = value.queryStructure!.operations[0];
180
+ const fields = operation.fields;
181
+ const rootType =
182
+ (operation.operation === "mutation"
183
+ ? schema.schema.getMutationType()
184
+ : schema.schema.getQueryType()) ?? undefined;
141
185
  return {
142
186
  type: "object" as const,
143
187
  properties: fields.reduce<Record<string, unknown>>((acc, field) => {
@@ -146,7 +190,10 @@ export const generateOpenAPI = ({
146
190
  type: "array",
147
191
  items: {
148
192
  type: "object",
149
- properties: processSelections(schema, field.name, field.selections!),
193
+ properties: processSelections(
194
+ resolveFieldType(rootType, field.name).objectType,
195
+ field,
196
+ ),
150
197
  required: (field.selections ?? [])
151
198
  .filter((f) => f.isRequired)
152
199
  .map((f) => f.alias || f.name),
@@ -155,7 +202,10 @@ export const generateOpenAPI = ({
155
202
  } else {
156
203
  acc[field.alias || field.name] = {
157
204
  type: "object",
158
- properties: processSelections(schema, field.name, field.selections!),
205
+ properties: processSelections(
206
+ resolveFieldType(rootType, field.name).objectType,
207
+ field,
208
+ ),
159
209
  required: (field.selections ?? [])
160
210
  .filter((f) => f.isRequired)
161
211
  .map((f) => f.alias || f.name),
@@ -12,6 +12,7 @@ import type { HandleGraphQLRequest } from "../gql/handleGraphQLRequestFactory";
12
12
  import { analyzeQuery } from "../../analyzeQuery";
13
13
  import { checkUserCredentials } from "../../databases";
14
14
  import { proxyRemoteRESTRequest } from "../../remoteREST/proxy";
15
+ import { actorFromSession, audit } from "../../logging/audit";
15
16
  import { getTokenService } from "../../singletons/authentication";
16
17
  import { getCache } from "../../singletons/cache";
17
18
  import { databasesConnections, repositoryMap } from "../../singletons/databases";
@@ -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
14
  import { databasesConnections } 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;
@@ -67,12 +69,25 @@ export const consoleRoutesFactory = ({
67
69
  }: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
68
70
  const sessions = createConsoleSessions({ env, consolePath, tokenService });
69
71
 
72
+ const clientAddress = (req: BunRequest, server?: Bun.Server<unknown>) =>
73
+ resolveClientAddress(req, server, env.rateLimit.trustProxy);
74
+
70
75
  const guarded =
71
- (handler: (req: BunRequest) => object | Promise<object>): ConsoleRouteHandler =>
72
- async (req: BunRequest) => {
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>) => {
73
85
  try {
74
- if (!(await sessions.authorize(req))) return new S404({ error: "Not Found" });
75
- 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));
76
91
  } catch (error) {
77
92
  return new S400({ errors: [{ message: (error as Error)?.message }] });
78
93
  }
@@ -99,15 +114,28 @@ export const consoleRoutesFactory = ({
99
114
  // Keyed by address against the anonymous ceiling: there is no session
100
115
  // yet, and the attempt is charged before the secret is compared, so a
101
116
  // guesser cannot spend attempts for free.
102
- const limit = await rateLimiter?.check(
103
- null,
104
- resolveClientAddress(req, server, env.rateLimit.trustProxy),
105
- );
117
+ const ip = clientAddress(req, server);
118
+ const limit = await rateLimiter?.check(null, ip);
106
119
  if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
107
120
 
121
+ const actor = { type: "admin_secret", ip } as const;
108
122
  try {
109
- return new S200(await sessions.login(req));
110
- } catch {
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
+ });
111
139
  // One message for a bad secret and for a malformed body alike: the
112
140
  // caller learns nothing about which it was.
113
141
  return new S401({ errors: [{ message: "Invalid admin secret" }] });
@@ -117,8 +145,14 @@ export const consoleRoutesFactory = ({
117
145
 
118
146
  [`${base}/logout`]: {
119
147
  ...cors,
120
- POST: async (req) => {
121
- await sessions.logout(req);
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
+ }
122
156
  return new S200({ ok: true });
123
157
  },
124
158
  },
@@ -264,7 +298,7 @@ export const consoleRoutesFactory = ({
264
298
 
265
299
  [`${base}/config`]: {
266
300
  ...cors,
267
- GET: guarded(() => ({
301
+ GET: guarded((_req, _server, scope) => ({
268
302
  name: projectConfiguration.name,
269
303
  version: projectConfiguration.version,
270
304
  prefixes,
@@ -274,12 +308,13 @@ export const consoleRoutesFactory = ({
274
308
  mcp: projectConfiguration.ai?.mcp?.enabled ?? false,
275
309
  cors: env.enableCors,
276
310
  },
311
+ session: { scope },
277
312
  })),
278
313
  },
279
314
 
280
315
  [`${base}/queues/publish`]: {
281
316
  ...cors,
282
- POST: guarded(async (req) => {
317
+ POST: guarded(async (req, server) => {
283
318
  const { publisher, message, key } = (await req.json()) as {
284
319
  publisher?: string;
285
320
  message?: string | object;
@@ -288,13 +323,18 @@ export const consoleRoutesFactory = ({
288
323
  if (!publisher || message == null) throw new Error("publisher and message are required");
289
324
  if (!Object.hasOwn(queueManager?.publisherMap() ?? {}, publisher))
290
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
+ });
291
331
  return { ok: await queueManager!.sendMessage(publisher, message, key) };
292
- }),
332
+ }, "write"),
293
333
  },
294
334
 
295
335
  [`${base}/cron`]: {
296
336
  ...cors,
297
- POST: guarded(async (req) => {
337
+ POST: guarded(async (req, server) => {
298
338
  const { name, action } = (await req.json()) as { name?: string; action?: string };
299
339
  const cron = getCronJobs();
300
340
  if (!cron) throw new Error("Cron is not enabled");
@@ -302,10 +342,15 @@ export const consoleRoutesFactory = ({
302
342
  if (action !== "trigger" && action !== "pause" && action !== "resume")
303
343
  throw new Error(`Unknown action "${action}"`);
304
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
+ });
305
350
  if (action === "trigger") await cron.trigger(name);
306
351
  else cron[action](name);
307
352
  return { ok: true };
308
- }),
353
+ }, "write"),
309
354
  },
310
355
  };
311
356
  };
@@ -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
  };
@@ -78,7 +78,6 @@ export const executeQueryJSON = async <T>(
78
78
 
79
79
  export const callStoredProcedure = async (
80
80
  sp: ProcedureResolver,
81
- variablesDefinition: VariableDefinition[],
82
81
  variables: Record<string, unknown> = {},
83
82
  ) => {
84
83
  const adapter = databaseAdapters[sp.db!.type];
@@ -94,7 +93,7 @@ export const callStoredProcedure = async (
94
93
  const startTime = Bun.nanoseconds();
95
94
 
96
95
  try {
97
- const result = await adapter.callStoredProcedure(sp, variablesDefinition, variables);
96
+ const result = await adapter.callStoredProcedure(sp, variables);
98
97
  log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
99
98
  return result;
100
99
  } catch (error) {
@@ -105,7 +105,6 @@ type DatabaseFunctions = {
105
105
  ) => Promise<T>;
106
106
  callStoredProcedure: (
107
107
  sp: ProcedureResolver,
108
- variablesDefinition: VariableDefinition[],
109
108
  parameters: Record<string, unknown>,
110
109
  // oxlint-disable-next-line typescript/no-explicit-any
111
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
+ };
@@ -161,7 +161,6 @@ export const executeQueryJSONSingle = executeQueryJSONFactory(true);
161
161
 
162
162
  export const callStoredProcedure = async (
163
163
  sp: ProcedureResolver,
164
- variablesDefinition: VariableDefinition[],
165
164
  variables: Record<string, unknown> = {},
166
165
  ) => {
167
166
  try {
@@ -170,8 +169,6 @@ export const callStoredProcedure = async (
170
169
  const request = pool!.request();
171
170
 
172
171
  for (const [key, value] of Object.entries(variables)) {
173
- if (!value) continue;
174
-
175
172
  const paramFound = sp.parameters.find((p) => p.name === `@${key}`);
176
173
 
177
174
  if (paramFound?.dataType === "varchar") {
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
8
  import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
9
9
  import { databasesConnections } from "../../../singletons/databases";
10
+ import { orderProcedureArguments } from "../../core/procedure-arguments";
10
11
  import { toMySQLPlaceholders } from "./placeholders";
11
12
 
12
13
  // Every pool bound comes from the schema, which is the only place they are
@@ -97,22 +98,24 @@ export const executeQueryJSONFactory =
97
98
  export const executeQueryJSON = executeQueryJSONFactory();
98
99
  export const executeQueryJSONSingle = executeQueryJSONFactory(true);
99
100
 
101
+ // The generated schema types every stored procedure mutation as `Boolean!`, so
102
+ // the call reports whether it ran, not what it selected. Returning the rows here
103
+ // would put an array behind a field the schema promises is a boolean.
100
104
  export const callStoredProcedure = async (
101
105
  sp: ProcedureResolver,
102
- variablesDefinition: VariableDefinition[],
103
106
  variables: Record<string, unknown>,
104
107
  ) => {
105
108
  try {
106
- const data = await executeQuery(
107
- `CALL ${sp.dottedQuotedName}(${Object.keys(variables)
108
- .map((_, i) => `$${i + 1}`)
109
- .join(", ")});`,
109
+ const args = orderProcedureArguments(sp, variables);
110
+
111
+ await executeQuery(
112
+ `CALL ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
110
113
  sp.db!,
111
- variablesDefinition,
114
+ args,
112
115
  variables,
113
116
  );
114
117
 
115
- return data;
118
+ return true;
116
119
  } catch {
117
120
  return false;
118
121
  }
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
8
  import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
9
9
  import { databasesConnections } from "../../../singletons/databases";
10
+ import { orderProcedureArguments } from "../../core/procedure-arguments";
10
11
  import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
11
12
 
12
13
  // Every pool bound comes from the schema, which is the only place they are
@@ -138,22 +139,24 @@ export const executeQueryJSONFactory =
138
139
  export const executeQueryJSON = executeQueryJSONFactory();
139
140
  export const executeQueryJSONSingle = executeQueryJSONFactory(true);
140
141
 
142
+ // The generated schema types every stored procedure mutation as `Boolean!`, so
143
+ // the call reports whether it ran, not what it selected. Returning the rows here
144
+ // would put an array behind a field the schema promises is a boolean.
141
145
  export const callStoredProcedure = async (
142
146
  sp: ProcedureResolver,
143
- variablesDefinition: VariableDefinition[],
144
147
  variables: Record<string, unknown>,
145
148
  ) => {
146
149
  try {
147
- const data = await executeQuery(
148
- `SELECT * FROM ${sp.dottedQuotedName}(${Object.keys(variables)
149
- .map((_, i) => `$${i + 1}`)
150
- .join(", ")});`,
150
+ const args = orderProcedureArguments(sp, variables);
151
+
152
+ await executeQuery(
153
+ `SELECT * FROM ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
151
154
  sp.db!,
152
- variablesDefinition,
155
+ args,
153
156
  variables,
154
157
  );
155
158
 
156
- return data;
159
+ return true;
157
160
  } catch {
158
161
  return false;
159
162
  }