@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
package/src/ai/mcp/index.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { timingSafeEqual } from "crypto";
|
|
2
|
-
|
|
3
1
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server";
|
|
4
2
|
|
|
3
|
+
import type { CapabilityAuthorizer } from "../../authentication/capabilities";
|
|
5
4
|
import type { AnalyzedConfiguration } from "../../configuration";
|
|
6
5
|
import type { CreateMcpServerOptions } from "./create-server";
|
|
7
6
|
|
|
8
7
|
import { createMcpServer } from "./create-server";
|
|
9
8
|
import { logger } from "../../logging";
|
|
10
|
-
|
|
11
|
-
const safeCompare = (a: string, b: string): boolean => {
|
|
12
|
-
const bufA = Buffer.from(a);
|
|
13
|
-
const bufB = Buffer.from(b);
|
|
14
|
-
if (bufA.length !== bufB.length) return false;
|
|
15
|
-
return timingSafeEqual(bufA, bufB);
|
|
16
|
-
};
|
|
9
|
+
import { audit } from "../../logging/audit";
|
|
17
10
|
|
|
18
11
|
export type CreateMCPRoutesOptions = CreateMcpServerOptions & {
|
|
19
12
|
requireAdminSecret?: boolean;
|
|
20
|
-
|
|
13
|
+
/** Decides whether the presented header grants `mcp`; without one the gate never opens. */
|
|
14
|
+
authorize?: CapabilityAuthorizer;
|
|
21
15
|
adminSecretHeader?: string;
|
|
22
16
|
};
|
|
23
17
|
|
|
@@ -34,16 +28,27 @@ const jsonRpcError = (status: number, code: number, message: string) =>
|
|
|
34
28
|
},
|
|
35
29
|
);
|
|
36
30
|
|
|
31
|
+
type RequestServer = { requestIP(req: Request): { address: string } | null };
|
|
32
|
+
|
|
37
33
|
const handleMcpPost =
|
|
38
34
|
(analyzedConfiguration: AnalyzedConfiguration, options: CreateMCPRoutesOptions) =>
|
|
39
|
-
async (req: Request) => {
|
|
35
|
+
async (req: Request, bunServer?: RequestServer) => {
|
|
40
36
|
if (options.requireAdminSecret) {
|
|
41
37
|
const headerName = options.adminSecretHeader ?? "x-admin-secret";
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
if (!provided || !expected || !safeCompare(provided, expected)) {
|
|
38
|
+
const grant = options.authorize?.(req.headers.get(headerName), "mcp");
|
|
39
|
+
if (!grant) {
|
|
45
40
|
return jsonRpcError(401, -32001, "Unauthorized: admin secret required");
|
|
46
41
|
}
|
|
42
|
+
const ip = bunServer?.requestIP(req)?.address;
|
|
43
|
+
audit().emit({
|
|
44
|
+
action: "admin_secret.used",
|
|
45
|
+
actor: {
|
|
46
|
+
type: "admin_secret",
|
|
47
|
+
scope: grant.superset ? "all" : "mcp",
|
|
48
|
+
...(ip ? { ip } : {}),
|
|
49
|
+
},
|
|
50
|
+
target: { kind: "mcp" },
|
|
51
|
+
});
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const server = createMcpServer(analyzedConfiguration, options);
|
package/src/ai/tools/core.ts
CHANGED
|
@@ -15,8 +15,10 @@ import type { BunRequest } from "bun";
|
|
|
15
15
|
import type { GraphQLNamedType, GraphQLSchema } from "graphql";
|
|
16
16
|
import type { GetSchemaReturn } from "../../configuration/getSchemas";
|
|
17
17
|
|
|
18
|
+
import { checkQueryCost } from "../../analyzeQuery/costLimit";
|
|
18
19
|
import { depthLimitRule } from "../../analyzeQuery/depthLimit";
|
|
19
20
|
import { categorizeSqlType, isNumericType, SqlTypeCategory } from "../../databases/sqlTypeUtils";
|
|
21
|
+
import { env } from "../../singletons/env";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* A per-role compiled schema bundle (tables, operations, handlers, …).
|
|
@@ -202,7 +204,10 @@ export type ValidationError = {
|
|
|
202
204
|
locations?: ReadonlyArray<{ line: number; column: number }>;
|
|
203
205
|
};
|
|
204
206
|
|
|
205
|
-
export type ValidateQueryFn = (
|
|
207
|
+
export type ValidateQueryFn = (
|
|
208
|
+
query: string,
|
|
209
|
+
variables?: Record<string, unknown>,
|
|
210
|
+
) => {
|
|
206
211
|
hasErrors: boolean;
|
|
207
212
|
validationErrors: readonly ValidationError[];
|
|
208
213
|
};
|
|
@@ -211,18 +216,37 @@ export type ValidateQueryFn = (query: string) => {
|
|
|
211
216
|
* Build a query validator for a role. Without a positive depth limit it
|
|
212
217
|
* delegates to the role's own `hasErrors`; with one it layers a depth-limit
|
|
213
218
|
* rule on the standard rule set.
|
|
219
|
+
*
|
|
220
|
+
* The cost budget applies on both branches, and there is no MCP-specific
|
|
221
|
+
* override for it. MCP gets its own depth knob because an agent legitimately
|
|
222
|
+
* writes deeper queries than an application does; nothing makes the same
|
|
223
|
+
* argument for asking a database for more rows.
|
|
214
224
|
*/
|
|
215
225
|
export const makeValidateQuery =
|
|
216
226
|
(role: RoleEntities, maxQueryDepth?: number): ValidateQueryFn =>
|
|
217
|
-
(query) => {
|
|
227
|
+
(query, variables) => {
|
|
218
228
|
if (maxQueryDepth === undefined || maxQueryDepth <= 0) {
|
|
219
|
-
return role.handlers.gql.hasErrors(query);
|
|
229
|
+
return role.handlers.gql.hasErrors(query, { variables });
|
|
220
230
|
}
|
|
221
|
-
const
|
|
231
|
+
const document = parse(query);
|
|
232
|
+
const errors = validate(role.schema, document, [
|
|
222
233
|
...specifiedRules,
|
|
223
234
|
depthLimitRule(maxQueryDepth),
|
|
224
235
|
]);
|
|
225
|
-
|
|
236
|
+
if (errors.length > 0) return { hasErrors: true, validationErrors: errors };
|
|
237
|
+
|
|
238
|
+
if (env.maxQueryCost > 0) {
|
|
239
|
+
const costError = checkQueryCost(
|
|
240
|
+
document,
|
|
241
|
+
role.schema,
|
|
242
|
+
variables ?? {},
|
|
243
|
+
{ defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize },
|
|
244
|
+
env.maxQueryCost,
|
|
245
|
+
);
|
|
246
|
+
if (costError) return { hasErrors: true, validationErrors: [costError] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { hasErrors: false, validationErrors: errors };
|
|
226
250
|
};
|
|
227
251
|
|
|
228
252
|
// ---- graphql_execute ----
|
|
@@ -246,7 +270,7 @@ export const executeGraphqlCore = async (
|
|
|
246
270
|
try {
|
|
247
271
|
if (containsNonQueryOperation(query)) return { kind: "non_query" };
|
|
248
272
|
|
|
249
|
-
const { hasErrors, validationErrors } = validateQuery(query);
|
|
273
|
+
const { hasErrors, validationErrors } = validateQuery(query, variables);
|
|
250
274
|
if (hasErrors) {
|
|
251
275
|
return {
|
|
252
276
|
kind: "validation",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GraphQLObjectType, GraphQLSchema } from "graphql";
|
|
1
|
+
import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema } from "graphql";
|
|
2
2
|
|
|
3
3
|
import type { FieldNode, SelectionNode } from "graphql";
|
|
4
4
|
import type { MergedEntities } from "../../configuration/getSchemas/mergeEntities";
|
|
@@ -55,6 +55,7 @@ export const analyzeSelections = (
|
|
|
55
55
|
const directives = analyzeDirectives(field.directives || [], generatedVariables);
|
|
56
56
|
|
|
57
57
|
const isRequired =
|
|
58
|
+
fieldType instanceof GraphQLNonNull &&
|
|
58
59
|
directives.filter(
|
|
59
60
|
(dir) => dir.name === "include" || dir.name === "skip" || dir.name === "when",
|
|
60
61
|
).length === 0;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { GraphQLError, GraphQLObjectType, Kind } from "graphql";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
DocumentNode,
|
|
5
|
+
FieldNode,
|
|
6
|
+
FragmentDefinitionNode,
|
|
7
|
+
GraphQLSchema,
|
|
8
|
+
OperationDefinitionNode,
|
|
9
|
+
SelectionSetNode,
|
|
10
|
+
ValueNode,
|
|
11
|
+
} from "graphql";
|
|
12
|
+
|
|
13
|
+
import { collectFragments } from "./fragments";
|
|
14
|
+
import { getFieldType, unwrapType } from "./typeUtils";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Page bounds the estimate is drawn against. `maxPageSize` is carried, not read:
|
|
18
|
+
* a resolved limit above it is rejected at execution, so the walk never has to
|
|
19
|
+
* clamp — but the estimator needs it the day that ordering changes.
|
|
20
|
+
*/
|
|
21
|
+
export type CostPageLimits = {
|
|
22
|
+
defaultPageSize: number;
|
|
23
|
+
maxPageSize: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type WalkContext = {
|
|
27
|
+
schema: GraphQLSchema;
|
|
28
|
+
variables: Record<string, unknown>;
|
|
29
|
+
pageLimits: CostPageLimits;
|
|
30
|
+
fragments: Map<string, FragmentDefinitionNode>;
|
|
31
|
+
// A fragment's cost depends only on its own body and type condition, so one
|
|
32
|
+
// expansion serves every spread of it. Without this a document whose
|
|
33
|
+
// fragments each spread the next one twice costs 2^n to estimate.
|
|
34
|
+
fragmentCosts: Map<string, number>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Resolve an argument to a number, following a variable into the runtime values.
|
|
38
|
+
// Mirrors `resolveLimitValue` in databases/common.ts so the estimate and the
|
|
39
|
+
// page-size cap agree on what "resolves to a number" means.
|
|
40
|
+
const resolveIntValue = (
|
|
41
|
+
value: ValueNode | undefined,
|
|
42
|
+
variables: Record<string, unknown>,
|
|
43
|
+
): number | undefined => {
|
|
44
|
+
if (!value) return undefined;
|
|
45
|
+
|
|
46
|
+
if (value.kind === Kind.INT) return Number.parseInt(value.value, 10);
|
|
47
|
+
|
|
48
|
+
if (value.kind === Kind.VARIABLE) {
|
|
49
|
+
const resolved = variables[value.name.value];
|
|
50
|
+
return typeof resolved === "number" ? resolved : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// How many rows a field's children are paid for. Only a list field multiplies:
|
|
57
|
+
// a to-one relationship, a single-row field and a scalar each cost their subtree
|
|
58
|
+
// once.
|
|
59
|
+
const multiplierFor = (field: FieldNode, isArray: boolean, ctx: WalkContext): number => {
|
|
60
|
+
if (!isArray) return 1;
|
|
61
|
+
|
|
62
|
+
const limit = field.arguments?.find((argument) => argument.name.value === "limit")?.value;
|
|
63
|
+
|
|
64
|
+
return resolveIntValue(limit, ctx.variables) ?? ctx.pageLimits.defaultPageSize;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const objectTypeNamed = (schema: GraphQLSchema, name: string): GraphQLObjectType | undefined => {
|
|
68
|
+
const type = schema.getType(name);
|
|
69
|
+
return type instanceof GraphQLObjectType ? type : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const costOfFragment = (name: string, ctx: WalkContext, onPath: Set<string>): number => {
|
|
73
|
+
const memoised = ctx.fragmentCosts.get(name);
|
|
74
|
+
if (memoised !== undefined) return memoised;
|
|
75
|
+
|
|
76
|
+
// A fragment already on the path is a cycle. `validate` rejects those before
|
|
77
|
+
// the estimate runs; cutting here only keeps a hand-built document finite.
|
|
78
|
+
if (onPath.has(name)) return 0;
|
|
79
|
+
|
|
80
|
+
const fragment = ctx.fragments.get(name);
|
|
81
|
+
if (!fragment) return 0;
|
|
82
|
+
|
|
83
|
+
onPath.add(name);
|
|
84
|
+
const cost = costOfSelectionSet(
|
|
85
|
+
fragment.selectionSet,
|
|
86
|
+
objectTypeNamed(ctx.schema, fragment.typeCondition.name.value),
|
|
87
|
+
ctx,
|
|
88
|
+
onPath,
|
|
89
|
+
);
|
|
90
|
+
onPath.delete(name);
|
|
91
|
+
|
|
92
|
+
ctx.fragmentCosts.set(name, cost);
|
|
93
|
+
return cost;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `cost(field) = 1 + multiplier(field) × Σ cost(child)`, summed across the
|
|
98
|
+
* selection set. An unresolved parent type costs its children once — the
|
|
99
|
+
* estimate degrades, it does not throw.
|
|
100
|
+
*/
|
|
101
|
+
const costOfSelectionSet = (
|
|
102
|
+
selectionSet: SelectionSetNode | undefined,
|
|
103
|
+
parentType: GraphQLObjectType | undefined,
|
|
104
|
+
ctx: WalkContext,
|
|
105
|
+
onPath: Set<string>,
|
|
106
|
+
): number => {
|
|
107
|
+
if (!selectionSet) return 0;
|
|
108
|
+
|
|
109
|
+
let total = 0;
|
|
110
|
+
|
|
111
|
+
for (const selection of selectionSet.selections) {
|
|
112
|
+
switch (selection.kind) {
|
|
113
|
+
case Kind.FIELD: {
|
|
114
|
+
if (selection.name.value.startsWith("__")) continue;
|
|
115
|
+
|
|
116
|
+
const fieldType = parentType ? getFieldType(parentType, selection.name.value) : undefined;
|
|
117
|
+
const unwrapped = fieldType ? unwrapType(fieldType) : undefined;
|
|
118
|
+
const childType = unwrapped?.type instanceof GraphQLObjectType ? unwrapped.type : undefined;
|
|
119
|
+
|
|
120
|
+
const children = costOfSelectionSet(selection.selectionSet, childType, ctx, onPath);
|
|
121
|
+
|
|
122
|
+
total += 1 + multiplierFor(selection, unwrapped?.isArray ?? false, ctx) * children;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case Kind.FRAGMENT_SPREAD: {
|
|
127
|
+
total += costOfFragment(selection.name.value, ctx, onPath);
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case Kind.INLINE_FRAGMENT: {
|
|
132
|
+
const condition = selection.typeCondition
|
|
133
|
+
? objectTypeNamed(ctx.schema, selection.typeCondition.name.value)
|
|
134
|
+
: parentType;
|
|
135
|
+
|
|
136
|
+
total += costOfSelectionSet(selection.selectionSet, condition, ctx, onPath);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return total;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const rootTypeFor = (
|
|
146
|
+
schema: GraphQLSchema,
|
|
147
|
+
operation: OperationDefinitionNode,
|
|
148
|
+
): GraphQLObjectType | undefined => {
|
|
149
|
+
switch (operation.operation) {
|
|
150
|
+
case "mutation":
|
|
151
|
+
return schema.getMutationType() ?? undefined;
|
|
152
|
+
case "subscription":
|
|
153
|
+
return schema.getSubscriptionType() ?? undefined;
|
|
154
|
+
default:
|
|
155
|
+
return schema.getQueryType() ?? undefined;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const costliestOperation = (
|
|
160
|
+
document: DocumentNode,
|
|
161
|
+
schema: GraphQLSchema,
|
|
162
|
+
variables: Record<string, unknown>,
|
|
163
|
+
pageLimits: CostPageLimits,
|
|
164
|
+
): { cost: number; operation: OperationDefinitionNode | undefined } => {
|
|
165
|
+
const ctx: WalkContext = {
|
|
166
|
+
schema,
|
|
167
|
+
variables,
|
|
168
|
+
pageLimits,
|
|
169
|
+
fragments: collectFragments(document),
|
|
170
|
+
fragmentCosts: new Map(),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let worst: { cost: number; operation: OperationDefinitionNode | undefined } = {
|
|
174
|
+
cost: 0,
|
|
175
|
+
operation: undefined,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const definition of document.definitions) {
|
|
179
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue;
|
|
180
|
+
|
|
181
|
+
const cost = costOfSelectionSet(
|
|
182
|
+
definition.selectionSet,
|
|
183
|
+
rootTypeFor(schema, definition),
|
|
184
|
+
ctx,
|
|
185
|
+
new Set<string>(),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (cost > worst.cost || worst.operation === undefined) {
|
|
189
|
+
worst = { cost, operation: definition };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return worst;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Estimate how many rows a document asks a database for, before executing it.
|
|
198
|
+
*
|
|
199
|
+
* Every field costs 1 for itself plus the cost of its children multiplied by the
|
|
200
|
+
* page size it requests — the `limit` argument for a list field, resolved through
|
|
201
|
+
* `variables`, falling back to `defaultPageSize`; 1 for anything that returns a
|
|
202
|
+
* single row. Fragment spreads are expanded, so moving a fan-out into a fragment
|
|
203
|
+
* does not hide it.
|
|
204
|
+
*
|
|
205
|
+
* A document carrying several operations is scored by its most expensive one:
|
|
206
|
+
* a request executes one of them.
|
|
207
|
+
*/
|
|
208
|
+
export const estimateQueryCost = (
|
|
209
|
+
document: DocumentNode,
|
|
210
|
+
schema: GraphQLSchema,
|
|
211
|
+
variables: Record<string, unknown>,
|
|
212
|
+
pageLimits: CostPageLimits,
|
|
213
|
+
): number => costliestOperation(document, schema, variables, pageLimits).cost;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Reject a document whose estimated cost exceeds `maxCost`. Returns `null` when
|
|
217
|
+
* it is within budget.
|
|
218
|
+
*/
|
|
219
|
+
export const checkQueryCost = (
|
|
220
|
+
document: DocumentNode,
|
|
221
|
+
schema: GraphQLSchema,
|
|
222
|
+
variables: Record<string, unknown>,
|
|
223
|
+
pageLimits: CostPageLimits,
|
|
224
|
+
maxCost: number,
|
|
225
|
+
): GraphQLError | null => {
|
|
226
|
+
const { cost, operation } = costliestOperation(document, schema, variables, pageLimits);
|
|
227
|
+
|
|
228
|
+
if (cost <= maxCost) return null;
|
|
229
|
+
|
|
230
|
+
const operationName = operation?.name?.value ?? "anonymous";
|
|
231
|
+
|
|
232
|
+
return new GraphQLError(
|
|
233
|
+
`Estimated query cost of ${cost} exceeds the maximum allowed cost of ${maxCost} (operation: "${operationName}"). Raise MAX_QUERY_COST to allow more expensive queries.`,
|
|
234
|
+
{ nodes: operation ? [operation] : undefined },
|
|
235
|
+
);
|
|
236
|
+
};
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { GraphQLError, Kind } from "graphql";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
DocumentNode,
|
|
7
|
-
FragmentDefinitionNode,
|
|
8
|
-
ValidationContext,
|
|
9
|
-
} from "graphql";
|
|
3
|
+
import type { ASTNode, ASTVisitor, FragmentDefinitionNode, ValidationContext } from "graphql";
|
|
4
|
+
|
|
5
|
+
import { collectFragments } from "./fragments";
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* Compute the depth of a selection set, resolving fragment spreads.
|
|
@@ -68,21 +64,6 @@ function computeDepth(
|
|
|
68
64
|
return max;
|
|
69
65
|
}
|
|
70
66
|
|
|
71
|
-
/**
|
|
72
|
-
* Collect all fragment definitions from a document.
|
|
73
|
-
*/
|
|
74
|
-
function collectFragments(document: DocumentNode): Map<string, FragmentDefinitionNode> {
|
|
75
|
-
const fragments = new Map<string, FragmentDefinitionNode>();
|
|
76
|
-
|
|
77
|
-
for (const def of document.definitions) {
|
|
78
|
-
if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
|
79
|
-
fragments.set(def.name.value, def);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return fragments;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
67
|
/**
|
|
87
68
|
* GraphQL validation rule that rejects queries exceeding a maximum nesting depth.
|
|
88
69
|
*
|
|
@@ -113,7 +94,7 @@ export const depthLimitRule =
|
|
|
113
94
|
|
|
114
95
|
context.reportError(
|
|
115
96
|
new GraphQLError(
|
|
116
|
-
`Query depth of ${depth} exceeds the maximum allowed depth of ${maxDepth} (operation: "${operationName}")
|
|
97
|
+
`Query depth of ${depth} exceeds the maximum allowed depth of ${maxDepth} (operation: "${operationName}"). Raise MAX_QUERY_DEPTH to allow deeper queries.`,
|
|
117
98
|
{ nodes: [def] },
|
|
118
99
|
),
|
|
119
100
|
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Kind } from "graphql";
|
|
2
|
+
|
|
3
|
+
import type { DocumentNode, FragmentDefinitionNode } from "graphql";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Collect all fragment definitions from a document.
|
|
7
|
+
*/
|
|
8
|
+
export const collectFragments = (document: DocumentNode): Map<string, FragmentDefinitionNode> => {
|
|
9
|
+
const fragments = new Map<string, FragmentDefinitionNode>();
|
|
10
|
+
|
|
11
|
+
for (const def of document.definitions) {
|
|
12
|
+
if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
|
13
|
+
fragments.set(def.name.value, def);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return fragments;
|
|
18
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Env } from "../types/env";
|
|
2
|
+
|
|
3
|
+
import { logger } from "../logging";
|
|
4
|
+
import { matchesAnySecret } from "./secrets";
|
|
5
|
+
|
|
6
|
+
export type Capability = "console:read" | "console:write" | "ai" | "mcp";
|
|
7
|
+
|
|
8
|
+
/** `superset` is true when the admin secret, not a scoped credential, matched. */
|
|
9
|
+
export type CapabilityGrant = { superset: boolean };
|
|
10
|
+
|
|
11
|
+
export type CapabilityAuthorizer = (
|
|
12
|
+
candidate: string | null,
|
|
13
|
+
capability: Capability,
|
|
14
|
+
) => CapabilityGrant | null;
|
|
15
|
+
|
|
16
|
+
type WarnLog = { warn: (obj: object, msg: string) => void };
|
|
17
|
+
|
|
18
|
+
// Each list is checked on its own so the rotation debug line inside
|
|
19
|
+
// matchesAnySecret keeps reporting the index within one credential.
|
|
20
|
+
const scopedSecretsFor = (env: Env, capability: Capability): string[][] => {
|
|
21
|
+
switch (capability) {
|
|
22
|
+
case "console:read":
|
|
23
|
+
return [env.console.writeSecrets, env.console.readSecrets];
|
|
24
|
+
case "console:write":
|
|
25
|
+
return [env.console.writeSecrets];
|
|
26
|
+
case "ai":
|
|
27
|
+
return [env.ai.secrets];
|
|
28
|
+
case "mcp":
|
|
29
|
+
return [env.ai.mcp.secrets];
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decides whether a presented secret grants a capability. The admin secret
|
|
35
|
+
* grants all of them and is reported as the superset so the caller can tell
|
|
36
|
+
* the two apart; it is also logged, since a scoped credential would have done.
|
|
37
|
+
*/
|
|
38
|
+
export const createCapabilityAuthorizer = (
|
|
39
|
+
env: Env,
|
|
40
|
+
log: WarnLog = logger("auth"),
|
|
41
|
+
): CapabilityAuthorizer => {
|
|
42
|
+
return (candidate, capability) => {
|
|
43
|
+
if (matchesAnySecret(candidate, env.admin.secrets)) {
|
|
44
|
+
log.warn({ capability }, "admin secret used where a scoped credential would do");
|
|
45
|
+
return { superset: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const secrets of scopedSecretsFor(env, capability)) {
|
|
49
|
+
if (matchesAnySecret(candidate, secrets)) return { superset: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -7,7 +7,7 @@ import { createPASETOService } from "./paseto";
|
|
|
7
7
|
export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): TokenService => {
|
|
8
8
|
switch (strategy) {
|
|
9
9
|
case "jwt": {
|
|
10
|
-
if (
|
|
10
|
+
if (env.jwt.secrets.length === 0) {
|
|
11
11
|
throw new Error(
|
|
12
12
|
"JWT_SECRET environment variable is required when using jwt token strategy",
|
|
13
13
|
);
|
|
@@ -15,7 +15,7 @@ export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): T
|
|
|
15
15
|
return createJWTService(env);
|
|
16
16
|
}
|
|
17
17
|
case "paseto_local": {
|
|
18
|
-
if (
|
|
18
|
+
if (env.paseto.localKeys.length === 0) {
|
|
19
19
|
throw new Error(
|
|
20
20
|
"PASETO_LOCAL_KEY environment variable is required when using paseto_local token strategy.",
|
|
21
21
|
);
|
|
@@ -23,7 +23,7 @@ export const createTokenService = (env: Env, strategy: TokenStrategy = "jwt"): T
|
|
|
23
23
|
return createPASETOService(env, "local");
|
|
24
24
|
}
|
|
25
25
|
case "paseto_public": {
|
|
26
|
-
if (!env.paseto.secretKey ||
|
|
26
|
+
if (!env.paseto.secretKey || env.paseto.publicKeys.length === 0) {
|
|
27
27
|
throw new Error(
|
|
28
28
|
"PASETO_SECRET_KEY and PASETO_PUBLIC_KEY environment variables are required when using paseto_public token strategy.",
|
|
29
29
|
);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { SignJWT, jwtVerify } from "jose";
|
|
1
|
+
import { SignJWT, errors, jwtVerify } from "jose";
|
|
4
2
|
|
|
5
3
|
import type { JWTPayload } from "jose";
|
|
6
4
|
import type { Env } from "../types/env";
|
|
@@ -9,6 +7,7 @@ import type { TokenRepository } from "./tokenRepository";
|
|
|
9
7
|
import type { TokenGenerationParameters, TokenOptions, TokenResponse, TokenService } from "./types";
|
|
10
8
|
|
|
11
9
|
import { parseDurationToSeconds } from "./duration";
|
|
10
|
+
import { matchesAnySecret } from "./secrets";
|
|
12
11
|
import { createTokenRepository } from "./tokenRepository";
|
|
13
12
|
import { logger } from "../logging";
|
|
14
13
|
|
|
@@ -16,18 +15,6 @@ import { logger } from "../logging";
|
|
|
16
15
|
const ACCESS_TOKEN_AUDIENCE = "access";
|
|
17
16
|
const REFRESH_TOKEN_AUDIENCE = "refresh";
|
|
18
17
|
|
|
19
|
-
// Timing-safe string comparison to prevent timing attacks
|
|
20
|
-
const safeCompare = (a: string, b: string): boolean => {
|
|
21
|
-
try {
|
|
22
|
-
const bufA = Buffer.from(a);
|
|
23
|
-
const bufB = Buffer.from(b);
|
|
24
|
-
if (bufA.length !== bufB.length) return false;
|
|
25
|
-
return timingSafeEqual(bufA, bufB);
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
|
|
31
18
|
export type { TokenGenerationParameters, TokenOptions, TokenResponse };
|
|
32
19
|
|
|
33
20
|
export type JWTTokenPayload = JWTPayload &
|
|
@@ -43,7 +30,9 @@ export const createJWTService = (
|
|
|
43
30
|
tokenRepositoryOverride?: TokenRepository,
|
|
44
31
|
): TokenService => {
|
|
45
32
|
const tokenRepository = tokenRepositoryOverride ?? createTokenRepository(env.cache.redisUrl);
|
|
46
|
-
|
|
33
|
+
// First entry signs; every entry verifies, so a rotation can overlap.
|
|
34
|
+
const jwtSecrets = env.jwt.secrets.map((secret) => new TextEncoder().encode(secret));
|
|
35
|
+
const signingSecret = jwtSecrets[0];
|
|
47
36
|
|
|
48
37
|
const createToken = async (
|
|
49
38
|
payload: TokenGenerationParameters,
|
|
@@ -67,7 +56,8 @@ export const createJWTService = (
|
|
|
67
56
|
jwt.setNotBefore(options.notBefore);
|
|
68
57
|
}
|
|
69
58
|
|
|
70
|
-
|
|
59
|
+
if (!signingSecret) throw new Error("No JWT secret configured");
|
|
60
|
+
return await jwt.sign(signingSecret);
|
|
71
61
|
} catch (error) {
|
|
72
62
|
logger("auth").child({ strategy: "jwt" }).error({ err: error }, "token creation failed");
|
|
73
63
|
throw new Error("Token creation failed");
|
|
@@ -78,23 +68,38 @@ export const createJWTService = (
|
|
|
78
68
|
token: string,
|
|
79
69
|
options: TokenOptions = {},
|
|
80
70
|
): Promise<T> => {
|
|
81
|
-
const
|
|
71
|
+
const verifyOptions = {
|
|
82
72
|
issuer: options.issuer,
|
|
83
73
|
audience: options.audience,
|
|
84
74
|
algorithms: [ALGORITHM],
|
|
85
75
|
typ: TYP,
|
|
86
|
-
}
|
|
76
|
+
};
|
|
87
77
|
|
|
88
|
-
|
|
78
|
+
let lastError: unknown = new Error("No JWT secret configured");
|
|
79
|
+
for (const [index, secret] of jwtSecrets.entries()) {
|
|
80
|
+
try {
|
|
81
|
+
const { payload } = await jwtVerify(token, secret, verifyOptions);
|
|
82
|
+
if (index > 0) {
|
|
83
|
+
logger("auth")
|
|
84
|
+
.child({ strategy: "jwt" })
|
|
85
|
+
.debug({ index }, "token verified with a previous secret");
|
|
86
|
+
}
|
|
87
|
+
return payload as T;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Only a signature mismatch means "try the next secret"; a claim
|
|
90
|
+
// failure came from the secret that did sign this token.
|
|
91
|
+
if (!(error instanceof errors.JWSSignatureVerificationFailed)) throw error;
|
|
92
|
+
lastError = error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw lastError;
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
const verifyTokenAndGetRole = async (
|
|
92
99
|
authHeader: string | null,
|
|
93
100
|
adminSecretHeader: string | null,
|
|
94
101
|
): Promise<string> => {
|
|
95
|
-
|
|
96
|
-
if (env.admin.secret && adminSecretHeader && safeCompare(adminSecretHeader, env.admin.secret))
|
|
97
|
-
return env.superadmin.role;
|
|
102
|
+
if (matchesAnySecret(adminSecretHeader, env.admin.secrets)) return env.superadmin.role;
|
|
98
103
|
|
|
99
104
|
if (!authHeader) return env.anonymousRole;
|
|
100
105
|
|
|
@@ -115,8 +120,8 @@ export const createJWTService = (
|
|
|
115
120
|
authHeader: string | null,
|
|
116
121
|
adminSecretHeader: string | null,
|
|
117
122
|
): Promise<SessionContext> => {
|
|
118
|
-
if (
|
|
119
|
-
return { sub: "superadmin", role: env.superadmin.role };
|
|
123
|
+
if (matchesAnySecret(adminSecretHeader, env.admin.secrets))
|
|
124
|
+
return { sub: "superadmin", role: env.superadmin.role, authMethod: "admin_secret" };
|
|
120
125
|
|
|
121
126
|
if (!authHeader || !authHeader.startsWith("Bearer "))
|
|
122
127
|
return { sub: "anonymous", role: env.anonymousRole };
|