* Clean up OpenAPI add source API * Update OpenAPI add source credential UI * Address OpenAPI add source PR feedback * Document OpenAPI credential configuration plan * Fix OpenAPI add flow CI
Unified · Split
| @@ -35,15 +35,10 @@ const MinimalSourceApi = HttpApi.make("sourcesApiTest") · 34 unmodified lines | ||
| 35 | 35 | .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" })); |
| 36 | 36 | |
| 37 | 37 | const makeMinimalOpenApiSourcePayload = ( |
| 38 | − targetScope: ScopeId, | |
| 39 | 38 | namespace: string, |
| 40 | − options: Omit< | |
| 41 | − Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], | |
| 42 | − "targetScope" | "namespace" | |
| 43 | − > = {}, | |
| 39 | + options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {}, | |
| 44 | 40 | ) => |
| 45 | 41 | makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, { |
| 46 | − targetScope, | |
| 47 | 42 | namespace, |
| 48 | 43 | ...options, |
| 49 | 44 | }); |
| @@ -72,7 +67,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines | ||
| 72 | 67 | Effect.gen(function* () { |
| 73 | 68 | const result = yield* client.openapi.addSpec({ |
| 74 | 69 | params: { scopeId: ScopeId.make(org) }, |
| 75 | − payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | |
| 70 | + payload: makeMinimalOpenApiSourcePayload(namespace), | |
| 76 | 71 | }); |
| 77 | 72 | expect(result.namespace).toBe(namespace); |
| 78 | 73 | expect(result.toolCount).toBeGreaterThan(0); |
| @@ -94,7 +89,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines | ||
| 94 | 89 | yield* asOrg(org, (client) => |
| 95 | 90 | client.openapi.addSpec({ |
| 96 | 91 | params: { scopeId: ScopeId.make(org) }, |
| 97 | − payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | |
| 92 | + payload: makeMinimalOpenApiSourcePayload(namespace), | |
| 98 | 93 | }), |
| 99 | 94 | ); |
| 100 | 95 | |
| @@ -136,11 +131,9 @@ describe("sources api (HTTP)", () => { · 35 unmodified lines | ||
| 136 | 131 | const result = yield* asOrg(org, (client) => |
| 137 | 132 | client.openapi.addSpec({ |
| 138 | 133 | params: { scopeId: ScopeId.make(org) }, |
| 139 | − payload: makeMinimalOpenApiSourcePayload( | |
| 140 | − ScopeId.make(org), | |
| 141 | − `ns_${crypto.randomUUID().replace(/-/g, "_")}`, | |
| 142 | − { baseUrl: "http://example.com" }, | |
| 143 | − ), | |
| 134 | + payload: makeMinimalOpenApiSourcePayload(`ns_${crypto.randomUUID().replace(/-/g, "_")}`, { | |
| 135 | + baseUrl: "http://example.com", | |
| 136 | + }), | |
| 144 | 137 | }), |
| 145 | 138 | ); |
| 146 | 139 | |
| @@ -167,8 +160,9 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines | ||
| 167 | 160 | client.openapi.addSpec({ |
| 168 | 161 | params: { scopeId }, |
| 169 | 162 | payload: { |
| 170 | − targetScope: scopeId, | |
| 171 | − spec: server.specJson, | |
| 163 | + spec: { kind: "blob", value: server.specJson }, | |
| 164 | + name: "Invocable Source API", | |
| 165 | + baseUrl: server.baseUrl, | |
| 172 | 166 | namespace, |
| 173 | 167 | }, |
| 174 | 168 | }), |
| @@ -414,7 +408,7 @@ describe("sources api (HTTP)", () => { · 239 unmodified lines | ||
| 414 | 408 | Effect.gen(function* () { |
| 415 | 409 | yield* client.openapi.addSpec({ |
| 416 | 410 | params: { scopeId: ScopeId.make(org) }, |
| 417 | − payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | |
| 411 | + payload: makeMinimalOpenApiSourcePayload(namespace), | |
| 418 | 412 | }); |
| 419 | 413 | yield* client.sources.remove({ |
| 420 | 414 | params: { scopeId: ScopeId.make(org), sourceId: namespace }, |
| @@ -468,7 +462,7 @@ describe("sources api (HTTP)", () => { · 47 unmodified lines | ||
| 468 | 462 | Effect.gen(function* () { |
| 469 | 463 | yield* client.openapi.addSpec({ |
| 470 | 464 | params: { scopeId: ScopeId.make(org) }, |
| 471 | − payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | |
| 465 | + payload: makeMinimalOpenApiSourcePayload(namespace), | |
| 472 | 466 | }); |
| 473 | 467 | yield* client.openapi.updateSource({ |
| 474 | 468 | params: { scopeId: ScopeId.make(org), namespace }, |
| @@ -502,11 +496,10 @@ describe("sources api (HTTP)", () => { · 27 unmodified lines | ||
| 502 | 496 | client.openapi.addSpec({ |
| 503 | 497 | params: { scopeId: ScopeId.make(orgId) }, |
| 504 | 498 | payload: { |
| 505 | − ...makeMinimalOpenApiSourcePayload(ScopeId.make(orgId), namespace), | |
| 499 | + ...makeMinimalOpenApiSourcePayload(namespace), | |
| 506 | 500 | headers: { |
| 507 | 501 | Authorization: { |
| 508 | − kind: "binding", | |
| 509 | − slot: "auth:personal-token", | |
| 502 | + kind: "secret", | |
| 510 | 503 | prefix: "Bearer ", |
| 511 | 504 | }, |
| 512 | 505 | }, |
| @@ -530,7 +523,7 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines | ||
| 530 | 523 | sourceId: namespace, |
| 531 | 524 | sourceScope: ScopeId.make(orgId), |
| 532 | 525 | scope: ScopeId.make(aliceScope), |
| 533 | − slot: "auth:personal-token", | |
| 526 | + slot: "header:authorization", | |
| 534 | 527 | value: { |
| 535 | 528 | kind: "secret", |
| 536 | 529 | secretId: SecretId.make("alice_pat"), |
| @@ -541,7 +534,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | ||
| 541 | 534 | sourceId: namespace, |
| 542 | 535 | sourceScopeId: ScopeId.make(orgId), |
| 543 | 536 | scopeId: ScopeId.make(aliceScope), |
| 544 | − slot: "auth:personal-token", | |
| 537 | + slot: "header:authorization", | |
| 545 | 538 | value: { |
| 546 | 539 | kind: "secret", |
| 547 | 540 | secretId: SecretId.make("alice_pat"), |
| @@ -568,7 +561,7 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines | ||
| 568 | 561 | sourceId: namespace, |
| 569 | 562 | sourceScope: ScopeId.make(orgId), |
| 570 | 563 | scope: ScopeId.make(bobScope), |
| 571 | − slot: "auth:personal-token", | |
| 564 | + slot: "header:authorization", | |
| 572 | 565 | value: { |
| 573 | 566 | kind: "secret", |
| 574 | 567 | secretId: SecretId.make("bob_pat"), |
| @@ -590,7 +583,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines | ||
| 590 | 583 | expect(aliceBindings).toContainEqual( |
| 591 | 584 | expect.objectContaining({ |
| 592 | 585 | scopeId: ScopeId.make(aliceScope), |
| 593 | − slot: "auth:personal-token", | |
| 586 | + slot: "header:authorization", | |
| 594 | 587 | value: { |
| 595 | 588 | kind: "secret", |
| 596 | 589 | secretId: SecretId.make("alice_pat"), |
| @@ -601,7 +594,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | ||
| 601 | 594 | expect( |
| 602 | 595 | aliceBindings.some( |
| 603 | 596 | (binding) => |
| 604 | − binding.slot === "auth:personal-token" && | |
| 597 | + binding.slot === "header:authorization" && | |
| 605 | 598 | binding.value.kind === "secret" && |
| 606 | 599 | binding.value.secretId === SecretId.make("bob_pat"), |
| 607 | 600 | ), |
| @@ -619,7 +612,7 @@ describe("sources api (HTTP)", () => { · 11 unmodified lines | ||
| 619 | 612 | expect(bobBindings).toContainEqual( |
| 620 | 613 | expect.objectContaining({ |
| 621 | 614 | scopeId: ScopeId.make(bobScope), |
| 622 | − slot: "auth:personal-token", | |
| 615 | + slot: "header:authorization", | |
| 623 | 616 | value: { |
| 624 | 617 | kind: "secret", |
| 625 | 618 | secretId: SecretId.make("bob_pat"), |
| @@ -630,7 +623,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | ||
| 630 | 623 | expect( |
| 631 | 624 | bobBindings.some( |
| 632 | 625 | (binding) => |
| 633 | − binding.slot === "auth:personal-token" && | |
| 626 | + binding.slot === "header:authorization" && | |
| 634 | 627 | binding.value.kind === "secret" && |
| 635 | 628 | binding.value.secretId === SecretId.make("alice_pat"), |
| 636 | 629 | ), |
| @@ -654,8 +647,9 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines | ||
| 654 | 647 | client.openapi.addSpec({ |
| 655 | 648 | params: { scopeId: ScopeId.make(org) }, |
| 656 | 649 | payload: { |
| 657 | − targetScope: ScopeId.make(org), | |
| 658 | − spec: CLOUDFLARE_SPEC, | |
| 650 | + spec: { kind: "blob", value: CLOUDFLARE_SPEC }, | |
| 651 | + name: namespace, | |
| 652 | + baseUrl: "https://api.cloudflare.com/client/v4", | |
| 659 | 653 | namespace, |
| 660 | 654 | }, |
| 661 | 655 | }), |
| @@ -45,8 +45,9 @@ describe("sources.refresh (HTTP)", () => { · 44 unmodified lines | ||
| 45 | 45 | client.openapi.addSpec({ |
| 46 | 46 | params: { scopeId: ScopeId.make(org) }, |
| 47 | 47 | payload: { |
| 48 | − targetScope: ScopeId.make(org), | |
| 49 | − spec: server.specUrl, | |
| 48 | + spec: { kind: "url", url: server.specUrl }, | |
| 49 | + name: namespace, | |
| 50 | + baseUrl: server.baseUrl, | |
| 50 | 51 | namespace, |
| 51 | 52 | }, |
| 52 | 53 | }), |
| @@ -107,8 +108,9 @@ describe("sources.refresh (HTTP)", () => { · 54 unmodified lines | ||
| 107 | 108 | client.openapi.addSpec({ |
| 108 | 109 | params: { scopeId: ScopeId.make(org) }, |
| 109 | 110 | payload: { |
| 110 | − targetScope: ScopeId.make(org), | |
| 111 | − spec: makeRefreshSpecText(), | |
| 111 | + spec: { kind: "blob", value: makeRefreshSpecText() }, | |
| 112 | + name: namespace, | |
| 113 | + baseUrl: "https://api.example.test", | |
| 112 | 114 | namespace, |
| 113 | 115 | }, |
| 114 | 116 | }), |
| @@ -20,15 +20,10 @@ const TenantIsolationApi = HttpApi.make("tenantIsolationTest") · 19 unmodified lines | ||
| 20 | 20 | .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" })); |
| 21 | 21 | |
| 22 | 22 | const makeTenantOpenApiSourcePayload = ( |
| 23 | − targetScope: ScopeId, | |
| 24 | 23 | namespace: string, |
| 25 | − options: Omit< | |
| 26 | − Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], | |
| 27 | − "targetScope" | "namespace" | |
| 28 | − > = {}, | |
| 24 | + options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {}, | |
| 29 | 25 | ) => |
| 30 | 26 | makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, { |
| 31 | − targetScope, | |
| 32 | 27 | namespace, |
| 33 | 28 | ...options, |
| 34 | 29 | }); |
| @@ -116,7 +111,7 @@ describe("tenant isolation (HTTP)", () => { · 81 unmodified lines | ||
| 116 | 111 | yield* asOrg(orgA, (client) => |
| 117 | 112 | client.openapi.addSpec({ |
| 118 | 113 | params: { scopeId: ScopeId.make(orgA) }, |
| 119 | − payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | |
| 114 | + payload: makeTenantOpenApiSourcePayload(namespaceA), | |
| 120 | 115 | }), |
| 121 | 116 | ); |
| 122 | 117 | |
| @@ -136,7 +131,7 @@ describe("tenant isolation (HTTP)", () => { · 13 unmodified lines | ||
| 136 | 131 | yield* asOrg(orgA, (client) => |
| 137 | 132 | client.openapi.addSpec({ |
| 138 | 133 | params: { scopeId: ScopeId.make(orgA) }, |
| 139 | − payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | |
| 134 | + payload: makeTenantOpenApiSourcePayload(namespaceA), | |
| 140 | 135 | }), |
| 141 | 136 | ); |
| 142 | 137 | |
| @@ -159,7 +154,7 @@ describe("tenant isolation (HTTP)", () => { · 16 unmodified lines | ||
| 159 | 154 | yield* asOrg(orgA, (client) => |
| 160 | 155 | client.openapi.addSpec({ |
| 161 | 156 | params: { scopeId: ScopeId.make(orgA) }, |
| 162 | − payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | |
| 157 | + payload: makeTenantOpenApiSourcePayload(namespaceA), | |
| 163 | 158 | }), |
| 164 | 159 | ); |
| 165 | 160 | |
| @@ -259,11 +254,10 @@ describe("tenant isolation (HTTP)", () => { · 93 unmodified lines | ||
| 259 | 254 | yield* client.openapi.addSpec({ |
| 260 | 255 | params: { scopeId: ScopeId.make(orgA) }, |
| 261 | 256 | payload: { |
| 262 | − ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | |
| 257 | + ...makeTenantOpenApiSourcePayload(namespaceA), | |
| 263 | 258 | headers: { |
| 264 | 259 | Authorization: { |
| 265 | − kind: "binding", | |
| 266 | − slot: "auth:token", | |
| 260 | + kind: "secret", | |
| 267 | 261 | prefix: "Bearer ", |
| 268 | 262 | }, |
| 269 | 263 | }, |
| @@ -275,7 +269,7 @@ describe("tenant isolation (HTTP)", () => { · 5 unmodified lines | ||
| 275 | 269 | sourceId: namespaceA, |
| 276 | 270 | sourceScope: ScopeId.make(orgA), |
| 277 | 271 | scope: ScopeId.make(orgA), |
| 278 | − slot: "auth:token", | |
| 272 | + slot: "header:authorization", | |
| 279 | 273 | value: { kind: "secret", secretId: secretIdA }, |
| 280 | 274 | }, |
| 281 | 275 | }); |
| @@ -303,16 +297,7 @@ describe("tenant isolation (HTTP)", () => { · 21 unmodified lines | ||
| 303 | 297 | Effect.gen(function* () { |
| 304 | 298 | yield* client.openapi.addSpec({ |
| 305 | 299 | params: { scopeId: ScopeId.make(orgA) }, |
| 306 | − payload: { | |
| 307 | − ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | |
| 308 | − headers: { | |
| 309 | − Authorization: { | |
| 310 | − kind: "binding", | |
| 311 | − slot: "auth:conn", | |
| 312 | − prefix: "Bearer ", | |
| 313 | − }, | |
| 314 | − }, | |
| 315 | − }, | |
| 300 | + payload: makeTenantOpenApiSourcePayload(namespaceA), | |
| 316 | 301 | }); |
| 317 | 302 | yield* client.openapi.setSourceBinding({ |
| 318 | 303 | params: { scopeId: ScopeId.make(orgA) }, |
| @@ -346,7 +331,7 @@ describe("tenant isolation (HTTP)", () => { · 27 unmodified lines | ||
| 346 | 331 | yield* asOrg(orgA, (client) => |
| 347 | 332 | client.openapi.addSpec({ |
| 348 | 333 | params: { scopeId: ScopeId.make(orgA) }, |
| 349 | − payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespace, { | |
| 334 | + payload: makeTenantOpenApiSourcePayload(namespace, { | |
| 350 | 335 | name: "Org A API", |
| 351 | 336 | baseUrl: "https://org-a.example.com", |
| 352 | 337 | }), |
| @@ -355,7 +340,7 @@ describe("tenant isolation (HTTP)", () => { · 2 unmodified lines | ||
| 355 | 340 | yield* asOrg(orgB, (client) => |
| 356 | 341 | client.openapi.addSpec({ |
| 357 | 342 | params: { scopeId: ScopeId.make(orgB) }, |
| 358 | − payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgB), namespace, { | |
| 343 | + payload: makeTenantOpenApiSourcePayload(namespace, { | |
| 359 | 344 | name: "Org B API", |
| 360 | 345 | baseUrl: "https://org-b.example.com", |
| 361 | 346 | }), |
| @@ -85,8 +85,10 @@ export const POST: APIRoute = async ({ request }) => { · 84 unmodified lines | ||
| 85 | 85 | // Add source to register its tools (Google Discovery needs auth so skip) |
| 86 | 86 | if (match.kind === "openapi") { |
| 87 | 87 | yield* executor.openapi.addSpec({ |
| 88 | − spec: match.endpoint, | |
| 88 | + spec: { kind: "url", url: match.endpoint }, | |
| 89 | + name: match.name, | |
| 89 | 90 | namespace: match.namespace, |
| 91 | + baseUrl: match.endpoint, | |
| 90 | 92 | scope: "test-scope", |
| 91 | 93 | }); |
| 92 | 94 | } else if (match.kind === "graphql") { |
| @@ -244,8 +244,9 @@ const program = Effect.gen(function* () { · 243 unmodified lines | ||
| 244 | 244 | console.log("-".repeat(72)); |
| 245 | 245 | |
| 246 | 246 | const addSpecResult = yield* executor.openapi.addSpec({ |
| 247 | − spec: exampleOpenApiSpec, | |
| 247 | + spec: { kind: "blob", value: exampleOpenApiSpec }, | |
| 248 | 248 | namespace: "example-api", |
| 249 | + name: "Example API", | |
| 249 | 250 | baseUrl: "https://example.com/api", |
| 250 | 251 | scope: "example-scope", |
| 251 | 252 | }); |
| @@ -81,7 +81,12 @@ const executor = await createExecutor({ · 80 unmodified lines | ||
| 81 | 81 | await executor.openapi.addSpec({ |
| 82 | 82 | namespace: "inventory", |
| 83 | 83 | scope: "docs-workspace", |
| 84 | − spec: JSON.stringify(inventoryApi), | |
| 84 | + name: "Inventory API", | |
| 85 | + baseUrl: "https://inventory.example.com", | |
| 86 | + spec: { | |
| 87 | + kind: "blob", | |
| 88 | + value: JSON.stringify(inventoryApi), | |
| 89 | + }, | |
| 85 | 90 | }); |
| 86 | 91 | // docs:end add-source |
| 87 | 92 | |
| @@ -44,9 +44,14 @@ await executor.mcp.addSource({ · 43 unmodified lines | ||
| 44 | 44 | // --------------------------------------------------------------------------- |
| 45 | 45 | |
| 46 | 46 | await executor.openapi.addSpec({ |
| 47 | − spec: "https://petstore3.swagger.io/api/v3/openapi.json", | |
| 47 | + spec: { | |
| 48 | + kind: "url", | |
| 49 | + url: "https://petstore3.swagger.io/api/v3/openapi.json", | |
| 50 | + }, | |
| 48 | 51 | namespace: "petstore", |
| 49 | 52 | scope: "my-app", |
| 53 | + name: "Petstore", | |
| 54 | + baseUrl: "https://petstore3.swagger.io/api/v3", | |
| 50 | 55 | }); |
| 51 | 56 | |
| 52 | 57 | // With auth headers (static or secret-backed) |
| @@ -0,0 +1,316 @@ | ||
| 1 | +# OpenAPI Add Source API Cleanup Notes | |
| 2 | + | |
| 3 | +Date: 2026-05-17 | |
| 4 | +Status: planning | |
| 5 | + | |
| 6 | +## Summary | |
| 7 | + | |
| 8 | +The current OpenAPI add-source API mixes source ownership, source shape, and | |
| 9 | +credential binding values in one payload. That makes the common org-shared | |
| 10 | +source with per-user credentials hard to reason about. We want the immediate | |
| 11 | +cleanup to make the OpenAPI add endpoint explicit and remove duplicated scope | |
| 12 | +fields, without solving the larger per-user source/tool discovery model yet. | |
| 13 | + | |
| 14 | +The product model for this pass is: | |
| 15 | + | |
| 16 | +- OpenAPI source rows and tool definitions are shared at the org/workspace | |
| 17 | + level in the web UI. | |
| 18 | +- Runtime credential values can vary by user or org, but they must match the | |
| 19 | + shared source credential shape. | |
| 20 | +- Spec fetch credentials are source-definition credentials. If the shared spec | |
| 21 | + URL requires auth, those credentials are part of maintaining the shared tool | |
| 22 | + surface. | |
| 23 | +- Different auth structures should be different source instances, not | |
| 24 | + different per-user modes inside one source. | |
| 25 | + | |
| 26 | +## Current Problems | |
| 27 | + | |
| 28 | +`POST /scopes/:scopeId/openapi/specs` currently accepts `targetScope` in the | |
| 29 | +payload, duplicating the URL `scopeId` for web usage. The endpoint handler maps | |
| 30 | +`payload.targetScope` to SDK `scope`. | |
| 31 | + | |
| 32 | +The payload also accepts `credentialTargetScope`, which is a fallback for direct | |
| 33 | +secret inputs that do not carry their own binding scope. This is especially | |
| 34 | +visible in `specFetchCredentials`, where the UI currently serializes direct | |
| 35 | +secret refs without per-entry scope. | |
| 36 | + | |
| 37 | +`spec` is a plain string that can mean either: | |
| 38 | + | |
| 39 | +- an HTTP URL to fetch, or | |
| 40 | +- raw OpenAPI JSON/YAML text. | |
| 41 | + | |
| 42 | +`name`, `namespace`, and `baseUrl` are optional in the SDK because they can be | |
| 43 | +derived from the spec. That is convenient for internal/programmatic callers, but | |
| 44 | +the web/API boundary already has explicit user-facing values after preview. | |
| 45 | + | |
| 46 | +## Immediate Endpoint Direction | |
| 47 | + | |
| 48 | +For the HTTP/web add endpoint: | |
| 49 | + | |
| 50 | +```txt | |
| 51 | +POST /scopes/:scopeId/openapi/specs | |
| 52 | +``` | |
| 53 | + | |
| 54 | +`scopeId` should be the source owner scope. Remove payload `targetScope`. | |
| 55 | + | |
| 56 | +Make the identity and request base explicit: | |
| 57 | + | |
| 58 | +```ts | |
| 59 | +{ | |
| 60 | + spec: OpenApiSpecInput; | |
| 61 | + name: string; | |
| 62 | + namespace: string; | |
| 63 | + baseUrl: string; | |
| 64 | + // source shape fields... | |
| 65 | +} | |
| 66 | +``` | |
| 67 | + | |
| 68 | +Use a discriminated spec input instead of a string that guesses: | |
| 69 | + | |
| 70 | +```ts | |
| 71 | +type OpenApiSpecInput = { kind: "url"; url: string } | { kind: "blob"; value: string }; | |
| 72 | +``` | |
| 73 | + | |
| 74 | +Remove `credentialTargetScope` from the HTTP payload. Scope should always be | |
| 75 | +explicit anywhere a concrete secret or connection value is written. | |
| 76 | + | |
| 77 | +Keep SDK-level `scope` on `OpenApiSpecConfig`; non-HTTP callers still need to | |
| 78 | +choose where a source is added. | |
| 79 | + | |
| 80 | +## Source Shape vs Values | |
| 81 | + | |
| 82 | +The source shape is shared. Values can be bound per scope. | |
| 83 | + | |
| 84 | +Source shape examples: | |
| 85 | + | |
| 86 | +```ts | |
| 87 | +headers: { | |
| 88 | + Authorization: { | |
| 89 | + kind: "secret", | |
| 90 | + prefix: "Bearer ", | |
| 91 | + }, | |
| 92 | +}, | |
| 93 | +queryParams: { | |
| 94 | + api_version: "2026-05-17", | |
| 95 | +} | |
| 96 | +``` | |
| 97 | + | |
| 98 | +This declares: | |
| 99 | + | |
| 100 | +- every user of the source uses an `Authorization` header with a bearer prefix; | |
| 101 | +- the concrete token value is supplied separately; | |
| 102 | +- `api_version` is a shared literal source config value. | |
| 103 | + | |
| 104 | +Under the hood, secret-shaped headers/query params still normalize to internal | |
| 105 | +source bindings: | |
| 106 | + | |
| 107 | +```ts | |
| 108 | +headers: { | |
| 109 | + Authorization: { | |
| 110 | + kind: "binding", | |
| 111 | + slot: "header:authorization", | |
| 112 | + prefix: "Bearer ", | |
| 113 | + }, | |
| 114 | +} | |
| 115 | +``` | |
| 116 | + | |
| 117 | +Plain text values are not credentials. They can stay directly in source config. | |
| 118 | + | |
| 119 | +Concrete values should be set through source value binding APIs. The existing | |
| 120 | +low-level operation is `setSourceBinding`; a later cleanup can add domain-level | |
| 121 | +wrappers such as `setHeaderValue` or `setQueryParamValue`. | |
| 122 | + | |
| 123 | +## Example: Org Default With One User Override | |
| 124 | + | |
| 125 | +```ts | |
| 126 | +const orgScope = ScopeId.make("org_123"); | |
| 127 | +const aliceScope = ScopeId.make("user-org:alice:org_123"); | |
| 128 | + | |
| 129 | +const source = await executor.openapi.addSpec({ | |
| 130 | + scope: orgScope, | |
| 131 | + spec: { | |
| 132 | + kind: "url", | |
| 133 | + url: "https://api.example.com/openapi.json", | |
| 134 | + }, | |
| 135 | + name: "Example API", | |
| 136 | + namespace: "example", | |
| 137 | + baseUrl: "https://api.example.com", | |
| 138 | + headers: { | |
| 139 | + Authorization: { | |
| 140 | + kind: "secret", | |
| 141 | + prefix: "Bearer ", | |
| 142 | + }, | |
| 143 | + }, | |
| 144 | + queryParams: { | |
| 145 | + api_version: "2026-05-17", | |
| 146 | + }, | |
| 147 | +}); | |
| 148 | + | |
| 149 | +await executor.secrets.set({ | |
| 150 | + id: SecretId.make("example-org-token"), | |
| 151 | + scope: orgScope, | |
| 152 | + name: "Example org token", | |
| 153 | + value: process.env.EXAMPLE_ORG_TOKEN!, | |
| 154 | +}); | |
| 155 | + | |
| 156 | +await executor.openapi.setHeaderValue({ | |
| 157 | + source, | |
| 158 | + scope: orgScope, | |
| 159 | + name: "Authorization", | |
| 160 | + value: { | |
| 161 | + kind: "secret", | |
| 162 | + secretId: SecretId.make("example-org-token"), | |
| 163 | + secretScope: orgScope, | |
| 164 | + }, | |
| 165 | +}); | |
| 166 | + | |
| 167 | +await executor.secrets.set({ | |
| 168 | + id: SecretId.make("alice-example-token"), | |
| 169 | + scope: aliceScope, | |
| 170 | + name: "Alice Example token", | |
| 171 | + value: process.env.ALICE_EXAMPLE_TOKEN!, | |
| 172 | +}); | |
| 173 | + | |
| 174 | +await executor.openapi.setHeaderValue({ | |
| 175 | + source, | |
| 176 | + scope: aliceScope, | |
| 177 | + name: "Authorization", | |
| 178 | + value: { | |
| 179 | + kind: "secret", | |
| 180 | + secretId: SecretId.make("alice-example-token"), | |
| 181 | + secretScope: aliceScope, | |
| 182 | + }, | |
| 183 | +}); | |
| 184 | +``` | |
| 185 | + | |
| 186 | +Resolution: | |
| 187 | + | |
| 188 | +- Alice gets `Authorization: Bearer <alice token>`. | |
| 189 | +- Other users fall back to `Authorization: Bearer <org token>`. | |
| 190 | +- The source/tool definition remains org-scoped and shared. | |
| 191 | + | |
| 192 | +## OAuth Shape | |
| 193 | + | |
| 194 | +OAuth follows the same split. The source declares shared OAuth metadata: | |
| 195 | + | |
| 196 | +- security scheme name; | |
| 197 | +- flow; | |
| 198 | +- authorization/token URLs; | |
| 199 | +- scopes; | |
| 200 | +- where client id/client secret/connection values are expected. | |
| 201 | + | |
| 202 | +Then values are bound separately: | |
| 203 | + | |
| 204 | +- OAuth app client id/secret can be org-level values. | |
| 205 | +- OAuth access/refresh token connection is commonly per user. | |
| 206 | + | |
| 207 | +Conceptually: | |
| 208 | + | |
| 209 | +```ts | |
| 210 | +const source = await executor.openapi.addSpec({ | |
| 211 | + scope: orgScope, | |
| 212 | + spec: { kind: "url", url: "https://api.example.com/openapi.json" }, | |
| 213 | + name: "Example API", | |
| 214 | + namespace: "example", | |
| 215 | + baseUrl: "https://api.example.com", | |
| 216 | + oauth2: { | |
| 217 | + securitySchemeName: "oauth2", | |
| 218 | + flow: "authorizationCode", | |
| 219 | + authorizationUrl: "https://api.example.com/oauth/authorize", | |
| 220 | + tokenUrl: "https://api.example.com/oauth/token", | |
| 221 | + scopes: ["read", "write"], | |
| 222 | + }, | |
| 223 | +}); | |
| 224 | + | |
| 225 | +await executor.openapi.setOAuthClientCredentials({ | |
| 226 | + source, | |
| 227 | + scope: orgScope, | |
| 228 | + clientId: { kind: "secret", secretId: "client-id", secretScope: orgScope }, | |
| 229 | + clientSecret: { kind: "secret", secretId: "client-secret", secretScope: orgScope }, | |
| 230 | +}); | |
| 231 | + | |
| 232 | +await executor.openapi.setOAuthConnection({ | |
| 233 | + source, | |
| 234 | + scope: aliceScope, | |
| 235 | + connectionId: aliceConnection.id, | |
| 236 | +}); | |
| 237 | +``` | |
| 238 | + | |
| 239 | +The current implementation can continue using internal OAuth slots. The public | |
| 240 | +SDK does not need to expose those slots for the simple flow. | |
| 241 | + | |
| 242 | +## Spec Fetch Credentials | |
| 243 | + | |
| 244 | +Spec fetch credentials differ from runtime credentials because they may be | |
| 245 | +needed before the source exists. | |
| 246 | + | |
| 247 | +For this immediate pass: | |
| 248 | + | |
| 249 | +- keep spec fetch credentials on `addSpec`; | |
| 250 | +- treat them as source-definition credentials; | |
| 251 | +- make the UI use the same secret scope selection semantics as other secret | |
| 252 | + inputs; | |
| 253 | +- remove `credentialTargetScope` by making any concrete spec fetch secret refs | |
| 254 | + carry explicit scope information. | |
| 255 | + | |
| 256 | +Longer term, if we want all persisted values to use bindings only, preview may | |
| 257 | +need either request-only credentials or callers must fetch private specs | |
| 258 | +themselves and pass `{ kind: "blob", value }`. | |
| 259 | + | |
| 260 | +## Sharing Boundary | |
| 261 | + | |
| 262 | +One source has one shared credential structure. Users can provide different | |
| 263 | +values for that structure, but they cannot change the structure per user. | |
| 264 | + | |
| 265 | +Supported: | |
| 266 | + | |
| 267 | +```txt | |
| 268 | +Source shape: Authorization header with Bearer prefix | |
| 269 | +Org value: shared token | |
| 270 | +Alice value: Alice token override | |
| 271 | +Bob value: Bob token override | |
| 272 | +``` | |
| 273 | + | |
| 274 | +Not supported as one source: | |
| 275 | + | |
| 276 | +```txt | |
| 277 | +Alice uses Authorization: Bearer <token> | |
| 278 | +Bob uses X-API-Key: <key> | |
| 279 | +Carol uses OAuth | |
| 280 | +``` | |
| 281 | + | |
| 282 | +Those are different source instances, even if they point at the same OpenAPI | |
| 283 | +spec and base URL. | |
| 284 | + | |
| 285 | +## MCP and Per-User Tool Discovery | |
| 286 | + | |
| 287 | +MCP exposes a larger issue: some servers may return different tools or tool | |
| 288 | +descriptions based on the authenticated user. That means auth can affect | |
| 289 | +discovery, not just invocation. | |
| 290 | + | |
| 291 | +This does not need to be solved in the OpenAPI add-source cleanup. Defer: | |
| 292 | + | |
| 293 | +- per-user source rows; | |
| 294 | +- per-user tool discovery; | |
| 295 | +- MCP auth-dependent tool descriptions; | |
| 296 | +- template/materialization flows for user-bound discovery sources. | |
| 297 | + | |
| 298 | +For now, keep OpenAPI on the stable shared tool surface model. | |
| 299 | + | |
| 300 | +## Deferred API Cleanup | |
| 301 | + | |
| 302 | +Leave `setSourceBinding` in place for edit/override flows for now. It is a | |
| 303 | +lower-level source value binding API and already supports text, secret, and | |
| 304 | +connection values. | |
| 305 | + | |
| 306 | +A later public SDK cleanup can add domain-level wrappers: | |
| 307 | + | |
| 308 | +- `setHeaderValue`; | |
| 309 | +- `setQueryParamValue`; | |
| 310 | +- `setSpecFetchHeaderValue`; | |
| 311 | +- `setSpecFetchQueryParamValue`; | |
| 312 | +- `setOAuthClientCredentials`; | |
| 313 | +- `setOAuthConnection`. | |
| 314 | + | |
| 315 | +Those wrappers can map to internal binding slots without exposing slots to the | |
| 316 | +consumer-facing simple flow. | |
| @@ -0,0 +1,509 @@ | ||
| 1 | +# OpenAPI SDK Credential Configuration Notes | |
| 2 | + | |
| 3 | +Date: 2026-05-17 | |
| 4 | +Status: planning | |
| 5 | + | |
| 6 | +## Summary | |
| 7 | + | |
| 8 | +The SDK should present OpenAPI source onboarding as two explicit phases: | |
| 9 | + | |
| 10 | +1. Add/import a shared source shape. | |
| 11 | +2. Configure scoped credential values for that source. | |
| 12 | + | |
| 13 | +The internal implementation can still use source slots and scoped bindings, | |
| 14 | +but normal SDK callers should not need to think about `setSourceBinding`, | |
| 15 | +`connectionSlot`, `clientIdSlot`, `credentialTargetScope`, or plugin-specific | |
| 16 | +binding row shapes. | |
| 17 | + | |
| 18 | +The product flow we want to preserve is: | |
| 19 | + | |
| 20 | +```txt | |
| 21 | +Admin adds Stripe/Linear/GitHub once at org scope. | |
| 22 | +Org may set default credentials. | |
| 23 | +Each user can sign in or provide their own token. | |
| 24 | +The source and tools stay shared. | |
| 25 | +Credentials remain scoped and overridable. | |
| 26 | +``` | |
| 27 | + | |
| 28 | +## Core Vocabulary | |
| 29 | + | |
| 30 | +**Source shape** is the shared definition: | |
| 31 | + | |
| 32 | +- OpenAPI spec and extracted operations. | |
| 33 | +- Base URL. | |
| 34 | +- Declared headers and query params the source knows how to send. | |
| 35 | +- Selected OAuth method, if any. | |
| 36 | +- OAuth flow metadata such as authorization URL, token URL, and scopes. | |
| 37 | + | |
| 38 | +**Scoped credential values** are the concrete values attached later: | |
| 39 | + | |
| 40 | +- Bearer/API key token values. | |
| 41 | +- Header/query param text values. | |
| 42 | +- OAuth client ID and client secret. | |
| 43 | +- OAuth connection IDs. | |
| 44 | +- Spec-fetch credential values. | |
| 45 | + | |
| 46 | +Source shape belongs to the source owner scope. Credential values belong to | |
| 47 | +the scope where they are configured and can inherit/override through the scope | |
| 48 | +stack. | |
| 49 | + | |
| 50 | +## Public SDK Shape | |
| 51 | + | |
| 52 | +The common bearer token flow should read like this: | |
| 53 | + | |
| 54 | +```ts | |
| 55 | +const org = ScopeId.make("org_acme"); | |
| 56 | +const user = ScopeId.make("user_rhys"); | |
| 57 | + | |
| 58 | +const source = await executor.openapi.addSpec({ | |
| 59 | + scope: org, | |
| 60 | + name: "Stripe", | |
| 61 | + namespace: "stripe", | |
| 62 | + baseUrl: "https://api.stripe.com", | |
| 63 | + spec: { | |
| 64 | + kind: "url", | |
| 65 | + url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", | |
| 66 | + }, | |
| 67 | + | |
| 68 | + headers: { | |
| 69 | + Authorization: { | |
| 70 | + prefix: "Bearer ", | |
| 71 | + }, | |
| 72 | + }, | |
| 73 | +}); | |
| 74 | + | |
| 75 | +await executor.openapi.configure(source, { | |
| 76 | + scope: user, | |
| 77 | + headers: { | |
| 78 | + Authorization: SecretId.make("stripe_api_key"), | |
| 79 | + }, | |
| 80 | +}); | |
| 81 | +``` | |
| 82 | + | |
| 83 | +The caller should not pass: | |
| 84 | + | |
| 85 | +```ts | |
| 86 | +kind: "binding"; | |
| 87 | +slot: "header:authorization"; | |
| 88 | +credentialTargetScope: user; | |
| 89 | +``` | |
| 90 | + | |
| 91 | +Those are internal implementation details. | |
| 92 | + | |
| 93 | +## Strict Configure Semantics | |
| 94 | + | |
| 95 | +`configure` fills declared holes in an existing source shape. It should not | |
| 96 | +create new source shape. | |
| 97 | + | |
| 98 | +If the source was imported with: | |
| 99 | + | |
| 100 | +```ts | |
| 101 | +headers: { | |
| 102 | + Authorization: { | |
| 103 | + prefix: "Bearer ", | |
| 104 | + }, | |
| 105 | +} | |
| 106 | +``` | |
| 107 | + | |
| 108 | +then this is valid: | |
| 109 | + | |
| 110 | +```ts | |
| 111 | +await executor.openapi.configure(source, { | |
| 112 | + scope: user, | |
| 113 | + headers: { | |
| 114 | + Authorization: SecretId.make("stripe_api_key"), | |
| 115 | + }, | |
| 116 | +}); | |
| 117 | +``` | |
| 118 | + | |
| 119 | +This should fail: | |
| 120 | + | |
| 121 | +```ts | |
| 122 | +await executor.openapi.configure(source, { | |
| 123 | + scope: user, | |
| 124 | + headers: { | |
| 125 | + Username: SecretId.make("stripe_api_key"), | |
| 126 | + }, | |
| 127 | +}); | |
| 128 | +``` | |
| 129 | + | |
| 130 | +because `Username` was not declared on the source. Allowing this would silently | |
| 131 | +write a binding for a value the source never sends. | |
| 132 | + | |
| 133 | +Suggested error: | |
| 134 | + | |
| 135 | +```txt | |
| 136 | +Unknown header "Username" for OpenAPI source "stripe". | |
| 137 | +Declared headers: Authorization. | |
| 138 | +``` | |
| 139 | + | |
| 140 | +The same rule applies to query params: | |
| 141 | + | |
| 142 | +```ts | |
| 143 | +await executor.openapi.configure(source, { | |
| 144 | + scope: user, | |
| 145 | + queryParams: { | |
| 146 | + api_key: SecretId.make("example_api_key"), | |
| 147 | + }, | |
| 148 | +}); | |
| 149 | +``` | |
| 150 | + | |
| 151 | +should only succeed if `api_key` was declared in the source shape. | |
| 152 | + | |
| 153 | +Adding or changing source shape should be a different operation: | |
| 154 | + | |
| 155 | +```ts | |
| 156 | +await executor.openapi.updateSource(source, { | |
| 157 | + headers: { | |
| 158 | + Authorization: { prefix: "Bearer " }, | |
| 159 | + Username: {}, | |
| 160 | + }, | |
| 161 | +}); | |
| 162 | + | |
| 163 | +await executor.openapi.configure(source, { | |
| 164 | + scope: user, | |
| 165 | + headers: { | |
| 166 | + Username: SecretId.make("basic_username"), | |
| 167 | + }, | |
| 168 | +}); | |
| 169 | +``` | |
| 170 | + | |
| 171 | +## Values Accepted By Configure | |
| 172 | + | |
| 173 | +`configure` should accept ergonomic value inputs and normalize them to binding | |
| 174 | +values internally. | |
| 175 | + | |
| 176 | +```ts | |
| 177 | +type OpenApiConfiguredCredentialValue = | |
| 178 | + | string | |
| 179 | + | SecretId | |
| 180 | + | { | |
| 181 | + kind: "secret"; | |
| 182 | + secretId: SecretId; | |
| 183 | + secretScope?: ScopeId; | |
| 184 | + } | |
| 185 | + | { | |
| 186 | + kind: "text"; | |
| 187 | + text: string; | |
| 188 | + }; | |
| 189 | +``` | |
| 190 | + | |
| 191 | +Examples: | |
| 192 | + | |
| 193 | +```ts | |
| 194 | +await executor.openapi.configure(source, { | |
| 195 | + scope: user, | |
| 196 | + headers: { | |
| 197 | + Authorization: SecretId.make("stripe_api_key"), | |
| 198 | + "X-Workspace": "acme", | |
| 199 | + }, | |
| 200 | + queryParams: { | |
| 201 | + version: "2026-05-17", | |
| 202 | + }, | |
| 203 | +}); | |
| 204 | +``` | |
| 205 | + | |
| 206 | +Internal binding writes: | |
| 207 | + | |
| 208 | +```ts | |
| 209 | +setBinding({ | |
| 210 | + source: { pluginId: "openapi", id: "stripe", scope: org }, | |
| 211 | + scope: user, | |
| 212 | + slot: "header:authorization", | |
| 213 | + value: { kind: "secret", secretId: SecretId.make("stripe_api_key") }, | |
| 214 | +}); | |
| 215 | + | |
| 216 | +setBinding({ | |
| 217 | + source: { pluginId: "openapi", id: "stripe", scope: org }, | |
| 218 | + scope: user, | |
| 219 | + slot: "header:x-workspace", | |
| 220 | + value: { kind: "text", text: "acme" }, | |
| 221 | +}); | |
| 222 | + | |
| 223 | +setBinding({ | |
| 224 | + source: { pluginId: "openapi", id: "stripe", scope: org }, | |
| 225 | + scope: user, | |
| 226 | + slot: "query_param:version", | |
| 227 | + value: { kind: "text", text: "2026-05-17" }, | |
| 228 | +}); | |
| 229 | +``` | |
| 230 | + | |
| 231 | +## Org Default With User Override | |
| 232 | + | |
| 233 | +```ts | |
| 234 | +const source = await executor.openapi.addSpec({ | |
| 235 | + scope: org, | |
| 236 | + name: "Example API", | |
| 237 | + namespace: "example", | |
| 238 | + baseUrl: "https://api.example.com", | |
| 239 | + spec: { | |
| 240 | + kind: "url", | |
| 241 | + url: "https://api.example.com/openapi.json", | |
| 242 | + }, | |
| 243 | + headers: { | |
| 244 | + Authorization: { | |
| 245 | + prefix: "Bearer ", | |
| 246 | + }, | |
| 247 | + }, | |
| 248 | +}); | |
| 249 | + | |
| 250 | +await executor.openapi.configure(source, { | |
| 251 | + scope: org, | |
| 252 | + headers: { | |
| 253 | + Authorization: SecretId.make("example_org_token"), | |
| 254 | + }, | |
| 255 | +}); | |
| 256 | + | |
| 257 | +await executor.openapi.configure(source, { | |
| 258 | + scope: user, | |
| 259 | + headers: { | |
| 260 | + Authorization: SecretId.make("example_user_token"), | |
| 261 | + }, | |
| 262 | +}); | |
| 263 | +``` | |
| 264 | + | |
| 265 | +Resolution: | |
| 266 | + | |
| 267 | +```txt | |
| 268 | +User with user binding: | |
| 269 | + Authorization: Bearer <example_user_token> | |
| 270 | + | |
| 271 | +User without user binding: | |
| 272 | + Authorization: Bearer <example_org_token> | |
| 273 | +``` | |
| 274 | + | |
| 275 | +The source and tools remain org-scoped. Only values vary by scope. | |
| 276 | + | |
| 277 | +## OAuth Is Not Just A Header | |
| 278 | + | |
| 279 | +OAuth eventually produces an `Authorization` header, but the setup is not just | |
| 280 | +"set the Authorization header value." OAuth has distinct source shape and | |
| 281 | +credential value concerns: | |
| 282 | + | |
| 283 | +- The selected OpenAPI security scheme. | |
| 284 | +- OAuth flow. | |
| 285 | +- Authorization URL. | |
| 286 | +- Token URL. | |
| 287 | +- Issuer URL when relevant. | |
| 288 | +- Scopes. | |
| 289 | +- Client ID. | |
| 290 | +- Client secret. | |
| 291 | +- Connection ID. | |
| 292 | +- Token refresh lifecycle. | |
| 293 | + | |
| 294 | +The source should choose one OAuth configuration at import time. After that, | |
| 295 | +credential operations should not ask the caller to repeat the security scheme | |
| 296 | +name in the common case. | |
| 297 | + | |
| 298 | +## Current OpenAPI OAuth Model | |
| 299 | + | |
| 300 | +Today preview returns `oauth2Presets`, one per supported OAuth option derived | |
| 301 | +from the spec. The add UI chooses one preset and persists one | |
| 302 | +`OAuth2SourceConfig` on the source: | |
| 303 | + | |
| 304 | +```ts | |
| 305 | +oauth2: { | |
| 306 | + kind: "oauth2", | |
| 307 | + securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 308 | + flow: selectedOAuth2Preset.flow, | |
| 309 | + authorizationUrl: selectedOAuth2Preset.authorizationUrl, | |
| 310 | + tokenUrl: selectedOAuth2Preset.tokenUrl, | |
| 311 | + issuerUrl: selectedOAuth2Preset.issuerUrl ?? null, | |
| 312 | + clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName), | |
| 313 | + clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName), | |
| 314 | + connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName), | |
| 315 | + scopes: [...oauth2SelectedScopes], | |
| 316 | +} | |
| 317 | +``` | |
| 318 | + | |
| 319 | +Client ID and client secret are stored as scoped bindings: | |
| 320 | + | |
| 321 | +```ts | |
| 322 | +setBinding({ | |
| 323 | + slot: oauth2.clientIdSlot, | |
| 324 | + scope: org, | |
| 325 | + value: { | |
| 326 | + kind: "secret", | |
| 327 | + secretId: SecretId.make("linear_client_id"), | |
| 328 | + }, | |
| 329 | +}); | |
| 330 | + | |
| 331 | +setBinding({ | |
| 332 | + slot: oauth2.clientSecretSlot, | |
| 333 | + scope: org, | |
| 334 | + value: { | |
| 335 | + kind: "secret", | |
| 336 | + secretId: SecretId.make("linear_client_secret"), | |
| 337 | + }, | |
| 338 | +}); | |
| 339 | +``` | |
| 340 | + | |
| 341 | +The user connection is also stored as a scoped binding: | |
| 342 | + | |
| 343 | +```ts | |
| 344 | +setBinding({ | |
| 345 | + slot: oauth2.connectionSlot, | |
| 346 | + scope: user, | |
| 347 | + value: { | |
| 348 | + kind: "connection", | |
| 349 | + connectionId: ConnectionId.make("linear_user_connection"), | |
| 350 | + }, | |
| 351 | +}); | |
| 352 | +``` | |
| 353 | + | |
| 354 | +That underlying model is sound. The clunky part is exposing the slot names and | |
| 355 | +raw binding operations as the normal SDK path. | |
| 356 | + | |
| 357 | +## Proposed OAuth SDK Flow | |
| 358 | + | |
| 359 | +If the spec has exactly one supported OAuth option, `addSpec` can infer it. If | |
| 360 | +there are multiple supported OAuth options, `addSpec` should require an | |
| 361 | +import-time selection. | |
| 362 | + | |
| 363 | +Single OAuth option: | |
| 364 | + | |
| 365 | +```ts | |
| 366 | +const source = await executor.openapi.addSpec({ | |
| 367 | + scope: org, | |
| 368 | + name: "Linear", | |
| 369 | + namespace: "linear", | |
| 370 | + baseUrl: "https://api.linear.app", | |
| 371 | + spec: { | |
| 372 | + kind: "url", | |
| 373 | + url: "https://example.com/linear-openapi.json", | |
| 374 | + }, | |
| 375 | + oauth: true, | |
| 376 | +}); | |
| 377 | +``` | |
| 378 | + | |
| 379 | +Multiple OAuth options: | |
| 380 | + | |
| 381 | +```ts | |
| 382 | +const source = await executor.openapi.addSpec({ | |
| 383 | + scope: org, | |
| 384 | + name: "Linear", | |
| 385 | + namespace: "linear", | |
| 386 | + baseUrl: "https://api.linear.app", | |
| 387 | + spec: { | |
| 388 | + kind: "url", | |
| 389 | + url: "https://example.com/linear-openapi.json", | |
| 390 | + }, | |
| 391 | + oauth: { | |
| 392 | + securityScheme: "linearOAuth", | |
| 393 | + }, | |
| 394 | +}); | |
| 395 | +``` | |
| 396 | + | |
| 397 | +Configure app credentials: | |
| 398 | + | |
| 399 | +```ts | |
| 400 | +await executor.openapi.configure(source, { | |
| 401 | + scope: org, | |
| 402 | + oauth: { | |
| 403 | + clientId: SecretId.make("linear_client_id"), | |
| 404 | + clientSecret: SecretId.make("linear_client_secret"), | |
| 405 | + }, | |
| 406 | +}); | |
| 407 | +``` | |
| 408 | + | |
| 409 | +Connect a user: | |
| 410 | + | |
| 411 | +```ts | |
| 412 | +await executor.openapi.connect(source, { | |
| 413 | + scope: user, | |
| 414 | +}); | |
| 415 | +``` | |
| 416 | + | |
| 417 | +Client credentials flow should use the same public concepts: | |
| 418 | + | |
| 419 | +```ts | |
| 420 | +await executor.openapi.configure(source, { | |
| 421 | + scope: org, | |
| 422 | + oauth: { | |
| 423 | + clientId: SecretId.make("service_client_id"), | |
| 424 | + clientSecret: SecretId.make("service_client_secret"), | |
| 425 | + }, | |
| 426 | +}); | |
| 427 | + | |
| 428 | +await executor.openapi.connect(source, { | |
| 429 | + scope: org, | |
| 430 | +}); | |
| 431 | +``` | |
| 432 | + | |
| 433 | +Internally this still writes: | |
| 434 | + | |
| 435 | +```txt | |
| 436 | +oauth2:<selected-security-scheme>:client-id | |
| 437 | +oauth2:<selected-security-scheme>:client-secret | |
| 438 | +oauth2:<selected-security-scheme>:connection | |
| 439 | +``` | |
| 440 | + | |
| 441 | +but SDK callers do not see those slots. | |
| 442 | + | |
| 443 | +## OAuth Validation | |
| 444 | + | |
| 445 | +`configure(source, { oauth: ... })` should fail if the source has no selected | |
| 446 | +OAuth config: | |
| 447 | + | |
| 448 | +```txt | |
| 449 | +OpenAPI source "stripe" does not declare OAuth credentials. | |
| 450 | +``` | |
| 451 | + | |
| 452 | +`connect(source, { scope })` should fail if the source has no selected OAuth | |
| 453 | +config: | |
| 454 | + | |
| 455 | +```txt | |
| 456 | +OpenAPI source "stripe" does not support OAuth connect. | |
| 457 | +``` | |
| 458 | + | |
| 459 | +`connect` should validate required client credentials before starting OAuth: | |
| 460 | + | |
| 461 | +```txt | |
| 462 | +Client ID must be configured before connecting. | |
| 463 | +Client secret must be configured before connecting. | |
| 464 | +``` | |
| 465 | + | |
| 466 | +For authorization-code flows, client secret may be optional for public PKCE | |
| 467 | +clients. For client-credentials flows, client secret is required. | |
| 468 | + | |
| 469 | +## Configure Should Be A Facade | |
| 470 | + | |
| 471 | +The public OpenAPI SDK can expose: | |
| 472 | + | |
| 473 | +```ts | |
| 474 | +executor.openapi.addSpec(...) | |
| 475 | +executor.openapi.configure(...) | |
| 476 | +executor.openapi.connect(...) | |
| 477 | +``` | |
| 478 | + | |
| 479 | +The lower-level core API can still expose generic bindings for advanced or UI | |
| 480 | +infrastructure cases: | |
| 481 | + | |
| 482 | +```ts | |
| 483 | +executor.sources.setBinding(...) | |
| 484 | +executor.sources.listBindings(...) | |
| 485 | +executor.sources.removeBinding(...) | |
| 486 | +executor.sources.getSlotManifest(...) | |
| 487 | +``` | |
| 488 | + | |
| 489 | +But the common OpenAPI SDK path should not require users to manually map: | |
| 490 | + | |
| 491 | +```txt | |
| 492 | +Authorization -> header:authorization | |
| 493 | +OAuth client ID -> oauth2:<scheme>:client-id | |
| 494 | +OAuth connection -> oauth2:<scheme>:connection | |
| 495 | +``` | |
| 496 | + | |
| 497 | +The SDK should derive that mapping from the selected source shape. | |
| 498 | + | |
| 499 | +## Design Rule | |
| 500 | + | |
| 501 | +`addSpec` defines what this source can use. | |
| 502 | + | |
| 503 | +`configure` assigns values to things this source already declared. | |
| 504 | + | |
| 505 | +`connect` creates or refreshes OAuth connection values for the selected OAuth | |
| 506 | +configuration. | |
| 507 | + | |
| 508 | +Bindings and slots are the internal storage/resolution mechanism behind those | |
| 509 | +operations. | |
| @@ -0,0 +1,209 @@ | ||
| 1 | +# Source Binding Consolidation Notes | |
| 2 | + | |
| 3 | +Date: 2026-05-17 | |
| 4 | +Status: exploring | |
| 5 | + | |
| 6 | +## Context | |
| 7 | + | |
| 8 | +The branch `codex/fix-openapi-add-flow` shipped a focused cleanup of the | |
| 9 | +OpenAPI add-source API (see `openapi-add-source-api-cleanup.md`). Looking | |
| 10 | +across the OpenAPI, GraphQL, and MCP plugins while reviewing that work | |
| 11 | +turned up a much larger duplication problem. This note captures the | |
| 12 | +direction we're considering before drafting a real plan. | |
| 13 | + | |
| 14 | +## What's duplicated today | |
| 15 | + | |
| 16 | +Each of OpenAPI, GraphQL, and MCP independently reimplements the same | |
| 17 | +source-binding machinery on top of the core `credential_binding` table: | |
| 18 | + | |
| 19 | +- A plugin-specific `*SourceBindingInput` / `*SourceBindingRef` type that | |
| 20 | + is a thin re-skin of core `CredentialBindingInput` / `CredentialBindingRef`. | |
| 21 | +- A `resolve*SourceBinding` helper that filters bindings by | |
| 22 | + `slotKey === slot` and picks the innermost-visible binding whose scope | |
| 23 | + rank ≤ the source's owner-scope rank. Identical across plugins. | |
| 24 | +- A `list*SourceBindings` helper applying the same scope-ceiling filter. | |
| 25 | +- A `validate*BindingTarget` helper that re-checks the same outer-scope | |
| 26 | + rule core's `assertCredentialBindingTargetNotOuter` already enforces. | |
| 27 | +- A `coreBindingToXxxBinding` adapter that exists only because the | |
| 28 | + per-plugin Ref type is a re-skin. | |
| 29 | +- Per-plugin HTTP endpoints: `setSourceBinding`, `listSourceBindings`, | |
| 30 | + `removeSourceBinding`. Same payloads, same semantics, mounted under | |
| 31 | + different paths. | |
| 32 | +- A `canonicalizeCredentialMap` / `canonicalizeAuth` that splits an input | |
| 33 | + "shape with secret refs" into a stored shape (with binding sentinels) | |
| 34 | + plus a list of bindings to write. Same algorithm per plugin. | |
| 35 | +- React glue: each plugin re-wires `secret-header-auth`, | |
| 36 | + `credential-target-scope`, and `oauth-sign-in` into its own add flow. | |
| 37 | + | |
| 38 | +Storage-side, each plugin owns nearly-identical child tables: | |
| 39 | + | |
| 40 | +- OpenAPI: `openapi_source_header`, `openapi_source_query_param`, | |
| 41 | + `openapi_source_spec_fetch_header`, | |
| 42 | + `openapi_source_spec_fetch_query_param`. | |
| 43 | +- GraphQL: `graphql_source_header`, `graphql_source_query_param`. | |
| 44 | +- MCP: `mcp_source_header`, `mcp_source_query_param`. | |
| 45 | + | |
| 46 | +All eight tables share the exact same columns: | |
| 47 | +`(source_id, scope_id, name, kind, text_value, slot_key, prefix)`. | |
| 48 | +They're discriminated only by which logical compartment the plugin is | |
| 49 | +modeling. They're written and read wholesale per source (bulk delete + | |
| 50 | +bulk insert; bulk findMany). No query filters by `name`, `slot_key`, | |
| 51 | +`kind`, or `prefix`. No join uses them. The Drizzle mirrors in | |
| 52 | +`apps/cloud` and `apps/local` are the only non-store consumers. | |
| 53 | + | |
| 54 | +## The arbitrary-source-types constraint | |
| 55 | + | |
| 56 | +We expect to support source types beyond HTTP-ish protocols: CLI | |
| 57 | +sources (argv + env), database connection strings (templated URIs like | |
| 58 | +`mysql://user:{{password_slot}}@host/db`), and others we haven't named. | |
| 59 | + | |
| 60 | +That changes the design center of any shared abstraction: | |
| 61 | + | |
| 62 | +- The HTTP-flavored vocabulary baked into the current shared bits | |
| 63 | + (`header:`, `query_param:`, `prefix`) doesn't generalize. | |
| 64 | +- A DB source doesn't have headers; a CLI source doesn't have query | |
| 65 | + params. The notion of "compartment" is plugin-specific. | |
| 66 | +- A connection-string source needs interpolation, not just whole-field | |
| 67 | + substitution. `prefix` is a degenerate one-hole template; argv and | |
| 68 | + URIs want full templating. | |
| 69 | + | |
| 70 | +The only protocol-agnostic primitive is: _"named slot at | |
| 71 | +(plugin, source, source_scope, scope) resolving to a value of kind | |
| 72 | +text | secret | connection."_ That's already what core | |
| 73 | +`credential_binding` provides. | |
| 74 | + | |
| 75 | +## Revised consolidation thesis | |
| 76 | + | |
| 77 | +Core should own the binding primitive, not just the storage. | |
| 78 | + | |
| 79 | +**Core owns:** | |
| 80 | + | |
| 81 | +- The `credential_binding` table (today). | |
| 82 | +- The resolver: given `(pluginId, sourceId, sourceScope, slot, scope)`, | |
| 83 | + return the innermost-visible binding subject to the source-scope | |
| 84 | + ceiling. Today this is duplicated per plugin. | |
| 85 | +- Validation: scope-stack membership, "binding target not outer than | |
| 86 | + source," secret/connection reachability. The facade already has the | |
| 87 | + inner checks; the plugin-side `validate*BindingTarget` helpers can go. | |
| 88 | +- HTTP endpoints for `setBinding` / `listBindings` / `removeBinding`, | |
| 89 | + parameterised by `pluginId` in the path (or payload). | |
| 90 | +- A small _slot manifest_ concept: each plugin declares, for a given | |
| 91 | + source, which slot keys exist, their human-readable labels, expected | |
| 92 | + value kind, required/optional, and any rendering hints. The UI uses | |
| 93 | + this to render a credential editor for any plugin without bespoke | |
| 94 | + React code. | |
| 95 | + | |
| 96 | +**Plugins own:** | |
| 97 | + | |
| 98 | +- Their source-shape storage. Whatever's plugin-specific (OpenAPI: | |
| 99 | + spec/baseUrl/operations; GraphQL: endpoint/operations; MCP: | |
| 100 | + transport/config; DB: connection-string template; CLI: argv/env). | |
| 101 | + The nearly-identical child tables go away; shape moves to JSON on | |
| 102 | + the source row or whatever structural columns the plugin actually | |
| 103 | + needs. | |
| 104 | +- The wire-format glue: how a resolved slot value becomes an HTTP | |
| 105 | + header, an MCP transport handshake parameter, a DB connection | |
| 106 | + string, a CLI argv entry. | |
| 107 | +- Their slot manifest content. OpenAPI generates it from the parsed | |
| 108 | + spec; MCP from connect-time metadata; DB from the URI template's | |
| 109 | + holes. | |
| 110 | + | |
| 111 | +**Plugins do not own:** | |
| 112 | + | |
| 113 | +- Re-skinned binding types. | |
| 114 | +- Their own resolver / lister / validator. | |
| 115 | +- Their own `setSourceBinding` HTTP endpoint. | |
| 116 | +- Hand-written credential-editor React glue, beyond plugin-specific | |
| 117 | + manifest hints. | |
| 118 | + | |
| 119 | +## What this changes vs the current plan | |
| 120 | + | |
| 121 | +The existing `openapi-add-source-api-cleanup.md` proposes deferred | |
| 122 | +domain wrappers (`setHeaderValue`, `setQueryParamValue`, | |
| 123 | +`setOAuthClientCredentials`, `setOAuthConnection`). Under the | |
| 124 | +consolidated model those wrappers stop making sense — they pre-suppose | |
| 125 | +HTTP-ish compartments. The consumer-facing API becomes "set this slot | |
| 126 | +on this source," parameterised by a slot key the plugin's manifest | |
| 127 | +defines. UX affordances like header preset pickers move into the UI | |
| 128 | +layer, driven by the manifest. | |
| 129 | + | |
| 130 | +The plan note's "source shape vs values" split is still right; it just | |
| 131 | +applies more aggressively. Shape lives in plugin source config (JSON | |
| 132 | +or columns the plugin actually queries on); values live in core | |
| 133 | +bindings. | |
| 134 | + | |
| 135 | +## On `prefix` | |
| 136 | + | |
| 137 | +`prefix` is a UI/UX affordance for the `Authorization: Bearer <token>` | |
| 138 | +pattern, generalised to "any prepended literal string." It is stored | |
| 139 | +in three places (core `ConfiguredCredentialBinding`, | |
| 140 | +`ScopedSecretCredentialInput`, plugin child-table columns) and | |
| 141 | +consumed at exactly one site per plugin: | |
| 142 | + | |
| 143 | +```ts | |
| 144 | +resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; | |
| 145 | +``` | |
| 146 | + | |
| 147 | +No escaping, no suffix, no conditional logic. It exists so the secret | |
| 148 | +itself doesn't have to bake `Bearer ` into the value and so the UI can | |
| 149 | +render a separate prefix input next to the secret picker. | |
| 150 | + | |
| 151 | +It does not generalise to connection strings or argv. The right move | |
| 152 | +is to drop `prefix` from core entirely and either: | |
| 153 | + | |
| 154 | +1. Have it live in the plugin's slot manifest as a pure UI hint, with | |
| 155 | + the resolver still returning the raw secret value and the plugin | |
| 156 | + doing the concatenation in its wire-format layer; or | |
| 157 | +2. Subsume it into a plugin-owned template ("this slot's value is | |
| 158 | + interpolated into this string"), which is the same idea generalised | |
| 159 | + to multi-hole shapes. | |
| 160 | + | |
| 161 | +Either way the resolver stops doing string concatenation. | |
| 162 | + | |
| 163 | +## On the OpenAPI normalized child tables | |
| 164 | + | |
| 165 | +In the local-cleanup framing they look wrong because the same four | |
| 166 | +columns are duplicated four times. In the arbitrary-source-types | |
| 167 | +framing they look wrong for a deeper reason: they bake HTTP | |
| 168 | +compartment vocabulary into the schema. They should go away rather | |
| 169 | +than be consolidated. | |
| 170 | + | |
| 171 | +Two reasonable destinations: | |
| 172 | + | |
| 173 | +- **JSON on the source row.** Same place OAuth2 config already lives. | |
| 174 | + Access pattern (read all, replace all) already matches what a JSON | |
| 175 | + blob gives you. | |
| 176 | +- **Plugin-owned structural columns where they matter.** If a plugin | |
| 177 | + truly benefits from a normalized shape table for its own queries, | |
| 178 | + it can have one; OpenAPI/GraphQL/MCP currently don't. | |
| 179 | + | |
| 180 | +## Open questions | |
| 181 | + | |
| 182 | +1. **Third-party plugins.** Do we expect plugin authors outside this | |
| 183 | + repo? That sets the stability bar for the slot manifest contract | |
| 184 | + and the resolver API. | |
| 185 | +2. **Templating layer.** Is there one core mini-template language | |
| 186 | + (`{{slot}}`) that plugins opt into, or is interpolation strictly | |
| 187 | + plugin-owned? Shared templating gives consistent UX (preview the | |
| 188 | + resolved value in the UI, redact in logs uniformly); per-plugin | |
| 189 | + templating gives more freedom but reinvents. | |
| 190 | +3. **Migration order.** Sketch the core API first and prove it on | |
| 191 | + OpenAPI as the proving ground, then port GraphQL and MCP in | |
| 192 | + follow-ups. Each step independently mergeable. | |
| 193 | +4. **Existing PR.** Land or shelve `codex/fix-openapi-add-flow` as-is | |
| 194 | + before the bigger consolidation opens; do not swallow the focused | |
| 195 | + cleanup into the larger refactor. | |
| 196 | + | |
| 197 | +## Suggested next steps | |
| 198 | + | |
| 199 | +- Close out the existing PR. | |
| 200 | +- Draft a real plan note that specifies the core API surface (resolver | |
| 201 | + signature, manifest shape, HTTP endpoints) before touching code. | |
| 202 | +- Once the shape is agreed, port OpenAPI first: delete the four child | |
| 203 | + tables, move shape to source-row storage, replace the plugin-level | |
| 204 | + binding helpers with core calls, replace the plugin-level HTTP group | |
| 205 | + with a core one. | |
| 206 | +- Port GraphQL and MCP to match. | |
| 207 | +- Pick the first arbitrary source type (likely DB connection string or | |
| 208 | + CLI) as a forcing function to validate the abstraction works beyond | |
| 209 | + HTTP-ish shapes. | |
| @@ -219,7 +219,13 @@ const buildSandboxBridge = (spec: string, namespace: string, baseUrl = "https:// · 218 unmodified lines | ||
| 219 | 219 | plugins, |
| 220 | 220 | onElicitation: "accept-all", |
| 221 | 221 | }); |
| 222 | − yield* executor.openapi.addSpec({ spec, scope: TEST_SCOPE, namespace, baseUrl }); | |
| 222 | + yield* executor.openapi.addSpec({ | |
| 223 | + spec: { kind: "blob", value: spec }, | |
| 224 | + scope: TEST_SCOPE, | |
| 225 | + namespace, | |
| 226 | + name: namespace, | |
| 227 | + baseUrl, | |
| 228 | + }); | |
| 223 | 229 | const invoker = makeExecutorToolInvoker(executor, { invokeOptions: autoApprove }); |
| 224 | 230 | return { executor, invoker, captured: recording.captured, sql }; |
| 225 | 231 | }), |
| @@ -1,11 +1,6 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { | |
| 4 | − InternalError, | |
| 5 | − ScopeId, | |
| 6 | − ScopedSecretCredentialInput, | |
| 7 | − SecretBackedValue, | |
| 8 | −} from "@executor-js/sdk/shared"; | |
| 3 | +import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared"; | |
| 9 | 4 | |
| 10 | 5 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; |
| 11 | 6 | import { SpecPreview } from "../sdk/preview"; |
| @@ -42,57 +37,54 @@ const SourceBindingParams = { · 30 unmodified lines | ||
| 42 | 37 | sourceScopeId: ScopeId, |
| 43 | 38 | }; |
| 44 | 39 | |
| 45 | −const SpecFetchCredentialsPayload = Schema.Struct({ | |
| 40 | +const OpenApiSpecInputPayload = Schema.Union([ | |
| 41 | + Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), | |
| 42 | + Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), | |
| 43 | +]); | |
| 44 | + | |
| 45 | +const PreviewSpecFetchCredentialsPayload = Schema.Struct({ | |
| 46 | 46 | headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), |
| 47 | 47 | queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | −const ConfiguredCredentialBindingPayload = Schema.Struct({ | |
| 51 | − kind: Schema.Literal("binding"), | |
| 52 | − slot: Schema.String, | |
| 50 | +const OpenApiSecretShapePayload = Schema.Struct({ | |
| 51 | + kind: Schema.Literal("secret"), | |
| 53 | 52 | prefix: Schema.optional(Schema.String), |
| 54 | 53 | }); |
| 55 | 54 | |
| 56 | −const ConfiguredCredentialValuePayload = Schema.Union([ | |
| 57 | − Schema.String, | |
| 58 | − ConfiguredCredentialBindingPayload, | |
| 59 | −]); | |
| 55 | +const OpenApiConfiguredValuePayload = Schema.Union([Schema.String, OpenApiSecretShapePayload]); | |
| 60 | 56 | |
| 61 | −const OpenApiCredentialInputPayload = Schema.Union([ | |
| 62 | − ScopedSecretCredentialInput, | |
| 63 | − SecretBackedValue, | |
| 64 | − ConfiguredCredentialValuePayload, | |
| 65 | −]); | |
| 57 | +const SpecFetchCredentialsPayload = Schema.Struct({ | |
| 58 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 59 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 60 | +}); | |
| 66 | 61 | |
| 67 | 62 | // --------------------------------------------------------------------------- |
| 68 | 63 | // Payloads |
| 69 | 64 | // --------------------------------------------------------------------------- |
| 70 | 65 | |
| 71 | 66 | const AddSpecPayload = Schema.Struct({ |
| 72 | − targetScope: ScopeId, | |
| 73 | − credentialTargetScope: Schema.optional(ScopeId), | |
| 74 | − spec: Schema.String, | |
| 67 | + spec: OpenApiSpecInputPayload, | |
| 75 | 68 | specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload), |
| 76 | − name: Schema.optional(Schema.String), | |
| 77 | − baseUrl: Schema.optional(Schema.String), | |
| 78 | − namespace: Schema.optional(Schema.String), | |
| 79 | − headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 80 | − queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 69 | + name: Schema.String, | |
| 70 | + baseUrl: Schema.String, | |
| 71 | + namespace: Schema.String, | |
| 72 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 73 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 81 | 74 | oauth2: Schema.optional(OAuth2SourceConfig), |
| 82 | 75 | }); |
| 83 | 76 | |
| 84 | 77 | const PreviewSpecPayload = Schema.Struct({ |
| 85 | 78 | spec: Schema.String, |
| 86 | − specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload), | |
| 79 | + specFetchCredentials: Schema.optional(PreviewSpecFetchCredentialsPayload), | |
| 87 | 80 | }); |
| 88 | 81 | |
| 89 | 82 | const UpdateSourcePayload = Schema.Struct({ |
| 90 | 83 | sourceScope: ScopeId, |
| 91 | 84 | name: Schema.optional(Schema.String), |
| 92 | 85 | baseUrl: Schema.optional(Schema.String), |
| 93 | − headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 94 | − queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | |
| 95 | − credentialTargetScope: Schema.optional(ScopeId), | |
| 86 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 87 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), | |
| 96 | 88 | // Set after a successful re-authenticate to refresh the source's |
| 97 | 89 | // stored OAuth2 metadata. |
| 98 | 90 | oauth2: Schema.optional(OAuth2SourceConfig), |
| @@ -3,10 +3,9 @@ import { Context, Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { addGroup, capture } from "@executor-js/api"; |
| 5 | 5 | import type { |
| 6 | − ConfiguredHeaderValue, | |
| 6 | + OpenApiConfiguredValueInput, | |
| 7 | 7 | OpenApiPluginExtension, |
| 8 | − HeaderValue, | |
| 9 | − OpenApiCredentialInput, | |
| 8 | + OpenApiPreviewSpecFetchCredentialsInput, | |
| 10 | 9 | OpenApiSpecFetchCredentialsInput, |
| 11 | 10 | OpenApiUpdateSourceInput, |
| 12 | 11 | } from "../sdk/plugin"; |
| @@ -55,13 +54,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines | ||
| 55 | 54 | return yield* ext.previewSpec({ |
| 56 | 55 | spec: payload.spec, |
| 57 | 56 | specFetchCredentials: payload.specFetchCredentials as |
| 58 | − | OpenApiSpecFetchCredentialsInput | |
| 57 | + | OpenApiPreviewSpecFetchCredentialsInput | |
| 59 | 58 | | undefined, |
| 60 | 59 | }); |
| 61 | 60 | }), |
| 62 | 61 | ), |
| 63 | 62 | ) |
| 64 | − .handle("addSpec", ({ payload }) => | |
| 63 | + .handle("addSpec", ({ params: path, payload }) => | |
| 65 | 64 | capture( |
| 66 | 65 | Effect.gen(function* () { |
| 67 | 66 | const ext = yield* OpenApiExtensionService; |
| @@ -70,15 +69,14 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 2 unmodified lines | ||
| 70 | 69 | specFetchCredentials: payload.specFetchCredentials as |
| 71 | 70 | | OpenApiSpecFetchCredentialsInput |
| 72 | 71 | | undefined, |
| 73 | − scope: payload.targetScope, | |
| 72 | + scope: path.scopeId, | |
| 74 | 73 | name: payload.name, |
| 75 | 74 | baseUrl: payload.baseUrl, |
| 76 | 75 | namespace: payload.namespace, |
| 77 | − credentialTargetScope: payload.credentialTargetScope, | |
| 78 | − headers: payload.headers as | |
| 79 | − | Record<string, HeaderValue | ConfiguredHeaderValue> | |
| 76 | + headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined, | |
| 77 | + queryParams: payload.queryParams as | |
| 78 | + | Record<string, OpenApiConfiguredValueInput> | |
| 80 | 79 | | undefined, |
| 81 | − queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined, | |
| 82 | 80 | oauth2: payload.oauth2, |
| 83 | 81 | }); |
| 84 | 82 | return { |
| @@ -119,11 +117,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 34 unmodified lines | ||
| 119 | 117 | yield* ext.updateSource(path.namespace, payload.sourceScope, { |
| 120 | 118 | name: payload.name, |
| 121 | 119 | baseUrl: payload.baseUrl, |
| 122 | − headers: payload.headers as | |
| 123 | − | Record<string, HeaderValue | ConfiguredHeaderValue> | |
| 120 | + headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined, | |
| 121 | + queryParams: payload.queryParams as | |
| 122 | + | Record<string, OpenApiConfiguredValueInput> | |
| 124 | 123 | | undefined, |
| 125 | − queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined, | |
| 126 | − credentialTargetScope: payload.credentialTargetScope, | |
| 127 | 124 | oauth2: payload.oauth2, |
| 128 | 125 | } as OpenApiUpdateSourceInput); |
| 129 | 126 | return { updated: true }; |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | +import * as Effect from "effect/Effect"; | |
| 3 | 4 | import * as Exit from "effect/Exit"; |
| 4 | 5 | import * as Match from "effect/Match"; |
| 5 | 6 | import * as Option from "effect/Option"; |
| @@ -66,13 +67,10 @@ import { · 60 unmodified lines | ||
| 66 | 67 | oauth2ClientSecretSlot, |
| 67 | 68 | oauth2ConnectionSlot, |
| 68 | 69 | queryParamBindingSlot, |
| 70 | + specFetchHeaderBindingSlot, | |
| 71 | + specFetchQueryParamBindingSlot, | |
| 69 | 72 | } from "../sdk/source-contracts"; |
| 70 | −import { | |
| 71 | − ConfiguredHeaderBinding, | |
| 72 | − OAuth2SourceConfig, | |
| 73 | − OpenApiSourceBindingInput, | |
| 74 | − type ServerInfo, | |
| 75 | −} from "../sdk/types"; | |
| 73 | +import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types"; | |
| 76 | 74 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 77 | 75 | |
| 78 | 76 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; |
| @@ -128,6 +126,19 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { · 49 unmodified lines | ||
| 128 | 126 | } |
| 129 | 127 | } |
| 130 | 128 | |
| 129 | +const specInputForAdd = (input: string) => { | |
| 130 | + const value = input.trim(); | |
| 131 | + const parsed = Effect.runSyncExit( | |
| 132 | + Effect.try({ | |
| 133 | + try: () => new URL(value), | |
| 134 | + catch: () => null, | |
| 135 | + }), | |
| 136 | + ); | |
| 137 | + return Exit.isSuccess(parsed) | |
| 138 | + ? { kind: "url" as const, url: value } | |
| 139 | + : { kind: "blob" as const, value }; | |
| 140 | +}; | |
| 141 | + | |
| 131 | 142 | type StrategySelection = |
| 132 | 143 | | { readonly kind: "none" } |
| 133 | 144 | | { readonly kind: "custom" } |
| @@ -256,35 +267,19 @@ export default function AddOpenApiSource(props: { · 122 unmodified lines | ||
| 256 | 267 | })), |
| 257 | 268 | [scopeStack], |
| 258 | 269 | ); |
| 259 | − const defaultCredentialTargetScope = | |
| 260 | − credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId; | |
| 261 | 270 | const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; |
| 262 | − const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>( | |
| 263 | − defaultCredentialTargetScope, | |
| 264 | − ); | |
| 265 | 271 | const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( |
| 266 | 272 | defaultOAuthTokenTargetScope, |
| 267 | 273 | ); |
| 268 | − const bindingScopeOptions = useMemo( | |
| 269 | − () => credentialScopeOptions.map((option) => ({ ...option })), | |
| 270 | − [credentialScopeOptions], | |
| 274 | + const sourceCredentialScopeOptions = useMemo( | |
| 275 | + () => credentialScopeOptions.filter((option) => option.scopeId === scopeId), | |
| 276 | + [credentialScopeOptions, scopeId], | |
| 271 | 277 | ); |
| 272 | − useEffect(() => { | |
| 273 | − if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) { | |
| 274 | − setCredentialTargetScope(defaultCredentialTargetScope); | |
| 275 | − } | |
| 276 | − }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]); | |
| 277 | 278 | useEffect(() => { |
| 278 | 279 | if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { |
| 279 | 280 | setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); |
| 280 | 281 | } |
| 281 | 282 | }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); |
| 282 | − const initialCredentialScopeOption = | |
| 283 | − credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ?? | |
| 284 | − credentialScopeOptions[0]; | |
| 285 | − const initialCredentialScopeOptions = initialCredentialScopeOption | |
| 286 | − ? [initialCredentialScopeOption] | |
| 287 | − : []; | |
| 288 | 283 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 289 | 284 | const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { |
| 290 | 285 | mode: "promiseExit", |
| @@ -294,9 +289,9 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 294 | 289 | mode: "promiseExit", |
| 295 | 290 | }); |
| 296 | 291 | const secretList = useSecretPickerSecrets(); |
| 297 | − const initialCredentialSecrets = useMemo( | |
| 298 | − () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), | |
| 299 | − [credentialTargetScope, secretList], | |
| 292 | + const sourceCredentialSecrets = useMemo( | |
| 293 | + () => secretList.filter((secret) => secret.scopeId === String(scopeId)), | |
| 294 | + [scopeId, secretList], | |
| 300 | 295 | ); |
| 301 | 296 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 302 | 297 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -336,35 +331,29 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines | ||
| 336 | 331 | ); |
| 337 | 332 | |
| 338 | 333 | const resolvedBaseUrl = baseUrl.trim(); |
| 334 | + const sourceScope = ScopeId.make(scopeId); | |
| 339 | 335 | |
| 340 | − const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; | |
| 341 | − const headerBindings: Array<{ | |
| 342 | − slot: string; | |
| 343 | − secretId: string; | |
| 344 | − scope: ScopeId; | |
| 345 | − secretScope: ScopeId; | |
| 346 | − }> = []; | |
| 347 | − const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {}; | |
| 348 | − const queryParamBindings: Array<{ | |
| 349 | − slot: string; | |
| 350 | − secretId: string; | |
| 351 | − scope: ScopeId; | |
| 352 | − secretScope: ScopeId; | |
| 353 | − }> = []; | |
| 336 | + type PendingSecretBinding = { | |
| 337 | + readonly slot: string; | |
| 338 | + readonly secretId: string; | |
| 339 | + readonly scope: ScopeId; | |
| 340 | + readonly secretScope: ScopeId; | |
| 341 | + }; | |
| 342 | + | |
| 343 | + const configuredHeaders: Record<string, { kind: "secret"; prefix?: string }> = {}; | |
| 344 | + const headerBindings: PendingSecretBinding[] = []; | |
| 345 | + const configuredQueryParams: Record<string, string | { kind: "secret"; prefix?: string }> = {}; | |
| 346 | + const queryParamBindings: PendingSecretBinding[] = []; | |
| 354 | 347 | for (const ch of customHeaders) { |
| 355 | 348 | if (!ch.name.trim()) continue; |
| 356 | 349 | const slot = headerBindingSlot(ch.name.trim()); |
| 357 | − configuredHeaders[ch.name.trim()] = ConfiguredHeaderBinding.make({ | |
| 358 | − kind: "binding", | |
| 359 | − slot, | |
| 360 | − prefix: ch.prefix, | |
| 361 | − }); | |
| 350 | + configuredHeaders[ch.name.trim()] = { kind: "secret", prefix: ch.prefix }; | |
| 362 | 351 | if (ch.secretId) { |
| 363 | 352 | headerBindings.push({ |
| 364 | 353 | slot, |
| 365 | 354 | secretId: ch.secretId, |
| 366 | − scope: ch.targetScope ?? credentialTargetScope, | |
| 367 | − secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, | |
| 355 | + scope: sourceScope, | |
| 356 | + secretScope: ch.secretScope ?? sourceScope, | |
| 368 | 357 | }); |
| 369 | 358 | } |
| 370 | 359 | } |
| @@ -373,16 +362,12 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | ||
| 373 | 362 | if (!name) continue; |
| 374 | 363 | if (param.secretId) { |
| 375 | 364 | const slot = queryParamBindingSlot(name); |
| 376 | − configuredQueryParams[name] = ConfiguredHeaderBinding.make({ | |
| 377 | − kind: "binding", | |
| 378 | − slot, | |
| 379 | − prefix: param.prefix, | |
| 380 | − }); | |
| 365 | + configuredQueryParams[name] = { kind: "secret", prefix: param.prefix }; | |
| 381 | 366 | queryParamBindings.push({ |
| 382 | 367 | slot, |
| 383 | 368 | secretId: param.secretId, |
| 384 | − scope: param.targetScope ?? credentialTargetScope, | |
| 385 | − secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, | |
| 369 | + scope: sourceScope, | |
| 370 | + secretScope: param.secretScope ?? sourceScope, | |
| 386 | 371 | }); |
| 387 | 372 | continue; |
| 388 | 373 | } |
| @@ -390,6 +375,52 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 390 | 375 | configuredQueryParams[name] = param.literalValue.trim(); |
| 391 | 376 | } |
| 392 | 377 | } |
| 378 | + const configuredSpecFetchHeaders: Record<string, { kind: "secret"; prefix?: string }> = {}; | |
| 379 | + const configuredSpecFetchQueryParams: Record< | |
| 380 | + string, | |
| 381 | + string | { kind: "secret"; prefix?: string } | |
| 382 | + > = {}; | |
| 383 | + const specFetchBindings: PendingSecretBinding[] = []; | |
| 384 | + for (const header of specFetchCredentials.headers) { | |
| 385 | + const name = header.name.trim(); | |
| 386 | + if (!name || !header.secretId) continue; | |
| 387 | + configuredSpecFetchHeaders[name] = { kind: "secret", prefix: header.prefix }; | |
| 388 | + specFetchBindings.push({ | |
| 389 | + slot: specFetchHeaderBindingSlot(name), | |
| 390 | + secretId: header.secretId, | |
| 391 | + scope: sourceScope, | |
| 392 | + secretScope: header.secretScope ?? sourceScope, | |
| 393 | + }); | |
| 394 | + } | |
| 395 | + for (const param of specFetchCredentials.queryParams) { | |
| 396 | + const name = param.name.trim(); | |
| 397 | + if (!name) continue; | |
| 398 | + if (param.secretId) { | |
| 399 | + configuredSpecFetchQueryParams[name] = { kind: "secret", prefix: param.prefix }; | |
| 400 | + specFetchBindings.push({ | |
| 401 | + slot: specFetchQueryParamBindingSlot(name), | |
| 402 | + secretId: param.secretId, | |
| 403 | + scope: sourceScope, | |
| 404 | + secretScope: param.secretScope ?? sourceScope, | |
| 405 | + }); | |
| 406 | + continue; | |
| 407 | + } | |
| 408 | + if (param.literalValue?.trim()) { | |
| 409 | + configuredSpecFetchQueryParams[name] = param.literalValue.trim(); | |
| 410 | + } | |
| 411 | + } | |
| 412 | + const configuredSpecFetchCredentials = | |
| 413 | + Object.keys(configuredSpecFetchHeaders).length > 0 || | |
| 414 | + Object.keys(configuredSpecFetchQueryParams).length > 0 | |
| 415 | + ? { | |
| 416 | + ...(Object.keys(configuredSpecFetchHeaders).length > 0 | |
| 417 | + ? { headers: configuredSpecFetchHeaders } | |
| 418 | + : {}), | |
| 419 | + ...(Object.keys(configuredSpecFetchQueryParams).length > 0 | |
| 420 | + ? { queryParams: configuredSpecFetchQueryParams } | |
| 421 | + : {}), | |
| 422 | + } | |
| 423 | + : null; | |
| 393 | 424 | |
| 394 | 425 | const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; |
| 395 | 426 | const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH); |
| @@ -447,8 +478,14 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines | ||
| 447 | 478 | const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( |
| 448 | 479 | (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), |
| 449 | 480 | ); |
| 481 | + const hasIncompleteSpecFetchCredentials = | |
| 482 | + specFetchCredentials.headers.some((header) => header.name.trim() && !header.secretId) || | |
| 483 | + specFetchCredentials.queryParams.some( | |
| 484 | + (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), | |
| 485 | + ); | |
| 450 | 486 | const willAddWithoutInitialCredentials = |
| 451 | 487 | Boolean(selectedOAuth2Preset && !oauth2Auth) || |
| 488 | + hasIncompleteSpecFetchCredentials || | |
| 452 | 489 | hasIncompleteHeaderCredentials || |
| 453 | 490 | hasIncompleteQueryCredentials; |
| 454 | 491 | |
| @@ -539,24 +576,6 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines | ||
| 539 | 576 | } |
| 540 | 577 | }; |
| 541 | 578 | |
| 542 | − const setInitialCredentialScope = (targetScope: ScopeId) => { | |
| 543 | − setCredentialTargetScope(targetScope); | |
| 544 | − setCustomHeaders((headers) => | |
| 545 | − headers.map((header) => ({ | |
| 546 | − ...header, | |
| 547 | − targetScope, | |
| 548 | − ...(header.secretScope && header.secretScope !== targetScope | |
| 549 | − ? { secretId: null, secretScope: undefined } | |
| 550 | − : {}), | |
| 551 | − })), | |
| 552 | − ); | |
| 553 | − setOauth2ClientIdSecretId(null); | |
| 554 | − setOauth2ClientSecretSecretId(null); | |
| 555 | − setOauth2ClientIdScope(null); | |
| 556 | − setOauth2ClientSecretScope(null); | |
| 557 | − setOauth2AuthState(null); | |
| 558 | − }; | |
| 559 | − | |
| 560 | 579 | const toggleOAuth2Scope = (scope: string) => { |
| 561 | 580 | setOauth2SelectedScopes((prev) => { |
| 562 | 581 | const copy = new Set(prev); |
| @@ -709,13 +728,13 @@ export default function AddOpenApiSource(props: { · 146 unmodified lines | ||
| 709 | 728 | const exit = await doAdd({ |
| 710 | 729 | params: { scopeId }, |
| 711 | 730 | payload: { |
| 712 | − targetScope: scopeId, | |
| 713 | − credentialTargetScope, | |
| 714 | − spec: specUrl, | |
| 715 | − specFetchCredentials: serializeHttpCredentials(specFetchCredentials), | |
| 731 | + spec: specInputForAdd(specUrl), | |
| 716 | 732 | name: displayName, |
| 717 | 733 | namespace, |
| 718 | − baseUrl: resolvedBaseUrl || undefined, | |
| 734 | + baseUrl: resolvedBaseUrl, | |
| 735 | + ...(configuredSpecFetchCredentials | |
| 736 | + ? { specFetchCredentials: configuredSpecFetchCredentials } | |
| 737 | + : {}), | |
| 719 | 738 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 720 | 739 | ...(Object.keys(configuredQueryParams).length > 0 |
| 721 | 740 | ? { queryParams: configuredQueryParams } |
| @@ -731,11 +750,9 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines | ||
| 731 | 750 | } |
| 732 | 751 | |
| 733 | 752 | const sourceId = exit.value.namespace; |
| 734 | − const sourceScope = ScopeId.make(scopeId); | |
| 735 | − const bindingScope = ScopeId.make(credentialTargetScope); | |
| 736 | 753 | const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); |
| 737 | − const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope; | |
| 738 | − const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope; | |
| 754 | + const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; | |
| 755 | + const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; | |
| 739 | 756 | |
| 740 | 757 | for (const binding of headerBindings) { |
| 741 | 758 | const bindingExit = await doSetBinding({ |
| @@ -783,13 +800,36 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines | ||
| 783 | 800 | } |
| 784 | 801 | } |
| 785 | 802 | |
| 803 | + for (const binding of specFetchBindings) { | |
| 804 | + const bindingExit = await doSetBinding({ | |
| 805 | + params: { scopeId }, | |
| 806 | + payload: OpenApiSourceBindingInput.make({ | |
| 807 | + sourceId, | |
| 808 | + sourceScope, | |
| 809 | + scope: binding.scope, | |
| 810 | + slot: binding.slot, | |
| 811 | + value: { | |
| 812 | + kind: "secret", | |
| 813 | + secretId: SecretId.make(binding.secretId), | |
| 814 | + secretScopeId: binding.secretScope, | |
| 815 | + }, | |
| 816 | + }), | |
| 817 | + reactivityKeys: bindingWriteKeys, | |
| 818 | + }); | |
| 819 | + if (Exit.isFailure(bindingExit)) { | |
| 820 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 821 | + setAdding(false); | |
| 822 | + return; | |
| 823 | + } | |
| 824 | + } | |
| 825 | + | |
| 786 | 826 | if (configuredOAuth2 && oauth2ClientIdSecretId) { |
| 787 | 827 | const bindingExit = await doSetBinding({ |
| 788 | 828 | params: { scopeId }, |
| 789 | 829 | payload: OpenApiSourceBindingInput.make({ |
| 790 | 830 | sourceId, |
| 791 | 831 | sourceScope, |
| 792 | − scope: bindingScope, | |
| 832 | + scope: sourceScope, | |
| 793 | 833 | slot: configuredOAuth2.clientIdSlot, |
| 794 | 834 | value: { |
| 795 | 835 | kind: "secret", |
| @@ -812,7 +852,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | ||
| 812 | 852 | payload: OpenApiSourceBindingInput.make({ |
| 813 | 853 | sourceId, |
| 814 | 854 | sourceScope, |
| 815 | − scope: bindingScope, | |
| 855 | + scope: sourceScope, | |
| 816 | 856 | slot: configuredOAuth2.clientSecretSlot, |
| 817 | 857 | value: { |
| 818 | 858 | kind: "secret", |
| @@ -908,7 +948,10 @@ export default function AddOpenApiSource(props: { · 89 unmodified lines | ||
| 908 | 948 | onChange={setSpecFetchCredentials} |
| 909 | 949 | existingSecrets={secretList} |
| 910 | 950 | sourceName={identity.name} |
| 911 | − targetScope={credentialTargetScope} | |
| 951 | + targetScope={sourceScope} | |
| 952 | + credentialScopeOptions={sourceCredentialScopeOptions} | |
| 953 | + bindingScopeOptions={sourceCredentialScopeOptions} | |
| 954 | + restrictSecretsToTargetScope | |
| 912 | 955 | labels={{ |
| 913 | 956 | headers: "Spec fetch headers", |
| 914 | 957 | queryParams: "Spec fetch query parameters", |
| @@ -1044,9 +1087,9 @@ export default function AddOpenApiSource(props: { · 129 unmodified lines | ||
| 1044 | 1087 | onHeadersChange={handleHeadersChange} |
| 1045 | 1088 | existingSecrets={secretList} |
| 1046 | 1089 | sourceName={identity.name} |
| 1047 | − targetScope={credentialTargetScope} | |
| 1048 | − credentialScopeOptions={initialCredentialScopeOptions} | |
| 1049 | − bindingScopeOptions={bindingScopeOptions} | |
| 1090 | + targetScope={sourceScope} | |
| 1091 | + credentialScopeOptions={sourceCredentialScopeOptions} | |
| 1092 | + bindingScopeOptions={sourceCredentialScopeOptions} | |
| 1050 | 1093 | restrictSecretsToTargetScope |
| 1051 | 1094 | emptyLabel="No credentials yet. Add the header value this method should use." |
| 1052 | 1095 | /> |
| @@ -1058,9 +1101,9 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | ||
| 1058 | 1101 | onChange={setRuntimeCredentials} |
| 1059 | 1102 | existingSecrets={secretList} |
| 1060 | 1103 | sourceName={identity.name} |
| 1061 | − targetScope={credentialTargetScope} | |
| 1062 | − credentialScopeOptions={initialCredentialScopeOptions} | |
| 1063 | − bindingScopeOptions={bindingScopeOptions} | |
| 1104 | + targetScope={sourceScope} | |
| 1105 | + credentialScopeOptions={sourceCredentialScopeOptions} | |
| 1106 | + bindingScopeOptions={sourceCredentialScopeOptions} | |
| 1064 | 1107 | restrictSecretsToTargetScope |
| 1065 | 1108 | sections={{ headers: false, queryParams: true }} |
| 1066 | 1109 | labels={{ queryParams: "Runtime query parameters" }} |
| @@ -1096,22 +1139,17 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines | ||
| 1096 | 1139 | value={oauth2ClientIdSecretId} |
| 1097 | 1140 | onSelect={(id: string, secretScopeId?: ScopeId) => { |
| 1098 | 1141 | setOauth2ClientIdSecretId(id); |
| 1099 | − setOauth2ClientIdScope(secretScopeId ?? credentialTargetScope); | |
| 1142 | + setOauth2ClientIdScope(secretScopeId ?? sourceScope); | |
| 1100 | 1143 | setOauth2AuthState(null); |
| 1101 | 1144 | }} |
| 1102 | − secrets={initialCredentialSecrets} | |
| 1145 | + secrets={sourceCredentialSecrets} | |
| 1103 | 1146 | sourceName={identity.name} |
| 1104 | 1147 | secretLabel="Client ID" |
| 1105 | − targetScope={oauth2ClientIdScope ?? credentialTargetScope} | |
| 1106 | − credentialScopeOptions={initialCredentialScopeOptions} | |
| 1148 | + targetScope={oauth2ClientIdScope ?? sourceScope} | |
| 1149 | + credentialScopeOptions={sourceCredentialScopeOptions} | |
| 1107 | 1150 | onCreatedScope={setOauth2ClientIdScope} |
| 1108 | 1151 | /> |
| 1109 | 1152 | </div> |
| 1110 | − <CredentialScopeDropdown | |
| 1111 | − value={credentialTargetScope} | |
| 1112 | − options={bindingScopeOptions} | |
| 1113 | − onChange={setInitialCredentialScope} | |
| 1114 | − /> | |
| 1115 | 1153 | </div> |
| 1116 | 1154 | </div> |
| 1117 | 1155 | <div className="space-y-1.5"> |
| @@ -1133,22 +1171,17 @@ export default function AddOpenApiSource(props: { · 15 unmodified lines | ||
| 1133 | 1171 | value={oauth2ClientSecretSecretId} |
| 1134 | 1172 | onSelect={(id: string, secretScopeId?: ScopeId) => { |
| 1135 | 1173 | setOauth2ClientSecretSecretId(id); |
| 1136 | − setOauth2ClientSecretScope(secretScopeId ?? credentialTargetScope); | |
| 1174 | + setOauth2ClientSecretScope(secretScopeId ?? sourceScope); | |
| 1137 | 1175 | setOauth2AuthState(null); |
| 1138 | 1176 | }} |
| 1139 | − secrets={initialCredentialSecrets} | |
| 1177 | + secrets={sourceCredentialSecrets} | |
| 1140 | 1178 | sourceName={identity.name} |
| 1141 | 1179 | secretLabel="Client Secret" |
| 1142 | − targetScope={oauth2ClientSecretScope ?? credentialTargetScope} | |
| 1143 | − credentialScopeOptions={initialCredentialScopeOptions} | |
| 1180 | + targetScope={oauth2ClientSecretScope ?? sourceScope} | |
| 1181 | + credentialScopeOptions={sourceCredentialScopeOptions} | |
| 1144 | 1182 | onCreatedScope={setOauth2ClientSecretScope} |
| 1145 | 1183 | /> |
| 1146 | 1184 | </div> |
| 1147 | − <CredentialScopeDropdown | |
| 1148 | − value={credentialTargetScope} | |
| 1149 | − options={bindingScopeOptions} | |
| 1150 | − onChange={setInitialCredentialScope} | |
| 1151 | − /> | |
| 1152 | 1185 | </div> |
| 1153 | 1186 | </div> |
| 1154 | 1187 | <div className="space-y-1.5"> |
| @@ -43,7 +43,7 @@ export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => · 42 unmodified lines | ||
| 43 | 43 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; |
| 44 | 44 | const source = { |
| 45 | 45 | id, |
| 46 | − scopeId: arg.payload.targetScope, | |
| 46 | + scopeId, | |
| 47 | 47 | kind: "openapi", |
| 48 | 48 | pluginId: "openapi", |
| 49 | 49 | name: arg.payload.name ?? id, |
| @@ -110,7 +110,10 @@ const testApiSourceConfig = (options: TestApiSourceOptions = {}) => · 109 unmodified lines | ||
| 110 | 110 | ...options, |
| 111 | 111 | }); |
| 112 | 112 | |
| 113 | −const testApiSpec = () => testApiSourceConfig().spec; | |
| 113 | +const testApiSpec = () => { | |
| 114 | + const spec = testApiSourceConfig().spec; | |
| 115 | + return spec.kind === "blob" ? spec.value : spec.url; | |
| 116 | +}; | |
| 114 | 117 | |
| 115 | 118 | // --------------------------------------------------------------------------- |
| 116 | 119 | // Implement handlers |
| @@ -172,6 +175,9 @@ const serveSpecRequiringHeader = () => { · 55 unmodified lines | ||
| 172 | 175 | Effect.sync(() => { |
| 173 | 176 | state.requests++; |
| 174 | 177 | state.lastToken = request.headers["x-spec-token"] ?? null; |
| 178 | + if (state.requests === 1) { | |
| 179 | + return null; | |
| 180 | + } | |
| 175 | 181 | if (state.lastToken !== "org-token") { |
| 176 | 182 | return HttpServerResponse.jsonUnsafe({ error: "missing token" }, { status: 401 }); |
| 177 | 183 | } |
| @@ -292,10 +298,10 @@ describe("OpenAPI Plugin", () => { · 114 unmodified lines | ||
| 292 | 298 | |
| 293 | 299 | expect(schema).not.toBeNull(); |
| 294 | 300 | expect(schema!.inputTypeScript).toContain("scope: string"); |
| 295 | − expect(schema!.inputTypeScript).toContain("spec: string"); | |
| 301 | + expect(schema!.inputTypeScript).toContain('kind: "url"'); | |
| 296 | 302 | expect( |
| 297 | 303 | (schema!.inputSchema as { properties?: Record<string, unknown> }).properties, |
| 298 | − ).toHaveProperty("credentialTargetScope"); | |
| 304 | + ).not.toHaveProperty("credentialTargetScope"); | |
| 299 | 305 | expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>"); |
| 300 | 306 | }), |
| 301 | 307 | ); |
| @@ -395,8 +401,7 @@ describe("OpenAPI Plugin", () => { · 93 unmodified lines | ||
| 395 | 401 | const input = testApiSourceConfig({ |
| 396 | 402 | scope: String(orgScope), |
| 397 | 403 | namespace: "org_direct_user_credential", |
| 398 | − queryParams: { token: { secretId: "user-query-token" } }, | |
| 399 | − credentialTargetScope: String(userScope), | |
| 404 | + queryParams: { token: { kind: "secret" } }, | |
| 400 | 405 | }); |
| 401 | 406 | |
| 402 | 407 | yield* executor.openapi.addSpec(input); |
| @@ -405,15 +410,7 @@ describe("OpenAPI Plugin", () => { · 2 unmodified lines | ||
| 405 | 410 | "org_direct_user_credential", |
| 406 | 411 | String(orgScope), |
| 407 | 412 | ); |
| 408 | − expect(bindings).toHaveLength(1); | |
| 409 | − expect(bindings[0]).toMatchObject({ | |
| 410 | − scopeId: userScope, | |
| 411 | − slot: "query_param:token", | |
| 412 | − value: { | |
| 413 | − kind: "secret", | |
| 414 | − secretId: SecretId.make("user-query-token"), | |
| 415 | − }, | |
| 416 | − }); | |
| 413 | + expect(bindings).toEqual([]); | |
| 417 | 414 | }), |
| 418 | 415 | ); |
| 419 | 416 | |
| @@ -443,12 +440,20 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines | ||
| 443 | 440 | testApiSourceConfig({ |
| 444 | 441 | namespace: "stale_binding", |
| 445 | 442 | baseUrl: "", |
| 446 | − credentialTargetScope: TEST_SCOPE, | |
| 447 | 443 | headers: { |
| 448 | − "X-Old": { secretId: "old-token" }, | |
| 444 | + "X-Old": { kind: "secret" }, | |
| 449 | 445 | }, |
| 450 | 446 | }), |
| 451 | 447 | ); |
| 448 | + yield* executor.openapi.setSourceBinding( | |
| 449 | + OpenApiSourceBindingInput.make({ | |
| 450 | + sourceId: "stale_binding", | |
| 451 | + sourceScope: ScopeId.make(TEST_SCOPE), | |
| 452 | + scope: ScopeId.make(TEST_SCOPE), | |
| 453 | + slot: "header:x-old", | |
| 454 | + value: { kind: "secret", secretId: SecretId.make("old-token") }, | |
| 455 | + }), | |
| 456 | + ); | |
| 452 | 457 | |
| 453 | 458 | yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { |
| 454 | 459 | headers: {}, |
| @@ -555,12 +560,20 @@ describe("OpenAPI Plugin", () => { · 100 unmodified lines | ||
| 555 | 560 | yield* addOpenApiTestSource(executor, server, { |
| 556 | 561 | scope: TEST_SCOPE, |
| 557 | 562 | namespace: "authed", |
| 558 | − credentialTargetScope: TEST_SCOPE, | |
| 559 | 563 | headers: { |
| 560 | − Authorization: { secretId: "test-api-token", prefix: "Bearer " }, | |
| 564 | + Authorization: { kind: "secret", prefix: "Bearer " }, | |
| 561 | 565 | "X-Static": "hello", |
| 562 | 566 | }, |
| 563 | 567 | }); |
| 568 | + yield* executor.openapi.setSourceBinding( | |
| 569 | + OpenApiSourceBindingInput.make({ | |
| 570 | + sourceId: "authed", | |
| 571 | + sourceScope: ScopeId.make(TEST_SCOPE), | |
| 572 | + scope: ScopeId.make(TEST_SCOPE), | |
| 573 | + slot: "header:authorization", | |
| 574 | + value: { kind: "secret", secretId: SecretId.make("test-api-token") }, | |
| 575 | + }), | |
| 576 | + ); | |
| 564 | 577 | |
| 565 | 578 | const result = unwrapInvocation( |
| 566 | 579 | yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), |
| @@ -574,12 +587,7 @@ describe("OpenAPI Plugin", () => { · 7 unmodified lines | ||
| 574 | 587 | ), |
| 575 | 588 | ); |
| 576 | 589 | |
| 577 | − it.effect("addSpec without credentialTargetScope defaults to the source's scope", () => | |
| 578 | − // Regression: config-sync calls addSpec without ever setting | |
| 579 | − // credentialTargetScope. Before the fix, any source with a | |
| 580 | − // header secret in executor.jsonc errored with | |
| 581 | − // "credentialTargetScope is required when adding direct OpenAPI | |
| 582 | − // credentials" the moment the daemon started. | |
| 590 | + it.effect("addSpec declares secret-backed header shape without a credential value", () => | |
| 583 | 591 | Effect.gen(function* () { |
| 584 | 592 | const clientLayer = FetchHttpClient.layer; |
| 585 | 593 | |
| @@ -607,7 +615,7 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines | ||
| 607 | 615 | baseUrl: "", |
| 608 | 616 | headers: { |
| 609 | 617 | Authorization: { |
| 610 | − secretId: "config-sync-token", | |
| 618 | + kind: "secret", | |
| 611 | 619 | prefix: "Bearer ", |
| 612 | 620 | }, |
| 613 | 621 | }, |
| @@ -618,15 +626,7 @@ describe("OpenAPI Plugin", () => { · 4 unmodified lines | ||
| 618 | 626 | "default_target_scope", |
| 619 | 627 | TEST_SCOPE, |
| 620 | 628 | ); |
| 621 | − expect(bindings).toHaveLength(1); | |
| 622 | − expect(bindings[0]).toMatchObject({ | |
| 623 | − scopeId: ScopeId.make(TEST_SCOPE), | |
| 624 | − slot: "header:authorization", | |
| 625 | − value: { | |
| 626 | − kind: "secret", | |
| 627 | − secretId: SecretId.make("config-sync-token"), | |
| 628 | − }, | |
| 629 | − }); | |
| 629 | + expect(bindings).toEqual([]); | |
| 630 | 630 | }), |
| 631 | 631 | ); |
| 632 | 632 | |
| @@ -975,6 +975,7 @@ describe("OpenAPI Plugin", () => { · 342 unmodified lines | ||
| 975 | 975 | testApiSourceConfig({ |
| 976 | 976 | scope: String(USER_SCOPE), |
| 977 | 977 | namespace: "shared", |
| 978 | + baseUrl: null, | |
| 978 | 979 | name: "User Source", |
| 979 | 980 | }), |
| 980 | 981 | ); |
| @@ -1017,6 +1018,7 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines | ||
| 1017 | 1018 | testApiSourceConfig({ |
| 1018 | 1019 | scope: String(USER_SCOPE), |
| 1019 | 1020 | namespace: "shared", |
| 1021 | + baseUrl: null, | |
| 1020 | 1022 | name: "User Source", |
| 1021 | 1023 | }), |
| 1022 | 1024 | ); |
| @@ -1057,7 +1059,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | ||
| 1057 | 1059 | testApiSourceConfig({ |
| 1058 | 1060 | scope: String(USER_SCOPE), |
| 1059 | 1061 | namespace: "shared", |
| 1060 | − baseUrl: "", | |
| 1062 | + baseUrl: null, | |
| 1061 | 1063 | name: "User Source", |
| 1062 | 1064 | }), |
| 1063 | 1065 | ); |
| @@ -1098,6 +1100,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | ||
| 1098 | 1100 | testApiSourceConfig({ |
| 1099 | 1101 | scope: String(USER_SCOPE), |
| 1100 | 1102 | namespace: "shared", |
| 1103 | + baseUrl: null, | |
| 1101 | 1104 | name: "User Source", |
| 1102 | 1105 | }), |
| 1103 | 1106 | ); |
| @@ -1149,19 +1152,19 @@ describe("OpenAPI Plugin", () => { · 45 unmodified lines | ||
| 1149 | 1152 | ); |
| 1150 | 1153 | |
| 1151 | 1154 | yield* executor.openapi.addSpec({ |
| 1152 | − spec: server.specJson, | |
| 1155 | + spec: { kind: "blob", value: server.specJson }, | |
| 1153 | 1156 | scope: String(ORG_SCOPE), |
| 1157 | + name: "Shadow Auth", | |
| 1154 | 1158 | namespace: "shadow_auth", |
| 1155 | 1159 | baseUrl: "https://org.example.com", |
| 1156 | − credentialTargetScope: String(ORG_SCOPE), | |
| 1157 | 1160 | headers: { |
| 1158 | − Authorization: { secretId: "org-api-token", prefix: "Bearer " }, | |
| 1161 | + Authorization: { kind: "secret", prefix: "Bearer " }, | |
| 1159 | 1162 | }, |
| 1160 | 1163 | }); |
| 1161 | 1164 | |
| 1162 | 1165 | const addResult = yield* executor.openapi |
| 1163 | 1166 | .addSpec({ |
| 1164 | − spec: server.specJson, | |
| 1167 | + spec: { kind: "blob", value: server.specJson }, | |
| 1165 | 1168 | scope: String(USER_SCOPE), |
| 1166 | 1169 | namespace: "shadow_auth", |
| 1167 | 1170 | baseUrl: server.baseUrl, |
| @@ -1202,20 +1205,31 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | ||
| 1202 | 1205 | ); |
| 1203 | 1206 | |
| 1204 | 1207 | yield* executor.openapi.addSpec({ |
| 1205 | − spec: server.specUrl, | |
| 1208 | + spec: { kind: "url", url: server.specUrl }, | |
| 1206 | 1209 | scope: String(ORG_SCOPE), |
| 1210 | + name: "Shared Spec Fetch", | |
| 1207 | 1211 | namespace: "shared_spec_fetch", |
| 1208 | − credentialTargetScope: String(ORG_SCOPE), | |
| 1212 | + baseUrl: "https://api.example.test", | |
| 1209 | 1213 | specFetchCredentials: { |
| 1210 | 1214 | headers: { |
| 1211 | − "X-Spec-Token": { secretId: "org-spec-token" }, | |
| 1215 | + "X-Spec-Token": { kind: "secret" }, | |
| 1212 | 1216 | }, |
| 1213 | 1217 | }, |
| 1214 | 1218 | }); |
| 1219 | + yield* executor.openapi.setSourceBinding( | |
| 1220 | + OpenApiSourceBindingInput.make({ | |
| 1221 | + sourceId: "shared_spec_fetch", | |
| 1222 | + sourceScope: ORG_SCOPE, | |
| 1223 | + scope: ORG_SCOPE, | |
| 1224 | + slot: "spec_fetch_header:x-spec-token", | |
| 1225 | + value: { kind: "secret", secretId: SecretId.make("org-spec-token") }, | |
| 1226 | + }), | |
| 1227 | + ); | |
| 1215 | 1228 | yield* executor.openapi.addSpec( |
| 1216 | 1229 | testApiSourceConfig({ |
| 1217 | 1230 | scope: String(USER_SCOPE), |
| 1218 | 1231 | namespace: "shared_spec_fetch", |
| 1232 | + baseUrl: null, | |
| 1219 | 1233 | name: "User Shadow", |
| 1220 | 1234 | }), |
| 1221 | 1235 | ); |
| @@ -1390,8 +1404,16 @@ describe("OpenAPI Plugin", () => { · 168 unmodified lines | ||
| 1390 | 1404 | testApiSourceConfig({ |
| 1391 | 1405 | namespace: "with_secret", |
| 1392 | 1406 | baseUrl: "http://example.com", |
| 1393 | − credentialTargetScope: TEST_SCOPE, | |
| 1394 | − queryParams: { token: { secretId: "api-key" } }, | |
| 1407 | + queryParams: { token: { kind: "secret" } }, | |
| 1408 | + }), | |
| 1409 | + ); | |
| 1410 | + yield* executor.openapi.setSourceBinding( | |
| 1411 | + OpenApiSourceBindingInput.make({ | |
| 1412 | + sourceId: "with_secret", | |
| 1413 | + sourceScope: ScopeId.make(TEST_SCOPE), | |
| 1414 | + scope: ScopeId.make(TEST_SCOPE), | |
| 1415 | + slot: "query_param:token", | |
| 1416 | + value: { kind: "secret", secretId: SecretId.make("api-key") }, | |
| 1395 | 1417 | }), |
| 1396 | 1418 | ); |
| 1397 | 1419 | |
| @@ -4,7 +4,6 @@ import { HttpClient } from "effect/unstable/http"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | 6 | ScopeId, |
| 7 | − SecretId, | |
| 8 | 7 | SourceDetectionResult, |
| 9 | 8 | StorageError, |
| 10 | 9 | ToolResult, |
| @@ -29,7 +28,6 @@ import { parse, resolveSpecText } from "./parse"; · 18 unmodified lines | ||
| 29 | 28 | import { extract } from "./extract"; |
| 30 | 29 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| 31 | 30 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; |
| 32 | −import { resolveBaseUrl } from "./openapi-utils"; | |
| 33 | 31 | import { previewSpec, SpecPreview } from "./preview"; |
| 34 | 32 | import { |
| 35 | 33 | makeDefaultOpenapiStore, |
| @@ -41,13 +39,10 @@ import { · 5 unmodified lines | ||
| 41 | 39 | } from "./store"; |
| 42 | 40 | import { |
| 43 | 41 | HeaderValue as HeaderValueSchema, |
| 44 | − ConfiguredHeaderValue as ConfiguredHeaderValueSchema, | |
| 45 | 42 | ConfiguredHeaderBinding, |
| 46 | 43 | OAuth2SourceConfig, |
| 47 | − OpenApiCredentialInput as OpenApiCredentialInputSchema, | |
| 48 | 44 | OpenApiSourceBindingInput, |
| 49 | 45 | OpenApiSourceBindingRef, |
| 50 | − type OpenApiCredentialInput as OpenApiCredentialInputValue, | |
| 51 | 46 | type OpenApiSourceBindingValue, |
| 52 | 47 | OperationBinding, |
| 53 | 48 | type ConfiguredHeaderValue as ConfiguredHeaderValueValue, |
| @@ -131,22 +126,34 @@ const extractUpstreamMessage = (body: unknown, status: number): string => { · 77 unmodified lines | ||
| 131 | 126 | |
| 132 | 127 | export type HeaderValue = HeaderValueValue; |
| 133 | 128 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 134 | −export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; | |
| 135 | −export type OpenApiCredentialInput = OpenApiCredentialInputValue; | |
| 136 | 129 | export type OpenApiOAuthInput = OAuth2SourceConfig; |
| 137 | 130 | |
| 131 | +export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; | |
| 132 | + | |
| 133 | +export type OpenApiSecretShapeInput = typeof OpenApiSecretShapeInputSchema.Type; | |
| 134 | + | |
| 135 | +export type OpenApiConfiguredValueInput = | |
| 136 | + | string | |
| 137 | + | OpenApiSecretShapeInput | |
| 138 | + | ConfiguredHeaderValueValue; | |
| 139 | + | |
| 138 | 140 | export interface OpenApiSpecFetchCredentialsInput { |
| 141 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 142 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 143 | +} | |
| 144 | + | |
| 145 | +export interface OpenApiPreviewSpecFetchCredentialsInput { | |
| 139 | 146 | readonly headers?: Record<string, HeaderValue>; |
| 140 | 147 | readonly queryParams?: Record<string, HeaderValue>; |
| 141 | 148 | } |
| 142 | 149 | |
| 143 | 150 | export interface OpenApiPreviewInput { |
| 144 | 151 | readonly spec: string; |
| 145 | − readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; | |
| 152 | + readonly specFetchCredentials?: OpenApiPreviewSpecFetchCredentialsInput; | |
| 146 | 153 | } |
| 147 | 154 | |
| 148 | 155 | export interface OpenApiSpecConfig { |
| 149 | − readonly spec: string; | |
| 156 | + readonly spec: OpenApiSpecInput; | |
| 150 | 157 | readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; |
| 151 | 158 | /** |
| 152 | 159 | * Executor scope id that owns this source row. Must be one of the |
| @@ -155,21 +162,19 @@ export interface OpenApiSpecConfig { · 2 unmodified lines | ||
| 155 | 162 | * every inner (per-user) scope via fall-through reads. |
| 156 | 163 | */ |
| 157 | 164 | readonly scope: string; |
| 158 | − readonly name?: string; | |
| 159 | − readonly baseUrl?: string; | |
| 160 | − readonly namespace?: string; | |
| 161 | − readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 162 | − readonly queryParams?: Record<string, OpenApiCredentialInput>; | |
| 165 | + readonly name: string; | |
| 166 | + readonly baseUrl: string; | |
| 167 | + readonly namespace: string; | |
| 168 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 169 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 163 | 170 | readonly oauth2?: OpenApiOAuthInput; |
| 164 | − readonly credentialTargetScope?: string; | |
| 165 | 171 | } |
| 166 | 172 | |
| 167 | 173 | export interface OpenApiUpdateSourceInput { |
| 168 | 174 | readonly name?: string; |
| 169 | 175 | readonly baseUrl?: string; |
| 170 | − readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 171 | − readonly queryParams?: Record<string, OpenApiCredentialInput>; | |
| 172 | − readonly credentialTargetScope?: string; | |
| 176 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 177 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 173 | 178 | /** Refresh the source's stored OAuth2 metadata after a successful |
| 174 | 179 | * re-authenticate. */ |
| 175 | 180 | readonly oauth2?: OpenApiOAuthInput; |
| @@ -243,23 +248,33 @@ const PreviewSpecInputSchema = Schema.Struct({ · 67 unmodified lines | ||
| 243 | 248 | ), |
| 244 | 249 | }); |
| 245 | 250 | |
| 246 | −const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); | |
| 251 | +const OpenApiSpecInputSchema = Schema.Union([ | |
| 252 | + Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), | |
| 253 | + Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), | |
| 254 | +]); | |
| 255 | +const OpenApiSecretShapeInputSchema = Schema.Struct({ | |
| 256 | + kind: Schema.Literal("secret"), | |
| 257 | + prefix: Schema.optional(Schema.String), | |
| 258 | +}); | |
| 259 | +const OpenApiConfiguredValueInputSchema = Schema.Union([ | |
| 260 | + Schema.String, | |
| 261 | + OpenApiSecretShapeInputSchema, | |
| 262 | +]); | |
| 247 | 263 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; |
| 248 | 264 | |
| 249 | 265 | const AddSourceInputSchema = Schema.Struct({ |
| 250 | 266 | scope: Schema.String, |
| 251 | − spec: Schema.String, | |
| 252 | − name: Schema.optional(Schema.String), | |
| 253 | − baseUrl: Schema.optional(Schema.String), | |
| 254 | − namespace: Schema.optional(Schema.String), | |
| 255 | − headers: Schema.optional(Schema.Record(Schema.String, OpenApiHeaderInputSchema)), | |
| 256 | − queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputSchema)), | |
| 267 | + spec: OpenApiSpecInputSchema, | |
| 268 | + name: Schema.String, | |
| 269 | + baseUrl: Schema.String, | |
| 270 | + namespace: Schema.String, | |
| 271 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), | |
| 272 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), | |
| 257 | 273 | oauth2: Schema.optional(OpenApiOAuthInputSchema), |
| 258 | − credentialTargetScope: Schema.optional(Schema.String), | |
| 259 | 274 | specFetchCredentials: Schema.optional( |
| 260 | 275 | Schema.Struct({ |
| 261 | − headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), | |
| 262 | − queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), | |
| 276 | + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), | |
| 277 | + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), | |
| 263 | 278 | }), |
| 264 | 279 | ), |
| 265 | 280 | }); |
| @@ -346,27 +361,17 @@ const specFetchQueryParamSlotFromName = (name: string): string => · 80 unmodified lines | ||
| 346 | 361 | `spec_fetch_query_param:${slotPart(name)}`; |
| 347 | 362 | |
| 348 | 363 | const canonicalizeHeaders = ( |
| 349 | − headers: Record<string, OpenApiCredentialInput> | undefined, | |
| 364 | + headers: Record<string, OpenApiConfiguredValueInput> | undefined, | |
| 350 | 365 | ): { |
| 351 | 366 | readonly headers: Record<string, ConfiguredHeaderValue>; |
| 352 | − readonly bindings: ReadonlyArray<{ | |
| 353 | − readonly slot: string; | |
| 354 | − readonly value: OpenApiSourceBindingValue; | |
| 355 | − readonly targetScope?: string; | |
| 356 | − }>; | |
| 357 | 367 | } => { |
| 358 | 368 | const nextHeaders: Record<string, ConfiguredHeaderValue> = {}; |
| 359 | − const bindings: Array<{ | |
| 360 | − slot: string; | |
| 361 | − value: OpenApiSourceBindingValue; | |
| 362 | − targetScope?: string; | |
| 363 | − }> = []; | |
| 364 | 369 | for (const [name, value] of Object.entries(headers ?? {})) { |
| 365 | 370 | if (typeof value === "string") { |
| 366 | 371 | nextHeaders[name] = value; |
| 367 | 372 | continue; |
| 368 | 373 | } |
| 369 | − if ("kind" in value) { | |
| 374 | + if (value.kind === "binding") { | |
| 370 | 375 | nextHeaders[name] = value; |
| 371 | 376 | continue; |
| 372 | 377 | } |
| @@ -376,44 +381,23 @@ const canonicalizeHeaders = ( · 3 unmodified lines | ||
| 376 | 381 | slot, |
| 377 | 382 | prefix: value.prefix, |
| 378 | 383 | }); |
| 379 | − bindings.push({ | |
| 380 | − slot, | |
| 381 | − targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 382 | − value: { | |
| 383 | − kind: "secret", | |
| 384 | − secretId: SecretId.make(value.secretId), | |
| 385 | − ...("secretScopeId" in value && value.secretScopeId | |
| 386 | − ? { secretScopeId: value.secretScopeId } | |
| 387 | − : {}), | |
| 388 | − }, | |
| 389 | − }); | |
| 390 | 384 | } |
| 391 | − return { headers: nextHeaders, bindings }; | |
| 385 | + return { headers: nextHeaders }; | |
| 392 | 386 | }; |
| 393 | 387 | |
| 394 | 388 | const canonicalizeCredentialMap = ( |
| 395 | − values: Record<string, OpenApiCredentialInput> | undefined, | |
| 389 | + values: Record<string, OpenApiConfiguredValueInput> | undefined, | |
| 396 | 390 | slotForName: (name: string) => string, |
| 397 | 391 | ): { |
| 398 | 392 | readonly values: Record<string, ConfiguredHeaderValue>; |
| 399 | − readonly bindings: ReadonlyArray<{ | |
| 400 | − readonly slot: string; | |
| 401 | − readonly value: OpenApiSourceBindingValue; | |
| 402 | − readonly targetScope?: string; | |
| 403 | − }>; | |
| 404 | 393 | } => { |
| 405 | 394 | const nextValues: Record<string, ConfiguredHeaderValue> = {}; |
| 406 | − const bindings: Array<{ | |
| 407 | − slot: string; | |
| 408 | − value: OpenApiSourceBindingValue; | |
| 409 | − targetScope?: string; | |
| 410 | − }> = []; | |
| 411 | 395 | for (const [name, value] of Object.entries(values ?? {})) { |
| 412 | 396 | if (typeof value === "string") { |
| 413 | 397 | nextValues[name] = value; |
| 414 | 398 | continue; |
| 415 | 399 | } |
| 416 | − if ("kind" in value) { | |
| 400 | + if (value.kind === "binding") { | |
| 417 | 401 | nextValues[name] = value; |
| 418 | 402 | continue; |
| 419 | 403 | } |
| @@ -423,35 +407,19 @@ const canonicalizeCredentialMap = ( · 3 unmodified lines | ||
| 423 | 407 | slot, |
| 424 | 408 | prefix: value.prefix, |
| 425 | 409 | }); |
| 426 | − bindings.push({ | |
| 427 | − slot, | |
| 428 | − targetScope: "targetScope" in value ? value.targetScope : undefined, | |
| 429 | − value: { | |
| 430 | − kind: "secret", | |
| 431 | − secretId: SecretId.make(value.secretId), | |
| 432 | − ...("secretScopeId" in value && value.secretScopeId | |
| 433 | − ? { secretScopeId: value.secretScopeId } | |
| 434 | − : {}), | |
| 435 | − }, | |
| 436 | − }); | |
| 437 | 410 | } |
| 438 | − return { values: nextValues, bindings }; | |
| 411 | + return { values: nextValues }; | |
| 439 | 412 | }; |
| 440 | 413 | |
| 441 | 414 | const canonicalizeSpecFetchCredentials = ( |
| 442 | 415 | credentials: |
| 443 | 416 | | { |
| 444 | − readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 445 | − readonly queryParams?: Record<string, OpenApiCredentialInput>; | |
| 417 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 418 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 446 | 419 | } |
| 447 | 420 | | undefined, |
| 448 | 421 | ): { |
| 449 | 422 | readonly credentials?: SourceConfig["specFetchCredentials"]; |
| 450 | − readonly bindings: ReadonlyArray<{ | |
| 451 | − readonly slot: string; | |
| 452 | − readonly value: OpenApiSourceBindingValue; | |
| 453 | − readonly targetScope?: string; | |
| 454 | − }>; | |
| 455 | 423 | } => { |
| 456 | 424 | const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); |
| 457 | 425 | const queryParams = canonicalizeCredentialMap( |
| @@ -469,7 +437,6 @@ const canonicalizeSpecFetchCredentials = ( · 11 unmodified lines | ||
| 469 | 437 | }; |
| 470 | 438 | return { |
| 471 | 439 | credentials: nextCredentials, |
| 472 | − bindings: [...headers.bindings, ...queryParams.bindings], | |
| 473 | 440 | }; |
| 474 | 441 | }; |
| 475 | 442 | |
| @@ -599,19 +566,6 @@ const validateOpenApiBindingTarget = ( · 123 unmodified lines | ||
| 599 | 566 | } |
| 600 | 567 | }); |
| 601 | 568 | |
| 602 | −const targetScopeForBinding = ( | |
| 603 | − fallbackTargetScope: string | undefined, | |
| 604 | − binding: { readonly slot: string; readonly targetScope?: string }, | |
| 605 | −): Effect.Effect<string, OpenApiOAuthError> => { | |
| 606 | − const targetScope = binding.targetScope ?? fallbackTargetScope; | |
| 607 | − if (targetScope) return Effect.succeed(targetScope); | |
| 608 | − return Effect.fail( | |
| 609 | − new OpenApiOAuthError({ | |
| 610 | − message: "credentialTargetScope is required when adding direct OpenAPI credentials", | |
| 611 | − }), | |
| 612 | − ); | |
| 613 | −}; | |
| 614 | − | |
| 615 | 569 | const findOuterSource = ( |
| 616 | 570 | ctx: PluginCtx<OpenapiStore>, |
| 617 | 571 | namespace: string, |
| @@ -790,7 +744,7 @@ const resolveOAuthConnectionId = ( · 172 unmodified lines | ||
| 790 | 744 | |
| 791 | 745 | const resolveSpecFetchInputCredentials = ( |
| 792 | 746 | ctx: PluginCtx<OpenapiStore>, |
| 793 | − credentials: OpenApiSpecFetchCredentialsInput | undefined, | |
| 747 | + credentials: OpenApiPreviewSpecFetchCredentialsInput | undefined, | |
| 794 | 748 | ) => |
| 795 | 749 | Effect.gen(function* () { |
| 796 | 750 | if (!credentials) return undefined; |
| @@ -850,13 +804,13 @@ const toOpenApiSourceConfig = ( · 53 unmodified lines | ||
| 850 | 804 | ): OpenApiSourceConfig => { |
| 851 | 805 | const configHeaders: Record<string, HeaderValueValue> = {}; |
| 852 | 806 | for (const [name, value] of Object.entries(config.headers ?? {})) { |
| 853 | − if (typeof value === "string" || !("kind" in value)) { | |
| 807 | + if (typeof value === "string") { | |
| 854 | 808 | configHeaders[name] = value; |
| 855 | 809 | } |
| 856 | 810 | } |
| 857 | 811 | return { |
| 858 | 812 | kind: "openapi", |
| 859 | − spec: config.spec, | |
| 813 | + spec: specInputToConfigString(config.spec), | |
| 860 | 814 | baseUrl: config.baseUrl, |
| 861 | 815 | namespace, |
| 862 | 816 | headers: headersToConfigValues( |
| @@ -865,24 +819,24 @@ const toOpenApiSourceConfig = ( · 2 unmodified lines | ||
| 865 | 819 | }; |
| 866 | 820 | }; |
| 867 | 821 | |
| 868 | −const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://"); | |
| 822 | +const specInputToConfigString = (spec: OpenApiSpecInput): string => | |
| 823 | + spec.kind === "url" ? spec.url : spec.value; | |
| 869 | 824 | |
| 870 | 825 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { |
| 871 | 826 | type RebuildInput = { |
| 872 | 827 | readonly specText: string; |
| 873 | 828 | readonly scope: string; |
| 874 | 829 | readonly sourceUrl?: string; |
| 875 | − readonly name?: string; | |
| 876 | − readonly baseUrl?: string; | |
| 877 | − readonly namespace?: string; | |
| 878 | − readonly headers?: Record<string, OpenApiHeaderInput>; | |
| 879 | − readonly queryParams?: Record<string, OpenApiCredentialInput>; | |
| 830 | + readonly name: string; | |
| 831 | + readonly baseUrl: string | undefined; | |
| 832 | + readonly namespace: string; | |
| 833 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 834 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 880 | 835 | readonly specFetchCredentials?: { |
| 881 | − readonly headers?: Record<string, OpenApiCredentialInput>; | |
| 882 | − readonly queryParams?: Record<string, OpenApiCredentialInput>; | |
| 836 | + readonly headers?: Record<string, OpenApiConfiguredValueInput>; | |
| 837 | + readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | |
| 883 | 838 | }; |
| 884 | 839 | readonly oauth2?: OpenApiOAuthInput; |
| 885 | − readonly credentialTargetScope?: string; | |
| 886 | 840 | }; |
| 887 | 841 | |
| 888 | 842 | // ctx comes from the plugin runtime — the same instance is passed to |
| @@ -893,11 +847,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 4 unmodified lines | ||
| 893 | 847 | const doc = yield* parse(input.specText); |
| 894 | 848 | const result = yield* extract(doc); |
| 895 | 849 | |
| 896 | − const namespace = | |
| 897 | − input.namespace ?? | |
| 898 | − Option.getOrElse(result.title, () => "api") | |
| 899 | − .toLowerCase() | |
| 900 | − .replace(/[^a-z0-9]+/g, "_"); | |
| 850 | + const namespace = input.namespace; | |
| 901 | 851 | const outerSource = yield* findOuterSource(ctx, namespace, input.scope); |
| 902 | 852 | if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") { |
| 903 | 853 | return yield* new OpenApiOAuthError({ |
| @@ -912,7 +862,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 8 unmodified lines | ||
| 912 | 862 | } |
| 913 | 863 | } |
| 914 | 864 | |
| 915 | − const baseUrl = outerSource ? undefined : (input.baseUrl ?? resolveBaseUrl(result.servers)); | |
| 865 | + const baseUrl = outerSource ? undefined : input.baseUrl; | |
| 916 | 866 | const canonicalHeaders = canonicalizeHeaders(input.headers); |
| 917 | 867 | const canonicalQueryParams = canonicalizeCredentialMap( |
| 918 | 868 | input.queryParams, |
| @@ -922,25 +872,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines | ||
| 922 | 872 | input.specFetchCredentials, |
| 923 | 873 | ); |
| 924 | 874 | const canonicalOAuth2 = canonicalizeOAuth2(input.oauth2); |
| 925 | − const directBindings = [ | |
| 926 | − ...canonicalHeaders.bindings, | |
| 927 | − ...canonicalQueryParams.bindings, | |
| 928 | − ...canonicalSpecFetchCredentials.bindings, | |
| 929 | − ...canonicalOAuth2.bindings, | |
| 930 | − ]; | |
| 931 | − for (const binding of directBindings) { | |
| 932 | − const bindingTargetScope = yield* targetScopeForBinding( | |
| 933 | − input.credentialTargetScope, | |
| 934 | − binding, | |
| 935 | − ); | |
| 936 | − yield* validateOpenApiBindingTarget(ctx, { | |
| 937 | − sourceId: namespace, | |
| 938 | − sourceScope: input.scope, | |
| 939 | − targetScope: bindingTargetScope, | |
| 940 | − }); | |
| 941 | − } | |
| 942 | 875 | const definitions = compileToolDefinitions(result.operations); |
| 943 | − const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace); | |
| 876 | + const sourceName = input.name; | |
| 944 | 877 | |
| 945 | 878 | const sourceConfig: SourceConfig = { |
| 946 | 879 | spec: input.specText, |
| @@ -989,23 +922,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 42 unmodified lines | ||
| 989 | 922 | })), |
| 990 | 923 | }); |
| 991 | 924 | |
| 992 | − if (directBindings.length > 0) { | |
| 993 | − for (const binding of directBindings) { | |
| 994 | − const bindingTargetScope = yield* targetScopeForBinding( | |
| 995 | − input.credentialTargetScope, | |
| 996 | − binding, | |
| 997 | − ); | |
| 998 | − yield* ctx.credentialBindings.set({ | |
| 999 | − targetScope: ScopeId.make(bindingTargetScope), | |
| 1000 | − pluginId: OPENAPI_PLUGIN_ID, | |
| 1001 | − sourceId: namespace, | |
| 1002 | − sourceScope: ScopeId.make(input.scope), | |
| 1003 | − slotKey: binding.slot, | |
| 1004 | − value: binding.value, | |
| 1005 | − }); | |
| 1006 | − } | |
| 1007 | − } | |
| 1008 | − | |
| 1009 | 925 | if (Object.keys(hoistedDefs).length > 0) { |
| 1010 | 926 | yield* ctx.core.definitions.register({ |
| 1011 | 927 | sourceId: namespace, |
| @@ -1046,13 +962,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 34 unmodified lines | ||
| 1046 | 962 | scope, |
| 1047 | 963 | sourceUrl, |
| 1048 | 964 | name: existing.name, |
| 1049 | − baseUrl: resolvedConfig.baseUrl, | |
| 965 | + baseUrl: existing.config.baseUrl, | |
| 1050 | 966 | namespace: existing.namespace, |
| 1051 | 967 | headers: existing.config.headers, |
| 1052 | 968 | queryParams: existing.config.queryParams, |
| 1053 | 969 | specFetchCredentials: existing.config.specFetchCredentials, |
| 1054 | 970 | oauth2: existing.config.oauth2, |
| 1055 | − credentialTargetScope: scope, | |
| 1056 | 971 | }); |
| 1057 | 972 | }); |
| 1058 | 973 | |
| @@ -1069,17 +984,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 10 unmodified lines | ||
| 1069 | 984 | // Resolve URL → text and parse BEFORE opening a transaction. |
| 1070 | 985 | // Holding `BEGIN` on the pool=1 Postgres connection across a |
| 1071 | 986 | // network fetch is the Hyperdrive deadlock path in production. |
| 1072 | − const credentials = yield* resolveSpecFetchInputCredentials( | |
| 1073 | − ctx, | |
| 1074 | − config.specFetchCredentials, | |
| 1075 | − ); | |
| 1076 | − const specText = yield* resolveSpecText(config.spec, credentials).pipe( | |
| 1077 | − Effect.provide(httpClientLayer), | |
| 1078 | − ); | |
| 987 | + const specText = | |
| 988 | + config.spec.kind === "url" | |
| 989 | + ? yield* resolveSpecText(config.spec.url).pipe(Effect.provide(httpClientLayer)) | |
| 990 | + : config.spec.value; | |
| 1079 | 991 | return yield* rebuildSource(ctx, { |
| 1080 | 992 | specText, |
| 1081 | 993 | scope: config.scope, |
| 1082 | − sourceUrl: isHttpUrl(config.spec) ? config.spec : undefined, | |
| 994 | + sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined, | |
| 1083 | 995 | name: config.name, |
| 1084 | 996 | baseUrl: config.baseUrl, |
| 1085 | 997 | namespace: config.namespace, |
| @@ -1087,11 +999,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line | ||
| 1087 | 999 | queryParams: config.queryParams, |
| 1088 | 1000 | specFetchCredentials: config.specFetchCredentials, |
| 1089 | 1001 | oauth2: config.oauth2, |
| 1090 | − // Default to the source's own scope. refreshSource and editSource | |
| 1091 | − // do the same; without this, config-sync's addSpec — which never | |
| 1092 | − // passes the field — fails with "credentialTargetScope is | |
| 1093 | − // required" the moment the jsonc declares any header secret. | |
| 1094 | − credentialTargetScope: config.credentialTargetScope ?? config.scope, | |
| 1095 | 1002 | }); |
| 1096 | 1003 | }); |
| 1097 | 1004 | |
| @@ -1164,17 +1071,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 66 unmodified lines | ||
| 1164 | 1071 | input.queryParams !== undefined |
| 1165 | 1072 | ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName) |
| 1166 | 1073 | : null; |
| 1167 | − const directBindings = [ | |
| 1168 | − ...(canonicalHeaders?.bindings ?? []), | |
| 1169 | − ...(canonicalQueryParams?.bindings ?? []), | |
| 1170 | − ...(canonicalOAuth2?.bindings ?? []), | |
| 1171 | − ]; | |
| 1172 | 1074 | const affectedPrefixes = [ |
| 1173 | 1075 | ...(input.headers !== undefined ? ["header:"] : []), |
| 1174 | 1076 | ...(input.queryParams !== undefined ? ["query_param:"] : []), |
| 1175 | 1077 | ...(input.oauth2 !== undefined ? ["oauth2:"] : []), |
| 1176 | 1078 | ]; |
| 1177 | − const targetScope = input.credentialTargetScope ?? scope; | |
| 1079 | + const targetScope = scope; | |
| 1178 | 1080 | if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { |
| 1179 | 1081 | const outerSource = yield* findOuterSource(ctx, namespace, scope); |
| 1180 | 1082 | if (outerSource) { |
| @@ -1183,7 +1085,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines | ||
| 1183 | 1085 | }); |
| 1184 | 1086 | } |
| 1185 | 1087 | } |
| 1186 | − if (affectedPrefixes.length > 0 || directBindings.length > 0) { | |
| 1088 | + if (affectedPrefixes.length > 0) { | |
| 1187 | 1089 | yield* validateOpenApiBindingTarget(ctx, { |
| 1188 | 1090 | sourceId: namespace, |
| 1189 | 1091 | sourceScope: scope, |
| @@ -1199,17 +1101,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 9 unmodified lines | ||
| 1199 | 1101 | queryParams: canonicalQueryParams?.values, |
| 1200 | 1102 | oauth2: canonicalOAuth2?.oauth2, |
| 1201 | 1103 | }); |
| 1202 | − if (affectedPrefixes.length > 0 || directBindings.length > 0) { | |
| 1104 | + if (affectedPrefixes.length > 0) { | |
| 1203 | 1105 | yield* ctx.credentialBindings.replaceForSource({ |
| 1204 | 1106 | targetScope: ScopeId.make(targetScope), |
| 1205 | 1107 | pluginId: OPENAPI_PLUGIN_ID, |
| 1206 | 1108 | sourceId: namespace, |
| 1207 | 1109 | sourceScope: ScopeId.make(scope), |
| 1208 | 1110 | slotPrefixes: affectedPrefixes, |
| 1209 | − bindings: directBindings.map((binding) => ({ | |
| 1210 | − slotKey: binding.slot, | |
| 1211 | − value: binding.value, | |
| 1212 | − })), | |
| 1111 | + bindings: [], | |
| 1213 | 1112 | }); |
| 1214 | 1113 | } |
| 1215 | 1114 | }), |
| @@ -90,8 +90,9 @@ const getRegisteredToolSchema = (namespace: string, specText: string, toolId: st · 89 unmodified lines | ||
| 90 | 90 | ); |
| 91 | 91 | |
| 92 | 92 | yield* executor.openapi.addSpec({ |
| 93 | − spec: specText, | |
| 93 | + spec: { kind: "blob", value: specText }, | |
| 94 | 94 | scope: TEST_SCOPE, |
| 95 | + name: namespace, | |
| 95 | 96 | namespace, |
| 96 | 97 | baseUrl: "", |
| 97 | 98 | }); |
| @@ -41,6 +41,12 @@ export const headerBindingSlot = (headerName: string): string => · 40 unmodified lines | ||
| 41 | 41 | export const queryParamBindingSlot = (name: string): string => |
| 42 | 42 | `query_param:${slugifySlotPart(name)}`; |
| 43 | 43 | |
| 44 | +export const specFetchHeaderBindingSlot = (headerName: string): string => | |
| 45 | + `spec_fetch_header:${slugifySlotPart(headerName)}`; | |
| 46 | + | |
| 47 | +export const specFetchQueryParamBindingSlot = (name: string): string => | |
| 48 | + `spec_fetch_query_param:${slugifySlotPart(name)}`; | |
| 49 | + | |
| 44 | 50 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => |
| 45 | 51 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; |
| 46 | 52 | |
| @@ -13,7 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | OpenApi, |
| 14 | 14 | } from "effect/unstable/httpapi"; |
| 15 | 15 | import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; |
| 16 | −import { isToolResult, type ScopeId } from "@executor-js/sdk/core"; | |
| 16 | +import { isToolResult } from "@executor-js/sdk/core"; | |
| 17 | 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; |
| 18 | 18 | |
| 19 | 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( |
| @@ -80,22 +80,45 @@ export interface OpenApiEchoTestServerShape extends OpenApiTestServerShape { · 60 unmodified lines | ||
| 80 | 80 | readonly clearRequests: Effect.Effect<void>; |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | −export type OpenApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec" | "baseUrl"> & { | |
| 83 | +export type OpenApiTestSourceOptions = Omit< | |
| 84 | + OpenApiSpecConfig, | |
| 85 | + "spec" | "baseUrl" | "name" | "namespace" | |
| 86 | +> & { | |
| 84 | 87 | readonly baseUrl?: string | null; |
| 88 | + readonly name?: string; | |
| 89 | + readonly namespace?: string; | |
| 85 | 90 | }; |
| 86 | 91 | |
| 87 | −export type OpenApiHttpApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec"> & { | |
| 92 | +export type OpenApiHttpApiTestSourceOptions = Omit< | |
| 93 | + OpenApiSpecConfig, | |
| 94 | + "spec" | "name" | "namespace" | "baseUrl" | |
| 95 | +> & { | |
| 96 | + readonly name?: string; | |
| 97 | + readonly namespace?: string; | |
| 98 | + readonly baseUrl?: string | null; | |
| 88 | 99 | readonly specBaseUrl?: string; |
| 89 | 100 | readonly transformSpec?: (spec: Record<string, unknown>) => Record<string, unknown>; |
| 90 | 101 | }; |
| 91 | 102 | |
| 103 | +type OpenApiHttpApiAddSpecCredentialInput = | |
| 104 | + | string | |
| 105 | + | { | |
| 106 | + readonly kind: "secret"; | |
| 107 | + readonly prefix?: string; | |
| 108 | + }; | |
| 109 | + | |
| 110 | +type OpenApiHttpApiAddSpecCredentialsInput = { | |
| 111 | + readonly headers?: Record<string, OpenApiHttpApiAddSpecCredentialInput>; | |
| 112 | + readonly queryParams?: Record<string, OpenApiHttpApiAddSpecCredentialInput>; | |
| 113 | +}; | |
| 114 | + | |
| 92 | 115 | export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit< |
| 93 | 116 | OpenApiHttpApiTestSourceOptions, |
| 94 | − "scope" | "credentialTargetScope" | |
| 95 | −> & { | |
| 96 | − readonly targetScope: ScopeId; | |
| 97 | − readonly credentialTargetScope?: ScopeId; | |
| 98 | −}; | |
| 117 | + "scope" | "headers" | "queryParams" | "specFetchCredentials" | |
| 118 | +> & | |
| 119 | + OpenApiHttpApiAddSpecCredentialsInput & { | |
| 120 | + readonly specFetchCredentials?: OpenApiHttpApiAddSpecCredentialsInput; | |
| 121 | + }; | |
| 99 | 122 | |
| 100 | 123 | export type OpenApiTestSourceExecutor = { |
| 101 | 124 | readonly openapi: Pick<OpenApiPluginExtension, "addSpec">; |
| @@ -125,9 +148,11 @@ export const makeOpenApiTestSourceConfig = ( · 23 unmodified lines | ||
| 125 | 148 | const { baseUrl, ...rest } = options; |
| 126 | 149 | return { |
| 127 | 150 | ...rest, |
| 128 | − spec: server.specJson, | |
| 151 | + spec: { kind: "blob", value: server.specJson }, | |
| 152 | + name: rest.name ?? "Test API", | |
| 153 | + namespace: rest.namespace ?? "test_api", | |
| 129 | 154 | ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? server.baseUrl }), |
| 130 | − }; | |
| 155 | + } as OpenApiSpecConfig; | |
| 131 | 156 | }; |
| 132 | 157 | |
| 133 | 158 | export const addOpenApiTestSource = ( |
| @@ -140,11 +165,17 @@ export const makeOpenApiHttpApiTestSourceConfig = ( · 6 unmodified lines | ||
| 140 | 165 | api: HttpApi.Any, |
| 141 | 166 | options: OpenApiHttpApiTestSourceOptions, |
| 142 | 167 | ): OpenApiSpecConfig => { |
| 143 | − const { specBaseUrl, transformSpec, ...config } = options; | |
| 168 | + const { baseUrl, specBaseUrl, transformSpec, ...config } = options; | |
| 144 | 169 | return { |
| 145 | 170 | ...config, |
| 146 | − spec: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }), | |
| 147 | − }; | |
| 171 | + spec: { | |
| 172 | + kind: "blob", | |
| 173 | + value: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }), | |
| 174 | + }, | |
| 175 | + name: config.name ?? "Test API", | |
| 176 | + namespace: config.namespace ?? "test_api", | |
| 177 | + ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? specBaseUrl ?? "https://api.example.test" }), | |
| 178 | + } as OpenApiSpecConfig; | |
| 148 | 179 | }; |
| 149 | 180 | |
| 150 | 181 | export const addOpenApiHttpApiTestSource = ( |
| @@ -157,26 +188,21 @@ export const makeOpenApiHttpApiTestAddSpecPayload = ( · 6 unmodified lines | ||
| 157 | 188 | api: HttpApi.Any, |
| 158 | 189 | options: OpenApiHttpApiTestAddSpecPayloadOptions, |
| 159 | 190 | ) => { |
| 160 | − const { targetScope, credentialTargetScope, ...sourceOptions } = options; | |
| 191 | + const { ...sourceOptions } = options; | |
| 161 | 192 | const config = makeOpenApiHttpApiTestSourceConfig(api, { |
| 162 | 193 | ...sourceOptions, |
| 163 | − scope: String(targetScope), | |
| 164 | − ...(credentialTargetScope !== undefined | |
| 165 | − ? { credentialTargetScope: String(credentialTargetScope) } | |
| 166 | − : {}), | |
| 194 | + scope: "unused-http-helper-scope", | |
| 167 | 195 | }); |
| 168 | 196 | return { |
| 169 | − targetScope, | |
| 170 | 197 | spec: config.spec, |
| 171 | 198 | namespace: config.namespace, |
| 172 | − ...(config.name !== undefined ? { name: config.name } : {}), | |
| 173 | − ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}), | |
| 174 | − ...(config.headers !== undefined ? { headers: config.headers } : {}), | |
| 175 | − ...(config.queryParams !== undefined ? { queryParams: config.queryParams } : {}), | |
| 199 | + name: config.name, | |
| 200 | + baseUrl: config.baseUrl, | |
| 201 | + ...(sourceOptions.headers !== undefined ? { headers: sourceOptions.headers } : {}), | |
| 202 | + ...(sourceOptions.queryParams !== undefined ? { queryParams: sourceOptions.queryParams } : {}), | |
| 176 | 203 | ...(config.oauth2 !== undefined ? { oauth2: config.oauth2 } : {}), |
| 177 | − ...(credentialTargetScope !== undefined ? { credentialTargetScope } : {}), | |
| 178 | − ...(config.specFetchCredentials !== undefined | |
| 179 | − ? { specFetchCredentials: config.specFetchCredentials } | |
| 204 | + ...(sourceOptions.specFetchCredentials !== undefined | |
| 205 | + ? { specFetchCredentials: sourceOptions.specFetchCredentials } | |
| 180 | 206 | : {}), |
| 181 | 207 | }; |
| 182 | 208 | }; |