@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,23 +1,17 @@
1
- import { timingSafeEqual } from "crypto";
2
-
3
1
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
4
2
 
3
+ import type { CapabilityAuthorizer } from "../../authentication/capabilities";
5
4
  import type { AnalyzedConfiguration } from "../../configuration";
6
5
  import type { CreateMcpServerOptions } from "./create-server";
7
6
 
8
7
  import { createMcpServer } from "./create-server";
9
8
  import { logger } from "../../logging";
10
-
11
- const safeCompare = (a: string, b: string): boolean => {
12
- const bufA = Buffer.from(a);
13
- const bufB = Buffer.from(b);
14
- if (bufA.length !== bufB.length) return false;
15
- return timingSafeEqual(bufA, bufB);
16
- };
9
+ import { audit } from "../../logging/audit";
17
10
 
18
11
  export type CreateMCPRoutesOptions = CreateMcpServerOptions & {
19
12
  requireAdminSecret?: boolean;
20
- adminSecret?: string;
13
+ /** Decides whether the presented header grants `mcp`; without one the gate never opens. */
14
+ authorize?: CapabilityAuthorizer;
21
15
  adminSecretHeader?: string;
22
16
  };
23
17
 
@@ -34,16 +28,27 @@ const jsonRpcError = (status: number, code: number, message: string) =>
34
28
  },
35
29
  );
36
30
 
31
+ type RequestServer = { requestIP(req: Request): { address: string } | null };
32
+
37
33
  const handleMcpPost =
38
34
  (analyzedConfiguration: AnalyzedConfiguration, options: CreateMCPRoutesOptions) =>
39
- async (req: Request) => {
35
+ async (req: Request, bunServer?: RequestServer) => {
40
36
  if (options.requireAdminSecret) {
41
37
  const headerName = options.adminSecretHeader ?? "x-admin-secret";
42
- const provided = req.headers.get(headerName);
43
- const expected = options.adminSecret ?? "";
44
- if (!provided || !expected || !safeCompare(provided, expected)) {
38
+ const grant = options.authorize?.(req.headers.get(headerName), "mcp");
39
+ if (!grant) {
45
40
  return jsonRpcError(401, -32001, "Unauthorized: admin secret required");
46
41
  }
42
+ const ip = bunServer?.requestIP(req)?.address;
43
+ audit().emit({
44
+ action: "admin_secret.used",
45
+ actor: {
46
+ type: "admin_secret",
47
+ scope: grant.superset ? "all" : "mcp",
48
+ ...(ip ? { ip } : {}),
49
+ },
50
+ target: { kind: "mcp" },
51
+ });
47
52
  }
48
53
 
49
54
  const server = createMcpServer(analyzedConfiguration, options);
@@ -1,4 +1,4 @@
1
- import { GraphQLObjectType, GraphQLSchema } from "graphql";
1
+ import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema } from "graphql";
2
2
 
3
3
  import type { FieldNode, SelectionNode } from "graphql";
4
4
  import type { MergedEntities } from "../../configuration/getSchemas/mergeEntities";
@@ -55,6 +55,7 @@ export const analyzeSelections = (
55
55
  const directives = analyzeDirectives(field.directives || [], generatedVariables);
56
56
 
57
57
  const isRequired =
58
+ fieldType instanceof GraphQLNonNull &&
58
59
  directives.filter(
59
60
  (dir) => dir.name === "include" || dir.name === "skip" || dir.name === "when",
60
61
  ).length === 0;
@@ -0,0 +1,54 @@
1
+ import type { Env } from "../types/env";
2
+
3
+ import { logger } from "../logging";
4
+ import { matchesAnySecret } from "./secrets";
5
+
6
+ export type Capability = "console:read" | "console:write" | "ai" | "mcp";
7
+
8
+ /** `superset` is true when the admin secret, not a scoped credential, matched. */
9
+ export type CapabilityGrant = { superset: boolean };
10
+
11
+ export type CapabilityAuthorizer = (
12
+ candidate: string | null,
13
+ capability: Capability,
14
+ ) => CapabilityGrant | null;
15
+
16
+ type WarnLog = { warn: (obj: object, msg: string) => void };
17
+
18
+ // Each list is checked on its own so the rotation debug line inside
19
+ // matchesAnySecret keeps reporting the index within one credential.
20
+ const scopedSecretsFor = (env: Env, capability: Capability): string[][] => {
21
+ switch (capability) {
22
+ case "console:read":
23
+ return [env.console.writeSecrets, env.console.readSecrets];
24
+ case "console:write":
25
+ return [env.console.writeSecrets];
26
+ case "ai":
27
+ return [env.ai.secrets];
28
+ case "mcp":
29
+ return [env.ai.mcp.secrets];
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Decides whether a presented secret grants a capability. The admin secret
35
+ * grants all of them and is reported as the superset so the caller can tell
36
+ * the two apart; it is also logged, since a scoped credential would have done.
37
+ */
38
+ export const createCapabilityAuthorizer = (
39
+ env: Env,
40
+ log: WarnLog = logger("auth"),
41
+ ): CapabilityAuthorizer => {
42
+ return (candidate, capability) => {
43
+ if (matchesAnySecret(candidate, env.admin.secrets)) {
44
+ log.warn({ capability }, "admin secret used where a scoped credential would do");
45
+ return { superset: true };
46
+ }
47
+
48
+ for (const secrets of scopedSecretsFor(env, capability)) {
49
+ if (matchesAnySecret(candidate, secrets)) return { superset: false };
50
+ }
51
+
52
+ return null;
53
+ };
54
+ };
@@ -7,7 +7,7 @@ import { createPASETOService } from "./paseto";
7
7
  export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): TokenService => {
8
8
  switch (strategy) {
9
9
  case "jwt": {
10
- if (!env.jwt.secret) {
10
+ if (env.jwt.secrets.length === 0) {
11
11
  throw new Error(
12
12
  "JWT_SECRET environment variable is required when using jwt token strategy",
13
13
  );
@@ -15,7 +15,7 @@ export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): T
15
15
  return createJWTService(env);
16
16
  }
17
17
  case "paseto_local": {
18
- if (!env.paseto.localKey) {
18
+ if (env.paseto.localKeys.length === 0) {
19
19
  throw new Error(
20
20
  "PASETO_LOCAL_KEY environment variable is required when using paseto_local token strategy.",
21
21
  );
@@ -23,7 +23,7 @@ export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): T
23
23
  return createPASETOService(env, "local");
24
24
  }
25
25
  case "paseto_public": {
26
- if (!env.paseto.secretKey || !env.paseto.publicKey) {
26
+ if (!env.paseto.secretKey || env.paseto.publicKeys.length === 0) {
27
27
  throw new Error(
28
28
  "PASETO_SECRET_KEY and PASETO_PUBLIC_KEY environment variables are required when using paseto_public token strategy.",
29
29
  );
@@ -1,6 +1,4 @@
1
- import { timingSafeEqual } from "crypto";
2
-
3
- import { SignJWT, jwtVerify } from "jose";
1
+ import { SignJWT, errors, jwtVerify } from "jose";
4
2
 
5
3
  import type { JWTPayload } from "jose";
6
4
  import type { Env } from "../types/env";
@@ -9,6 +7,7 @@ import type { TokenRepository } from "./tokenRepository";
9
7
  import type { TokenGenerationParameters, TokenOptions, TokenResponse, TokenService } from "./types";
10
8
 
11
9
  import { parseDurationToSeconds } from "./duration";
10
+ import { matchesAnySecret } from "./secrets";
12
11
  import { createTokenRepository } from "./tokenRepository";
13
12
  import { logger } from "../logging";
14
13
 
@@ -16,18 +15,6 @@ import { logger } from "../logging";
16
15
  const ACCESS_TOKEN_AUDIENCE = "access";
17
16
  const REFRESH_TOKEN_AUDIENCE = "refresh";
18
17
 
19
- // Timing-safe string comparison to prevent timing attacks
20
- const safeCompare = (a: string, b: string): boolean => {
21
- try {
22
- const bufA = Buffer.from(a);
23
- const bufB = Buffer.from(b);
24
- if (bufA.length !== bufB.length) return false;
25
- return timingSafeEqual(bufA, bufB);
26
- } catch {
27
- return false;
28
- }
29
- };
30
-
31
18
  export type { TokenGenerationParameters, TokenOptions, TokenResponse };
32
19
 
33
20
  export type JWTTokenPayload = JWTPayload &
@@ -43,7 +30,9 @@ export const createJWTService = (
43
30
  tokenRepositoryOverride?: TokenRepository,
44
31
  ): TokenService => {
45
32
  const tokenRepository = tokenRepositoryOverride ?? createTokenRepository(env.cache.redisUrl);
46
- const jwtSecret = new TextEncoder().encode(env.jwt.secret);
33
+ // First entry signs; every entry verifies, so a rotation can overlap.
34
+ const jwtSecrets = env.jwt.secrets.map((secret) => new TextEncoder().encode(secret));
35
+ const signingSecret = jwtSecrets[0];
47
36
 
48
37
  const createToken = async (
49
38
  payload: TokenGenerationParameters,
@@ -67,7 +56,8 @@ export const createJWTService = (
67
56
  jwt.setNotBefore(options.notBefore);
68
57
  }
69
58
 
70
- return await jwt.sign(jwtSecret);
59
+ if (!signingSecret) throw new Error("No JWT secret configured");
60
+ return await jwt.sign(signingSecret);
71
61
  } catch (error) {
72
62
  logger("auth").child({ strategy: "jwt" }).error({ err: error }, "token creation failed");
73
63
  throw new Error("Token creation failed");
@@ -78,23 +68,38 @@ export const createJWTService = (
78
68
  token: string,
79
69
  options: TokenOptions = {},
80
70
  ): Promise<T> => {
81
- const { payload } = await jwtVerify(token, jwtSecret, {
71
+ const verifyOptions = {
82
72
  issuer: options.issuer,
83
73
  audience: options.audience,
84
74
  algorithms: [ALGORITHM],
85
75
  typ: TYP,
86
- });
76
+ };
87
77
 
88
- return payload as T;
78
+ let lastError: unknown = new Error("No JWT secret configured");
79
+ for (const [index, secret] of jwtSecrets.entries()) {
80
+ try {
81
+ const { payload } = await jwtVerify(token, secret, verifyOptions);
82
+ if (index > 0) {
83
+ logger("auth")
84
+ .child({ strategy: "jwt" })
85
+ .debug({ index }, "token verified with a previous secret");
86
+ }
87
+ return payload as T;
88
+ } catch (error) {
89
+ // Only a signature mismatch means "try the next secret"; a claim
90
+ // failure came from the secret that did sign this token.
91
+ if (!(error instanceof errors.JWSSignatureVerificationFailed)) throw error;
92
+ lastError = error;
93
+ }
94
+ }
95
+ throw lastError;
89
96
  };
90
97
 
91
98
  const verifyTokenAndGetRole = async (
92
99
  authHeader: string | null,
93
100
  adminSecretHeader: string | null,
94
101
  ): Promise<string> => {
95
- // Use timing-safe comparison to prevent timing attacks
96
- if (env.admin.secret && adminSecretHeader && safeCompare(adminSecretHeader, env.admin.secret))
97
- return env.superadmin.role;
102
+ if (matchesAnySecret(adminSecretHeader, env.admin.secrets)) return env.superadmin.role;
98
103
 
99
104
  if (!authHeader) return env.anonymousRole;
100
105
 
@@ -115,8 +120,8 @@ export const createJWTService = (
115
120
  authHeader: string | null,
116
121
  adminSecretHeader: string | null,
117
122
  ): Promise<SessionContext> => {
118
- if (env.admin.secret && adminSecretHeader && safeCompare(adminSecretHeader, env.admin.secret))
119
- return { sub: "superadmin", role: env.superadmin.role };
123
+ if (matchesAnySecret(adminSecretHeader, env.admin.secrets))
124
+ return { sub: "superadmin", role: env.superadmin.role, authMethod: "admin_secret" };
120
125
 
121
126
  if (!authHeader || !authHeader.startsWith("Bearer "))
122
127
  return { sub: "anonymous", role: env.anonymousRole };
@@ -1,5 +1,4 @@
1
- import { timingSafeEqual } from "crypto";
2
-
1
+ import { PasetoDecryptionFailed, PasetoSignatureInvalid } from "paseto-ts/lib/errors";
3
2
  import { decrypt, encrypt, sign, verify } from "paseto-ts/v4";
4
3
 
5
4
  import type { Env } from "../types/env";
@@ -8,6 +7,7 @@ import type { TokenRepository } from "./tokenRepository";
8
7
  import type { TokenGenerationParameters, TokenOptions, TokenResponse, TokenService } from "./types";
9
8
 
10
9
  import { parseDurationToSeconds, toPasetoDuration } from "./duration";
10
+ import { matchesAnySecret } from "./secrets";
11
11
  import { createTokenRepository } from "./tokenRepository";
12
12
  import { logger } from "../logging";
13
13
 
@@ -15,18 +15,6 @@ import { logger } from "../logging";
15
15
  const ACCESS_TOKEN_AUDIENCE = "access";
16
16
  const REFRESH_TOKEN_AUDIENCE = "refresh";
17
17
 
18
- // Timing-safe string comparison to prevent timing attacks
19
- const safeCompare = (a: string, b: string): boolean => {
20
- try {
21
- const bufA = Buffer.from(a);
22
- const bufB = Buffer.from(b);
23
- if (bufA.length !== bufB.length) return false;
24
- return timingSafeEqual(bufA, bufB);
25
- } catch {
26
- return false;
27
- }
28
- };
29
-
30
18
  type PasetoPayload = {
31
19
  sub: string;
32
20
  role: string;
@@ -56,7 +44,12 @@ export const createPASETOService = (
56
44
  ): TokenService => {
57
45
  const tokenRepository = tokenRepositoryOverride ?? createTokenRepository(env.cache.redisUrl);
58
46
 
59
- // Token creation and verification functions based on mode
47
+ // In local mode the first key encrypts and every key decrypts. In public
48
+ // mode the secret key signs and every public key verifies. Either way a
49
+ // rotation can overlap.
50
+ const verifyingKeys = mode === "local" ? env.paseto.localKeys : env.paseto.publicKeys;
51
+ const signingKey = mode === "local" ? env.paseto.localKeys[0] : env.paseto.secretKey;
52
+
60
53
  const createPasetoToken = async (
61
54
  payload: Record<string, unknown>,
62
55
  expiry: string,
@@ -66,19 +59,37 @@ export const createPASETOService = (
66
59
  exp: toPasetoDuration(expiry),
67
60
  };
68
61
 
62
+ if (!signingKey) throw new Error("No PASETO key configured");
69
63
  if (mode === "local") {
70
- return encrypt(env.paseto.localKey!, pasetoPayload);
64
+ return encrypt(signingKey, pasetoPayload);
71
65
  }
72
- return sign(env.paseto.secretKey!, pasetoPayload);
66
+ return sign(signingKey, pasetoPayload);
73
67
  };
74
68
 
75
69
  const verifyPasetoToken = async (token: string): Promise<PasetoPayload> => {
76
- if (mode === "local") {
77
- const { payload } = await decrypt<PasetoPayload>(env.paseto.localKey!, token);
78
- return payload;
70
+ let lastError: unknown = new Error("No PASETO key configured");
71
+ for (const [index, key] of verifyingKeys.entries()) {
72
+ try {
73
+ const { payload } =
74
+ mode === "local"
75
+ ? await decrypt<PasetoPayload>(key, token)
76
+ : await verify<PasetoPayload>(key, token);
77
+ if (index > 0) {
78
+ logger("auth")
79
+ .child({ strategy: "paseto" })
80
+ .debug({ index }, "token verified with a previous key");
81
+ }
82
+ return payload;
83
+ } catch (error) {
84
+ // Only a key mismatch means "try the next key"; a claim failure came
85
+ // from the key that did produce this token.
86
+ const keyMismatch =
87
+ error instanceof PasetoDecryptionFailed || error instanceof PasetoSignatureInvalid;
88
+ if (!keyMismatch) throw error;
89
+ lastError = error;
90
+ }
79
91
  }
80
- const { payload } = await verify<PasetoPayload>(env.paseto.publicKey!, token);
81
- return payload;
92
+ throw lastError;
82
93
  };
83
94
 
84
95
  const createToken = async (
@@ -132,8 +143,7 @@ export const createPASETOService = (
132
143
  authHeader: string | null,
133
144
  adminSecretHeader: string | null,
134
145
  ): Promise<string> => {
135
- if (env.admin.secret && adminSecretHeader && safeCompare(adminSecretHeader, env.admin.secret))
136
- return env.superadmin.role;
146
+ if (matchesAnySecret(adminSecretHeader, env.admin.secrets)) return env.superadmin.role;
137
147
 
138
148
  if (!authHeader) return env.anonymousRole;
139
149
 
@@ -153,8 +163,8 @@ export const createPASETOService = (
153
163
  authHeader: string | null,
154
164
  adminSecretHeader: string | null,
155
165
  ): Promise<SessionContext> => {
156
- if (env.admin.secret && adminSecretHeader && safeCompare(adminSecretHeader, env.admin.secret))
157
- return { sub: "superadmin", role: env.superadmin.role };
166
+ if (matchesAnySecret(adminSecretHeader, env.admin.secrets))
167
+ return { sub: "superadmin", role: env.superadmin.role, authMethod: "admin_secret" };
158
168
 
159
169
  if (!authHeader || !authHeader.startsWith("Bearer "))
160
170
  return { sub: "anonymous", role: env.anonymousRole };
@@ -0,0 +1,30 @@
1
+ import { timingSafeEqual } from "crypto";
2
+
3
+ import { logger } from "../logging";
4
+
5
+ // Timing-safe string comparison to prevent timing attacks
6
+ export const safeCompare = (a: string, b: string): boolean => {
7
+ try {
8
+ const bufA = Buffer.from(a);
9
+ const bufB = Buffer.from(b);
10
+ if (bufA.length !== bufB.length) return false;
11
+ return timingSafeEqual(bufA, bufB);
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
16
+
17
+ /**
18
+ * True when `candidate` equals any entry of a rotating secret set. The first
19
+ * entry is the current secret; a match further along means a caller still
20
+ * holds a previous one, which is logged so the operator knows when it is safe
21
+ * to drop it.
22
+ */
23
+ export const matchesAnySecret = (candidate: string | null, secrets: string[]): boolean => {
24
+ if (!candidate) return false;
25
+
26
+ const index = secrets.findIndex((secret) => safeCompare(candidate, secret));
27
+ if (index > 0) logger("auth").debug({ index }, "matched a previous secret");
28
+
29
+ return index >= 0;
30
+ };
@@ -7,23 +7,6 @@ import type { TypedOperation } from "../../../types/zod/operation";
7
7
 
8
8
  import { EntitySource, createResolverEntry } from "../../../types/resolver";
9
9
  import { columnFieldName } from "../../../databases/transformers/graphqlName";
10
- import { SqlTypeCategory, categorizeSqlType } from "../../../databases/sqlTypeUtils";
11
-
12
- const dataTypeToOpenApiType = (dataType: string): string => {
13
- const category = categorizeSqlType(dataType);
14
-
15
- switch (category) {
16
- case SqlTypeCategory.INTEGER:
17
- case SqlTypeCategory.FLOAT:
18
- return "number";
19
- case SqlTypeCategory.BOOLEAN:
20
- return "boolean";
21
- case SqlTypeCategory.DATE_TIME:
22
- case SqlTypeCategory.STRING:
23
- default:
24
- return "string";
25
- }
26
- };
27
10
 
28
11
  export const mergeEntities = (
29
12
  entityOfRole: EntitiesOfRole,
@@ -236,15 +219,6 @@ export const mergeEntities = (
236
219
  */
237
220
  columnSqlName: (table: string, fieldName: string): string =>
238
221
  queriesMap[table]?.columns.find((c) => columnFieldName(c) === fieldName)?.name ?? fieldName,
239
- getColumnTypeForOpenApi: (table: string, column: string) => {
240
- const tableObj = queriesMap[table];
241
-
242
- const columnObj = tableObj?.columns.find((c) => c.name === column);
243
-
244
- if (!columnObj) return "string";
245
-
246
- return dataTypeToOpenApiType(columnObj.dataType);
247
- },
248
222
  };
249
223
  };
250
224
 
@@ -5,6 +5,7 @@ import type { SessionContext } from "../../utils/sessionVariables";
5
5
 
6
6
  import { resolveVariableRef } from "../../analyzeQuery/resolveVariables";
7
7
  import { checkUserCredentials } from "../../databases";
8
+ import { actorFromSession, audit } from "../../logging/audit";
8
9
  import { getTokenService } from "../../singletons/authentication";
9
10
 
10
11
  // Handle auth mutations: auth_login / auth_refresh / auth_logout
@@ -35,9 +36,23 @@ export const handleAuthMutation = async (
35
36
  passwordValue.toString(),
36
37
  );
37
38
 
39
+ const actor = { type: "credentials", sub: usernameValue.toString() } as const;
38
40
  if (!result.valid) {
41
+ audit().emit({
42
+ action: "auth.login",
43
+ outcome: "failure",
44
+ actor,
45
+ target: { kind: "auth", via: "graphql" },
46
+ reason: "Invalid username or password",
47
+ });
39
48
  throw new Error("Invalid username or password");
40
49
  }
50
+ audit().emit({
51
+ action: "auth.login",
52
+ outcome: "success",
53
+ actor,
54
+ target: { kind: "auth", via: "graphql", role: result.role },
55
+ });
41
56
 
42
57
  const data = await getTokenService().createTokenPair({
43
58
  sub: usernameValue.toString(),
@@ -104,9 +119,11 @@ export const handleAuthMutation = async (
104
119
 
105
120
  if (field.name === "auth_logout") {
106
121
  const tokenService = getTokenService();
122
+ const revoked: string[] = [];
107
123
 
108
124
  if (session?.jti) {
109
125
  await tokenService.revoke(session.jti);
126
+ revoked.push(session.jti);
110
127
  }
111
128
 
112
129
  const refreshCookie = req?.cookies?.get("refresh_token");
@@ -116,6 +133,7 @@ export const handleAuthMutation = async (
116
133
  audience: "refresh",
117
134
  });
118
135
  await tokenService.revoke(refreshPayload.jti);
136
+ revoked.push(refreshPayload.jti);
119
137
  } catch {
120
138
  // tampered or expired cookie — nothing to revoke
121
139
  }
@@ -125,6 +143,14 @@ export const handleAuthMutation = async (
125
143
  req.cookies.delete("refresh_token");
126
144
  }
127
145
 
146
+ if (revoked.length > 0) {
147
+ audit().emit({
148
+ action: "auth.logout",
149
+ actor: actorFromSession(session),
150
+ target: { kind: "auth", via: "graphql", revoked },
151
+ });
152
+ }
153
+
128
154
  return {
129
155
  data: {
130
156
  [field.alias || field.name]: true,
@@ -17,6 +17,7 @@ import { resolveVariableRef, resolveVariables } from "../../analyzeQuery/resolve
17
17
  import { callStoredProcedure, executeQueryJSON, generateSQL } from "../../databases";
18
18
  import { proxyRemoteField } from "../../remoteSchemas/proxy";
19
19
  import { getAgent } from "../../singletons/ai";
20
+ import { actorFromSession, audit } from "../../logging/audit";
20
21
  import { databasesConnections, repositoryMap } from "../../singletons/databases";
21
22
  import { env } from "../../singletons/env";
22
23
  import { queueManager } from "../../singletons/queues";
@@ -44,7 +45,7 @@ export const handleGraphQLRequestFactory = (
44
45
  ) => Promise<{ data: object }>
45
46
  >
46
47
  > = {
47
- [EntitySource.QUEUE_PUBLISHER]: async (field, variables) => {
48
+ [EntitySource.QUEUE_PUBLISHER]: async (field, variables, _queryAnalysis, _req, session) => {
48
49
  const data = resolveVariableRef(variables, field.arguments?.data);
49
50
 
50
51
  const publisher = entities.queuesMap[field.name];
@@ -53,6 +54,12 @@ export const handleGraphQLRequestFactory = (
53
54
  throw new Error(`Queue publisher not found: ${field.name}`);
54
55
  }
55
56
 
57
+ audit().emit({
58
+ action: "queue.publish",
59
+ actor: actorFromSession(session),
60
+ target: { kind: "publisher", name: publisher.resolverName },
61
+ });
62
+
56
63
  return {
57
64
  data: {
58
65
  [field.alias || field.name]: await queueManager?.sendMessage(
@@ -100,7 +107,7 @@ export const handleGraphQLRequestFactory = (
100
107
  };
101
108
  },
102
109
 
103
- [EntitySource.STORED_PROCEDURE]: async (field, variables, queryAnalysis) => {
110
+ [EntitySource.STORED_PROCEDURE]: async (field, variables) => {
104
111
  const sp = entities.mutationsMap[field.name];
105
112
 
106
113
  if (!sp) {
@@ -116,7 +123,6 @@ export const handleGraphQLRequestFactory = (
116
123
 
117
124
  const result = await callStoredProcedure(
118
125
  sp,
119
- queryAnalysis.operations[0].variables ?? [],
120
126
  argumentsReplaced as Record<string, string | number | boolean | null>,
121
127
  );
122
128
 
@@ -364,8 +370,14 @@ export const handleGraphQLRequestFactory = (
364
370
  let aiData: Record<string, unknown> = {};
365
371
  for (const field of aiFields) {
366
372
  const alias = field.alias || field.name;
367
- const prompt = field.arguments?.prompt;
368
- aiData[alias] = await getAgent()(String(prompt ?? ""));
373
+ const prompt = String(field.arguments?.prompt ?? "");
374
+ audit().emit({
375
+ action: "ai.ask",
376
+ actor: actorFromSession(session),
377
+ target: { kind: "ai", via: "graphql" },
378
+ prompt,
379
+ });
380
+ aiData[alias] = await getAgent()(prompt);
369
381
  }
370
382
 
371
383
  // Skip SQL generation if there are no table fields
@@ -10,6 +10,7 @@ import { getTokenService } from "../../singletons/authentication";
10
10
  import { createSubscriptionStrategyRegistry, getStrategyForSource } from "../../subscriptions";
11
11
  import { createQueryEventEmitter } from "../../utils/event-emitter";
12
12
  import { logger } from "../../logging";
13
+ import { audit } from "../../logging/audit";
13
14
 
14
15
  export const queryEventEmitter = createQueryEventEmitter();
15
16
 
@@ -60,6 +61,14 @@ const handleGraphQLSubscriptionFactory = (
60
61
 
61
62
  subscriptionMapping.set(ws, session);
62
63
 
64
+ if (session.authMethod === "admin_secret") {
65
+ audit().emit({
66
+ action: "admin_secret.used",
67
+ actor: { type: "admin_secret", ip: ws.remoteAddress },
68
+ target: { kind: "websocket" },
69
+ });
70
+ }
71
+
63
72
  logger("subscriptions").debug({ role: session.role }, "client connected");
64
73
 
65
74
  ws.send(