Unified · Split
| @@ -109,3 +109,51 @@ scenario( · 108 unmodified lines | |||
| 109 | }); | 109 | }); |
| 110 | }), | 110 | }), |
| 111 | ); | 111 | ); |
| 112 | |||
| 113 | scenario( | ||
| 114 | "Org scope · plugin API requests carry the URL organization selector", | ||
| 115 | {}, | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const target = yield* Target; | ||
| 118 | const browser = yield* Browser; | ||
| 119 | const identity = yield* target.newIdentity(); | ||
| 120 | |||
| 121 | const spec = JSON.stringify({ | ||
| 122 | openapi: "3.0.3", | ||
| 123 | info: { title: "Header Probe", version: "1.0.0" }, | ||
| 124 | servers: [{ url: "https://api.example.com" }], | ||
| 125 | paths: { | ||
| 126 | "/ping": { | ||
| 127 | get: { | ||
| 128 | operationId: "ping", | ||
| 129 | responses: { "200": { description: "ok" } }, | ||
| 130 | }, | ||
| 131 | }, | ||
| 132 | }, | ||
| 133 | }); | ||
| 134 | |||
| 135 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 136 | await step("Open the OpenAPI add form", async () => { | ||
| 137 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | ||
| 138 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); | ||
| 139 | }); | ||
| 140 | |||
| 141 | const orgSlug = new URL(page.url()).pathname.split("/")[1]; | ||
| 142 | expect(orgSlug, "the add form landed on an org-scoped URL").toBeTruthy(); | ||
| 143 | |||
| 144 | await step("Paste an inline spec", async () => { | ||
| 145 | const previewRequest = page.waitForRequest( | ||
| 146 | (request) => request.url().includes("/api/openapi/preview"), | ||
| 147 | { timeout: 15_000 }, | ||
| 148 | ); | ||
| 149 | |||
| 150 | await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec); | ||
| 151 | |||
| 152 | expect( | ||
| 153 | (await previewRequest).headers()["x-executor-organization"], | ||
| 154 | "plugin-owned preview requests use the URL's org selector", | ||
| 155 | ).toBe(orgSlug); | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | }), | ||
| 159 | ); | ||
| @@ -77,4 +77,17 @@ describe("createPluginAtomClient", () => { · 76 unmodified lines | |||
| 77 | expect(transformed.url).toBe("https://executor.example/api/graphql/sources"); | 77 | expect(transformed.url).toBe("https://executor.example/api/graphql/sources"); |
| 78 | expect(transformed.headers.authorization).toBe("Bearer key_123"); | 78 | expect(transformed.headers.authorization).toBe("Bearer key_123"); |
| 79 | }); | 79 | }); |
| 80 | |||
| 81 | it("can apply host-supplied dynamic request headers to plugin requests", () => { | ||
| 82 | const request = HttpClientRequest.get("/openapi/integrations/acme"); | ||
| 83 | const transformed = applyPluginAtomClientRequestTransform(request, { | ||
| 84 | headers: () => ({ | ||
| 85 | "x-executor-organization": "newvale", | ||
| 86 | "x-empty": null, | ||
| 87 | }), | ||
| 88 | }); | ||
| 89 | |||
| 90 | expect(transformed.headers["x-executor-organization"]).toBe("newvale"); | ||
| 91 | expect(transformed.headers["x-empty"]).toBeUndefined(); | ||
| 92 | }); | ||
| 80 | }); | 93 | }); |
| @@ -238,11 +238,18 @@ export interface CreatePluginAtomClientOptions { · 237 unmodified lines | |||
| 238 | /** Optional dynamic Authorization header for hosts whose active | 238 | /** Optional dynamic Authorization header for hosts whose active |
| 239 | * Executor Server Connection requires Basic or Bearer auth. */ | 239 | * Executor Server Connection requires Basic or Bearer auth. */ |
| 240 | readonly authorizationHeader?: string | null | (() => string | null); | 240 | readonly authorizationHeader?: string | null | (() => string | null); |
| 241 | /** Optional dynamic request headers supplied by the host shell. */ | ||
| 242 | readonly headers?: | ||
| 243 | | Readonly<Record<string, string | null | undefined>> | ||
| 244 | | (() => Readonly<Record<string, string | null | undefined>>); | ||
| 241 | } | 245 | } |
| 242 | 246 | ||
| 243 | export interface PluginAtomClientRequestTransformOptions { | 247 | export interface PluginAtomClientRequestTransformOptions { |
| 244 | readonly baseUrl?: () => string; | 248 | readonly baseUrl?: () => string; |
| 245 | readonly authorizationHeader?: string | null | (() => string | null); | 249 | readonly authorizationHeader?: string | null | (() => string | null); |
| 250 | readonly headers?: | ||
| 251 | | Readonly<Record<string, string | null | undefined>> | ||
| 252 | | (() => Readonly<Record<string, string | null | undefined>>); | ||
| 246 | } | 253 | } |
| 247 | 254 | ||
| 248 | /** @internal */ | 255 | /** @internal */ |
| @@ -258,6 +265,14 @@ export const applyPluginAtomClientRequestTransform = ( · 9 unmodified lines | |||
| 258 | if (authorization) { | 265 | if (authorization) { |
| 259 | next = HttpClientRequest.setHeader(next, "authorization", authorization); | 266 | next = HttpClientRequest.setHeader(next, "authorization", authorization); |
| 260 | } | 267 | } |
| 268 | const headers = typeof options.headers === "function" ? options.headers() : options.headers; | ||
| 269 | if (headers) { | ||
| 270 | for (const [name, value] of Object.entries(headers)) { | ||
| 271 | if (value !== undefined && value !== null) { | ||
| 272 | next = HttpClientRequest.setHeader(next, name, value); | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } | ||
| 261 | return next; | 276 | return next; |
| 262 | }; | 277 | }; |
| 263 | 278 | ||
| @@ -278,7 +293,7 @@ export const createPluginAtomClient = < · 14 unmodified lines | |||
| 278 | group: G, | 293 | group: G, |
| 279 | options: CreatePluginAtomClientOptions = {}, | 294 | options: CreatePluginAtomClientOptions = {}, |
| 280 | ) => { | 295 | ) => { |
| 281 | const { baseUrl = "/api", authorizationHeader } = options; | 296 | const { baseUrl = "/api", authorizationHeader, headers } = options; |
| 282 | const pluginId = group.identifier; | 297 | const pluginId = group.identifier; |
| 283 | const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); | 298 | const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); |
| 284 | const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; | 299 | const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; |
| @@ -286,8 +301,9 @@ export const createPluginAtomClient = < · 1 unmodified line | |||
| 286 | const getAuthorizationHeader = | 301 | const getAuthorizationHeader = |
| 287 | typeof authorizationHeader === "function" ? authorizationHeader : null; | 302 | typeof authorizationHeader === "function" ? authorizationHeader : null; |
| 288 | const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; | 303 | const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; |
| 304 | const hasHeaders = headers !== undefined; | ||
| 289 | const transformClient = | 305 | const transformClient = |
| 290 | getBaseUrl || hasAuthorization | 306 | getBaseUrl || hasAuthorization || hasHeaders |
| 291 | ? HttpClient.mapRequest((request) => | 307 | ? HttpClient.mapRequest((request) => |
| 292 | applyPluginAtomClientRequestTransform(request, { | 308 | applyPluginAtomClientRequestTransform(request, { |
| 293 | ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), | 309 | ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), |
| @@ -296,6 +312,7 @@ export const createPluginAtomClient = < · 2 unmodified lines | |||
| 296 | : authorizationHeader !== undefined | 312 | : authorizationHeader !== undefined |
| 297 | ? { authorizationHeader } | 313 | ? { authorizationHeader } |
| 298 | : {}), | 314 | : {}), |
| 315 | ...(headers !== undefined ? { headers } : {}), | ||
| 299 | }), | 316 | }), |
| 300 | ) | 317 | ) |
| 301 | : undefined; | 318 | : undefined; |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | import { | 2 | import { |
| 3 | getExecutorOrganizationHeaders, | ||
| 3 | getExecutorApiBaseUrl, | 4 | getExecutorApiBaseUrl, |
| 4 | getExecutorServerAuthorizationHeader, | 5 | getExecutorServerAuthorizationHeader, |
| 5 | } from "@executor-js/react/api/server-connection"; | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { GraphqlGroup } from "../api/group"; · 2 unmodified lines | |||
| 8 | export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { | 9 | export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { |
| 9 | baseUrl: getExecutorApiBaseUrl, | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | authorizationHeader: getExecutorServerAuthorizationHeader, | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 11 | }); | 13 | }); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | import { | 2 | import { |
| 3 | getExecutorOrganizationHeaders, | ||
| 3 | getExecutorApiBaseUrl, | 4 | getExecutorApiBaseUrl, |
| 4 | getExecutorServerAuthorizationHeader, | 5 | getExecutorServerAuthorizationHeader, |
| 5 | } from "@executor-js/react/api/server-connection"; | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { McpGroup } from "../api/group"; · 2 unmodified lines | |||
| 8 | export const McpClient = createPluginAtomClient(McpGroup, { | 9 | export const McpClient = createPluginAtomClient(McpGroup, { |
| 9 | baseUrl: getExecutorApiBaseUrl, | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | authorizationHeader: getExecutorServerAuthorizationHeader, | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 11 | }); | 13 | }); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | import { | 2 | import { |
| 3 | getExecutorOrganizationHeaders, | ||
| 3 | getExecutorApiBaseUrl, | 4 | getExecutorApiBaseUrl, |
| 4 | getExecutorServerAuthorizationHeader, | 5 | getExecutorServerAuthorizationHeader, |
| 5 | } from "@executor-js/react/api/server-connection"; | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { OnePasswordGroup } from "../api/group"; · 2 unmodified lines | |||
| 8 | export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, { | 9 | export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, { |
| 9 | baseUrl: getExecutorApiBaseUrl, | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | authorizationHeader: getExecutorServerAuthorizationHeader, | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 11 | }); | 13 | }); |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | import { | 2 | import { |
| 3 | getExecutorOrganizationHeaders, | ||
| 3 | getExecutorApiBaseUrl, | 4 | getExecutorApiBaseUrl, |
| 4 | getExecutorServerAuthorizationHeader, | 5 | getExecutorServerAuthorizationHeader, |
| 5 | } from "@executor-js/react/api/server-connection"; | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { OpenApiGroup } from "../api/group"; · 2 unmodified lines | |||
| 8 | export const OpenApiClient = createPluginAtomClient(OpenApiGroup, { | 9 | export const OpenApiClient = createPluginAtomClient(OpenApiGroup, { |
| 9 | baseUrl: getExecutorApiBaseUrl, | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | authorizationHeader: getExecutorServerAuthorizationHeader, | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 11 | }); | 13 | }); |
| @@ -96,6 +96,11 @@ export const getActiveOrgSlug = (): string | null => { · 95 unmodified lines | |||
| 96 | return first && isValidOrgSlug(first) ? first : null; | 96 | return first && isValidOrgSlug(first) ? first : null; |
| 97 | }; | 97 | }; |
| 98 | 98 | ||
| 99 | export const getExecutorOrganizationHeaders = (): Readonly<Record<string, string>> => { | ||
| 100 | const orgSlug = getActiveOrgSlug(); | ||
| 101 | return orgSlug ? { [EXECUTOR_ORG_HEADER]: orgSlug } : {}; | ||
| 102 | }; | ||
| 103 | |||
| 99 | export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; | 104 | export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; |
| 100 | 105 | ||
| 101 | export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { | 106 | export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { |