@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.
@@ -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
- // Each field line roughly: `name(args): Type <directives>` separated by newlines.
68
- // We scan line-ish fragments split on top-level commas/newlines.
69
- const lines = body.split('\n').map((l) => l.trim()).filter(Boolean);
70
- for (const line of lines) {
71
- // Strip leading modifiers; capture field name as the first identifier.
72
- const nameMatch = /^(\w+)/.exec(line);
73
- if (!nameMatch) continue;
74
- const name = nameMatch[1]!;
75
- const external = /@external\b/.test(line);
76
- const provides = matchFields(line, '@provides');
77
- const requires = matchFields(line, '@requires');
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
+ }