@graphoria/server 0.2.2 → 0.3.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 +25 -20
- 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/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/config/types/ai.d.ts +0 -6
- package/dist/src/config/types/ai.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +8 -29
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/cron.d.ts +0 -94
- package/dist/src/config/types/cron.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -87
- 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/config/types/queue.d.ts +0 -47
- package/dist/src/config/types/queue.d.ts.map +1 -1
- package/dist/src/config/types/remote-rest.d.ts +0 -9
- package/dist/src/config/types/remote-rest.d.ts.map +1 -1
- package/dist/src/config/types/remote-schema.d.ts +0 -10
- package/dist/src/config/types/remote-schema.d.ts.map +1 -1
- 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 -4
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.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 -2
- package/dist/src/configuration/index.d.ts.map +1 -1
- 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 +21 -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/cron/index.d.ts +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/cron/index.test.d.ts +2 -0
- package/dist/src/cron/index.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 +6 -2
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +4 -3
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- 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 +29 -6
- 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 +16 -0
- 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 +25 -6
- 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/singletons/env.d.ts +19 -0
- 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 +73 -0
- 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 +3 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +535 -536
- package/playgrounds/scalar/index.html +1 -1
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -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/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +51 -10
- package/src/console/session.ts +125 -0
- package/src/cron/index.ts +6 -2
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +12 -2
- package/src/databases/core/function-mapping.ts +5 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -14
- 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 +30 -13
- 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 +76 -15
- 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 +82 -13
- 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 +21 -1
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +10 -0
- package/src/ai/README.md +0 -257
|
@@ -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
|
+
};
|
|
@@ -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 {
|
|
@@ -11,6 +11,7 @@ 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";
|
|
@@ -82,7 +83,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
82
83
|
const result = await operation.handler(
|
|
83
84
|
{
|
|
84
85
|
databases: databasesConnections,
|
|
85
|
-
gqlQuery:
|
|
86
|
+
gqlQuery: operatorQuery,
|
|
86
87
|
queues: queueManager,
|
|
87
88
|
repository: repositoryMap,
|
|
88
89
|
},
|
|
@@ -162,6 +163,8 @@ export const handleGraphQLRequestFactory = (
|
|
|
162
163
|
|
|
163
164
|
const queryCache = new LRUCache<string, CachedQuery>({ max: 1000 });
|
|
164
165
|
|
|
166
|
+
const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
|
|
167
|
+
|
|
165
168
|
// undefined = unparseable query (never cached)
|
|
166
169
|
const getCacheEntry = (query: string): CachedQuery | undefined => {
|
|
167
170
|
const hit = queryCache.get(query);
|
|
@@ -207,20 +210,54 @@ export const handleGraphQLRequestFactory = (
|
|
|
207
210
|
// Return the introspection result for clients like GraphiQL or Apollo Client
|
|
208
211
|
introspectionResult: { data: gqlEntities.introspection },
|
|
209
212
|
noDataResult: { data: { _no_data: "No data available" } },
|
|
210
|
-
|
|
213
|
+
// `enforceDepthLimit: false` and `enforceCostLimit: false` are for
|
|
214
|
+
// operator-authored queries (REST operations), which are config, not
|
|
215
|
+
// attacker input. The depth verdict is never cached under them: the cache is
|
|
216
|
+
// keyed on query text alone, so storing a depth-free verdict would serve a
|
|
217
|
+
// caller sending the same text over the wire.
|
|
218
|
+
//
|
|
219
|
+
// Two flags rather than one because they are exempted for the same reason
|
|
220
|
+
// but enforced in different places — the depth rule inside the memoised
|
|
221
|
+
// `validate`, the cost check after it. Overloading one flag would leave the
|
|
222
|
+
// REST call sites reading as if they only exempted depth.
|
|
223
|
+
hasErrors: (
|
|
224
|
+
query: string,
|
|
225
|
+
options?: {
|
|
226
|
+
enforceDepthLimit?: boolean;
|
|
227
|
+
enforceCostLimit?: boolean;
|
|
228
|
+
variables?: Record<string, unknown>;
|
|
229
|
+
},
|
|
230
|
+
) => {
|
|
231
|
+
const enforceDepthLimit = options?.enforceDepthLimit ?? true;
|
|
232
|
+
const enforceCostLimit = options?.enforceCostLimit ?? true;
|
|
211
233
|
const entry = getCacheEntry(query);
|
|
212
234
|
// Unparseable queries aren't cached; let parse surface the syntax error
|
|
213
235
|
const document = entry?.document ?? parse(query);
|
|
214
236
|
|
|
215
|
-
let validationErrors = entry?.validationErrors;
|
|
237
|
+
let validationErrors = enforceDepthLimit ? entry?.validationErrors : undefined;
|
|
216
238
|
if (!validationErrors) {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
? [...specifiedRules, depthLimitRule(env.maxQueryDepth)]
|
|
220
|
-
: undefined; // undefined = use default specifiedRules
|
|
239
|
+
const maxDepth = enforceDepthLimit ? env.maxQueryDepth : 0;
|
|
240
|
+
const rules = maxDepth > 0 ? [...specifiedRules, depthLimitRule(maxDepth)] : undefined; // undefined = use default specifiedRules
|
|
221
241
|
|
|
222
242
|
validationErrors = validate(gqlEntities.schema, document, rules);
|
|
223
|
-
if (entry) entry.validationErrors = validationErrors;
|
|
243
|
+
if (entry && enforceDepthLimit) entry.validationErrors = validationErrors;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Outside the block above, and never cached: the estimate depends on the
|
|
247
|
+
// request's variable values, so the same query text has different verdicts
|
|
248
|
+
// for different callers. Only for a query that already typechecks — an
|
|
249
|
+
// estimate drawn from a document that does not resolve against the schema
|
|
250
|
+
// would bury the error that actually explains the rejection.
|
|
251
|
+
if (enforceCostLimit && env.maxQueryCost > 0 && validationErrors.length === 0) {
|
|
252
|
+
const costError = checkQueryCost(
|
|
253
|
+
document,
|
|
254
|
+
gqlEntities.schema,
|
|
255
|
+
options?.variables ?? {},
|
|
256
|
+
pageLimits,
|
|
257
|
+
env.maxQueryCost,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (costError) validationErrors = [costError];
|
|
224
261
|
}
|
|
225
262
|
|
|
226
263
|
return {
|
|
@@ -234,6 +271,18 @@ export const handleGraphQLRequestFactory = (
|
|
|
234
271
|
variables: Record<string, unknown> = {},
|
|
235
272
|
req?: BunRequest,
|
|
236
273
|
session?: SessionContext,
|
|
274
|
+
// `enforcePageLimits: false` is for operator-authored queries (REST
|
|
275
|
+
// operations, cron jobs, the `gqlQuery` handed to operation hooks). They
|
|
276
|
+
// are configuration, not caller input, so a page cap could only reject
|
|
277
|
+
// the operator's own intent. Use `gql.operatorQuery` rather than passing
|
|
278
|
+
// this by hand.
|
|
279
|
+
//
|
|
280
|
+
// `timeoutMs` is the per-operation override only. Left undefined, each
|
|
281
|
+
// engine stays on its own default — the pool's on Postgres and SQL
|
|
282
|
+
// Server, QUERY_TIMEOUT_MS on MySQL, which has no pool-level route — so
|
|
283
|
+
// an ordinary caller query is bounded without the factory resolving
|
|
284
|
+
// anything.
|
|
285
|
+
options?: { enforcePageLimits?: boolean; timeoutMs?: number },
|
|
237
286
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
238
287
|
): Promise<{ data: any }> => {
|
|
239
288
|
const log = logger("graphql").child({ role: session?.role });
|
|
@@ -339,7 +388,14 @@ export const handleGraphQLRequestFactory = (
|
|
|
339
388
|
],
|
|
340
389
|
};
|
|
341
390
|
|
|
342
|
-
const sqlQueries = generateSQL(
|
|
391
|
+
const sqlQueries = generateSQL(
|
|
392
|
+
entities,
|
|
393
|
+
tableQueryAnalysis,
|
|
394
|
+
resolved.allVariables,
|
|
395
|
+
false,
|
|
396
|
+
(options?.enforcePageLimits ?? true) ? pageLimits : null,
|
|
397
|
+
options?.timeoutMs,
|
|
398
|
+
);
|
|
343
399
|
|
|
344
400
|
const data = await Promise.all<object>(
|
|
345
401
|
sqlQueries.map(([db, query]) =>
|
|
@@ -348,6 +404,7 @@ export const handleGraphQLRequestFactory = (
|
|
|
348
404
|
db,
|
|
349
405
|
resolved.variables,
|
|
350
406
|
resolved.allVariables as Record<string, string | number | boolean | null>,
|
|
407
|
+
options?.timeoutMs,
|
|
351
408
|
),
|
|
352
409
|
),
|
|
353
410
|
);
|
|
@@ -409,7 +466,20 @@ export const handleGraphQLRequestFactory = (
|
|
|
409
466
|
},
|
|
410
467
|
};
|
|
411
468
|
|
|
412
|
-
|
|
469
|
+
/**
|
|
470
|
+
* `handler` for operator-authored queries — REST operations, cron jobs, and
|
|
471
|
+
* the `gqlQuery` handed to operation hooks and handlers. Their text comes from
|
|
472
|
+
* configuration, never from a caller, so page limits do not apply.
|
|
473
|
+
*/
|
|
474
|
+
const operatorQuery = (
|
|
475
|
+
query: Parameters<typeof gql.handler>[0],
|
|
476
|
+
variables?: Parameters<typeof gql.handler>[1],
|
|
477
|
+
req?: Parameters<typeof gql.handler>[2],
|
|
478
|
+
session?: Parameters<typeof gql.handler>[3],
|
|
479
|
+
timeoutMs?: number,
|
|
480
|
+
) => gql.handler(query, variables, req, session, { enforcePageLimits: false, timeoutMs });
|
|
481
|
+
|
|
482
|
+
return { ...gql, operatorQuery };
|
|
413
483
|
};
|
|
414
484
|
|
|
415
485
|
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";
|
|
@@ -96,7 +97,9 @@ const handleGraphQLSubscriptionFactory = (
|
|
|
96
97
|
const schemaEntity = roles[session.role!];
|
|
97
98
|
|
|
98
99
|
// Validate query
|
|
99
|
-
const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query
|
|
100
|
+
const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query, {
|
|
101
|
+
variables,
|
|
102
|
+
});
|
|
100
103
|
|
|
101
104
|
if (hasErrors) {
|
|
102
105
|
queryEventEmitter.sendErrorToSingleClient(ws, id, validationErrors);
|
|
@@ -146,15 +149,32 @@ const handleGraphQLSubscriptionFactory = (
|
|
|
146
149
|
return;
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// The SQL builders read `$n` from a definition's index in the
|
|
153
|
+
// operation's own variable list, so the analysis handed to them has to
|
|
154
|
+
// be the resolved one — the raw analysis knows nothing of the statics
|
|
155
|
+
// that binding a role filter mints. Same construction as the HTTP path.
|
|
156
|
+
const resolvedAnalysis: AnalysisResult = {
|
|
157
|
+
...analysis,
|
|
158
|
+
operations: [
|
|
159
|
+
{
|
|
160
|
+
...operation,
|
|
161
|
+
fields: resolved.fields,
|
|
162
|
+
variables: resolved.variables,
|
|
163
|
+
},
|
|
164
|
+
...analysis.operations.slice(1),
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
|
|
149
168
|
// Build context for the strategy
|
|
150
169
|
const context: SubscriptionContext = {
|
|
151
170
|
ws,
|
|
152
171
|
subscriptionId: id,
|
|
153
|
-
analysis,
|
|
172
|
+
analysis: resolvedAnalysis,
|
|
154
173
|
field: resolved.fields[0],
|
|
155
174
|
variableDefinitions: resolved.variables,
|
|
156
175
|
variables: resolved.allVariables,
|
|
157
176
|
schemaEntity,
|
|
177
|
+
session,
|
|
158
178
|
eventEmitter: queryEventEmitter,
|
|
159
179
|
};
|
|
160
180
|
|
|
@@ -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),
|
|
@@ -108,7 +108,7 @@ export const handleRESTRequestFactory = (
|
|
|
108
108
|
|
|
109
109
|
if (routesInitDataPromises[route.routeKey] === undefined) {
|
|
110
110
|
routesInitDataPromises[route.routeKey] = await route.hooks?.init?.({
|
|
111
|
-
gqlQuery: gqlSuperadminHandler?.
|
|
111
|
+
gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
|
|
112
112
|
databases: databasesConnections,
|
|
113
113
|
queues: queueManager,
|
|
114
114
|
repository: repositoryMap,
|
|
@@ -269,7 +269,7 @@ export const handleRESTRequestFactory = (
|
|
|
269
269
|
try {
|
|
270
270
|
const result = await route.handler(
|
|
271
271
|
{
|
|
272
|
-
gqlQuery: gqlSuperadminHandler?.
|
|
272
|
+
gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
|
|
273
273
|
databases: databasesConnections,
|
|
274
274
|
queues: queueManager,
|
|
275
275
|
repository: repositoryMap,
|
|
@@ -330,7 +330,7 @@ export const handleRESTRequestFactory = (
|
|
|
330
330
|
// Execute the GraphQL request, then transform via afterRequest before
|
|
331
331
|
// caching so hits and misses return the same shape.
|
|
332
332
|
const result = await applyAfterRequest(
|
|
333
|
-
await gql.
|
|
333
|
+
await gql.operatorQuery(queryAnalysis!, variables, req, session, route.timeout),
|
|
334
334
|
);
|
|
335
335
|
|
|
336
336
|
// Cache the (already transformed) result
|
|
@@ -344,7 +344,9 @@ export const handleRESTRequestFactory = (
|
|
|
344
344
|
} else if (queryAnalysis) {
|
|
345
345
|
// No caching for this route, execute normally
|
|
346
346
|
return new S200(
|
|
347
|
-
await applyAfterRequest(
|
|
347
|
+
await applyAfterRequest(
|
|
348
|
+
await gql.operatorQuery(queryAnalysis, variables, req, session, route.timeout),
|
|
349
|
+
),
|
|
348
350
|
);
|
|
349
351
|
}
|
|
350
352
|
|
|
@@ -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.`);
|