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 | 77 | expect(transformed.url).toBe("https://executor.example/api/graphql/sources"); |
| 78 | 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 | 238 | /** Optional dynamic Authorization header for hosts whose active |
| 239 | 239 | * Executor Server Connection requires Basic or Bearer auth. */ |
| 240 | 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 | 247 | export interface PluginAtomClientRequestTransformOptions { |
| 244 | 248 | readonly baseUrl?: () => string; |
| 245 | 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 | 255 | /** @internal */ |
| @@ -258,6 +265,14 @@ export const applyPluginAtomClientRequestTransform = ( · 9 unmodified lines | ||
| 258 | 265 | if (authorization) { |
| 259 | 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 | 276 | return next; |
| 262 | 277 | }; |
| 263 | 278 | |
| @@ -278,7 +293,7 @@ export const createPluginAtomClient = < · 14 unmodified lines | ||
| 278 | 293 | group: G, |
| 279 | 294 | options: CreatePluginAtomClientOptions = {}, |
| 280 | 295 | ) => { |
| 281 | − const { baseUrl = "/api", authorizationHeader } = options; | |
| 296 | + const { baseUrl = "/api", authorizationHeader, headers } = options; | |
| 282 | 297 | const pluginId = group.identifier; |
| 283 | 298 | const bundle = HttpApi.make(`plugin-${pluginId}`).add(group); |
| 284 | 299 | const getBaseUrl = typeof baseUrl === "function" ? baseUrl : null; |
| @@ -286,8 +301,9 @@ export const createPluginAtomClient = < · 1 unmodified line | ||
| 286 | 301 | const getAuthorizationHeader = |
| 287 | 302 | typeof authorizationHeader === "function" ? authorizationHeader : null; |
| 288 | 303 | const hasAuthorization = authorizationHeader !== undefined && authorizationHeader !== null; |
| 304 | + const hasHeaders = headers !== undefined; | |
| 289 | 305 | const transformClient = |
| 290 | − getBaseUrl || hasAuthorization | |
| 306 | + getBaseUrl || hasAuthorization || hasHeaders | |
| 291 | 307 | ? HttpClient.mapRequest((request) => |
| 292 | 308 | applyPluginAtomClientRequestTransform(request, { |
| 293 | 309 | ...(getBaseUrl ? { baseUrl: getBaseUrl } : {}), |
| @@ -296,6 +312,7 @@ export const createPluginAtomClient = < · 2 unmodified lines | ||
| 296 | 312 | : authorizationHeader !== undefined |
| 297 | 313 | ? { authorizationHeader } |
| 298 | 314 | : {}), |
| 315 | + ...(headers !== undefined ? { headers } : {}), | |
| 299 | 316 | }), |
| 300 | 317 | ) |
| 301 | 318 | : undefined; |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | 2 | import { |
| 3 | + getExecutorOrganizationHeaders, | |
| 3 | 4 | getExecutorApiBaseUrl, |
| 4 | 5 | getExecutorServerAuthorizationHeader, |
| 5 | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { GraphqlGroup } from "../api/group"; · 2 unmodified lines | ||
| 8 | 9 | export const GraphqlClient = createPluginAtomClient(GraphqlGroup, { |
| 9 | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | + headers: getExecutorOrganizationHeaders, | |
| 11 | 13 | }); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | 2 | import { |
| 3 | + getExecutorOrganizationHeaders, | |
| 3 | 4 | getExecutorApiBaseUrl, |
| 4 | 5 | getExecutorServerAuthorizationHeader, |
| 5 | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { McpGroup } from "../api/group"; · 2 unmodified lines | ||
| 8 | 9 | export const McpClient = createPluginAtomClient(McpGroup, { |
| 9 | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | + headers: getExecutorOrganizationHeaders, | |
| 11 | 13 | }); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | 2 | import { |
| 3 | + getExecutorOrganizationHeaders, | |
| 3 | 4 | getExecutorApiBaseUrl, |
| 4 | 5 | getExecutorServerAuthorizationHeader, |
| 5 | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { OnePasswordGroup } from "../api/group"; · 2 unmodified lines | ||
| 8 | 9 | export const OnePasswordClient = createPluginAtomClient(OnePasswordGroup, { |
| 9 | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | + headers: getExecutorOrganizationHeaders, | |
| 11 | 13 | }); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; |
| 2 | 2 | import { |
| 3 | + getExecutorOrganizationHeaders, | |
| 3 | 4 | getExecutorApiBaseUrl, |
| 4 | 5 | getExecutorServerAuthorizationHeader, |
| 5 | 6 | } from "@executor-js/react/api/server-connection"; |
| @@ -8,4 +9,5 @@ import { OpenApiGroup } from "../api/group"; · 2 unmodified lines | ||
| 8 | 9 | export const OpenApiClient = createPluginAtomClient(OpenApiGroup, { |
| 9 | 10 | baseUrl: getExecutorApiBaseUrl, |
| 10 | 11 | authorizationHeader: getExecutorServerAuthorizationHeader, |
| 12 | + headers: getExecutorOrganizationHeaders, | |
| 11 | 13 | }); |
| @@ -96,6 +96,11 @@ export const getActiveOrgSlug = (): string | null => { · 95 unmodified lines | ||
| 96 | 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 | 104 | export const getExecutorServerConnection = (): ExecutorServerConnection => activeConnection; |
| 100 | 105 | |
| 101 | 106 | export const setExecutorServerConnection = (input: ExecutorServerConnectionInput): void => { |