@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.
- package/README.md +29 -20
- package/dist/src/ai/mcp/index.d.ts +9 -2
- package/dist/src/ai/mcp/index.d.ts.map +1 -1
- package/dist/src/ai/tools/core.d.ts +6 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/core.test.d.ts +2 -0
- package/dist/src/ai/tools/core.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
- package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
- package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/fragments.d.ts +6 -0
- package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.d.ts +18 -0
- package/dist/src/authentication/capabilities.d.ts.map +1 -0
- package/dist/src/authentication/capabilities.test.d.ts +2 -0
- package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/secrets.d.ts +9 -0
- package/dist/src/authentication/secrets.d.ts.map +1 -0
- package/dist/src/authentication/secrets.test.d.ts +2 -0
- package/dist/src/authentication/secrets.test.d.ts.map +1 -0
- package/dist/src/config/types/auth.d.ts +8 -0
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +4 -0
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/operation.test.d.ts +2 -0
- package/dist/src/config/types/operation.test.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.d.ts +27 -0
- package/dist/src/configuration/crossReferences.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.test.d.ts +2 -0
- package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +70 -6
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +41 -3
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
- package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts +7 -5
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +29 -0
- package/dist/src/console/session.d.ts.map +1 -0
- package/dist/src/console/session.test.d.ts +2 -0
- package/dist/src/console/session.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +12 -4
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +7 -3
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +5 -4
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
- package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
- package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +30 -7
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/format.d.ts +2 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +17 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/format.d.ts +2 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +26 -7
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +2 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/audit.d.ts +27 -0
- package/dist/src/logging/audit.d.ts.map +1 -0
- package/dist/src/logging/audit.test.d.ts +2 -0
- package/dist/src/logging/audit.test.d.ts.map +1 -0
- package/dist/src/singletons/env.d.ts +27 -4
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queryTimeout.d.ts +3 -0
- package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
- package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
- package/dist/src/subscriptions/types.d.ts +4 -1
- package/dist/src/subscriptions/types.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +97 -12
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +12 -0
- package/dist/src/types/zod/auth.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +28 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts +65 -0
- package/dist/src/utils/rateLimit.d.ts.map +1 -0
- package/dist/src/utils/rateLimit.test.d.ts +2 -0
- package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +6 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/src/utils/sessionVariables.d.ts +2 -0
- package/dist/src/utils/sessionVariables.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +548 -685
- package/src/ai/mcp/index.ts +19 -14
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -0
- package/src/authentication/capabilities.ts +54 -0
- package/src/authentication/index.ts +3 -3
- package/src/authentication/jwt.ts +30 -25
- package/src/authentication/paseto.ts +36 -26
- package/src/authentication/secrets.ts +30 -0
- package/src/config/types/auth.ts +7 -0
- package/src/config/types/db.ts +7 -0
- package/src/config/types/operation.ts +4 -0
- package/src/configuration/crossReferences.ts +340 -0
- package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
- package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/gqlAuthOperations.ts +26 -0
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +97 -15
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +31 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/generateOpenAPI.ts +61 -11
- package/src/configuration/rest/handleRESTRequestFactory.ts +32 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +102 -16
- package/src/console/session.ts +129 -0
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +13 -4
- package/src/databases/core/function-mapping.ts +5 -1
- package/src/databases/core/procedure-arguments.ts +25 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -17
- package/src/databases/engines/mssql/format.ts +3 -1
- package/src/databases/engines/mssql/query/index.ts +12 -2
- package/src/databases/engines/mysql/connection.ts +40 -20
- package/src/databases/engines/mysql/format.ts +3 -1
- package/src/databases/engines/mysql/query/index.ts +25 -4
- package/src/databases/engines/postgresql/connection.ts +86 -22
- package/src/databases/engines/postgresql/format.ts +3 -1
- package/src/databases/engines/postgresql/query/index.ts +12 -3
- package/src/databases/high-level-operations.ts +13 -2
- package/src/index.ts +127 -19
- package/src/logging/audit.ts +86 -0
- package/src/singletons/queryTimeout.ts +20 -0
- package/src/subscriptions/strategies/database.ts +19 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/subscriptions/utils/polling.ts +6 -3
- package/src/types/env.ts +47 -9
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +16 -0
- package/src/utils/sessionVariables.ts +2 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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(
|
|
64
|
+
return encrypt(signingKey, pasetoPayload);
|
|
71
65
|
}
|
|
72
|
-
return sign(
|
|
66
|
+
return sign(signingKey, pasetoPayload);
|
|
73
67
|
};
|
|
74
68
|
|
|
75
69
|
const verifyPasetoToken = async (token: string): Promise<PasetoPayload> => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
};
|
package/src/config/types/auth.ts
CHANGED
|
@@ -137,6 +137,13 @@ export const RolePermissionZod = z
|
|
|
137
137
|
.union([z.array(z.string()), z.literal("ALL")])
|
|
138
138
|
.optional()
|
|
139
139
|
.default([]),
|
|
140
|
+
/** Request ceiling for this role, overriding RATE_LIMIT_MAX. 0 disables it */
|
|
141
|
+
rateLimit: z
|
|
142
|
+
.strictObject({
|
|
143
|
+
max: z.number().int().min(0),
|
|
144
|
+
windowMs: z.number().int().positive().optional(),
|
|
145
|
+
})
|
|
146
|
+
.optional(),
|
|
140
147
|
})
|
|
141
148
|
.default({
|
|
142
149
|
tables: [],
|
package/src/config/types/db.ts
CHANGED
|
@@ -168,11 +168,18 @@ export const MSSQLConnectionOptionsZod = z.strictObject({
|
|
|
168
168
|
min: z.number().int().nonnegative().default(0),
|
|
169
169
|
/** Maximum time in seconds a connection can be idle before being closed */
|
|
170
170
|
idleTimeout: z.number().nonnegative().default(30),
|
|
171
|
+
/**
|
|
172
|
+
* Maximum time in seconds to wait for a free connection when the pool is
|
|
173
|
+
* saturated. Without a bound, a slow-query storm queues callers instead of
|
|
174
|
+
* failing them. Bun's SQL driver exposes no equivalent, so this is MSSQL only.
|
|
175
|
+
*/
|
|
176
|
+
acquireTimeout: z.number().positive().default(30),
|
|
171
177
|
})
|
|
172
178
|
.default({
|
|
173
179
|
max: 10,
|
|
174
180
|
min: 0,
|
|
175
181
|
idleTimeout: 30,
|
|
182
|
+
acquireTimeout: 30,
|
|
176
183
|
}),
|
|
177
184
|
/** Maximum time in seconds to wait when establishing a connection */
|
|
178
185
|
connectionTimeout: z.number().nonnegative().default(30),
|
|
@@ -108,6 +108,8 @@ export const OperationZod = z
|
|
|
108
108
|
rest: OperationRestConfigZod.optional(),
|
|
109
109
|
graphql: OperationGraphQLConfigZod.optional().default({ enabled: true }),
|
|
110
110
|
cache: OperationCacheConfigZod.optional(),
|
|
111
|
+
/** Statement timeout in milliseconds. Overrides QUERY_TIMEOUT_MS for this operation. */
|
|
112
|
+
timeout: z.number().int().positive().optional(),
|
|
111
113
|
})
|
|
112
114
|
.refine(
|
|
113
115
|
(operation) => {
|
|
@@ -249,6 +251,8 @@ export type BaseOperation<TInput, TOutput, TInitData> = {
|
|
|
249
251
|
graphql?: OperationGraphQLConfig;
|
|
250
252
|
/** Cache configuration */
|
|
251
253
|
cache?: OperationCacheConfig;
|
|
254
|
+
/** Statement timeout in milliseconds. Overrides `QUERY_TIMEOUT_MS` for this operation. */
|
|
255
|
+
timeout?: number;
|
|
252
256
|
};
|
|
253
257
|
|
|
254
258
|
/**
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { isString } from "es-toolkit/compat";
|
|
2
|
+
|
|
3
|
+
import type { Configuration } from "../types/configuration";
|
|
4
|
+
import type { ProcedureResolver, TableResolver } from "../types/db";
|
|
5
|
+
|
|
6
|
+
import { FILTER_OPERATORS } from "../config/types/auth";
|
|
7
|
+
import { columnFieldName } from "../databases/transformers/graphqlName";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Everything the configuration is allowed to point at that only exists once the
|
|
11
|
+
* databases have been introspected.
|
|
12
|
+
*/
|
|
13
|
+
export type KnownEntities = {
|
|
14
|
+
tables: TableResolver[];
|
|
15
|
+
storedProcedures: ProcedureResolver[];
|
|
16
|
+
/**
|
|
17
|
+
* Every table name a database reported, keyed by database name and recorded
|
|
18
|
+
* *before* exclusions are applied. `excludedTables` names are absent from
|
|
19
|
+
* `tables` by construction, so they cannot be resolved against it.
|
|
20
|
+
*/
|
|
21
|
+
tableNamesByDatabase: Record<string, string[]>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const OPERATORS = new Set<string>(FILTER_OPERATORS);
|
|
25
|
+
|
|
26
|
+
const isOperatorObject = (value: object): boolean =>
|
|
27
|
+
Object.keys(value).some((key) => OPERATORS.has(key));
|
|
28
|
+
|
|
29
|
+
const editDistance = (a: string, b: string): number => {
|
|
30
|
+
if (a === b) return 0;
|
|
31
|
+
if (!a.length) return b.length;
|
|
32
|
+
if (!b.length) return a.length;
|
|
33
|
+
|
|
34
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
35
|
+
|
|
36
|
+
for (let i = 1; i <= a.length; i++) {
|
|
37
|
+
const current = [i];
|
|
38
|
+
|
|
39
|
+
for (let j = 1; j <= b.length; j++) {
|
|
40
|
+
current[j] = Math.min(
|
|
41
|
+
previous[j]! + 1,
|
|
42
|
+
current[j - 1]! + 1,
|
|
43
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
previous = current;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return previous[b.length]!;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Closest candidate, or nothing when none is close enough. The budget — half the
|
|
55
|
+
* misspelling's length, never more than three edits — is what keeps the hint
|
|
56
|
+
* honest: without it every unresolved name draws a confident suggestion picked
|
|
57
|
+
* from whatever happened to be shortest.
|
|
58
|
+
*/
|
|
59
|
+
const nearest = (value: string, candidates: readonly string[]): string | undefined => {
|
|
60
|
+
const needle = value.toLowerCase();
|
|
61
|
+
|
|
62
|
+
let best: string | undefined;
|
|
63
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
const distance = editDistance(needle, candidate.toLowerCase());
|
|
67
|
+
|
|
68
|
+
if (distance < bestDistance) {
|
|
69
|
+
bestDistance = distance;
|
|
70
|
+
best = candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const budget = Math.min(3, Math.max(1, Math.floor(needle.length / 2)));
|
|
75
|
+
|
|
76
|
+
return best !== undefined && bestDistance <= budget ? best : undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const segment = (key: string): string =>
|
|
80
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
|
|
81
|
+
|
|
82
|
+
const unresolved = (
|
|
83
|
+
path: string,
|
|
84
|
+
kind: string,
|
|
85
|
+
value: string,
|
|
86
|
+
candidates: readonly string[],
|
|
87
|
+
): string => {
|
|
88
|
+
const suggestion = nearest(value, candidates);
|
|
89
|
+
|
|
90
|
+
return `${path} — no ${kind} named ${JSON.stringify(value)}${
|
|
91
|
+
suggestion ? ` (did you mean ${JSON.stringify(suggestion)}?)` : ""
|
|
92
|
+
}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Column references are matched case-insensitively against both the GraphQL
|
|
97
|
+
* field name and the raw SQL name. That is looser than the query builder, which
|
|
98
|
+
* compares field names exactly — deliberately so. A boot-blocking check that is
|
|
99
|
+
* stricter than the runtime would refuse configurations that work today; one
|
|
100
|
+
* that is slightly looser still catches every genuine typo.
|
|
101
|
+
*/
|
|
102
|
+
const columnNames = (table: TableResolver): string[] =>
|
|
103
|
+
table.columns.flatMap((column) => {
|
|
104
|
+
const field = columnFieldName(column);
|
|
105
|
+
|
|
106
|
+
return field === column.name ? [field] : [field, column.name];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const hasColumn = (table: TableResolver, name: string): boolean =>
|
|
110
|
+
columnNames(table).some((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
111
|
+
|
|
112
|
+
/** Table names reachable from `table` through a relation, in either direction. */
|
|
113
|
+
const relatedTableNames = (table: TableResolver): string[] => [
|
|
114
|
+
...table.relationships.map((fk) => fk.toResolverName),
|
|
115
|
+
...table.relationshipsReversed.map((fk) => fk.fromResolverName),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const collectListErrors = (
|
|
119
|
+
path: string,
|
|
120
|
+
kind: string,
|
|
121
|
+
permission: "ALL" | string[] | undefined,
|
|
122
|
+
candidates: readonly string[],
|
|
123
|
+
): string[] => {
|
|
124
|
+
if (!Array.isArray(permission)) return [];
|
|
125
|
+
|
|
126
|
+
const known = new Set(candidates.map((name) => name.toLowerCase()));
|
|
127
|
+
|
|
128
|
+
return permission
|
|
129
|
+
.filter((name) => !known.has(name.toLowerCase()))
|
|
130
|
+
.map((name) => unresolved(`${path}[${JSON.stringify(name)}]`, kind, name, candidates));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every name a configuration points at that resolves to nothing, with the config
|
|
135
|
+
* path that carries it. Returns them all rather than throwing on the first, so a
|
|
136
|
+
* misconfigured deployment is fixed in one pass.
|
|
137
|
+
*
|
|
138
|
+
* A permission naming something that does not exist fails closed — the role
|
|
139
|
+
* simply loses the entity — which is why these never surfaced on their own: the
|
|
140
|
+
* rule the operator wrote silently does not apply.
|
|
141
|
+
*/
|
|
142
|
+
export const collectCrossReferenceErrors = (
|
|
143
|
+
configuration: Configuration,
|
|
144
|
+
known: KnownEntities,
|
|
145
|
+
): string[] => {
|
|
146
|
+
const errors: string[] = [];
|
|
147
|
+
|
|
148
|
+
const tableNames = known.tables.map((table) => table.resolverName);
|
|
149
|
+
const tablesByName = new Map(
|
|
150
|
+
known.tables.map((table) => [table.resolverName.toLowerCase(), table]),
|
|
151
|
+
);
|
|
152
|
+
const procedureNames = known.storedProcedures.map((procedure) => procedure.name);
|
|
153
|
+
const queueNames = configuration.queues.map((queue) => queue.name);
|
|
154
|
+
const operationNames = Object.keys(configuration.operations);
|
|
155
|
+
const remoteSchemaNames = (configuration.remoteSchemas ?? []).map((remote) => remote.name);
|
|
156
|
+
const remoteRESTNames = (configuration.remoteREST ?? []).map((remote) => remote.name);
|
|
157
|
+
|
|
158
|
+
const collectFilterErrors = (
|
|
159
|
+
filter: object,
|
|
160
|
+
table: TableResolver,
|
|
161
|
+
path: string,
|
|
162
|
+
visited: ReadonlySet<string>,
|
|
163
|
+
): void => {
|
|
164
|
+
for (const [key, value] of Object.entries(filter) as [string, unknown][]) {
|
|
165
|
+
const keyPath = `${path}${segment(key)}`;
|
|
166
|
+
|
|
167
|
+
// Mirrors buildConditions: an object carrying a known operator is a column
|
|
168
|
+
// filter, anything else is a nested relation.
|
|
169
|
+
if (typeof value !== "object" || value === null || isOperatorObject(value)) {
|
|
170
|
+
if (!hasColumn(table, key)) {
|
|
171
|
+
errors.push(
|
|
172
|
+
unresolved(keyPath, `column on table ${JSON.stringify(table.resolverName)}`, key, [
|
|
173
|
+
...columnNames(table),
|
|
174
|
+
...relatedTableNames(table),
|
|
175
|
+
]),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const related = relatedTableNames(table);
|
|
183
|
+
const nested = related.includes(key) ? tablesByName.get(key.toLowerCase()) : undefined;
|
|
184
|
+
|
|
185
|
+
if (!nested) {
|
|
186
|
+
errors.push(
|
|
187
|
+
unresolved(
|
|
188
|
+
keyPath,
|
|
189
|
+
`column or relation on table ${JSON.stringify(table.resolverName)}`,
|
|
190
|
+
key,
|
|
191
|
+
[...columnNames(table), ...related],
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A filter that walks back into a table already on the path would recurse
|
|
199
|
+
// forever; the runtime would too, so stop rather than report it here.
|
|
200
|
+
if (visited.has(nested.resolverName)) continue;
|
|
201
|
+
|
|
202
|
+
collectFilterErrors(value, nested, keyPath, new Set([...visited, nested.resolverName]));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (const [role, permissions] of Object.entries(configuration.auth.permissions ?? {})) {
|
|
207
|
+
const base = `auth.permissions${segment(role)}`;
|
|
208
|
+
|
|
209
|
+
errors.push(
|
|
210
|
+
...collectListErrors(
|
|
211
|
+
`${base}.storedProcedures`,
|
|
212
|
+
"stored procedure",
|
|
213
|
+
permissions.storedProcedures,
|
|
214
|
+
procedureNames,
|
|
215
|
+
),
|
|
216
|
+
...collectListErrors(`${base}.queues`, "queue", permissions.queues, queueNames),
|
|
217
|
+
...collectListErrors(
|
|
218
|
+
`${base}.operations`,
|
|
219
|
+
"operation",
|
|
220
|
+
permissions.operations,
|
|
221
|
+
operationNames,
|
|
222
|
+
),
|
|
223
|
+
...collectListErrors(
|
|
224
|
+
`${base}.remoteSchemas`,
|
|
225
|
+
"remote schema",
|
|
226
|
+
permissions.remoteSchemas,
|
|
227
|
+
remoteSchemaNames,
|
|
228
|
+
),
|
|
229
|
+
...collectListErrors(
|
|
230
|
+
`${base}.remoteREST`,
|
|
231
|
+
"remote REST API",
|
|
232
|
+
permissions.remoteREST,
|
|
233
|
+
remoteRESTNames,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
if (isString(permissions.tables)) continue;
|
|
238
|
+
|
|
239
|
+
for (const [tableKey, tablePermission] of Object.entries(permissions.tables)) {
|
|
240
|
+
const tablePath = `${base}.tables${segment(tableKey)}`;
|
|
241
|
+
const table = tablesByName.get(tableKey.toLowerCase());
|
|
242
|
+
|
|
243
|
+
if (!table) {
|
|
244
|
+
errors.push(unresolved(tablePath, "table or view", tableKey, tableNames));
|
|
245
|
+
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!isString(tablePermission.columns)) {
|
|
250
|
+
for (const column of tablePermission.columns) {
|
|
251
|
+
if (!hasColumn(table, column)) {
|
|
252
|
+
errors.push(
|
|
253
|
+
unresolved(
|
|
254
|
+
`${tablePath}.columns[${JSON.stringify(column)}]`,
|
|
255
|
+
"column",
|
|
256
|
+
column,
|
|
257
|
+
columnNames(table),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const [index, orderBy] of (tablePermission.orderBy ?? []).entries()) {
|
|
265
|
+
if (!hasColumn(table, orderBy.column)) {
|
|
266
|
+
errors.push(
|
|
267
|
+
unresolved(
|
|
268
|
+
`${tablePath}.orderBy[${index}].column`,
|
|
269
|
+
"column",
|
|
270
|
+
orderBy.column,
|
|
271
|
+
columnNames(table),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (tablePermission.filter) {
|
|
278
|
+
collectFilterErrors(
|
|
279
|
+
tablePermission.filter,
|
|
280
|
+
table,
|
|
281
|
+
`${tablePath}.filter`,
|
|
282
|
+
new Set([table.resolverName]),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [index, queue] of configuration.queues.entries()) {
|
|
289
|
+
const topicNames = Object.keys(queue.topics);
|
|
290
|
+
const base = `queues[${index}]`;
|
|
291
|
+
|
|
292
|
+
for (const [group, entries] of [
|
|
293
|
+
["publishers", queue.publishers],
|
|
294
|
+
["subscribers", queue.subscribers],
|
|
295
|
+
] as const) {
|
|
296
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
297
|
+
if (!topicNames.includes(entry.topic)) {
|
|
298
|
+
errors.push(
|
|
299
|
+
unresolved(`${base}.${group}${segment(key)}.topic`, "topic", entry.topic, topicNames),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const [index, database] of configuration.enabledDatabases.entries()) {
|
|
307
|
+
if (!database.schema) continue;
|
|
308
|
+
|
|
309
|
+
const introspected = known.tableNamesByDatabase[database.name] ?? [];
|
|
310
|
+
const base = `databases[${index}].schema`;
|
|
311
|
+
|
|
312
|
+
errors.push(
|
|
313
|
+
...collectListErrors(
|
|
314
|
+
`${base}.excludedTables`,
|
|
315
|
+
"table or view",
|
|
316
|
+
database.schema.excludedTables,
|
|
317
|
+
introspected,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// Exact, unlike every other name here, because the override lookup itself is
|
|
322
|
+
// exact — two tables can differ only in case, and picking one of them for a
|
|
323
|
+
// key that matches both would be a guess. A mis-cased key is reported rather
|
|
324
|
+
// than quietly resolved.
|
|
325
|
+
for (const tableKey of Object.keys(database.schema.database)) {
|
|
326
|
+
if (!introspected.includes(tableKey)) {
|
|
327
|
+
errors.push(
|
|
328
|
+
unresolved(
|
|
329
|
+
`${base}.database${segment(tableKey)}`,
|
|
330
|
+
"table or view",
|
|
331
|
+
tableKey,
|
|
332
|
+
introspected,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return errors;
|
|
340
|
+
};
|
|
@@ -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
|
|
|
@@ -103,7 +103,7 @@ export const generateTableType = (mergedEntities: MergedEntities) =>
|
|
|
103
103
|
lj(
|
|
104
104
|
relationshipsReversed,
|
|
105
105
|
({ fromResolverName, fromInternalName }) =>
|
|
106
|
-
`${fromResolverName}(where: ${fromInternalName}WhereInput, orderBy: [${fromInternalName}OrderByInput]): [${fromInternalName}]`,
|
|
106
|
+
`${fromResolverName}(where: ${fromInternalName}WhereInput, orderBy: [${fromInternalName}OrderByInput], limit: Int, offset: Int): [${fromInternalName}]`,
|
|
107
107
|
),
|
|
108
108
|
])}
|
|
109
109
|
}
|
|
@@ -36,7 +36,7 @@ export const buildExecute = (roles: RolesWithGqlHandler, defaultRole: string) =>
|
|
|
36
36
|
if (gql.isIntrospectionQuery(query)) return gql.introspectionResult;
|
|
37
37
|
if (gql.isNoDataQuery(query)) return gql.noDataResult;
|
|
38
38
|
|
|
39
|
-
const { hasErrors, validationErrors } = gql.hasErrors(query);
|
|
39
|
+
const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
|
|
40
40
|
|
|
41
41
|
if (hasErrors) {
|
|
42
42
|
return {
|
|
@@ -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,
|