@bhooai/nexus-graphql 2.0.11 → 2.0.13
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/package.json +6 -1
- package/src/builtin/helloSubgraph.ts +38 -0
- package/src/builtin/index.ts +3 -0
- package/src/explorer/explorerHtml.ts +1642 -0
- package/src/federation/createFederatedGateway.ts +6 -1
- package/src/federation/executor.ts +81 -6
- package/src/federation/parseMetadata.ts +13 -11
- package/src/gateway/graphqlHttpHandler.ts +31 -4
- package/src/index.ts +4 -1
- package/src/mutationCsrf.ts +25 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { parse, type DocumentNode, type ExecutionResult } from 'graphql';
|
|
2
2
|
import type { Subgraph, GraphQLContext, ExecuteParams } from '../types.js';
|
|
3
3
|
import { composeSupergraph, type Supergraph } from './composeSupergraph.js';
|
|
4
|
-
import { executeFederated, planQuery, type FetchPlan } from './executor.js';
|
|
4
|
+
import { executeFederated, subscribeFederated, planQuery, type FetchPlan } from './executor.js';
|
|
5
5
|
|
|
6
6
|
export interface FederatedGateway {
|
|
7
7
|
/** Client-facing merged schema. */
|
|
@@ -9,6 +9,7 @@ export interface FederatedGateway {
|
|
|
9
9
|
supergraph: Supergraph;
|
|
10
10
|
subgraphs: Subgraph[];
|
|
11
11
|
execute(params: Omit<ExecuteParams, 'schema' | 'resolvers'>): Promise<ExecutionResult>;
|
|
12
|
+
subscribe(params: Omit<ExecuteParams, 'schema' | 'resolvers'>): Promise<AsyncIterable<ExecutionResult> | ExecutionResult>;
|
|
12
13
|
/** Inspect the fetch plan for a query (golden tests / debugging). */
|
|
13
14
|
plan(document: DocumentNode): FetchPlan;
|
|
14
15
|
}
|
|
@@ -29,6 +30,10 @@ export function createFederatedGateway(subgraphs: Subgraph[]): FederatedGateway
|
|
|
29
30
|
const document: DocumentNode = params.document;
|
|
30
31
|
return executeFederated(supergraph, document, params.variableValues, (params.contextValue as GraphQLContext) ?? {});
|
|
31
32
|
},
|
|
33
|
+
async subscribe(params) {
|
|
34
|
+
const document: DocumentNode = params.document;
|
|
35
|
+
return subscribeFederated(supergraph, document, params.variableValues, (params.contextValue as GraphQLContext) ?? {});
|
|
36
|
+
},
|
|
32
37
|
plan(document) {
|
|
33
38
|
return planQuery(supergraph, document);
|
|
34
39
|
},
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { execute, parse, isObjectType, isInterfaceType, type DocumentNode, type SelectionSetNode, type FieldNode, type InlineFragmentNode, type FragmentSpreadNode, type ExecutionResult } from 'graphql';
|
|
1
|
+
import { execute, subscribe, parse, isObjectType, isInterfaceType, type DocumentNode, type SelectionSetNode, type FieldNode, type InlineFragmentNode, type FragmentSpreadNode, type FragmentDefinitionNode, type ExecutionResult } from 'graphql';
|
|
2
|
+
|
|
3
|
+
/** Introspection root fields (`__schema`/`__type`) live on the merged schema, not any one subgraph. */
|
|
4
|
+
function isIntrospectionSelection(sel: SelectionSetNode): boolean {
|
|
5
|
+
return sel.selections.some(
|
|
6
|
+
(s) => s.kind === 'Field' && (s.name.value === '__schema' || s.name.value === '__type'),
|
|
7
|
+
);
|
|
8
|
+
}
|
|
2
9
|
import type { Supergraph } from './composeSupergraph.js';
|
|
3
10
|
import type { Subgraph, GraphQLContext } from '../types.js';
|
|
4
|
-
import { createFieldResolver } from '../gateway/fieldResolver.js';
|
|
11
|
+
import { createFieldResolver, createSubscribeFieldResolver } from '../gateway/fieldResolver.js';
|
|
5
12
|
|
|
6
13
|
/**
|
|
7
14
|
* Naive sequential federated executor (Phase 6, v1).
|
|
@@ -67,11 +74,24 @@ export async function executeFederated(
|
|
|
67
74
|
contextValue: GraphQLContext,
|
|
68
75
|
): Promise<ExecutionResult> {
|
|
69
76
|
const op = document.definitions.find((d) => d.kind === 'OperationDefinition') as
|
|
70
|
-
| { selectionSet: SelectionSetNode; operation: string }
|
|
77
|
+
| { selectionSet: SelectionSetNode; operation: string; variableDefinitions?: readonly { variable?: { name?: { value?: string } } }[] }
|
|
71
78
|
| undefined;
|
|
72
79
|
if (!op) return { errors: [{ message: 'No operation in document' }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
73
80
|
const rootType = op.operation === 'mutation' ? 'Mutation' : op.operation === 'subscription' ? 'Subscription' : 'Query';
|
|
74
81
|
|
|
82
|
+
// Introspection (`__schema`/`__type`) belongs to the merged schema, not a
|
|
83
|
+
// subgraph — execute it directly against the composed schema so the explorer
|
|
84
|
+
// Docs panel and standard clients can fetch the full schema.
|
|
85
|
+
if (isIntrospectionSelection(op.selectionSet)) {
|
|
86
|
+
return execute({
|
|
87
|
+
schema: supergraph.schema,
|
|
88
|
+
document,
|
|
89
|
+
rootValue: undefined,
|
|
90
|
+
contextValue,
|
|
91
|
+
variableValues,
|
|
92
|
+
}) as Promise<ExecutionResult>;
|
|
93
|
+
}
|
|
94
|
+
|
|
75
95
|
const subgraphByName = new Map<string, Subgraph>(supergraph.subgraphs.map((s) => [s.name, s]));
|
|
76
96
|
|
|
77
97
|
// 1. Root pass: group top-level fields by owner and execute trimmed subqueries.
|
|
@@ -89,7 +109,7 @@ export async function executeFederated(
|
|
|
89
109
|
for (const [subName, fields] of rootGroups) {
|
|
90
110
|
const sub = subgraphByName.get(subName)!;
|
|
91
111
|
const sel = trimSelection(fields, rootType, subName, supergraph, true);
|
|
92
|
-
const sdl = `${op.operation} { ${sel} }`;
|
|
112
|
+
const sdl = `${op.operation}${varDefsToSdl(op.variableDefinitions)} { ${sel} }`;
|
|
93
113
|
const res = await execSubgraph(sub, parse(sdl), variableValues, contextValue);
|
|
94
114
|
if (res.errors) return { errors: res.errors };
|
|
95
115
|
Object.assign(data, res.data as Record<string, unknown>);
|
|
@@ -127,6 +147,43 @@ async function execSubgraph(sub: Subgraph, document: DocumentNode, variableValue
|
|
|
127
147
|
});
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Execute a federated subscription in-process. Subscriptions are not federated
|
|
152
|
+
* in v1 — a subscription's root field is owned by exactly one subgraph, so we
|
|
153
|
+
* route the whole operation to that subgraph and subscribe with its resolvers.
|
|
154
|
+
*/
|
|
155
|
+
export async function subscribeFederated(
|
|
156
|
+
supergraph: Supergraph,
|
|
157
|
+
document: DocumentNode,
|
|
158
|
+
variableValues: Record<string, any> | undefined,
|
|
159
|
+
contextValue: GraphQLContext,
|
|
160
|
+
): Promise<AsyncIterable<ExecutionResult> | ExecutionResult> {
|
|
161
|
+
const op = document.definitions.find((d) => d.kind === 'OperationDefinition') as
|
|
162
|
+
| { selectionSet: SelectionSetNode; operation: string }
|
|
163
|
+
| undefined;
|
|
164
|
+
if (!op) return { errors: [{ message: 'No operation in document' }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
165
|
+
if (op.operation !== 'subscription') return { errors: [{ message: 'subscribe() only supports subscription operations' }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
166
|
+
|
|
167
|
+
const field = op.selectionSet.selections.find((s) => s.kind === 'Field') as FieldNode | undefined;
|
|
168
|
+
if (!field) return { errors: [{ message: 'No subscription field found' }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
169
|
+
|
|
170
|
+
const owner = supergraph.ownership.get('Subscription')?.get(field.name.value);
|
|
171
|
+
if (!owner) return { errors: [{ message: `No subgraph owns Subscription.${field.name.value}` }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
172
|
+
|
|
173
|
+
const sub = supergraph.subgraphs.find((s) => s.name === owner);
|
|
174
|
+
if (!sub) return { errors: [{ message: `Subgraph "${owner}" not found` }] as unknown as NonNullable<ExecutionResult['errors']> };
|
|
175
|
+
|
|
176
|
+
return subscribe({
|
|
177
|
+
schema: sub.schema,
|
|
178
|
+
document,
|
|
179
|
+
rootValue: undefined,
|
|
180
|
+
contextValue,
|
|
181
|
+
variableValues,
|
|
182
|
+
subscribeFieldResolver: createSubscribeFieldResolver(sub.resolvers),
|
|
183
|
+
fieldResolver: createFieldResolver(sub.resolvers),
|
|
184
|
+
}) as Promise<AsyncIterable<ExecutionResult> | ExecutionResult>;
|
|
185
|
+
}
|
|
186
|
+
|
|
130
187
|
/** Unwrap a GraphQL type name to its base named type. */
|
|
131
188
|
function baseType(type: string): string {
|
|
132
189
|
return type.replace(/[\[\]!]/g, '');
|
|
@@ -187,9 +244,27 @@ function trimSelection(selections: readonly (FieldNode | InlineFragmentNode | Fr
|
|
|
187
244
|
return parts.join(' ');
|
|
188
245
|
}
|
|
189
246
|
|
|
247
|
+
/** Serialize a document's variable definitions back to SDL, e.g. `($id: ID!, $title: String!)`. */
|
|
248
|
+
function varDefsToSdl(defs: readonly { variable?: { name?: { value?: string } }; type?: import('graphql').TypeNode; defaultValue?: import('graphql').ConstValueNode }[] | undefined): string {
|
|
249
|
+
if (!defs || defs.length === 0) return '';
|
|
250
|
+
const parts = defs.map((d) => {
|
|
251
|
+
const name = d.variable?.name?.value;
|
|
252
|
+
if (!name || !d.type) return '';
|
|
253
|
+
return `$${name}: ${typeToSdl(d.type)}${d.defaultValue !== undefined ? ` = ${valueToSdl(d.defaultValue)}` : ''}`;
|
|
254
|
+
}).filter(Boolean);
|
|
255
|
+
return parts.length ? `(${parts.join(', ')})` : '';
|
|
256
|
+
}
|
|
257
|
+
function typeToSdl(t: import('graphql').TypeNode): string {
|
|
258
|
+
switch (t.kind) {
|
|
259
|
+
case 'NamedType': return t.name.value;
|
|
260
|
+
case 'ListType': return `[${typeToSdl(t.type)}]`;
|
|
261
|
+
case 'NonNullType': return `${typeToSdl(t.type)}!`;
|
|
262
|
+
default: return '';
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
190
266
|
/** Serialize a field's arguments to SDL, e.g. `(id: "u1", limit: $limit)`. */
|
|
191
|
-
function argsToSdl(args: readonly import('graphql').ArgumentNode[] | undefined): string {
|
|
192
|
-
if (!args || args.length === 0) return '';
|
|
267
|
+
function argsToSdl(args: readonly import('graphql').ArgumentNode[] | undefined): string { if (!args || args.length === 0) return '';
|
|
193
268
|
return '(' + args.map((a) => `${a.name.value}: ${valueToSdl(a.value)}`).join(', ') + ')';
|
|
194
269
|
}
|
|
195
270
|
function valueToSdl(v: import('graphql').ValueNode): string {
|
|
@@ -64,17 +64,19 @@ function balancedBody(s: string, start: number): string {
|
|
|
64
64
|
/** Parse field definitions in a type body into name → FieldMeta. */
|
|
65
65
|
function parseFields(body: string): Map<string, FieldMeta> {
|
|
66
66
|
const out = new Map<string, FieldMeta>();
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
67
|
+
// A field is `name(args)?: Type <directives>` — a type like `[String!]!` never
|
|
68
|
+
// contains `@`, so a field run ends when the next field name (before its `:`)
|
|
69
|
+
// begins. Scanning with a regex (rather than splitting on newlines) handles
|
|
70
|
+
// bodies that pack many fields onto one line, e.g.
|
|
71
|
+
// type Message @key(fields: "id") { id: ID! roomId: String! text: String! }
|
|
72
|
+
const fieldRe = /(\w+)\s*(?:\([^)]*\))?\s*:\s*[\w\[\]!()]+\s*(?:@\w+(?:\s*\([^)]*\))?)*/g;
|
|
73
|
+
let m: RegExpExecArray | null;
|
|
74
|
+
while ((m = fieldRe.exec(body)) !== null) {
|
|
75
|
+
const name = m[1]!;
|
|
76
|
+
const text = m[0];
|
|
77
|
+
const external = /@external\b/.test(text);
|
|
78
|
+
const provides = matchFields(text, '@provides');
|
|
79
|
+
const requires = matchFields(text, '@requires');
|
|
78
80
|
out.set(name, { external, provides, requires });
|
|
79
81
|
}
|
|
80
82
|
return out;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { parse, type DocumentNode } from 'graphql';
|
|
1
|
+
import { parse, type DocumentNode, type OperationDefinitionNode } from 'graphql';
|
|
2
2
|
import type { RequestContext, Handler } from '@bhooai/nexus-core';
|
|
3
3
|
import type { Gateway, GraphQLContext } from '../types.js';
|
|
4
|
+
import { verifyMutationCsrf } from '@bhooai/nexus-auth';
|
|
4
5
|
|
|
5
6
|
export interface GraphqlHandlerOptions {
|
|
6
7
|
/** The gateway to execute against. */
|
|
@@ -9,12 +10,23 @@ export interface GraphqlHandlerOptions {
|
|
|
9
10
|
context?: (ctx: RequestContext) => GraphQLContext | Promise<GraphQLContext>;
|
|
10
11
|
/** Allow introspection queries (default follows config.graphql.introspection = true). */
|
|
11
12
|
introspection?: boolean;
|
|
13
|
+
/** Require a matching CSRF token to execute mutations (default true). The token may be sent via the `X-CSRF-Token` header or a `csrf` argument, and must match the double-submit cookie. Obtain it from GET /csrf-token or the GraphQL `csrfToken` query. */
|
|
14
|
+
requireMutationCsrf?: boolean;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
interface GraphQLRequestBody {
|
|
15
18
|
query?: string;
|
|
16
19
|
variables?: Record<string, any>;
|
|
17
20
|
operationName?: string;
|
|
21
|
+
csrf?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when any operation in the document is a mutation. */
|
|
25
|
+
function hasMutationOperation(doc: DocumentNode): boolean {
|
|
26
|
+
for (const def of doc.definitions) {
|
|
27
|
+
if (def.kind === 'OperationDefinition' && (def as OperationDefinitionNode).operation === 'mutation') return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
/** True when a parsed operation set contains an introspection field (`__schema`/`__type`). */
|
|
@@ -33,15 +45,16 @@ function isIntrospectionDocument(doc: DocumentNode): boolean {
|
|
|
33
45
|
/**
|
|
34
46
|
* HTTP handler for `/graphql`. Accepts POST with `{ query, variables, operationName }`
|
|
35
47
|
* (or GET with `?query=`). Applies the gateway in-process. Introspection is
|
|
36
|
-
* rejected when disabled.
|
|
48
|
+
* rejected when disabled. Mutations additionally require a CSRF token (via the
|
|
49
|
+
* `X-CSRF-Token` header or a `csrf` argument) that matches the double-submit cookie.
|
|
37
50
|
*/
|
|
38
51
|
export function graphqlHttpHandler(options: GraphqlHandlerOptions): Handler {
|
|
39
|
-
const { gateway, introspection = true } = options;
|
|
52
|
+
const { gateway, introspection = true, requireMutationCsrf = true } = options;
|
|
40
53
|
return async (ctx: RequestContext) => {
|
|
41
54
|
let body: GraphQLRequestBody;
|
|
42
55
|
if (ctx.method === 'GET') {
|
|
43
56
|
const q = ctx.query.query;
|
|
44
|
-
body = { query: Array.isArray(q) ? q[0] : q, variables: parseJsonQuery(ctx.query.variables), operationName: single(ctx.query.operationName) };
|
|
57
|
+
body = { query: Array.isArray(q) ? q[0] : q, variables: parseJsonQuery(ctx.query.variables), operationName: single(ctx.query.operationName), csrf: single(ctx.query.csrf) };
|
|
45
58
|
} else {
|
|
46
59
|
body = (ctx.body as GraphQLRequestBody) ?? {};
|
|
47
60
|
}
|
|
@@ -64,6 +77,20 @@ export function graphqlHttpHandler(options: GraphqlHandlerOptions): Handler {
|
|
|
64
77
|
return;
|
|
65
78
|
}
|
|
66
79
|
|
|
80
|
+
// Mutations are unsafe: require a matching CSRF token equal to the double-submit
|
|
81
|
+
// cookie. The token may arrive as an argument (body.csrf, variables.csrf, GET
|
|
82
|
+
// ?csrf=) OR in the X-CSRF-Token header — either channel matching the cookie passes.
|
|
83
|
+
if (requireMutationCsrf && hasMutationOperation(document)) {
|
|
84
|
+
const headerToken = single(ctx.headers['x-csrf-token'] as string | string[] | undefined);
|
|
85
|
+
const argToken =
|
|
86
|
+
body.csrf ??
|
|
87
|
+
(body.variables && typeof body.variables === 'object' ? (body.variables as Record<string, unknown>).csrf as string : undefined);
|
|
88
|
+
if (!verifyMutationCsrf(ctx, argToken, headerToken)) {
|
|
89
|
+
ctx.json({ errors: [{ message: 'Invalid CSRF token: required for mutations.' }] }, 403);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
const contextValue = options.context
|
|
68
95
|
? await options.context(ctx)
|
|
69
96
|
: { request: ctx, user: (ctx.state.user as GraphQLContext['user']) };
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from './types.js';
|
|
2
|
+
export * from './mutationCsrf.js';
|
|
2
3
|
export * from './subgraph/federationDirectives.js';
|
|
3
4
|
export * from './subgraph/parseKeys.js';
|
|
4
5
|
export * from './subgraph/defineSubgraph.js';
|
|
6
|
+
export * from './explorer/explorerHtml.js';
|
|
5
7
|
export * from './gateway/fieldResolver.js';
|
|
6
8
|
export * from './gateway/publicSchema.js';
|
|
7
9
|
export * from './gateway/createGateway.js';
|
|
@@ -11,4 +13,5 @@ export * from './subscriptions/SubscriptionServer.js';
|
|
|
11
13
|
export * from './federation/parseMetadata.js';
|
|
12
14
|
export * from './federation/composeSupergraph.js';
|
|
13
15
|
export * from './federation/executor.js';
|
|
14
|
-
export * from './federation/createFederatedGateway.js';
|
|
16
|
+
export * from './federation/createFederatedGateway.js';
|
|
17
|
+
export * from './builtin/helloSubgraph.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GraphQLContext } from './types.js';
|
|
2
|
+
import { verifyMutationCsrf } from '@bhooai/nexus-auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate a mutation's CSRF token against the request's double-submit cookie.
|
|
6
|
+
* The token may be passed as the `csrfToken` argument or in the `X-CSRF-Token`
|
|
7
|
+
* header — either channel matching the cookie passes. Throws when missing or
|
|
8
|
+
* mismatched, so per-mutation resolvers can gate writes explicitly (defense-in-depth
|
|
9
|
+
* on top of the HTTP-level enforcement).
|
|
10
|
+
*/
|
|
11
|
+
export function assertMutationCsrf(context: GraphQLContext, csrfToken: string | undefined): void {
|
|
12
|
+
const ctx = context?.request as
|
|
13
|
+
| { headers?: Record<string, string | string[] | undefined> }
|
|
14
|
+
| undefined;
|
|
15
|
+
if (!ctx) throw new Error('Invalid CSRF token: required for this mutation.');
|
|
16
|
+
const headerToken =
|
|
17
|
+
ctx.headers?.['x-csrf-token'] !== undefined
|
|
18
|
+
? Array.isArray(ctx.headers['x-csrf-token'])
|
|
19
|
+
? (ctx.headers['x-csrf-token'] as string[])[0]
|
|
20
|
+
: (ctx.headers['x-csrf-token'] as string)
|
|
21
|
+
: undefined;
|
|
22
|
+
if (!verifyMutationCsrf(ctx as Parameters<typeof verifyMutationCsrf>[0], csrfToken, headerToken)) {
|
|
23
|
+
throw new Error('Invalid CSRF token: required for this mutation.');
|
|
24
|
+
}
|
|
25
|
+
}
|