History

Keep a query-carried credential out of GraphQL introspection's failure log (#1575)

* Keep a query-carried credential out of GraphQL introspection's failure log query is a supported credential carrier, so an endpoint can be reached with ?token=<secret>. Introspection built its request from a URL string, and setUrl keeps a string verbatim as request.url; every HttpClientError renders method + request.url into its message getter, and the failure cause is logged raw. So a transport failure or a non-JSON response wrote the secret to the log. Build the request from a URL object instead. setUrl then moves the query into request.urlParams and clears it from request.url, so the secret is absent from the message and from anything else rendering the URL. The client recombines the two when it executes, so nothing changes on the wire. Handles the endpoint's own query string too, since a configured endpoint can carry a credential. * Fail introspection on an unparseable endpoint instead of dropping its query params * Assert the typed error without manual tag checks * Reject endpoint userinfo and pin the introspection request URL Userinfo in the endpoint (user:pass@host) stays in request.url after the query is split off, so it still rendered into HttpClientError messages. Reject it with the same invalid-endpoint failure, which echoes no part of the endpoint. Classify invalid-endpoint in the health check as an integration config problem. It previously fell through to the generic branch and told the operator to check a credential that was never sent. The urlParams split re-serializes the query through URLSearchParams, so it is not byte-transparent: %20 becomes +, ~ is percent-encoded, and a valueless flag gains an =. Raw bytes only survive inside request.url, the one field every error message renders, so log safety wins. Document the normalization in the changeset and pin the exact dialed URLs by test. ---------

Sergio Fernández committed 2 months ago

Commit e5526f31 parent 4879d477 files · +467 −11Browse files

Unified · Split

7 files
  1. .changeset/graphql-introspection-credential-log.md +24 −0
  2. packages/plugins/graphql/src/sdk/errors.ts +1 −0
  3. packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts +139 −0
  4. packages/plugins/graphql/src/sdk/introspect-request-url.test.ts +182 −0
  5. packages/plugins/graphql/src/sdk/introspect.ts +57 −11
  6. packages/plugins/graphql/src/sdk/plugin.test.ts +50 −0
  7. packages/plugins/graphql/src/sdk/plugin.ts +14 −0
.changeset/graphql-introspection-credential-log.md+24−0
@@ -0,0 +1,24 @@
1---
2"executor": patch
3---
4
5**GraphQL introspection no longer logs a credential carried in the endpoint URL**
6
7`query` is a supported credential carrier, so a GraphQL endpoint can be reached with `?token=<secret>`. Introspection built its request from a URL **string**, and `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`. Every `HttpClientError` renders `${method} ${request.url}` into its `message` getter, and introspection logs the raw failure cause — so on any transport failure or non-JSON response, the connection's secret was written to the process log.
8
9The request is now built from a URL **object**, which moves the query into `request.urlParams` and clears it from `request.url`. The secret is therefore absent from the error message, and from anything else that renders the request URL. The credential still reaches the upstream: the client recombines url and urlParams when it executes the request. The endpoint's own query string is handled the same way, not just the separately-supplied query parameters, since a configured endpoint can carry a credential too.
10
11**The query string is now normalized on the wire.** Recombination appends each pair through `URLSearchParams`, so the query is re-serialized in form-urlencoded form instead of passed through byte-for-byte:
12
13- a space written as `%20` is sent as `+`
14- `~` and `!'()` are percent-encoded
15- a valueless `?flag` is sent as `flag=`
16
17Key order, repeated keys, and already-encoded reserved characters are unchanged, and every parameter still decodes to the same value. This is not avoidable while the fix holds: raw query bytes only survive inside `request.url`, which is the one field every error message renders, so byte-transparency and keeping the credential out of the log cannot both hold. An upstream that signs its raw query string is the case to watch. The exact resulting URLs are pinned by test.
18
19Two endpoints are now rejected up front with an `invalid-endpoint` failure rather than dialed:
20
21- an endpoint that is not a valid URL, which cannot be split this way and would otherwise be sent without the query parameters it was asked to include
22- an endpoint carrying userinfo (`https://user:pass@host/…`), which `URL` keeps in the origin, so it would stay in `request.url` and leak into error messages exactly the way a query-carried secret used to
23
24Neither rejection echoes any part of the endpoint. A health check on such an integration now reports the invalid configuration and points the operator at the endpoint URL, instead of blaming the credential that was never sent.
packages/plugins/graphql/src/sdk/errors.ts+1−0
@@ -11,6 +11,7 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn · 10 unmodified lines
11 Schema.Literals([11 Schema.Literals([
12 "network",12 "network",
13 "http",13 "http",
14 "invalid-endpoint",
14 "invalid-json",15 "invalid-json",
15 "invalid-shape",16 "invalid-shape",
16 "missing-schema",17 "missing-schema",
packages/plugins/graphql/src/sdk/introspect-credential-logging.test.ts+139−0
@@ -0,0 +1,139 @@
1// ---------------------------------------------------------------------------
2// Introspection must not log a credential carried in the query string.
3//
4// `query` is a supported credential carrier, so a GraphQL endpoint can be
5// reached with `?token=<secret>`. Introspection logs the raw failure cause on
6// any transport error, and every `HttpClientError` renders `${method}
7// ${request.url}` into its message — so if the request is built from a URL
8// STRING, the secret is inside that message and goes straight to the log.
9//
10// Building the request from a URL OBJECT moves the query into
11// `request.urlParams`, out of `request.url` and therefore out of the message,
12// while the client still recombines the two when it executes.
13//
14// Both directions are asserted. A test that only checked "the secret is absent"
15// would pass just as happily against a logger that captured nothing at all, or
16// a change that stopped sending the parameter entirely.
17// ---------------------------------------------------------------------------
18
19import { describe, expect, it } from "@effect/vitest";
20import { Cause, Effect, Layer, Logger } from "effect";
21import { FetchHttpClient } from "effect/unstable/http";
22
23import { GraphqlIntrospectionError } from "./errors";
24import { introspect } from "./introspect";
25
26const SECRET = "tok_live_introspection_MUST_NOT_LOG";
27const ENDPOINT = "https://graph.example.test/graphql";
28
29/** Collects everything a logger would have written, message and cause alike —
30 * `Cause.pretty` is the renderer that rebuilds the first line from the error's
31 * live `message` getter, which is the exact path the leak took. */
32const capturingLogger = (sink: Array<string>) =>
33 Logger.make<unknown, void>((options) => {
34 sink.push(String(options.message));
35 sink.push(Cause.pretty(options.cause));
36 });
37
38/** A fetch that records the URL it was handed and then fails at the transport
39 * layer, which is what drives introspection down its logging path. */
40const failingFetch = (seen: Array<string>): typeof globalThis.fetch =>
41 (async (input: RequestInfo | URL) => {
42 seen.push(input instanceof Request ? input.url : String(input));
43 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host
44 throw new Error("getaddrinfo ENOTFOUND graph.example.test");
45 }) as typeof globalThis.fetch;
46
47const clientLayer = (seen: Array<string>) =>
48 FetchHttpClient.layer.pipe(
49 Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(failingFetch(seen))),
50 );
51
52describe("GraphQL introspection credential logging", () => {
53 it.effect("does not write a query-carried credential to the log", () =>
54 Effect.gen(function* () {
55 const logged: Array<string> = [];
56 const seen: Array<string> = [];
57
58 yield* introspect(ENDPOINT, undefined, { token: SECRET }).pipe(
59 Effect.flip,
60 Effect.provide(clientLayer(seen)),
61 Effect.provide(Logger.layer([capturingLogger(logged)])),
62 );
63
64 const output = logged.join("\n");
65
66 // Positive control FIRST: prove the logger actually captured the failure.
67 // Without this, an empty capture would satisfy every assertion below.
68 expect(output).toContain("graphql introspection request failed");
69 expect(output).toContain("graph.example.test");
70
71 // The credential is absent from everything that was logged.
72 expect(output).not.toContain(SECRET);
73 expect(output).not.toContain("token=");
74 }),
75 );
76
77 it.effect("still sends the query-carried credential on the wire", () =>
78 Effect.gen(function* () {
79 const logged: Array<string> = [];
80 const seen: Array<string> = [];
81
82 yield* introspect(ENDPOINT, undefined, { token: SECRET }).pipe(
83 Effect.flip,
84 Effect.provide(clientLayer(seen)),
85 Effect.provide(Logger.layer([capturingLogger(logged)])),
86 );
87
88 // Keeping it out of the log is only correct if it still reaches the
89 // upstream — otherwise this "fix" silently breaks authentication.
90 expect(seen).toHaveLength(1);
91 expect(seen[0]).toContain(`token=${SECRET}`);
92 }),
93 );
94
95 it.effect("keeps a credential carried in the endpoint's own query out of the log", () =>
96 Effect.gen(function* () {
97 // A configured endpoint can carry the secret itself, with no separate
98 // queryParams argument at all.
99 const logged: Array<string> = [];
100 const seen: Array<string> = [];
101
102 yield* introspect(`${ENDPOINT}?token=${SECRET}`).pipe(
103 Effect.flip,
104 Effect.provide(clientLayer(seen)),
105 Effect.provide(Logger.layer([capturingLogger(logged)])),
106 );
107
108 const output = logged.join("\n");
109 expect(output).toContain("graphql introspection request failed");
110 expect(output).not.toContain(SECRET);
111 expect(seen[0]).toContain(`token=${SECRET}`);
112 }),
113 );
114
115 it.effect("fails a malformed endpoint instead of dialing it without the query params", () =>
116 Effect.gen(function* () {
117 // Only a parseable endpoint can carry the query in `urlParams`. An
118 // unparseable one must fail rather than quietly dial without the
119 // credential it was told to send.
120 const logged: Array<string> = [];
121 const seen: Array<string> = [];
122
123 const error = yield* introspect("not a url", undefined, { token: SECRET }).pipe(
124 Effect.flip,
125 Effect.provide(clientLayer(seen)),
126 Effect.provide(Logger.layer([capturingLogger(logged)])),
127 );
128
129 expect(error).toBeInstanceOf(GraphqlIntrospectionError);
130 expect(error.reason).toBe("invalid-endpoint");
131
132 // Nothing was sent: no request at all, rather than one missing the token.
133 expect(seen).toHaveLength(0);
134 // And the rejection itself does not echo the credential anywhere.
135 expect(Cause.pretty(Cause.fail(error))).not.toContain(SECRET);
136 expect(logged.join("\n")).not.toContain(SECRET);
137 }),
138 );
139});
packages/plugins/graphql/src/sdk/introspect-request-url.test.ts+182−0
@@ -0,0 +1,182 @@
1// ---------------------------------------------------------------------------
2// The exact URL introspection dials.
3//
4// Keeping a query-carried credential out of the log means splitting the query
5// off `request.url` and into `request.urlParams` (see
6// `introspect-credential-logging.test.ts`). The client recombines the two by
7// appending each pair through `URLSearchParams`, so the query is re-serialized
8// in form-urlencoded form rather than passed through byte-for-byte.
9//
10// That is a real, if narrow, wire change, and it is not recoverable: raw query
11// bytes only survive inside `request.url`, which is the single field every
12// `HttpClientError` message renders. So the normalization is pinned here by
13// exact string equality rather than described in prose — a `toContain` check
14// would let any of these encodings drift silently, and an upstream that signs
15// its query string would notice.
16// ---------------------------------------------------------------------------
17
18import { describe, expect, it } from "@effect/vitest";
19import { Cause, Effect, Layer, Logger } from "effect";
20import { FetchHttpClient } from "effect/unstable/http";
21
22import { GraphqlIntrospectionError } from "./errors";
23import { introspect } from "./introspect";
24
25const ENDPOINT = "https://graph.example.test/graphql";
26
27/** Records the URL handed to the platform, then fails the transport so the
28 * effect finishes without a live host. */
29const recordingFetch = (seen: Array<string>): typeof globalThis.fetch =>
30 (async (input: RequestInfo | URL) => {
31 seen.push(input instanceof Request ? input.url : String(input));
32 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: simulates the platform fetch rejecting on a dead host
33 throw new Error("getaddrinfo ENOTFOUND graph.example.test");
34 }) as typeof globalThis.fetch;
35
36const clientLayer = (seen: Array<string>) =>
37 FetchHttpClient.layer.pipe(
38 Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(recordingFetch(seen))),
39 );
40
41const silentLogger = Logger.layer([Logger.make<unknown, void>(() => {})]);
42
43/** Runs introspection against a fetch that always fails, and returns the one
44 * URL it was asked to dial. */
45const dialedUrl = (endpoint: string, queryParams?: Record<string, string>) =>
46 Effect.gen(function* () {
47 const seen: Array<string> = [];
48 yield* introspect(endpoint, undefined, queryParams).pipe(
49 Effect.flip,
50 Effect.provide(clientLayer(seen)),
51 Effect.provide(silentLogger),
52 );
53 expect(seen).toHaveLength(1);
54 return seen[0];
55 });
56
57describe("GraphQL introspection request URL", () => {
58 it.effect("merges query params into an endpoint that already has some", () =>
59 Effect.gen(function* () {
60 // Endpoint params keep their position and order; merged params are
61 // appended after them.
62 const url = yield* dialedUrl(`${ENDPOINT}?apiVersion=2&region=eu`, {
63 token: "s3cr3t",
64 trace: "on",
65 });
66 expect(url).toBe(`${ENDPOINT}?apiVersion=2&region=eu&token=s3cr3t&trace=on`);
67 }),
68 );
69
70 it.effect("adds a query to an endpoint that has none", () =>
71 Effect.gen(function* () {
72 const url = yield* dialedUrl(ENDPOINT, { token: "s3cr3t" });
73 expect(url).toBe(`${ENDPOINT}?token=s3cr3t`);
74 }),
75 );
76
77 it.effect("overwrites a duplicate param in place rather than appending it", () =>
78 Effect.gen(function* () {
79 // The supplied param wins, and it keeps the endpoint param's position —
80 // so the stale value is gone, not merely shadowed.
81 const url = yield* dialedUrl(`${ENDPOINT}?token=stale&keep=1`, { token: "fresh" });
82 expect(url).toBe(`${ENDPOINT}?token=fresh&keep=1`);
83 }),
84 );
85
86 it.effect("collapses repeats of an overwritten key to the supplied value", () =>
87 Effect.gen(function* () {
88 const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2`, { a: "3" });
89 expect(url).toBe(`${ENDPOINT}?a=3`);
90 }),
91 );
92
93 it.effect("keeps repeated keys the caller did not overwrite", () =>
94 Effect.gen(function* () {
95 const url = yield* dialedUrl(`${ENDPOINT}?a=1&a=2&b=3`);
96 expect(url).toBe(`${ENDPOINT}?a=1&a=2&b=3`);
97 }),
98 );
99
100 it.effect("re-encodes a %20 space as +", () =>
101 Effect.gen(function* () {
102 // Documented normalization, not an accident: `URLSearchParams` serializes
103 // a space in form-urlencoded form. The decoded value is unchanged.
104 const url = yield* dialedUrl(`${ENDPOINT}?token=a%20b`);
105 expect(url).toBe(`${ENDPOINT}?token=a+b`);
106 }),
107 );
108
109 it.effect("leaves a + space as +", () =>
110 Effect.gen(function* () {
111 const url = yield* dialedUrl(`${ENDPOINT}?token=a+b`);
112 expect(url).toBe(`${ENDPOINT}?token=a+b`);
113 }),
114 );
115
116 it.effect("percent-encodes a literal tilde", () =>
117 Effect.gen(function* () {
118 // `~` is legal in a raw query and is left alone by `URL`, but the
119 // form-urlencoded serializer escapes it.
120 const url = yield* dialedUrl(`${ENDPOINT}?sig=~tilde~`);
121 expect(url).toBe(`${ENDPOINT}?sig=%7Etilde%7E`);
122 }),
123 );
124
125 it.effect("gives a valueless flag a trailing =", () =>
126 Effect.gen(function* () {
127 const url = yield* dialedUrl(`${ENDPOINT}?debug`);
128 expect(url).toBe(`${ENDPOINT}?debug=`);
129 }),
130 );
131
132 it.effect("leaves an already-encoded reserved character encoded", () =>
133 Effect.gen(function* () {
134 const url = yield* dialedUrl(`${ENDPOINT}?a=%2B`);
135 expect(url).toBe(`${ENDPOINT}?a=%2B`);
136 }),
137 );
138
139 it.effect("rejects an endpoint carrying userinfo, without echoing it", () =>
140 Effect.gen(function* () {
141 // `user:pass@host` is a credential placement that `URL` keeps in the
142 // origin, so it would ride along in `request.url` and render into every
143 // `HttpClientError` message — the exact leak the query split closes.
144 const seen: Array<string> = [];
145 const logged: Array<string> = [];
146
147 const error = yield* introspect("https://svc:hunter2@graph.example.test/graphql").pipe(
148 Effect.flip,
149 Effect.provide(clientLayer(seen)),
150 Effect.provide(
151 Logger.layer([
152 Logger.make<unknown, void>((options) => {
153 logged.push(String(options.message));
154 logged.push(Cause.pretty(options.cause));
155 }),
156 ]),
157 ),
158 );
159
160 expect(error).toBeInstanceOf(GraphqlIntrospectionError);
161 expect(error.reason).toBe("invalid-endpoint");
162
163 // Nothing was dialed, and neither the error nor the log names the secret.
164 expect(seen).toHaveLength(0);
165 const rendered = `${Cause.pretty(Cause.fail(error))}\n${logged.join("\n")}`;
166 expect(rendered).not.toContain("hunter2");
167 expect(rendered).not.toContain("svc:");
168 }),
169 );
170
171 it.effect("rejects userinfo even when only a username is present", () =>
172 Effect.gen(function* () {
173 const seen: Array<string> = [];
174 const error = yield* introspect("https://svc@graph.example.test/graphql", undefined, {
175 token: "s3cr3t",
176 }).pipe(Effect.flip, Effect.provide(clientLayer(seen)), Effect.provide(silentLogger));
177
178 expect(error.reason).toBe("invalid-endpoint");
179 expect(seen).toHaveLength(0);
180 }),
181 );
182});
packages/plugins/graphql/src/sdk/introspect.ts+57−11
@@ -235,18 +235,64 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 234 unmodified lines
235 queryParams?: Record<string, string>,235 queryParams?: Record<string, string>,
236) {236) {
237 const client = yield* HttpClient.HttpClient;237 const client = yield* HttpClient.HttpClient;
238 const requestEndpoint =238 // Hand `post` a URL OBJECT rather than a string, deliberately.
239 queryParams && Object.keys(queryParams).length > 0239 //
240 ? (() => {240 // `HttpClientRequest.setUrl` keeps a string verbatim as `request.url`, and
241 const url = new URL(endpoint);241 // every `HttpClientError` renders `${method} ${request.url}` into its
242 for (const [name, value] of Object.entries(queryParams)) {242 // `message` getter. The `query` carrier is a supported credential placement,
243 url.searchParams.set(name, value);243 // so an endpoint reached with `?token=…` put that secret inside the error
244 }244 // message — and the `Effect.logError(…, cause)` below writes the message
245 return url.toString();245 // straight to the log on any transport failure or non-JSON response.
246 })()246 //
247 : endpoint;247 // Given a URL object, `setUrl` moves the query into `request.urlParams` and
248 // clears it from `request.url`, so the same failure logs the bare endpoint.
249 // The query still reaches the upstream: the client recombines url + urlParams
250 // when it executes the request. Handling the endpoint's OWN query the same
251 // way (not just the `queryParams` argument) matters — a configured endpoint
252 // can carry a credential in its query string too.
253 //
254 // The split is NOT byte-transparent, and deliberately so. Recombination
255 // appends each pair through `URLSearchParams`, whose form-urlencoded
256 // serializer writes a space as `+` rather than `%20`, percent-encodes `~` and
257 // `!'()`, and gives a valueless `?flag` a trailing `=`. Key order, duplicate
258 // keys and already-encoded reserved characters survive unchanged. No request
259 // shape avoids this: the raw query bytes only survive inside `request.url`,
260 // which is the one field every error message renders, so byte-transparency
261 // and keeping the credential out of the log cannot both hold. Log safety
262 // wins. `introspect-request-url.test.ts` pins the exact resulting URLs.
263 //
264 // An endpoint that does not parse has no such split available: it would go
265 // verbatim into `request.url` — query, credential and all — and `queryParams`
266 // could not be applied to it at all. Reject it instead of dialing a request
267 // that silently omits the credential the caller asked us to send. The
268 // endpoint is deliberately left out of the message, since it may be carrying
269 // the secret.
270 if (!URL.canParse(endpoint)) {
271 return yield* new GraphqlIntrospectionError({
272 message: "GraphQL endpoint is not a valid URL",
273 reason: "invalid-endpoint",
274 });
275 }
276
277 const requestUrl = new URL(endpoint);
278
279 // Userinfo (`https://user:pass@host/…`) is a credential placement with no
280 // split of its own: `URL` keeps it in the origin, so it stays in
281 // `request.url` and renders into every `HttpClientError` message exactly the
282 // way a query-carried secret used to. Reject it rather than log it, with the
283 // same constant message that echoes no part of the endpoint.
284 if (requestUrl.username !== "" || requestUrl.password !== "") {
285 return yield* new GraphqlIntrospectionError({
286 message: "GraphQL endpoint must not embed credentials in the URL",
287 reason: "invalid-endpoint",
288 });
289 }
290
291 for (const [name, value] of Object.entries(queryParams ?? {})) {
292 requestUrl.searchParams.set(name, value);
293 }
248 294
249 let request = HttpClientRequest.post(requestEndpoint).pipe(295 let request = HttpClientRequest.post(requestUrl).pipe(
250 HttpClientRequest.setHeader("Content-Type", "application/json"),296 HttpClientRequest.setHeader("Content-Type", "application/json"),
251 HttpClientRequest.setHeader("Accept", "application/json"),297 HttpClientRequest.setHeader("Accept", "application/json"),
252 HttpClientRequest.setHeader("User-Agent", "executor-graphql"),298 HttpClientRequest.setHeader("User-Agent", "executor-graphql"),
packages/plugins/graphql/src/sdk/plugin.test.ts+50−0
@@ -886,6 +886,56 @@ describe("graphqlPlugin real protocol server", () => { · 885 unmodified lines
886 }),886 }),
887 );887 );
888 888
889 it.effect("reports an unparseable endpoint as a config problem, not a credential one", () =>
890 Effect.gen(function* () {
891 const executor = yield* makeExecutor();
892 yield* executor.graphql.addIntegration({
893 endpoint: "not a url",
894 slug: "health_bad_endpoint",
895 });
896
897 const result = yield* executor.connections.validate({
898 owner: "org",
899 integration: IntegrationSlug.make("health_bad_endpoint"),
900 template: AuthTemplateSlug.make("none"),
901 value: "unused",
902 });
903
904 expect(result).toMatchObject({
905 status: "unknown",
906 detail:
907 "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.",
908 });
909 // No request was ever sent, so nothing upstream judged the credential.
910 // Telling the operator to check it would send them down a dead end.
911 expect(result.detail).not.toContain("credential");
912 }),
913 );
914
915 it.effect("reports an endpoint with embedded userinfo as a config problem", () =>
916 Effect.gen(function* () {
917 const executor = yield* makeExecutor();
918 yield* executor.graphql.addIntegration({
919 endpoint: "https://svc:hunter2@graph.example.test/graphql",
920 slug: "health_userinfo_endpoint",
921 });
922
923 const result = yield* executor.connections.validate({
924 owner: "org",
925 integration: IntegrationSlug.make("health_userinfo_endpoint"),
926 template: AuthTemplateSlug.make("none"),
927 value: "unused",
928 });
929
930 expect(result).toMatchObject({
931 status: "unknown",
932 detail:
933 "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.",
934 });
935 expect(result.detail).not.toContain("hunter2");
936 }),
937 );
938
889 it.effect("persists the introspection failure when tool sync is incomplete", () =>939 it.effect("persists the introspection failure when tool sync is incomplete", () =>
890 Effect.gen(function* () {940 Effect.gen(function* () {
891 const server = yield* serveTestHttpApp(() =>941 const server = yield* serveTestHttpApp(() =>
packages/plugins/graphql/src/sdk/plugin.ts+14−0
@@ -99,6 +99,8 @@ const GRAPHQL_INVALID_SCHEMA_DETAIL = · 98 unmodified lines
99const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method.";99const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method.";
100const GRAPHQL_NETWORK_DETAIL =100const GRAPHQL_NETWORK_DETAIL =
101 "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again.";101 "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again.";
102const GRAPHQL_INVALID_ENDPOINT_DETAIL =
103 "The GraphQL endpoint URL is invalid. Edit the integration configuration, then try again.";
102 104
103const truncateHealthDetail = (text: string, max = 240): string => {105const truncateHealthDetail = (text: string, max = 240): string => {
104 const normalized = text.replaceAll(/\s+/g, " ").trim();106 const normalized = text.replaceAll(/\s+/g, " ").trim();
@@ -145,6 +147,18 @@ const healthFromIntrospectionError = ( · 40 unmodified lines
145 const upstream = error.upstreamMessage;147 const upstream = error.upstreamMessage;
146 const httpStatus = error.status;148 const httpStatus = error.status;
147 149
150 // Classified first, and never as a credential problem: the endpoint was
151 // rejected before any request went out, so nothing upstream judged the
152 // credential. Sending the operator to re-enter a working secret would be a
153 // dead end — the URL in the integration config is what needs the edit.
154 if (error.reason === "invalid-endpoint") {
155 return {
156 status: "unknown",
157 checkedAt,
158 detail: GRAPHQL_INVALID_ENDPOINT_DETAIL,
159 };
160 }
161
148 if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) {162 if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) {
149 const statusDetail =163 const statusDetail =
150 httpStatus === 401 || httpStatus === 403164 httpStatus === 401 || httpStatus === 403