@salesforce/graphiti 11.31.14 → 11.32.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/CHANGELOG.md +10 -0
- package/dist/intent/build-detail.js +3 -1
- package/dist/intent/build-detail.js.map +1 -1
- package/dist/intent/build-list.js +8 -1
- package/dist/intent/build-list.js.map +1 -1
- package/dist/intent/build-mutation.js +6 -1
- package/dist/intent/build-mutation.js.map +1 -1
- package/dist/intent/select-child-relationship.js +9 -0
- package/dist/intent/select-child-relationship.js.map +1 -1
- package/dist/lib/graphql-name.d.ts +18 -0
- package/dist/lib/graphql-name.js +22 -0
- package/dist/lib/graphql-name.js.map +1 -1
- package/dist/lib/variable-promotion.js +10 -0
- package/dist/lib/variable-promotion.js.map +1 -1
- package/dist/schemas/fields.d.ts +10 -0
- package/dist/schemas/fields.js +21 -2
- package/dist/schemas/fields.js.map +1 -1
- package/dist/schemas/input-schemas.js +14 -14
- package/dist/schemas/input-schemas.js.map +1 -1
- package/package.json +1 -1
- package/src/intent/__tests__/build-aggregate.spec.ts +29 -0
- package/src/intent/__tests__/build-create-validation.spec.ts +37 -0
- package/src/intent/__tests__/build-detail.spec.ts +96 -0
- package/src/intent/__tests__/build-list.spec.ts +162 -1
- package/src/intent/__tests__/build-update-validation.spec.ts +20 -0
- package/src/intent/build-detail.ts +3 -1
- package/src/intent/build-list.ts +8 -1
- package/src/intent/build-mutation.ts +6 -1
- package/src/intent/select-child-relationship.ts +10 -0
- package/src/lib/__tests__/graphql-name.spec.ts +72 -7
- package/src/lib/graphql-name.ts +26 -0
- package/src/lib/variable-promotion.ts +12 -0
- package/src/mcp/tools/__tests__/error-surface.contract.spec.ts +23 -13
- package/src/schemas/__tests__/input-schemas.spec.ts +153 -0
- package/src/schemas/fields.ts +29 -2
- package/src/schemas/input-schemas.ts +15 -13
|
@@ -68,6 +68,7 @@ const SCHEMA_SDL = `
|
|
|
68
68
|
Id: ID!
|
|
69
69
|
Name: StringValue
|
|
70
70
|
Industry: StringValue
|
|
71
|
+
Amount__c: StringValue
|
|
71
72
|
Owner: OwnerUnion
|
|
72
73
|
Contacts(first: Int, where: Contact_Filter, orderBy: Contact_OrderBy): ContactConnection
|
|
73
74
|
}
|
|
@@ -390,3 +391,98 @@ describe("intent/build-detail", () => {
|
|
|
390
391
|
expect(out.query).toMatch(/first\s*:\s*\$childFirst\b/);
|
|
391
392
|
});
|
|
392
393
|
});
|
|
394
|
+
|
|
395
|
+
describe("intent/build-detail — selection-set injection (W-22735537)", () => {
|
|
396
|
+
it("rejects a fields[] breakout", async () => {
|
|
397
|
+
await expect(
|
|
398
|
+
buildDetail(
|
|
399
|
+
{ org: ORG, object: "Account", fields: ["Id } injectedAlias: Name { value"] },
|
|
400
|
+
noopPrimeDeps(),
|
|
401
|
+
),
|
|
402
|
+
).rejects.toThrow(/fields entry .* is not a valid field path/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("rejects a parentFields[] breakout", async () => {
|
|
406
|
+
await expect(
|
|
407
|
+
buildDetail(
|
|
408
|
+
{
|
|
409
|
+
org: ORG,
|
|
410
|
+
object: "Account",
|
|
411
|
+
fields: ["Id"],
|
|
412
|
+
parentFields: ["Owner.Name } evil { value"],
|
|
413
|
+
},
|
|
414
|
+
noopPrimeDeps(),
|
|
415
|
+
),
|
|
416
|
+
).rejects.toThrow(/parentFields entry .* is not a valid field path/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("rejects a childRelationships[].relationshipName breakout", async () => {
|
|
420
|
+
await expect(
|
|
421
|
+
buildDetail(
|
|
422
|
+
{
|
|
423
|
+
org: ORG,
|
|
424
|
+
object: "Account",
|
|
425
|
+
fields: ["Id"],
|
|
426
|
+
childRelationships: [
|
|
427
|
+
{ relationshipName: "Contacts } injectedSibling: Account { Id", fields: ["Id"] },
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
noopPrimeDeps(),
|
|
431
|
+
),
|
|
432
|
+
).rejects.toThrow(/selectChildRelationship: relationshipName .* is not a valid GraphQL Name/);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("rejects a childRelationships[].fields breakout", async () => {
|
|
436
|
+
await expect(
|
|
437
|
+
buildDetail(
|
|
438
|
+
{
|
|
439
|
+
org: ORG,
|
|
440
|
+
object: "Account",
|
|
441
|
+
fields: ["Id"],
|
|
442
|
+
childRelationships: [
|
|
443
|
+
{ relationshipName: "Contacts", fields: ["Id } evil: LastName { value"] },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
noopPrimeDeps(),
|
|
447
|
+
),
|
|
448
|
+
).rejects.toThrow(/childRelationships fields entry .* is not a valid field path/);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("rejects a childRelationships[].filter object-KEY breakout", async () => {
|
|
452
|
+
await expect(
|
|
453
|
+
buildDetail(
|
|
454
|
+
{
|
|
455
|
+
org: ORG,
|
|
456
|
+
object: "Account",
|
|
457
|
+
fields: ["Id"],
|
|
458
|
+
childRelationships: [
|
|
459
|
+
{
|
|
460
|
+
relationshipName: "Contacts",
|
|
461
|
+
fields: ["Id"],
|
|
462
|
+
filter: { "Title } evil { value": { eq: "x" } },
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
},
|
|
466
|
+
noopPrimeDeps(),
|
|
467
|
+
),
|
|
468
|
+
).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("accepts a legit dotted parentField (Owner.Name)", async () => {
|
|
472
|
+
const out = await buildDetail(
|
|
473
|
+
{ org: ORG, object: "Account", fields: ["Id"], parentFields: ["Owner.Name"] },
|
|
474
|
+
noopPrimeDeps(),
|
|
475
|
+
);
|
|
476
|
+
expect(out.query).toContain("Owner");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// PR #678 review (Ciaran Hannigan): the __c custom-field accept-case the
|
|
480
|
+
// charset tests assert in isolation, exercised end-to-end through the builder.
|
|
481
|
+
it("accepts a Salesforce custom field (__c) and renders it", async () => {
|
|
482
|
+
const out = await buildDetail(
|
|
483
|
+
{ org: ORG, object: "Account", fields: ["Id", "Amount__c"] },
|
|
484
|
+
noopPrimeDeps(),
|
|
485
|
+
);
|
|
486
|
+
expect(out.query).toContain("Amount__c");
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* For full license text, see the LICENSE.txt file
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { buildSchema } from "graphql";
|
|
7
|
+
import { buildSchema, parse } from "graphql";
|
|
8
8
|
import { describe, expect, it, vi } from "vitest";
|
|
9
9
|
import { makeNoopPrimeDeps } from "../../__tests__/helpers/prime-deps.js";
|
|
10
10
|
import * as sessionModule from "../../lib/session.js";
|
|
@@ -55,6 +55,7 @@ const SCHEMA_SDL = `
|
|
|
55
55
|
Id: ID!
|
|
56
56
|
Name: StringValue
|
|
57
57
|
Industry: StringValue
|
|
58
|
+
Amount__c: StringValue
|
|
58
59
|
Owner: OwnerUnion
|
|
59
60
|
Contacts(first: Int, where: Contact_Filter, orderBy: Contact_OrderBy): ContactConnection
|
|
60
61
|
}
|
|
@@ -471,3 +472,163 @@ describe("intent/build-list", () => {
|
|
|
471
472
|
expect(out.query).toMatch(/first\s*:\s*10\b/);
|
|
472
473
|
});
|
|
473
474
|
});
|
|
475
|
+
|
|
476
|
+
describe("intent/build-list — selection-set injection (W-22735537)", () => {
|
|
477
|
+
it("rejects a fields[] selection-set breakout", async () => {
|
|
478
|
+
await expect(
|
|
479
|
+
buildList(
|
|
480
|
+
{ org: ORG, object: "Account", fields: ["Id } injectedAlias: Name { value"] },
|
|
481
|
+
noopPrimeDeps(),
|
|
482
|
+
),
|
|
483
|
+
).rejects.toThrow(/fields entry .* is not a valid field path/);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("rejects a parentFields[] breakout", async () => {
|
|
487
|
+
await expect(
|
|
488
|
+
buildList(
|
|
489
|
+
{
|
|
490
|
+
org: ORG,
|
|
491
|
+
object: "Account",
|
|
492
|
+
fields: ["Id"],
|
|
493
|
+
parentFields: ["Owner.Name } evil { value"],
|
|
494
|
+
},
|
|
495
|
+
noopPrimeDeps(),
|
|
496
|
+
),
|
|
497
|
+
).rejects.toThrow(/parentFields entry .* is not a valid field path/);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("rejects a childRelationships[].fields breakout", async () => {
|
|
501
|
+
await expect(
|
|
502
|
+
buildList(
|
|
503
|
+
{
|
|
504
|
+
org: ORG,
|
|
505
|
+
object: "Account",
|
|
506
|
+
fields: ["Id"],
|
|
507
|
+
childRelationships: [
|
|
508
|
+
{ relationshipName: "Contacts", fields: ["Id } evil: LastName { value"] },
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
noopPrimeDeps(),
|
|
512
|
+
),
|
|
513
|
+
).rejects.toThrow(/childRelationships fields entry .* is not a valid field path/);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("rejects a childRelationships[].relationshipName breakout", async () => {
|
|
517
|
+
await expect(
|
|
518
|
+
buildList(
|
|
519
|
+
{
|
|
520
|
+
org: ORG,
|
|
521
|
+
object: "Account",
|
|
522
|
+
fields: ["Id"],
|
|
523
|
+
childRelationships: [
|
|
524
|
+
{ relationshipName: "Contacts } injectedSibling: Account { Id", fields: ["Id"] },
|
|
525
|
+
],
|
|
526
|
+
},
|
|
527
|
+
noopPrimeDeps(),
|
|
528
|
+
),
|
|
529
|
+
).rejects.toThrow(/selectChildRelationship: relationshipName .* is not a valid GraphQL Name/);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("rejects a filter object-KEY breakout (argument-position injection)", async () => {
|
|
533
|
+
await expect(
|
|
534
|
+
buildList(
|
|
535
|
+
{
|
|
536
|
+
org: ORG,
|
|
537
|
+
object: "Account",
|
|
538
|
+
fields: ["Id"],
|
|
539
|
+
filter: { "Industry } evilField { value } sib: Name(x": { eq: "Tech" } },
|
|
540
|
+
},
|
|
541
|
+
noopPrimeDeps(),
|
|
542
|
+
),
|
|
543
|
+
).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("rejects an orderBy object-KEY breakout", async () => {
|
|
547
|
+
await expect(
|
|
548
|
+
buildList(
|
|
549
|
+
{
|
|
550
|
+
org: ORG,
|
|
551
|
+
object: "Account",
|
|
552
|
+
fields: ["Id"],
|
|
553
|
+
orderBy: { "Name } evil { value": { order: "ASC" } },
|
|
554
|
+
},
|
|
555
|
+
noopPrimeDeps(),
|
|
556
|
+
),
|
|
557
|
+
).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("accepts a legit dotted parentField (Owner.Name) and a legit filter", async () => {
|
|
561
|
+
const out = await buildList(
|
|
562
|
+
{
|
|
563
|
+
org: ORG,
|
|
564
|
+
object: "Account",
|
|
565
|
+
fields: ["Id", "Name"],
|
|
566
|
+
parentFields: ["Owner.Name"],
|
|
567
|
+
filter: { Industry: { eq: "Tech" } },
|
|
568
|
+
},
|
|
569
|
+
noopPrimeDeps(),
|
|
570
|
+
);
|
|
571
|
+
expect(out.query).toContain("Owner");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// PR #678 review (Ciaran Hannigan): the __c custom-field accept-case the
|
|
575
|
+
// charset tests assert in isolation, exercised end-to-end through the builder.
|
|
576
|
+
it("accepts a Salesforce custom field (__c) and renders it", async () => {
|
|
577
|
+
const out = await buildList(
|
|
578
|
+
{ org: ORG, object: "Account", fields: ["Id", "Amount__c"] },
|
|
579
|
+
noopPrimeDeps(),
|
|
580
|
+
);
|
|
581
|
+
expect(out.query).toContain("Amount__c");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("rejects a scope argument breakout", async () => {
|
|
585
|
+
await expect(
|
|
586
|
+
buildList(
|
|
587
|
+
{
|
|
588
|
+
org: ORG,
|
|
589
|
+
object: "Account",
|
|
590
|
+
fields: ["Id"],
|
|
591
|
+
scope: "{}) { Id } injectedSibling: Account(scope: MINE",
|
|
592
|
+
},
|
|
593
|
+
noopPrimeDeps(),
|
|
594
|
+
),
|
|
595
|
+
).rejects.toThrow(/buildList: scope .* is not a valid GraphQL Name/);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("accepts a legit scope enum token and a $var scope", async () => {
|
|
599
|
+
const enumOut = await buildList(
|
|
600
|
+
{ org: ORG, object: "Account", fields: ["Id"], scope: "EVERYTHING" },
|
|
601
|
+
noopPrimeDeps(),
|
|
602
|
+
);
|
|
603
|
+
expect(enumOut.query).toContain("scope: EVERYTHING");
|
|
604
|
+
const varOut = await buildList(
|
|
605
|
+
{ org: ORG, object: "Account", fields: ["Id"], scope: "$myScope" },
|
|
606
|
+
noopPrimeDeps(),
|
|
607
|
+
);
|
|
608
|
+
expect(varOut.query).toContain("$myScope");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("rejects a NESTED filter object-KEY breakout (recursion guard)", async () => {
|
|
612
|
+
await expect(
|
|
613
|
+
buildList(
|
|
614
|
+
{
|
|
615
|
+
org: ORG,
|
|
616
|
+
object: "Account",
|
|
617
|
+
fields: ["Id"],
|
|
618
|
+
filter: { Industry: { "eq } evil { value": "Tech" } },
|
|
619
|
+
},
|
|
620
|
+
noopPrimeDeps(),
|
|
621
|
+
),
|
|
622
|
+
).rejects.toThrow(/key '.*' is not a valid GraphQL Name/);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("renders a parseable document with no injected sibling (graphql-js reparse)", async () => {
|
|
626
|
+
const out = await buildList(
|
|
627
|
+
{ org: ORG, object: "Account", fields: ["Id", "Name"], filter: { Industry: { eq: "Tech" } } },
|
|
628
|
+
noopPrimeDeps(),
|
|
629
|
+
);
|
|
630
|
+
expect(() => parse(out.query)).not.toThrow();
|
|
631
|
+
expect(out.query).not.toContain("injectedAlias");
|
|
632
|
+
expect(out.query).not.toContain("evil");
|
|
633
|
+
});
|
|
634
|
+
});
|
|
@@ -131,4 +131,24 @@ describe("intent/build-update — GraphQL name validation", () => {
|
|
|
131
131
|
).rejects.toThrow(/buildMutation: operationName 'has spaces' is not a valid GraphQL Name/);
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
describe("returnFields validation (W-22735537)", () => {
|
|
136
|
+
it("rejects a selection-set breakout payload", async () => {
|
|
137
|
+
await expect(
|
|
138
|
+
buildUpdate(
|
|
139
|
+
{ org: ORG, object: "Account", returnFields: ["Id } injectedAlias: Name { value"] },
|
|
140
|
+
noopPrimeDeps(),
|
|
141
|
+
),
|
|
142
|
+
).rejects.toThrow(/returnFields entry .* is not a valid field path/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("accepts a legit dot-path (Owner.Name) — the guard allows dotted segments", async () => {
|
|
146
|
+
await expect(
|
|
147
|
+
buildUpdate(
|
|
148
|
+
{ org: ORG, object: "Account", returnFields: ["Id", "Owner.Name"] },
|
|
149
|
+
noopPrimeDeps(),
|
|
150
|
+
),
|
|
151
|
+
).resolves.toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
134
154
|
});
|
|
@@ -8,7 +8,7 @@ import { buildOutput } from "./build-output.js";
|
|
|
8
8
|
import { getSchemaWithPriming } from "./get-schema-with-priming.js";
|
|
9
9
|
import { selectChildRelationship } from "./select-child-relationship.js";
|
|
10
10
|
import { type DetailSpec, type ToolOutput } from "./types.js";
|
|
11
|
-
import { assertGraphqlName } from "../lib/graphql-name.js";
|
|
11
|
+
import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
|
|
12
12
|
import { selectDottedFieldPath } from "../lib/path-selection.js";
|
|
13
13
|
import { type PrimeDeps } from "../lib/prime-schema.js";
|
|
14
14
|
import { addVariable, createSession, deepSetArg } from "../lib/session.js";
|
|
@@ -54,11 +54,13 @@ export async function buildDetail(spec: DetailSpec, deps?: PrimeDeps): Promise<T
|
|
|
54
54
|
const node = connectionNodePath(connection);
|
|
55
55
|
|
|
56
56
|
for (const field of spec.fields) {
|
|
57
|
+
assertDottedGraphqlName(field, "buildDetail", "fields entry");
|
|
57
58
|
selectDottedFieldPath(session, schema, node, field);
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
if (spec.parentFields) {
|
|
61
62
|
for (const pf of spec.parentFields) {
|
|
63
|
+
assertDottedGraphqlName(pf, "buildDetail", "parentFields entry");
|
|
62
64
|
selectDottedFieldPath(session, schema, node, pf);
|
|
63
65
|
}
|
|
64
66
|
}
|
package/src/intent/build-list.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { buildOutput } from "./build-output.js";
|
|
|
8
8
|
import { getSchemaWithPriming } from "./get-schema-with-priming.js";
|
|
9
9
|
import { selectChildRelationship } from "./select-child-relationship.js";
|
|
10
10
|
import { type ListSpec, type ToolOutput } from "./types.js";
|
|
11
|
-
import { assertGraphqlName } from "../lib/graphql-name.js";
|
|
11
|
+
import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
|
|
12
12
|
import { selectDottedFieldPath } from "../lib/path-selection.js";
|
|
13
13
|
import { type PrimeDeps } from "../lib/prime-schema.js";
|
|
14
14
|
import { addVariable, createSession, deepSetArg, selectLeaf } from "../lib/session.js";
|
|
@@ -47,11 +47,13 @@ export async function buildList(spec: ListSpec, deps?: PrimeDeps): Promise<ToolO
|
|
|
47
47
|
const nodePath = [...connectionPath, "edges", "node"];
|
|
48
48
|
|
|
49
49
|
for (const field of spec.fields) {
|
|
50
|
+
assertDottedGraphqlName(field, "buildList", "fields entry");
|
|
50
51
|
selectDottedFieldPath(session, schema, nodePath, field);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
if (spec.parentFields) {
|
|
54
55
|
for (const pf of spec.parentFields) {
|
|
56
|
+
assertDottedGraphqlName(pf, "buildList", "parentFields entry");
|
|
55
57
|
selectDottedFieldPath(session, schema, nodePath, pf);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -103,6 +105,11 @@ export async function buildList(spec: ListSpec, deps?: PrimeDeps): Promise<ToolO
|
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
if (spec.scope) {
|
|
108
|
+
// `scope` renders into an argument position and is stored verbatim, so an
|
|
109
|
+
// unconstrained string is a selection-set / argument breakout (W-22735537).
|
|
110
|
+
// Allow a bare enum token (e.g. MINE) or a $varName; reject anything else.
|
|
111
|
+
const scopeName = spec.scope.startsWith("$") ? spec.scope.slice(1) : spec.scope;
|
|
112
|
+
assertGraphqlName(scopeName, "buildList", "scope");
|
|
106
113
|
promoteVariables(session, schema, connectionPath, "scope", spec.scope, extraWarnings);
|
|
107
114
|
deepSetArg(session, connectionPath, "scope", [], spec.scope);
|
|
108
115
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { buildOutput } from "./build-output.js";
|
|
8
8
|
import { getSchemaWithPriming } from "./get-schema-with-priming.js";
|
|
9
9
|
import { type CreateSpec, type ToolOutput, type UpdateSpec } from "./types.js";
|
|
10
|
-
import { assertGraphqlName } from "../lib/graphql-name.js";
|
|
10
|
+
import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
|
|
11
11
|
import { selectDottedFieldPath } from "../lib/path-selection.js";
|
|
12
12
|
import { type PrimeDeps } from "../lib/prime-schema.js";
|
|
13
13
|
import { addVariable, createSession, deepSetArg } from "../lib/session.js";
|
|
@@ -63,6 +63,11 @@ export async function buildMutation(
|
|
|
63
63
|
|
|
64
64
|
const extraWarnings: string[] = [];
|
|
65
65
|
for (const field of spec.returnFields ?? ["Id"]) {
|
|
66
|
+
// Validate the charset BEFORE the warn-swallowing try below, so a
|
|
67
|
+
// selection-set breakout (W-22735537) hard-fails instead of being demoted
|
|
68
|
+
// to a non-gating warning. A legit dot-path passes here and is then handled
|
|
69
|
+
// (warn-and-skip) by selectDottedFieldPath as an unsupported mutation-result path.
|
|
70
|
+
assertDottedGraphqlName(field, "buildMutation", "returnFields entry");
|
|
66
71
|
try {
|
|
67
72
|
selectDottedFieldPath(session, schema, recordPath, field);
|
|
68
73
|
} catch (err) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { type GraphQLSchema } from "graphql";
|
|
8
8
|
import { type ChildRelationshipSpec } from "./types.js";
|
|
9
|
+
import { assertDottedGraphqlName, assertGraphqlName } from "../lib/graphql-name.js";
|
|
9
10
|
import { selectDottedFieldPath } from "../lib/path-selection.js";
|
|
10
11
|
import { deepSetArg, type QuerySession } from "../lib/session.js";
|
|
11
12
|
import { normalizeOrderBy, promoteArg } from "../lib/variable-promotion.js";
|
|
@@ -27,10 +28,19 @@ export function selectChildRelationship(
|
|
|
27
28
|
child: ChildRelationshipSpec,
|
|
28
29
|
warnings?: string[],
|
|
29
30
|
): void {
|
|
31
|
+
// `relationshipName` is appended to the path and rendered verbatim as the
|
|
32
|
+
// connection field name (query-builder emits node.fieldName directly), so an
|
|
33
|
+
// unconstrained value is a selection-set breakout — the same class as `fields`
|
|
34
|
+
// below and `object`/`scope` on the top-level builders. Guard it here too: the
|
|
35
|
+
// MCP + CLI-mirror boundaries already enforce the GraphQL Name charset via zod,
|
|
36
|
+
// but the builders are also reachable directly (CLI, eval harness) (W-22735537).
|
|
37
|
+
assertGraphqlName(child.relationshipName, "selectChildRelationship", "relationshipName");
|
|
38
|
+
|
|
30
39
|
const childConnectionPath = [...parentNodePath, child.relationshipName];
|
|
31
40
|
const childNodePath = [...childConnectionPath, "edges", "node"];
|
|
32
41
|
|
|
33
42
|
for (const field of child.fields) {
|
|
43
|
+
assertDottedGraphqlName(field, "selectChildRelationship", "childRelationships fields entry");
|
|
34
44
|
selectDottedFieldPath(session, schema, childNodePath, field);
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertDottedGraphqlName,
|
|
10
|
+
assertGraphqlName,
|
|
11
|
+
DOTTED_GRAPHQL_NAME_RE,
|
|
12
|
+
GRAPHQL_NAME_RE,
|
|
13
|
+
} from "../graphql-name.js";
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Single source of truth for the GraphQL Name guard shared by every intent
|
|
@@ -14,12 +19,18 @@ import { assertGraphqlName, GRAPHQL_NAME_RE } from "../graphql-name.js";
|
|
|
14
19
|
*/
|
|
15
20
|
describe("lib/graphql-name", () => {
|
|
16
21
|
describe("assertGraphqlName", () => {
|
|
17
|
-
it.each([
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
it.each([
|
|
23
|
+
"Account",
|
|
24
|
+
"Custom_Object__c",
|
|
25
|
+
"MyNS__Field__c", // namespaced custom field (PR #678 review: __c regression guard)
|
|
26
|
+
"_Foo",
|
|
27
|
+
"_",
|
|
28
|
+
"a",
|
|
29
|
+
"a1_2b",
|
|
30
|
+
"CreateAccount",
|
|
31
|
+
])("accepts the valid GraphQL Name %j", (value) => {
|
|
32
|
+
expect(() => assertGraphqlName(value, "buildX", "operationName")).not.toThrow();
|
|
33
|
+
});
|
|
23
34
|
|
|
24
35
|
it.each([
|
|
25
36
|
"", // empty
|
|
@@ -61,4 +72,58 @@ describe("lib/graphql-name", () => {
|
|
|
61
72
|
expect(GRAPHQL_NAME_RE.test("")).toBe(false);
|
|
62
73
|
});
|
|
63
74
|
});
|
|
75
|
+
|
|
76
|
+
// W-22735537: dotted field-path guard for returnFields / fields / parentFields
|
|
77
|
+
// / childRelationships.fields. Each '.'-separated segment must be a GraphQL
|
|
78
|
+
// Name; a breakout string (containing { } : or whitespace) cannot match.
|
|
79
|
+
describe("assertDottedGraphqlName", () => {
|
|
80
|
+
it.each([
|
|
81
|
+
"Id",
|
|
82
|
+
"Name",
|
|
83
|
+
"Owner.Name",
|
|
84
|
+
"CreatedBy.Profile.Name",
|
|
85
|
+
"Amount__c", // Salesforce custom field (PR #678 review: __c regression guard)
|
|
86
|
+
"Owner.Custom__c", // dotted path ending in a custom field
|
|
87
|
+
"MyNS__Field__c", // namespaced custom field (double underscore in the middle)
|
|
88
|
+
"_a._b",
|
|
89
|
+
"a1.b2.c3",
|
|
90
|
+
"_",
|
|
91
|
+
])("accepts the valid dotted field path %j", (value) => {
|
|
92
|
+
expect(() => assertDottedGraphqlName(value, "buildX", "returnFields entry")).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([
|
|
96
|
+
"", // empty
|
|
97
|
+
".Name", // leading dot
|
|
98
|
+
"Owner.", // trailing dot
|
|
99
|
+
"Owner..Name", // empty segment
|
|
100
|
+
"1Bad.Name", // segment leading digit
|
|
101
|
+
"Owner.Na-me", // hyphen in segment
|
|
102
|
+
"a b", // space
|
|
103
|
+
"Id } injectedAlias: Name { value", // the W-22735537 selection-set breakout
|
|
104
|
+
"Name { value } evil",
|
|
105
|
+
"Id @skip(if:true)",
|
|
106
|
+
"Owner.Name { value",
|
|
107
|
+
])("rejects the invalid field path %j", (value) => {
|
|
108
|
+
expect(() => assertDottedGraphqlName(value, "buildX", "returnFields entry")).toThrow(
|
|
109
|
+
/is not a valid field path/,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("throws the exact builder- and field-prefixed message", () => {
|
|
114
|
+
expect(() => assertDottedGraphqlName("a b", "buildMutation", "returnFields entry")).toThrow(
|
|
115
|
+
/^buildMutation: returnFields entry 'a b' is not a valid field path/,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("DOTTED_GRAPHQL_NAME_RE", () => {
|
|
121
|
+
it("accepts dotted paths and rejects breakout strings", () => {
|
|
122
|
+
expect(DOTTED_GRAPHQL_NAME_RE.test("Owner.Name")).toBe(true);
|
|
123
|
+
expect(DOTTED_GRAPHQL_NAME_RE.test("Id")).toBe(true);
|
|
124
|
+
expect(DOTTED_GRAPHQL_NAME_RE.test("Id } x: y { z")).toBe(false);
|
|
125
|
+
expect(DOTTED_GRAPHQL_NAME_RE.test("Owner.")).toBe(false);
|
|
126
|
+
expect(DOTTED_GRAPHQL_NAME_RE.test("")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
64
129
|
});
|
package/src/lib/graphql-name.ts
CHANGED
|
@@ -33,3 +33,29 @@ export function assertGraphqlName(value: string, builder: string, field: string)
|
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Dotted field path: one GraphQL Name, then zero or more `.`-separated Names —
|
|
39
|
+
* e.g. `Id`, `Owner.Name`, `CreatedBy.Profile.Name`. Each segment must be a
|
|
40
|
+
* valid GraphQL Name on its own, and only the literal segment-separating dot is
|
|
41
|
+
* allowed between them. Because no segment can contain `{`, `}`, `:`, or
|
|
42
|
+
* whitespace, a string that matches cannot close a selection block early or
|
|
43
|
+
* hoist a sibling selection — closing the W-22735537 selection-set injection
|
|
44
|
+
* where caller-supplied `returnFields` / `fields` / `parentFields` reach the
|
|
45
|
+
* rendered query verbatim.
|
|
46
|
+
*/
|
|
47
|
+
export const DOTTED_GRAPHQL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Throw if `value` is not a valid dotted field path (see
|
|
51
|
+
* {@link DOTTED_GRAPHQL_NAME_RE}). The intent-layer twin of the zod
|
|
52
|
+
* `dottedGraphqlName()` factory, used by the builders, which are also reachable
|
|
53
|
+
* directly (CLI, eval harness) and so cannot rely on the zod boundary.
|
|
54
|
+
*/
|
|
55
|
+
export function assertDottedGraphqlName(value: string, builder: string, field: string): void {
|
|
56
|
+
if (!DOTTED_GRAPHQL_NAME_RE.test(value)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${builder}: ${field} '${value}' is not a valid field path (each '.'-separated segment must be a GraphQL Name matching ${GRAPHQL_NAME_RE})`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { type GraphQLSchema } from "graphql";
|
|
30
|
+
import { GRAPHQL_NAME_RE } from "./graphql-name.js";
|
|
30
31
|
import { addVariable, type QuerySession } from "./session.js";
|
|
31
32
|
import { inferTypeFromArgsPath } from "../commands/query-helpers.js";
|
|
32
33
|
|
|
@@ -127,6 +128,17 @@ function walk(
|
|
|
127
128
|
}
|
|
128
129
|
if (value && typeof value === "object") {
|
|
129
130
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
131
|
+
// Object keys are rendered RAW as field/operator names by
|
|
132
|
+
// query-builder's valueToGraphQL (input-object keys cannot be quoted in
|
|
133
|
+
// GraphQL), so an unvalidated key like `Id: {eq:"1"}}) { evil { value }` is
|
|
134
|
+
// a selection-set / argument breakout. Validate every key (at every depth)
|
|
135
|
+
// as a GraphQL Name and hard-fail — values are separately quoted and safe.
|
|
136
|
+
// This is the Class-B half of W-22735537; runs before deepSetArg renders it.
|
|
137
|
+
if (!GRAPHQL_NAME_RE.test(k)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${argName}: key '${k}' is not a valid GraphQL Name (must match ${GRAPHQL_NAME_RE}); filter/orderBy keys render as field/operator names in the query.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
130
142
|
walk(session, schema, fieldSchemaPath, argName, v, [...pathInsideArg, k], warnings);
|
|
131
143
|
}
|
|
132
144
|
}
|
|
@@ -147,28 +147,38 @@ describe("mcp/tools error surface — category prefixes (contract)", () => {
|
|
|
147
147
|
}
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
// A second, independent reflection path through a DIFFERENT
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// just one
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
150
|
+
// A second, independent reflection path through a DIFFERENT throw site \u2014 the
|
|
151
|
+
// walker's field-resolution check in walker.ts (resolvePath), not build-raw.ts's
|
|
152
|
+
// own `command <i>` wrapper \u2014 proving the chokepoint covers the whole error
|
|
153
|
+
// surface, not just one throw site. Driven via `sf_gql_raw` because its
|
|
154
|
+
// `commands` are unguarded free-form strings (z.array(z.string)); the typed
|
|
155
|
+
// tools' field-path args are now charset-gated by dottedGraphqlName at the zod
|
|
156
|
+
// boundary (W-22735537), so a control-char leaf is rejected there before ever
|
|
157
|
+
// reaching the walker. A `select` leaf that fails resolution is reflected
|
|
158
|
+
// verbatim as `Field "<seg>" not found on type ...`; an embedded newline + ANSI
|
|
159
|
+
// ESC must reach the host escaped, not raw.
|
|
160
|
+
it("UserInput: a control-char field name reflected by walker navigation is neutralized (sf_gql_raw)", async () => {
|
|
161
|
+
const server = new McpServer({ name: "graphiti-mcp", version: "test" });
|
|
162
|
+
registerSfGqlRawTool(server, { primeDeps: makeNoopPrimeDeps(ORG, ORG_URL, SCHEMA) });
|
|
163
|
+
const [c, s] = InMemoryTransport.createLinkedPair();
|
|
164
|
+
const client = new Client({ name: "test", version: "0.0.0" });
|
|
165
|
+
await Promise.all([server.connect(s), client.connect(c)]);
|
|
158
166
|
try {
|
|
167
|
+
// No literal space in the payload: tokenizeCommand only splits on " ",
|
|
168
|
+
// and the alias split keys off the last ":" — so a colon-free single
|
|
169
|
+
// token keeps every control char in one `select` spec that flows intact
|
|
170
|
+
// into the walker's `Field "<seg>" not found on type ...` throw.
|
|
159
171
|
const result = await client.callTool({
|
|
160
|
-
name: "
|
|
172
|
+
name: "sf_gql_raw",
|
|
161
173
|
arguments: {
|
|
162
174
|
org: ORG,
|
|
163
|
-
|
|
164
|
-
fields: ["Id"],
|
|
165
|
-
parentFields: ["Owner.Bogus\n\nSYSTEM:\x1b[31m"],
|
|
175
|
+
commands: ["select uiapi/query/Account/edges/node/Bogus\n\nSYSTEM\x1b[31m"],
|
|
166
176
|
},
|
|
167
177
|
});
|
|
168
178
|
const text = errorText(result);
|
|
169
179
|
expect(result.isError).toBe(true);
|
|
170
180
|
expect(text).toMatch(/^UserInput: /);
|
|
171
|
-
expect(text).toMatch(/not found on
|
|
181
|
+
expect(text).toMatch(/not found on type/);
|
|
172
182
|
// No raw control / ANSI byte reaches the host; escaped + still legible.
|
|
173
183
|
expect(text).not.toMatch(DANGEROUS_HOST_RE);
|
|
174
184
|
expect(text).toContain("\\x0a\\x0a");
|