@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
|
@@ -11,11 +11,13 @@ import type { Auth } from "../../types/configuration";
|
|
|
11
11
|
import type { SessionContext } from "../../utils/sessionVariables";
|
|
12
12
|
|
|
13
13
|
import { analyzeQuery } from "../../analyzeQuery";
|
|
14
|
+
import { checkQueryCost } from "../../analyzeQuery/costLimit";
|
|
14
15
|
import { depthLimitRule } from "../../analyzeQuery/depthLimit";
|
|
15
16
|
import { resolveVariableRef, resolveVariables } from "../../analyzeQuery/resolveVariables";
|
|
16
17
|
import { callStoredProcedure, executeQueryJSON, generateSQL } from "../../databases";
|
|
17
18
|
import { proxyRemoteField } from "../../remoteSchemas/proxy";
|
|
18
19
|
import { getAgent } from "../../singletons/ai";
|
|
20
|
+
import { actorFromSession, audit } from "../../logging/audit";
|
|
19
21
|
import { databasesConnections, repositoryMap } from "../../singletons/databases";
|
|
20
22
|
import { env } from "../../singletons/env";
|
|
21
23
|
import { queueManager } from "../../singletons/queues";
|
|
@@ -43,7 +45,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
43
45
|
) => Promise<{ data: object }>
|
|
44
46
|
>
|
|
45
47
|
> = {
|
|
46
|
-
[EntitySource.QUEUE_PUBLISHER]: async (field, variables) => {
|
|
48
|
+
[EntitySource.QUEUE_PUBLISHER]: async (field, variables, _queryAnalysis, _req, session) => {
|
|
47
49
|
const data = resolveVariableRef(variables, field.arguments?.data);
|
|
48
50
|
|
|
49
51
|
const publisher = entities.queuesMap[field.name];
|
|
@@ -52,6 +54,12 @@ export const handleGraphQLRequestFactory = (
|
|
|
52
54
|
throw new Error(`Queue publisher not found: ${field.name}`);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
audit().emit({
|
|
58
|
+
action: "queue.publish",
|
|
59
|
+
actor: actorFromSession(session),
|
|
60
|
+
target: { kind: "publisher", name: publisher.resolverName },
|
|
61
|
+
});
|
|
62
|
+
|
|
55
63
|
return {
|
|
56
64
|
data: {
|
|
57
65
|
[field.alias || field.name]: await queueManager?.sendMessage(
|
|
@@ -82,7 +90,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
82
90
|
const result = await operation.handler(
|
|
83
91
|
{
|
|
84
92
|
databases: databasesConnections,
|
|
85
|
-
gqlQuery:
|
|
93
|
+
gqlQuery: operatorQuery,
|
|
86
94
|
queues: queueManager,
|
|
87
95
|
repository: repositoryMap,
|
|
88
96
|
},
|
|
@@ -99,7 +107,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
99
107
|
};
|
|
100
108
|
},
|
|
101
109
|
|
|
102
|
-
[EntitySource.STORED_PROCEDURE]: async (field, variables
|
|
110
|
+
[EntitySource.STORED_PROCEDURE]: async (field, variables) => {
|
|
103
111
|
const sp = entities.mutationsMap[field.name];
|
|
104
112
|
|
|
105
113
|
if (!sp) {
|
|
@@ -115,7 +123,6 @@ export const handleGraphQLRequestFactory = (
|
|
|
115
123
|
|
|
116
124
|
const result = await callStoredProcedure(
|
|
117
125
|
sp,
|
|
118
|
-
queryAnalysis.operations[0].variables ?? [],
|
|
119
126
|
argumentsReplaced as Record<string, string | number | boolean | null>,
|
|
120
127
|
);
|
|
121
128
|
|
|
@@ -162,6 +169,8 @@ export const handleGraphQLRequestFactory = (
|
|
|
162
169
|
|
|
163
170
|
const queryCache = new LRUCache<string, CachedQuery>({ max: 1000 });
|
|
164
171
|
|
|
172
|
+
const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
|
|
173
|
+
|
|
165
174
|
// undefined = unparseable query (never cached)
|
|
166
175
|
const getCacheEntry = (query: string): CachedQuery | undefined => {
|
|
167
176
|
const hit = queryCache.get(query);
|
|
@@ -207,20 +216,54 @@ export const handleGraphQLRequestFactory = (
|
|
|
207
216
|
// Return the introspection result for clients like GraphiQL or Apollo Client
|
|
208
217
|
introspectionResult: { data: gqlEntities.introspection },
|
|
209
218
|
noDataResult: { data: { _no_data: "No data available" } },
|
|
210
|
-
|
|
219
|
+
// `enforceDepthLimit: false` and `enforceCostLimit: false` are for
|
|
220
|
+
// operator-authored queries (REST operations), which are config, not
|
|
221
|
+
// attacker input. The depth verdict is never cached under them: the cache is
|
|
222
|
+
// keyed on query text alone, so storing a depth-free verdict would serve a
|
|
223
|
+
// caller sending the same text over the wire.
|
|
224
|
+
//
|
|
225
|
+
// Two flags rather than one because they are exempted for the same reason
|
|
226
|
+
// but enforced in different places — the depth rule inside the memoised
|
|
227
|
+
// `validate`, the cost check after it. Overloading one flag would leave the
|
|
228
|
+
// REST call sites reading as if they only exempted depth.
|
|
229
|
+
hasErrors: (
|
|
230
|
+
query: string,
|
|
231
|
+
options?: {
|
|
232
|
+
enforceDepthLimit?: boolean;
|
|
233
|
+
enforceCostLimit?: boolean;
|
|
234
|
+
variables?: Record<string, unknown>;
|
|
235
|
+
},
|
|
236
|
+
) => {
|
|
237
|
+
const enforceDepthLimit = options?.enforceDepthLimit ?? true;
|
|
238
|
+
const enforceCostLimit = options?.enforceCostLimit ?? true;
|
|
211
239
|
const entry = getCacheEntry(query);
|
|
212
240
|
// Unparseable queries aren't cached; let parse surface the syntax error
|
|
213
241
|
const document = entry?.document ?? parse(query);
|
|
214
242
|
|
|
215
|
-
let validationErrors = entry?.validationErrors;
|
|
243
|
+
let validationErrors = enforceDepthLimit ? entry?.validationErrors : undefined;
|
|
216
244
|
if (!validationErrors) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
? [...specifiedRules, depthLimitRule(env.maxQueryDepth)]
|
|
220
|
-
: undefined; // undefined = use default specifiedRules
|
|
245
|
+
const maxDepth = enforceDepthLimit ? env.maxQueryDepth : 0;
|
|
246
|
+
const rules = maxDepth > 0 ? [...specifiedRules, depthLimitRule(maxDepth)] : undefined; // undefined = use default specifiedRules
|
|
221
247
|
|
|
222
248
|
validationErrors = validate(gqlEntities.schema, document, rules);
|
|
223
|
-
if (entry) entry.validationErrors = validationErrors;
|
|
249
|
+
if (entry && enforceDepthLimit) entry.validationErrors = validationErrors;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Outside the block above, and never cached: the estimate depends on the
|
|
253
|
+
// request's variable values, so the same query text has different verdicts
|
|
254
|
+
// for different callers. Only for a query that already typechecks — an
|
|
255
|
+
// estimate drawn from a document that does not resolve against the schema
|
|
256
|
+
// would bury the error that actually explains the rejection.
|
|
257
|
+
if (enforceCostLimit && env.maxQueryCost > 0 && validationErrors.length === 0) {
|
|
258
|
+
const costError = checkQueryCost(
|
|
259
|
+
document,
|
|
260
|
+
gqlEntities.schema,
|
|
261
|
+
options?.variables ?? {},
|
|
262
|
+
pageLimits,
|
|
263
|
+
env.maxQueryCost,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (costError) validationErrors = [costError];
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
return {
|
|
@@ -234,6 +277,18 @@ export const handleGraphQLRequestFactory = (
|
|
|
234
277
|
variables: Record<string, unknown> = {},
|
|
235
278
|
req?: BunRequest,
|
|
236
279
|
session?: SessionContext,
|
|
280
|
+
// `enforcePageLimits: false` is for operator-authored queries (REST
|
|
281
|
+
// operations, cron jobs, the `gqlQuery` handed to operation hooks). They
|
|
282
|
+
// are configuration, not caller input, so a page cap could only reject
|
|
283
|
+
// the operator's own intent. Use `gql.operatorQuery` rather than passing
|
|
284
|
+
// this by hand.
|
|
285
|
+
//
|
|
286
|
+
// `timeoutMs` is the per-operation override only. Left undefined, each
|
|
287
|
+
// engine stays on its own default — the pool's on Postgres and SQL
|
|
288
|
+
// Server, QUERY_TIMEOUT_MS on MySQL, which has no pool-level route — so
|
|
289
|
+
// an ordinary caller query is bounded without the factory resolving
|
|
290
|
+
// anything.
|
|
291
|
+
options?: { enforcePageLimits?: boolean; timeoutMs?: number },
|
|
237
292
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
238
293
|
): Promise<{ data: any }> => {
|
|
239
294
|
const log = logger("graphql").child({ role: session?.role });
|
|
@@ -315,8 +370,14 @@ export const handleGraphQLRequestFactory = (
|
|
|
315
370
|
let aiData: Record<string, unknown> = {};
|
|
316
371
|
for (const field of aiFields) {
|
|
317
372
|
const alias = field.alias || field.name;
|
|
318
|
-
const prompt = field.arguments?.prompt;
|
|
319
|
-
|
|
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);
|
|
320
381
|
}
|
|
321
382
|
|
|
322
383
|
// Skip SQL generation if there are no table fields
|
|
@@ -339,7 +400,14 @@ export const handleGraphQLRequestFactory = (
|
|
|
339
400
|
],
|
|
340
401
|
};
|
|
341
402
|
|
|
342
|
-
const sqlQueries = generateSQL(
|
|
403
|
+
const sqlQueries = generateSQL(
|
|
404
|
+
entities,
|
|
405
|
+
tableQueryAnalysis,
|
|
406
|
+
resolved.allVariables,
|
|
407
|
+
false,
|
|
408
|
+
(options?.enforcePageLimits ?? true) ? pageLimits : null,
|
|
409
|
+
options?.timeoutMs,
|
|
410
|
+
);
|
|
343
411
|
|
|
344
412
|
const data = await Promise.all<object>(
|
|
345
413
|
sqlQueries.map(([db, query]) =>
|
|
@@ -348,6 +416,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
348
416
|
db,
|
|
349
417
|
resolved.variables,
|
|
350
418
|
resolved.allVariables as Record<string, string | number | boolean | null>,
|
|
419
|
+
options?.timeoutMs,
|
|
351
420
|
),
|
|
352
421
|
),
|
|
353
422
|
);
|
|
@@ -409,7 +478,20 @@ export const handleGraphQLRequestFactory = (
|
|
|
409
478
|
},
|
|
410
479
|
};
|
|
411
480
|
|
|
412
|
-
|
|
481
|
+
/**
|
|
482
|
+
* `handler` for operator-authored queries — REST operations, cron jobs, and
|
|
483
|
+
* the `gqlQuery` handed to operation hooks and handlers. Their text comes from
|
|
484
|
+
* configuration, never from a caller, so page limits do not apply.
|
|
485
|
+
*/
|
|
486
|
+
const operatorQuery = (
|
|
487
|
+
query: Parameters<typeof gql.handler>[0],
|
|
488
|
+
variables?: Parameters<typeof gql.handler>[1],
|
|
489
|
+
req?: Parameters<typeof gql.handler>[2],
|
|
490
|
+
session?: Parameters<typeof gql.handler>[3],
|
|
491
|
+
timeoutMs?: number,
|
|
492
|
+
) => gql.handler(query, variables, req, session, { enforcePageLimits: false, timeoutMs });
|
|
493
|
+
|
|
494
|
+
return { ...gql, operatorQuery };
|
|
413
495
|
};
|
|
414
496
|
|
|
415
497
|
export type HandleGraphQLRequest = ReturnType<typeof handleGraphQLRequestFactory>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
|
+
import type { AnalysisResult } from "../../analyzeQuery/types";
|
|
2
3
|
import type { AnalyzedConfiguration } from "../../configuration";
|
|
3
4
|
import type { SubscriptionContext } from "../../subscriptions";
|
|
4
5
|
import type { SessionContext } from "../../utils/sessionVariables";
|
|
@@ -9,6 +10,7 @@ import { getTokenService } from "../../singletons/authentication";
|
|
|
9
10
|
import { createSubscriptionStrategyRegistry, getStrategyForSource } from "../../subscriptions";
|
|
10
11
|
import { createQueryEventEmitter } from "../../utils/event-emitter";
|
|
11
12
|
import { logger } from "../../logging";
|
|
13
|
+
import { audit } from "../../logging/audit";
|
|
12
14
|
|
|
13
15
|
export const queryEventEmitter = createQueryEventEmitter();
|
|
14
16
|
|
|
@@ -59,6 +61,14 @@ const handleGraphQLSubscriptionFactory = (
|
|
|
59
61
|
|
|
60
62
|
subscriptionMapping.set(ws, session);
|
|
61
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
|
+
|
|
62
72
|
logger("subscriptions").debug({ role: session.role }, "client connected");
|
|
63
73
|
|
|
64
74
|
ws.send(
|
|
@@ -96,7 +106,9 @@ const handleGraphQLSubscriptionFactory = (
|
|
|
96
106
|
const schemaEntity = roles[session.role!];
|
|
97
107
|
|
|
98
108
|
// Validate query
|
|
99
|
-
const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query
|
|
109
|
+
const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query, {
|
|
110
|
+
variables,
|
|
111
|
+
});
|
|
100
112
|
|
|
101
113
|
if (hasErrors) {
|
|
102
114
|
queryEventEmitter.sendErrorToSingleClient(ws, id, validationErrors);
|
|
@@ -146,15 +158,32 @@ const handleGraphQLSubscriptionFactory = (
|
|
|
146
158
|
return;
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
// The SQL builders read `$n` from a definition's index in the
|
|
162
|
+
// operation's own variable list, so the analysis handed to them has to
|
|
163
|
+
// be the resolved one — the raw analysis knows nothing of the statics
|
|
164
|
+
// that binding a role filter mints. Same construction as the HTTP path.
|
|
165
|
+
const resolvedAnalysis: AnalysisResult = {
|
|
166
|
+
...analysis,
|
|
167
|
+
operations: [
|
|
168
|
+
{
|
|
169
|
+
...operation,
|
|
170
|
+
fields: resolved.fields,
|
|
171
|
+
variables: resolved.variables,
|
|
172
|
+
},
|
|
173
|
+
...analysis.operations.slice(1),
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
|
|
149
177
|
// Build context for the strategy
|
|
150
178
|
const context: SubscriptionContext = {
|
|
151
179
|
ws,
|
|
152
180
|
subscriptionId: id,
|
|
153
|
-
analysis,
|
|
181
|
+
analysis: resolvedAnalysis,
|
|
154
182
|
field: resolved.fields[0],
|
|
155
183
|
variableDefinitions: resolved.variables,
|
|
156
184
|
variables: resolved.allVariables,
|
|
157
185
|
schemaEntity,
|
|
186
|
+
session,
|
|
158
187
|
eventEmitter: queryEventEmitter,
|
|
159
188
|
};
|
|
160
189
|
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
virtualColumnExpression,
|
|
20
20
|
virtualColumnFunction,
|
|
21
21
|
} from "../types/configuration";
|
|
22
|
+
import { collectCrossReferenceErrors } from "./crossReferences";
|
|
22
23
|
import { getSchema, getSchemas } from "./getSchemas";
|
|
23
24
|
import { generateOpenAPI } from "./rest/generateOpenAPI";
|
|
24
25
|
|
|
@@ -60,12 +61,29 @@ export const analyzeConfiguration = async (configuration: Configuration, options
|
|
|
60
61
|
// Use pre-calculated enabledDatabases from configuration parsing
|
|
61
62
|
const enabledDatabases = configuration.enabledDatabases;
|
|
62
63
|
|
|
63
|
-
const [
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
const [
|
|
65
|
+
{ tables, storedProcedures, tableNamesByDatabase },
|
|
66
|
+
resolvedRemoteSchemas,
|
|
67
|
+
resolvedRemoteREST,
|
|
68
|
+
] = await Promise.all([
|
|
69
|
+
getDatabasesStructure(enabledDatabases, configuration.auth),
|
|
70
|
+
resolveRemoteSchemas(configuration.remoteSchemas ?? []),
|
|
71
|
+
resolveRemoteRESTApis(configuration.remoteREST ?? []),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const crossReferenceErrors = collectCrossReferenceErrors(configuration, {
|
|
75
|
+
tables,
|
|
76
|
+
storedProcedures,
|
|
77
|
+
tableNamesByDatabase,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (crossReferenceErrors.length) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Configuration references ${crossReferenceErrors.length} name${
|
|
83
|
+
crossReferenceErrors.length === 1 ? "" : "s"
|
|
84
|
+
} that do not exist:\n${crossReferenceErrors.map((error) => ` - ${error}`).join("\n")}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
69
87
|
|
|
70
88
|
const operationsWithRestEndpoints = Object.fromEntries(
|
|
71
89
|
Object.entries(configuration.operations).filter(([, operationConfig]) => operationConfig.rest),
|
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
selections: SelectionAnalysis[] = [],
|
|
48
|
+
parentType: GraphQLObjectType | undefined,
|
|
49
|
+
fieldParent: SelectionAnalysis,
|
|
13
50
|
) =>
|
|
14
|
-
selections
|
|
15
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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";
|
|
@@ -108,7 +109,7 @@ export const handleRESTRequestFactory = (
|
|
|
108
109
|
|
|
109
110
|
if (routesInitDataPromises[route.routeKey] === undefined) {
|
|
110
111
|
routesInitDataPromises[route.routeKey] = await route.hooks?.init?.({
|
|
111
|
-
gqlQuery: gqlSuperadminHandler?.
|
|
112
|
+
gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
|
|
112
113
|
databases: databasesConnections,
|
|
113
114
|
queues: queueManager,
|
|
114
115
|
repository: repositoryMap,
|
|
@@ -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
|
|
|
@@ -269,7 +295,7 @@ export const handleRESTRequestFactory = (
|
|
|
269
295
|
try {
|
|
270
296
|
const result = await route.handler(
|
|
271
297
|
{
|
|
272
|
-
gqlQuery: gqlSuperadminHandler?.
|
|
298
|
+
gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
|
|
273
299
|
databases: databasesConnections,
|
|
274
300
|
queues: queueManager,
|
|
275
301
|
repository: repositoryMap,
|
|
@@ -330,7 +356,7 @@ export const handleRESTRequestFactory = (
|
|
|
330
356
|
// Execute the GraphQL request, then transform via afterRequest before
|
|
331
357
|
// caching so hits and misses return the same shape.
|
|
332
358
|
const result = await applyAfterRequest(
|
|
333
|
-
await gql.
|
|
359
|
+
await gql.operatorQuery(queryAnalysis!, variables, req, session, route.timeout),
|
|
334
360
|
);
|
|
335
361
|
|
|
336
362
|
// Cache the (already transformed) result
|
|
@@ -344,7 +370,9 @@ export const handleRESTRequestFactory = (
|
|
|
344
370
|
} else if (queryAnalysis) {
|
|
345
371
|
// No caching for this route, execute normally
|
|
346
372
|
return new S200(
|
|
347
|
-
await applyAfterRequest(
|
|
373
|
+
await applyAfterRequest(
|
|
374
|
+
await gql.operatorQuery(queryAnalysis, variables, req, session, route.timeout),
|
|
375
|
+
),
|
|
348
376
|
);
|
|
349
377
|
}
|
|
350
378
|
|
|
@@ -42,11 +42,20 @@ export const buildApiRoutes = (
|
|
|
42
42
|
let hasError = false;
|
|
43
43
|
|
|
44
44
|
if (routeConfig.query) {
|
|
45
|
-
|
|
45
|
+
// Operation queries come from the configuration, not from a caller, so the
|
|
46
|
+
// depth and cost limits do not apply to them — they would turn an
|
|
47
|
+
// operator's own deep or wide query into a dead route.
|
|
48
|
+
const validationErrors = gql.hasErrors(routeConfig.query, {
|
|
49
|
+
enforceDepthLimit: false,
|
|
50
|
+
enforceCostLimit: false,
|
|
51
|
+
});
|
|
46
52
|
|
|
47
53
|
if (validationErrors.hasErrors && gqlSuperadminHandler) {
|
|
48
54
|
// Re-validate with superadmin handler if available
|
|
49
|
-
const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query
|
|
55
|
+
const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query, {
|
|
56
|
+
enforceDepthLimit: false,
|
|
57
|
+
enforceCostLimit: false,
|
|
58
|
+
});
|
|
50
59
|
|
|
51
60
|
if (superadminErrors.hasErrors) {
|
|
52
61
|
throw new Error(`Endpoint ${routeKey} has invalid query.`);
|