* 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 | .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" })); | 35 | .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" })); |
| 36 | 36 | ||
| 37 | const makeMinimalOpenApiSourcePayload = ( | 37 | const makeMinimalOpenApiSourcePayload = ( |
| 38 | targetScope: ScopeId, | ||
| 39 | namespace: string, | 38 | namespace: string, |
| 40 | options: Omit< | 39 | options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {}, |
| 41 | Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], | ||
| 42 | "targetScope" | "namespace" | ||
| 43 | > = {}, | ||
| 44 | ) => | 40 | ) => |
| 45 | makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, { | 41 | makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, { |
| 46 | targetScope, | ||
| 47 | namespace, | 42 | namespace, |
| 48 | ...options, | 43 | ...options, |
| 49 | }); | 44 | }); |
| @@ -72,7 +67,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines | |||
| 72 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 73 | const result = yield* client.openapi.addSpec({ | 68 | const result = yield* client.openapi.addSpec({ |
| 74 | params: { scopeId: ScopeId.make(org) }, | 69 | params: { scopeId: ScopeId.make(org) }, |
| 75 | payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | 70 | payload: makeMinimalOpenApiSourcePayload(namespace), |
| 76 | }); | 71 | }); |
| 77 | expect(result.namespace).toBe(namespace); | 72 | expect(result.namespace).toBe(namespace); |
| 78 | expect(result.toolCount).toBeGreaterThan(0); | 73 | expect(result.toolCount).toBeGreaterThan(0); |
| @@ -94,7 +89,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines | |||
| 94 | yield* asOrg(org, (client) => | 89 | yield* asOrg(org, (client) => |
| 95 | client.openapi.addSpec({ | 90 | client.openapi.addSpec({ |
| 96 | params: { scopeId: ScopeId.make(org) }, | 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 | const result = yield* asOrg(org, (client) => | 131 | const result = yield* asOrg(org, (client) => |
| 137 | client.openapi.addSpec({ | 132 | client.openapi.addSpec({ |
| 138 | params: { scopeId: ScopeId.make(org) }, | 133 | params: { scopeId: ScopeId.make(org) }, |
| 139 | payload: makeMinimalOpenApiSourcePayload( | 134 | payload: makeMinimalOpenApiSourcePayload(`ns_${crypto.randomUUID().replace(/-/g, "_")}`, { |
| 140 | ScopeId.make(org), | 135 | baseUrl: "http://example.com", |
| 141 | `ns_${crypto.randomUUID().replace(/-/g, "_")}`, | 136 | }), |
| 142 | { baseUrl: "http://example.com" }, | ||
| 143 | ), | ||
| 144 | }), | 137 | }), |
| 145 | ); | 138 | ); |
| 146 | 139 | ||
| @@ -167,8 +160,9 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines | |||
| 167 | client.openapi.addSpec({ | 160 | client.openapi.addSpec({ |
| 168 | params: { scopeId }, | 161 | params: { scopeId }, |
| 169 | payload: { | 162 | payload: { |
| 170 | targetScope: scopeId, | 163 | spec: { kind: "blob", value: server.specJson }, |
| 171 | spec: server.specJson, | 164 | name: "Invocable Source API", |
| 165 | baseUrl: server.baseUrl, | ||
| 172 | namespace, | 166 | namespace, |
| 173 | }, | 167 | }, |
| 174 | }), | 168 | }), |
| @@ -414,7 +408,7 @@ describe("sources api (HTTP)", () => { · 239 unmodified lines | |||
| 414 | Effect.gen(function* () { | 408 | Effect.gen(function* () { |
| 415 | yield* client.openapi.addSpec({ | 409 | yield* client.openapi.addSpec({ |
| 416 | params: { scopeId: ScopeId.make(org) }, | 410 | params: { scopeId: ScopeId.make(org) }, |
| 417 | payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | 411 | payload: makeMinimalOpenApiSourcePayload(namespace), |
| 418 | }); | 412 | }); |
| 419 | yield* client.sources.remove({ | 413 | yield* client.sources.remove({ |
| 420 | params: { scopeId: ScopeId.make(org), sourceId: namespace }, | 414 | params: { scopeId: ScopeId.make(org), sourceId: namespace }, |
| @@ -468,7 +462,7 @@ describe("sources api (HTTP)", () => { · 47 unmodified lines | |||
| 468 | Effect.gen(function* () { | 462 | Effect.gen(function* () { |
| 469 | yield* client.openapi.addSpec({ | 463 | yield* client.openapi.addSpec({ |
| 470 | params: { scopeId: ScopeId.make(org) }, | 464 | params: { scopeId: ScopeId.make(org) }, |
| 471 | payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace), | 465 | payload: makeMinimalOpenApiSourcePayload(namespace), |
| 472 | }); | 466 | }); |
| 473 | yield* client.openapi.updateSource({ | 467 | yield* client.openapi.updateSource({ |
| 474 | params: { scopeId: ScopeId.make(org), namespace }, | 468 | params: { scopeId: ScopeId.make(org), namespace }, |
| @@ -502,11 +496,10 @@ describe("sources api (HTTP)", () => { · 27 unmodified lines | |||
| 502 | client.openapi.addSpec({ | 496 | client.openapi.addSpec({ |
| 503 | params: { scopeId: ScopeId.make(orgId) }, | 497 | params: { scopeId: ScopeId.make(orgId) }, |
| 504 | payload: { | 498 | payload: { |
| 505 | ...makeMinimalOpenApiSourcePayload(ScopeId.make(orgId), namespace), | 499 | ...makeMinimalOpenApiSourcePayload(namespace), |
| 506 | headers: { | 500 | headers: { |
| 507 | Authorization: { | 501 | Authorization: { |
| 508 | kind: "binding", | 502 | kind: "secret", |
| 509 | slot: "auth:personal-token", | ||
| 510 | prefix: "Bearer ", | 503 | prefix: "Bearer ", |
| 511 | }, | 504 | }, |
| 512 | }, | 505 | }, |
| @@ -530,7 +523,7 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines | |||
| 530 | sourceId: namespace, | 523 | sourceId: namespace, |
| 531 | sourceScope: ScopeId.make(orgId), | 524 | sourceScope: ScopeId.make(orgId), |
| 532 | scope: ScopeId.make(aliceScope), | 525 | scope: ScopeId.make(aliceScope), |
| 533 | slot: "auth:personal-token", | 526 | slot: "header:authorization", |
| 534 | value: { | 527 | value: { |
| 535 | kind: "secret", | 528 | kind: "secret", |
| 536 | secretId: SecretId.make("alice_pat"), | 529 | secretId: SecretId.make("alice_pat"), |
| @@ -541,7 +534,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | |||
| 541 | sourceId: namespace, | 534 | sourceId: namespace, |
| 542 | sourceScopeId: ScopeId.make(orgId), | 535 | sourceScopeId: ScopeId.make(orgId), |
| 543 | scopeId: ScopeId.make(aliceScope), | 536 | scopeId: ScopeId.make(aliceScope), |
| 544 | slot: "auth:personal-token", | 537 | slot: "header:authorization", |
| 545 | value: { | 538 | value: { |
| 546 | kind: "secret", | 539 | kind: "secret", |
| 547 | secretId: SecretId.make("alice_pat"), | 540 | secretId: SecretId.make("alice_pat"), |
| @@ -568,7 +561,7 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines | |||
| 568 | sourceId: namespace, | 561 | sourceId: namespace, |
| 569 | sourceScope: ScopeId.make(orgId), | 562 | sourceScope: ScopeId.make(orgId), |
| 570 | scope: ScopeId.make(bobScope), | 563 | scope: ScopeId.make(bobScope), |
| 571 | slot: "auth:personal-token", | 564 | slot: "header:authorization", |
| 572 | value: { | 565 | value: { |
| 573 | kind: "secret", | 566 | kind: "secret", |
| 574 | secretId: SecretId.make("bob_pat"), | 567 | secretId: SecretId.make("bob_pat"), |
| @@ -590,7 +583,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines | |||
| 590 | expect(aliceBindings).toContainEqual( | 583 | expect(aliceBindings).toContainEqual( |
| 591 | expect.objectContaining({ | 584 | expect.objectContaining({ |
| 592 | scopeId: ScopeId.make(aliceScope), | 585 | scopeId: ScopeId.make(aliceScope), |
| 593 | slot: "auth:personal-token", | 586 | slot: "header:authorization", |
| 594 | value: { | 587 | value: { |
| 595 | kind: "secret", | 588 | kind: "secret", |
| 596 | secretId: SecretId.make("alice_pat"), | 589 | secretId: SecretId.make("alice_pat"), |
| @@ -601,7 +594,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | |||
| 601 | expect( | 594 | expect( |
| 602 | aliceBindings.some( | 595 | aliceBindings.some( |
| 603 | (binding) => | 596 | (binding) => |
| 604 | binding.slot === "auth:personal-token" && | 597 | binding.slot === "header:authorization" && |
| 605 | binding.value.kind === "secret" && | 598 | binding.value.kind === "secret" && |
| 606 | binding.value.secretId === SecretId.make("bob_pat"), | 599 | binding.value.secretId === SecretId.make("bob_pat"), |
| 607 | ), | 600 | ), |
| @@ -619,7 +612,7 @@ describe("sources api (HTTP)", () => { · 11 unmodified lines | |||
| 619 | expect(bobBindings).toContainEqual( | 612 | expect(bobBindings).toContainEqual( |
| 620 | expect.objectContaining({ | 613 | expect.objectContaining({ |
| 621 | scopeId: ScopeId.make(bobScope), | 614 | scopeId: ScopeId.make(bobScope), |
| 622 | slot: "auth:personal-token", | 615 | slot: "header:authorization", |
| 623 | value: { | 616 | value: { |
| 624 | kind: "secret", | 617 | kind: "secret", |
| 625 | secretId: SecretId.make("bob_pat"), | 618 | secretId: SecretId.make("bob_pat"), |
| @@ -630,7 +623,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines | |||
| 630 | expect( | 623 | expect( |
| 631 | bobBindings.some( | 624 | bobBindings.some( |
| 632 | (binding) => | 625 | (binding) => |
| 633 | binding.slot === "auth:personal-token" && | 626 | binding.slot === "header:authorization" && |
| 634 | binding.value.kind === "secret" && | 627 | binding.value.kind === "secret" && |
| 635 | binding.value.secretId === SecretId.make("alice_pat"), | 628 | binding.value.secretId === SecretId.make("alice_pat"), |
| 636 | ), | 629 | ), |
| @@ -654,8 +647,9 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines | |||
| 654 | client.openapi.addSpec({ | 647 | client.openapi.addSpec({ |
| 655 | params: { scopeId: ScopeId.make(org) }, | 648 | params: { scopeId: ScopeId.make(org) }, |
| 656 | payload: { | 649 | payload: { |
| 657 | targetScope: ScopeId.make(org), | 650 | spec: { kind: "blob", value: CLOUDFLARE_SPEC }, |
| 658 | spec: CLOUDFLARE_SPEC, | 651 | name: namespace, |
| 652 | baseUrl: "https://api.cloudflare.com/client/v4", | ||
| 659 | namespace, | 653 | namespace, |
| 660 | }, | 654 | }, |
| 661 | }), | 655 | }), |
| @@ -45,8 +45,9 @@ describe("sources.refresh (HTTP)", () => { · 44 unmodified lines | |||
| 45 | client.openapi.addSpec({ | 45 | client.openapi.addSpec({ |
| 46 | params: { scopeId: ScopeId.make(org) }, | 46 | params: { scopeId: ScopeId.make(org) }, |
| 47 | payload: { | 47 | payload: { |
| 48 | targetScope: ScopeId.make(org), | 48 | spec: { kind: "url", url: server.specUrl }, |
| 49 | spec: server.specUrl, | 49 | name: namespace, |
| 50 | baseUrl: server.baseUrl, | ||
| 50 | namespace, | 51 | namespace, |
| 51 | }, | 52 | }, |
| 52 | }), | 53 | }), |
| @@ -107,8 +108,9 @@ describe("sources.refresh (HTTP)", () => { · 54 unmodified lines | |||
| 107 | client.openapi.addSpec({ | 108 | client.openapi.addSpec({ |
| 108 | params: { scopeId: ScopeId.make(org) }, | 109 | params: { scopeId: ScopeId.make(org) }, |
| 109 | payload: { | 110 | payload: { |
| 110 | targetScope: ScopeId.make(org), | 111 | spec: { kind: "blob", value: makeRefreshSpecText() }, |
| 111 | spec: makeRefreshSpecText(), | 112 | name: namespace, |
| 113 | baseUrl: "https://api.example.test", | ||
| 112 | namespace, | 114 | namespace, |
| 113 | }, | 115 | }, |
| 114 | }), | 116 | }), |
| @@ -20,15 +20,10 @@ const TenantIsolationApi = HttpApi.make("tenantIsolationTest") · 19 unmodified lines | |||
| 20 | .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" })); | 20 | .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" })); |
| 21 | 21 | ||
| 22 | const makeTenantOpenApiSourcePayload = ( | 22 | const makeTenantOpenApiSourcePayload = ( |
| 23 | targetScope: ScopeId, | ||
| 24 | namespace: string, | 23 | namespace: string, |
| 25 | options: Omit< | 24 | options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {}, |
| 26 | Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], | ||
| 27 | "targetScope" | "namespace" | ||
| 28 | > = {}, | ||
| 29 | ) => | 25 | ) => |
| 30 | makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, { | 26 | makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, { |
| 31 | targetScope, | ||
| 32 | namespace, | 27 | namespace, |
| 33 | ...options, | 28 | ...options, |
| 34 | }); | 29 | }); |
| @@ -116,7 +111,7 @@ describe("tenant isolation (HTTP)", () => { · 81 unmodified lines | |||
| 116 | yield* asOrg(orgA, (client) => | 111 | yield* asOrg(orgA, (client) => |
| 117 | client.openapi.addSpec({ | 112 | client.openapi.addSpec({ |
| 118 | params: { scopeId: ScopeId.make(orgA) }, | 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 | yield* asOrg(orgA, (client) => | 131 | yield* asOrg(orgA, (client) => |
| 137 | client.openapi.addSpec({ | 132 | client.openapi.addSpec({ |
| 138 | params: { scopeId: ScopeId.make(orgA) }, | 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 | yield* asOrg(orgA, (client) => | 154 | yield* asOrg(orgA, (client) => |
| 160 | client.openapi.addSpec({ | 155 | client.openapi.addSpec({ |
| 161 | params: { scopeId: ScopeId.make(orgA) }, | 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 | yield* client.openapi.addSpec({ | 254 | yield* client.openapi.addSpec({ |
| 260 | params: { scopeId: ScopeId.make(orgA) }, | 255 | params: { scopeId: ScopeId.make(orgA) }, |
| 261 | payload: { | 256 | payload: { |
| 262 | ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | 257 | ...makeTenantOpenApiSourcePayload(namespaceA), |
| 263 | headers: { | 258 | headers: { |
| 264 | Authorization: { | 259 | Authorization: { |
| 265 | kind: "binding", | 260 | kind: "secret", |
| 266 | slot: "auth:token", | ||
| 267 | prefix: "Bearer ", | 261 | prefix: "Bearer ", |
| 268 | }, | 262 | }, |
| 269 | }, | 263 | }, |
| @@ -275,7 +269,7 @@ describe("tenant isolation (HTTP)", () => { · 5 unmodified lines | |||
| 275 | sourceId: namespaceA, | 269 | sourceId: namespaceA, |
| 276 | sourceScope: ScopeId.make(orgA), | 270 | sourceScope: ScopeId.make(orgA), |
| 277 | scope: ScopeId.make(orgA), | 271 | scope: ScopeId.make(orgA), |
| 278 | slot: "auth:token", | 272 | slot: "header:authorization", |
| 279 | value: { kind: "secret", secretId: secretIdA }, | 273 | value: { kind: "secret", secretId: secretIdA }, |
| 280 | }, | 274 | }, |
| 281 | }); | 275 | }); |
| @@ -303,16 +297,7 @@ describe("tenant isolation (HTTP)", () => { · 21 unmodified lines | |||
| 303 | Effect.gen(function* () { | 297 | Effect.gen(function* () { |
| 304 | yield* client.openapi.addSpec({ | 298 | yield* client.openapi.addSpec({ |
| 305 | params: { scopeId: ScopeId.make(orgA) }, | 299 | params: { scopeId: ScopeId.make(orgA) }, |
| 306 | payload: { | 300 | payload: makeTenantOpenApiSourcePayload(namespaceA), |
| 307 | ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA), | ||
| 308 | headers: { | ||
| 309 | Authorization: { | ||
| 310 | kind: "binding", | ||
| 311 | slot: "auth:conn", | ||
| 312 | prefix: "Bearer ", | ||
| 313 | }, | ||
| 314 | }, | ||
| 315 | }, | ||
| 316 | }); | 301 | }); |
| 317 | yield* client.openapi.setSourceBinding({ | 302 | yield* client.openapi.setSourceBinding({ |
| 318 | params: { scopeId: ScopeId.make(orgA) }, | 303 | params: { scopeId: ScopeId.make(orgA) }, |
| @@ -346,7 +331,7 @@ describe("tenant isolation (HTTP)", () => { · 27 unmodified lines | |||
| 346 | yield* asOrg(orgA, (client) => | 331 | yield* asOrg(orgA, (client) => |
| 347 | client.openapi.addSpec({ | 332 | client.openapi.addSpec({ |
| 348 | params: { scopeId: ScopeId.make(orgA) }, | 333 | params: { scopeId: ScopeId.make(orgA) }, |
| 349 | payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespace, { | 334 | payload: makeTenantOpenApiSourcePayload(namespace, { |
| 350 | name: "Org A API", | 335 | name: "Org A API", |
| 351 | baseUrl: "https://org-a.example.com", | 336 | baseUrl: "https://org-a.example.com", |
| 352 | }), | 337 | }), |
| @@ -355,7 +340,7 @@ describe("tenant isolation (HTTP)", () => { · 2 unmodified lines | |||
| 355 | yield* asOrg(orgB, (client) => | 340 | yield* asOrg(orgB, (client) => |
| 356 | client.openapi.addSpec({ | 341 | client.openapi.addSpec({ |
| 357 | params: { scopeId: ScopeId.make(orgB) }, | 342 | params: { scopeId: ScopeId.make(orgB) }, |
| 358 | payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgB), namespace, { | 343 | payload: makeTenantOpenApiSourcePayload(namespace, { |
| 359 | name: "Org B API", | 344 | name: "Org B API", |
| 360 | baseUrl: "https://org-b.example.com", | 345 | baseUrl: "https://org-b.example.com", |
| 361 | }), | 346 | }), |
| @@ -85,8 +85,10 @@ export const POST: APIRoute = async ({ request }) => { · 84 unmodified lines | |||
| 85 | // Add source to register its tools (Google Discovery needs auth so skip) | 85 | // Add source to register its tools (Google Discovery needs auth so skip) |
| 86 | if (match.kind === "openapi") { | 86 | if (match.kind === "openapi") { |
| 87 | yield* executor.openapi.addSpec({ | 87 | yield* executor.openapi.addSpec({ |
| 88 | spec: match.endpoint, | 88 | spec: { kind: "url", url: match.endpoint }, |
| 89 | name: match.name, | ||
| 89 | namespace: match.namespace, | 90 | namespace: match.namespace, |
| 91 | baseUrl: match.endpoint, | ||
| 90 | scope: "test-scope", | 92 | scope: "test-scope", |
| 91 | }); | 93 | }); |
| 92 | } else if (match.kind === "graphql") { | 94 | } else if (match.kind === "graphql") { |
| @@ -244,8 +244,9 @@ const program = Effect.gen(function* () { · 243 unmodified lines | |||
| 244 | console.log("-".repeat(72)); | 244 | console.log("-".repeat(72)); |
| 245 | 245 | ||
| 246 | const addSpecResult = yield* executor.openapi.addSpec({ | 246 | const addSpecResult = yield* executor.openapi.addSpec({ |
| 247 | spec: exampleOpenApiSpec, | 247 | spec: { kind: "blob", value: exampleOpenApiSpec }, |
| 248 | namespace: "example-api", | 248 | namespace: "example-api", |
| 249 | name: "Example API", | ||
| 249 | baseUrl: "https://example.com/api", | 250 | baseUrl: "https://example.com/api", |
| 250 | scope: "example-scope", | 251 | scope: "example-scope", |
| 251 | }); | 252 | }); |
| @@ -81,7 +81,12 @@ const executor = await createExecutor({ · 80 unmodified lines | |||
| 81 | await executor.openapi.addSpec({ | 81 | await executor.openapi.addSpec({ |
| 82 | namespace: "inventory", | 82 | namespace: "inventory", |
| 83 | scope: "docs-workspace", | 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 | // docs:end add-source | 91 | // docs:end add-source |
| 87 | 92 | ||
| @@ -44,9 +44,14 @@ await executor.mcp.addSource({ · 43 unmodified lines | |||
| 44 | // --------------------------------------------------------------------------- | 44 | // --------------------------------------------------------------------------- |
| 45 | 45 | ||
| 46 | await executor.openapi.addSpec({ | 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 | namespace: "petstore", | 51 | namespace: "petstore", |
| 49 | scope: "my-app", | 52 | scope: "my-app", |
| 53 | name: "Petstore", | ||
| 54 | baseUrl: "https://petstore3.swagger.io/api/v3", | ||
| 50 | }); | 55 | }); |
| 51 | 56 | ||
| 52 | // With auth headers (static or secret-backed) | 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 | plugins, | 219 | plugins, |
| 220 | onElicitation: "accept-all", | 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 | const invoker = makeExecutorToolInvoker(executor, { invokeOptions: autoApprove }); | 229 | const invoker = makeExecutorToolInvoker(executor, { invokeOptions: autoApprove }); |
| 224 | return { executor, invoker, captured: recording.captured, sql }; | 230 | return { executor, invoker, captured: recording.captured, sql }; |
| 225 | }), | 231 | }), |
| @@ -1,11 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { | 3 | import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared"; |
| 4 | InternalError, | ||
| 5 | ScopeId, | ||
| 6 | ScopedSecretCredentialInput, | ||
| 7 | SecretBackedValue, | ||
| 8 | } from "@executor-js/sdk/shared"; | ||
| 9 | 4 | ||
| 10 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; | 5 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; |
| 11 | import { SpecPreview } from "../sdk/preview"; | 6 | import { SpecPreview } from "../sdk/preview"; |
| @@ -42,57 +37,54 @@ const SourceBindingParams = { · 30 unmodified lines | |||
| 42 | sourceScopeId: ScopeId, | 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 | headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), | 46 | headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), |
| 47 | queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), | 47 | queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), |
| 48 | }); | 48 | }); |
| 49 | 49 | ||
| 50 | const ConfiguredCredentialBindingPayload = Schema.Struct({ | 50 | const OpenApiSecretShapePayload = Schema.Struct({ |
| 51 | kind: Schema.Literal("binding"), | 51 | kind: Schema.Literal("secret"), |
| 52 | slot: Schema.String, | ||
| 53 | prefix: Schema.optional(Schema.String), | 52 | prefix: Schema.optional(Schema.String), |
| 54 | }); | 53 | }); |
| 55 | 54 | ||
| 56 | const ConfiguredCredentialValuePayload = Schema.Union([ | 55 | const OpenApiConfiguredValuePayload = Schema.Union([Schema.String, OpenApiSecretShapePayload]); |
| 57 | Schema.String, | ||
| 58 | ConfiguredCredentialBindingPayload, | ||
| 59 | ]); | ||
| 60 | 56 | ||
| 61 | const OpenApiCredentialInputPayload = Schema.Union([ | 57 | const SpecFetchCredentialsPayload = Schema.Struct({ |
| 62 | ScopedSecretCredentialInput, | 58 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 63 | SecretBackedValue, | 59 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 64 | ConfiguredCredentialValuePayload, | 60 | }); |
| 65 | ]); | ||
| 66 | 61 | ||
| 67 | // --------------------------------------------------------------------------- | 62 | // --------------------------------------------------------------------------- |
| 68 | // Payloads | 63 | // Payloads |
| 69 | // --------------------------------------------------------------------------- | 64 | // --------------------------------------------------------------------------- |
| 70 | 65 | ||
| 71 | const AddSpecPayload = Schema.Struct({ | 66 | const AddSpecPayload = Schema.Struct({ |
| 72 | targetScope: ScopeId, | 67 | spec: OpenApiSpecInputPayload, |
| 73 | credentialTargetScope: Schema.optional(ScopeId), | ||
| 74 | spec: Schema.String, | ||
| 75 | specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload), | 68 | specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload), |
| 76 | name: Schema.optional(Schema.String), | 69 | name: Schema.String, |
| 77 | baseUrl: Schema.optional(Schema.String), | 70 | baseUrl: Schema.String, |
| 78 | namespace: Schema.optional(Schema.String), | 71 | namespace: Schema.String, |
| 79 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | 72 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 80 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | 73 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 81 | oauth2: Schema.optional(OAuth2SourceConfig), | 74 | oauth2: Schema.optional(OAuth2SourceConfig), |
| 82 | }); | 75 | }); |
| 83 | 76 | ||
| 84 | const PreviewSpecPayload = Schema.Struct({ | 77 | const PreviewSpecPayload = Schema.Struct({ |
| 85 | spec: Schema.String, | 78 | spec: Schema.String, |
| 86 | specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload), | 79 | specFetchCredentials: Schema.optional(PreviewSpecFetchCredentialsPayload), |
| 87 | }); | 80 | }); |
| 88 | 81 | ||
| 89 | const UpdateSourcePayload = Schema.Struct({ | 82 | const UpdateSourcePayload = Schema.Struct({ |
| 90 | sourceScope: ScopeId, | 83 | sourceScope: ScopeId, |
| 91 | name: Schema.optional(Schema.String), | 84 | name: Schema.optional(Schema.String), |
| 92 | baseUrl: Schema.optional(Schema.String), | 85 | baseUrl: Schema.optional(Schema.String), |
| 93 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | 86 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 94 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)), | 87 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), |
| 95 | credentialTargetScope: Schema.optional(ScopeId), | ||
| 96 | // Set after a successful re-authenticate to refresh the source's | 88 | // Set after a successful re-authenticate to refresh the source's |
| 97 | // stored OAuth2 metadata. | 89 | // stored OAuth2 metadata. |
| 98 | oauth2: Schema.optional(OAuth2SourceConfig), | 90 | oauth2: Schema.optional(OAuth2SourceConfig), |
| @@ -3,10 +3,9 @@ import { Context, Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { addGroup, capture } from "@executor-js/api"; | 4 | import { addGroup, capture } from "@executor-js/api"; |
| 5 | import type { | 5 | import type { |
| 6 | ConfiguredHeaderValue, | 6 | OpenApiConfiguredValueInput, |
| 7 | OpenApiPluginExtension, | 7 | OpenApiPluginExtension, |
| 8 | HeaderValue, | 8 | OpenApiPreviewSpecFetchCredentialsInput, |
| 9 | OpenApiCredentialInput, | ||
| 10 | OpenApiSpecFetchCredentialsInput, | 9 | OpenApiSpecFetchCredentialsInput, |
| 11 | OpenApiUpdateSourceInput, | 10 | OpenApiUpdateSourceInput, |
| 12 | } from "../sdk/plugin"; | 11 | } from "../sdk/plugin"; |
| @@ -55,13 +54,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines | |||
| 55 | return yield* ext.previewSpec({ | 54 | return yield* ext.previewSpec({ |
| 56 | spec: payload.spec, | 55 | spec: payload.spec, |
| 57 | specFetchCredentials: payload.specFetchCredentials as | 56 | specFetchCredentials: payload.specFetchCredentials as |
| 58 | | OpenApiSpecFetchCredentialsInput | 57 | | OpenApiPreviewSpecFetchCredentialsInput |
| 59 | | undefined, | 58 | | undefined, |
| 60 | }); | 59 | }); |
| 61 | }), | 60 | }), |
| 62 | ), | 61 | ), |
| 63 | ) | 62 | ) |
| 64 | .handle("addSpec", ({ payload }) => | 63 | .handle("addSpec", ({ params: path, payload }) => |
| 65 | capture( | 64 | capture( |
| 66 | Effect.gen(function* () { | 65 | Effect.gen(function* () { |
| 67 | const ext = yield* OpenApiExtensionService; | 66 | const ext = yield* OpenApiExtensionService; |
| @@ -70,15 +69,14 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 2 unmodified lines | |||
| 70 | specFetchCredentials: payload.specFetchCredentials as | 69 | specFetchCredentials: payload.specFetchCredentials as |
| 71 | | OpenApiSpecFetchCredentialsInput | 70 | | OpenApiSpecFetchCredentialsInput |
| 72 | | undefined, | 71 | | undefined, |
| 73 | scope: payload.targetScope, | 72 | scope: path.scopeId, |
| 74 | name: payload.name, | 73 | name: payload.name, |
| 75 | baseUrl: payload.baseUrl, | 74 | baseUrl: payload.baseUrl, |
| 76 | namespace: payload.namespace, | 75 | namespace: payload.namespace, |
| 77 | credentialTargetScope: payload.credentialTargetScope, | 76 | headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined, |
| 78 | headers: payload.headers as | 77 | queryParams: payload.queryParams as |
| 79 | | Record<string, HeaderValue | ConfiguredHeaderValue> | 78 | | Record<string, OpenApiConfiguredValueInput> |
| 80 | | undefined, | 79 | | undefined, |
| 81 | queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined, | ||
| 82 | oauth2: payload.oauth2, | 80 | oauth2: payload.oauth2, |
| 83 | }); | 81 | }); |
| 84 | return { | 82 | return { |
| @@ -119,11 +117,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 34 unmodified lines | |||
| 119 | yield* ext.updateSource(path.namespace, payload.sourceScope, { | 117 | yield* ext.updateSource(path.namespace, payload.sourceScope, { |
| 120 | name: payload.name, | 118 | name: payload.name, |
| 121 | baseUrl: payload.baseUrl, | 119 | baseUrl: payload.baseUrl, |
| 122 | headers: payload.headers as | 120 | headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined, |
| 123 | | Record<string, HeaderValue | ConfiguredHeaderValue> | 121 | queryParams: payload.queryParams as |
| 122 | | Record<string, OpenApiConfiguredValueInput> | ||
| 124 | | undefined, | 123 | | undefined, |
| 125 | queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined, | ||
| 126 | credentialTargetScope: payload.credentialTargetScope, | ||
| 127 | oauth2: payload.oauth2, | 124 | oauth2: payload.oauth2, |
| 128 | } as OpenApiUpdateSourceInput); | 125 | } as OpenApiUpdateSourceInput); |
| 129 | return { updated: true }; | 126 | return { updated: true }; |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 4 | import * as Match from "effect/Match"; | 5 | import * as Match from "effect/Match"; |
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| @@ -66,13 +67,10 @@ import { · 60 unmodified lines | |||
| 66 | oauth2ClientSecretSlot, | 67 | oauth2ClientSecretSlot, |
| 67 | oauth2ConnectionSlot, | 68 | oauth2ConnectionSlot, |
| 68 | queryParamBindingSlot, | 69 | queryParamBindingSlot, |
| 70 | specFetchHeaderBindingSlot, | ||
| 71 | specFetchQueryParamBindingSlot, | ||
| 69 | } from "../sdk/source-contracts"; | 72 | } from "../sdk/source-contracts"; |
| 70 | import { | 73 | import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types"; |
| 71 | ConfiguredHeaderBinding, | ||
| 72 | OAuth2SourceConfig, | ||
| 73 | OpenApiSourceBindingInput, | ||
| 74 | type ServerInfo, | ||
| 75 | } from "../sdk/types"; | ||
| 76 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; | 74 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 77 | 75 | ||
| 78 | export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | 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 | type StrategySelection = | 142 | type StrategySelection = |
| 132 | | { readonly kind: "none" } | 143 | | { readonly kind: "none" } |
| 133 | | { readonly kind: "custom" } | 144 | | { readonly kind: "custom" } |
| @@ -256,35 +267,19 @@ export default function AddOpenApiSource(props: { · 122 unmodified lines | |||
| 256 | })), | 267 | })), |
| 257 | [scopeStack], | 268 | [scopeStack], |
| 258 | ); | 269 | ); |
| 259 | const defaultCredentialTargetScope = | ||
| 260 | credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId; | ||
| 261 | const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; | 270 | const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId; |
| 262 | const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>( | ||
| 263 | defaultCredentialTargetScope, | ||
| 264 | ); | ||
| 265 | const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( | 271 | const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>( |
| 266 | defaultOAuthTokenTargetScope, | 272 | defaultOAuthTokenTargetScope, |
| 267 | ); | 273 | ); |
| 268 | const bindingScopeOptions = useMemo( | 274 | const sourceCredentialScopeOptions = useMemo( |
| 269 | () => credentialScopeOptions.map((option) => ({ ...option })), | 275 | () => credentialScopeOptions.filter((option) => option.scopeId === scopeId), |
| 270 | [credentialScopeOptions], | 276 | [credentialScopeOptions, scopeId], |
| 271 | ); | 277 | ); |
| 272 | useEffect(() => { | ||
| 273 | if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) { | ||
| 274 | setCredentialTargetScope(defaultCredentialTargetScope); | ||
| 275 | } | ||
| 276 | }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]); | ||
| 277 | useEffect(() => { | 278 | useEffect(() => { |
| 278 | if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { | 279 | if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { |
| 279 | setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); | 280 | setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); |
| 280 | } | 281 | } |
| 281 | }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); | 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 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); | 283 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 289 | const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { | 284 | const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { |
| 290 | mode: "promiseExit", | 285 | mode: "promiseExit", |
| @@ -294,9 +289,9 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | |||
| 294 | mode: "promiseExit", | 289 | mode: "promiseExit", |
| 295 | }); | 290 | }); |
| 296 | const secretList = useSecretPickerSecrets(); | 291 | const secretList = useSecretPickerSecrets(); |
| 297 | const initialCredentialSecrets = useMemo( | 292 | const sourceCredentialSecrets = useMemo( |
| 298 | () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)), | 293 | () => secretList.filter((secret) => secret.scopeId === String(scopeId)), |
| 299 | [credentialTargetScope, secretList], | 294 | [scopeId, secretList], |
| 300 | ); | 295 | ); |
| 301 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ | 296 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 302 | popupName: OPENAPI_OAUTH_POPUP_NAME, | 297 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -336,35 +331,29 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines | |||
| 336 | ); | 331 | ); |
| 337 | 332 | ||
| 338 | const resolvedBaseUrl = baseUrl.trim(); | 333 | const resolvedBaseUrl = baseUrl.trim(); |
| 334 | const sourceScope = ScopeId.make(scopeId); | ||
| 339 | 335 | ||
| 340 | const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {}; | 336 | type PendingSecretBinding = { |
| 341 | const headerBindings: Array<{ | 337 | readonly slot: string; |
| 342 | slot: string; | 338 | readonly secretId: string; |
| 343 | secretId: string; | 339 | readonly scope: ScopeId; |
| 344 | scope: ScopeId; | 340 | readonly secretScope: ScopeId; |
| 345 | secretScope: ScopeId; | 341 | }; |
| 346 | }> = []; | 342 | |
| 347 | const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {}; | 343 | const configuredHeaders: Record<string, { kind: "secret"; prefix?: string }> = {}; |
| 348 | const queryParamBindings: Array<{ | 344 | const headerBindings: PendingSecretBinding[] = []; |
| 349 | slot: string; | 345 | const configuredQueryParams: Record<string, string | { kind: "secret"; prefix?: string }> = {}; |
| 350 | secretId: string; | 346 | const queryParamBindings: PendingSecretBinding[] = []; |
| 351 | scope: ScopeId; | ||
| 352 | secretScope: ScopeId; | ||
| 353 | }> = []; | ||
| 354 | for (const ch of customHeaders) { | 347 | for (const ch of customHeaders) { |
| 355 | if (!ch.name.trim()) continue; | 348 | if (!ch.name.trim()) continue; |
| 356 | const slot = headerBindingSlot(ch.name.trim()); | 349 | const slot = headerBindingSlot(ch.name.trim()); |
| 357 | configuredHeaders[ch.name.trim()] = ConfiguredHeaderBinding.make({ | 350 | configuredHeaders[ch.name.trim()] = { kind: "secret", prefix: ch.prefix }; |
| 358 | kind: "binding", | ||
| 359 | slot, | ||
| 360 | prefix: ch.prefix, | ||
| 361 | }); | ||
| 362 | if (ch.secretId) { | 351 | if (ch.secretId) { |
| 363 | headerBindings.push({ | 352 | headerBindings.push({ |
| 364 | slot, | 353 | slot, |
| 365 | secretId: ch.secretId, | 354 | secretId: ch.secretId, |
| 366 | scope: ch.targetScope ?? credentialTargetScope, | 355 | scope: sourceScope, |
| 367 | secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, | 356 | secretScope: ch.secretScope ?? sourceScope, |
| 368 | }); | 357 | }); |
| 369 | } | 358 | } |
| 370 | } | 359 | } |
| @@ -373,16 +362,12 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines | |||
| 373 | if (!name) continue; | 362 | if (!name) continue; |
| 374 | if (param.secretId) { | 363 | if (param.secretId) { |
| 375 | const slot = queryParamBindingSlot(name); | 364 | const slot = queryParamBindingSlot(name); |
| 376 | configuredQueryParams[name] = ConfiguredHeaderBinding.make({ | 365 | configuredQueryParams[name] = { kind: "secret", prefix: param.prefix }; |
| 377 | kind: "binding", | ||
| 378 | slot, | ||
| 379 | prefix: param.prefix, | ||
| 380 | }); | ||
| 381 | queryParamBindings.push({ | 366 | queryParamBindings.push({ |
| 382 | slot, | 367 | slot, |
| 383 | secretId: param.secretId, | 368 | secretId: param.secretId, |
| 384 | scope: param.targetScope ?? credentialTargetScope, | 369 | scope: sourceScope, |
| 385 | secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, | 370 | secretScope: param.secretScope ?? sourceScope, |
| 386 | }); | 371 | }); |
| 387 | continue; | 372 | continue; |
| 388 | } | 373 | } |
| @@ -390,6 +375,52 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | |||
| 390 | configuredQueryParams[name] = param.literalValue.trim(); | 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 | const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; | 425 | const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; |
| 395 | const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH); | 426 | const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH); |
| @@ -447,8 +478,14 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines | |||
| 447 | const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( | 478 | const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( |
| 448 | (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), | 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 | const willAddWithoutInitialCredentials = | 486 | const willAddWithoutInitialCredentials = |
| 451 | Boolean(selectedOAuth2Preset && !oauth2Auth) || | 487 | Boolean(selectedOAuth2Preset && !oauth2Auth) || |
| 488 | hasIncompleteSpecFetchCredentials || | ||
| 452 | hasIncompleteHeaderCredentials || | 489 | hasIncompleteHeaderCredentials || |
| 453 | hasIncompleteQueryCredentials; | 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 | const toggleOAuth2Scope = (scope: string) => { | 579 | const toggleOAuth2Scope = (scope: string) => { |
| 561 | setOauth2SelectedScopes((prev) => { | 580 | setOauth2SelectedScopes((prev) => { |
| 562 | const copy = new Set(prev); | 581 | const copy = new Set(prev); |
| @@ -709,13 +728,13 @@ export default function AddOpenApiSource(props: { · 146 unmodified lines | |||
| 709 | const exit = await doAdd({ | 728 | const exit = await doAdd({ |
| 710 | params: { scopeId }, | 729 | params: { scopeId }, |
| 711 | payload: { | 730 | payload: { |
| 712 | targetScope: scopeId, | 731 | spec: specInputForAdd(specUrl), |
| 713 | credentialTargetScope, | ||
| 714 | spec: specUrl, | ||
| 715 | specFetchCredentials: serializeHttpCredentials(specFetchCredentials), | ||
| 716 | name: displayName, | 732 | name: displayName, |
| 717 | namespace, | 733 | namespace, |
| 718 | baseUrl: resolvedBaseUrl || undefined, | 734 | baseUrl: resolvedBaseUrl, |
| 735 | ...(configuredSpecFetchCredentials | ||
| 736 | ? { specFetchCredentials: configuredSpecFetchCredentials } | ||
| 737 | : {}), | ||
| 719 | ...(hasHeaders ? { headers: configuredHeaders } : {}), | 738 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 720 | ...(Object.keys(configuredQueryParams).length > 0 | 739 | ...(Object.keys(configuredQueryParams).length > 0 |
| 721 | ? { queryParams: configuredQueryParams } | 740 | ? { queryParams: configuredQueryParams } |
| @@ -731,11 +750,9 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines | |||
| 731 | } | 750 | } |
| 732 | 751 | ||
| 733 | const sourceId = exit.value.namespace; | 752 | const sourceId = exit.value.namespace; |
| 734 | const sourceScope = ScopeId.make(scopeId); | ||
| 735 | const bindingScope = ScopeId.make(credentialTargetScope); | ||
| 736 | const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); | 753 | const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); |
| 737 | const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope; | 754 | const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; |
| 738 | const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope; | 755 | const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; |
| 739 | 756 | ||
| 740 | for (const binding of headerBindings) { | 757 | for (const binding of headerBindings) { |
| 741 | const bindingExit = await doSetBinding({ | 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 | if (configuredOAuth2 && oauth2ClientIdSecretId) { | 826 | if (configuredOAuth2 && oauth2ClientIdSecretId) { |
| 787 | const bindingExit = await doSetBinding({ | 827 | const bindingExit = await doSetBinding({ |
| 788 | params: { scopeId }, | 828 | params: { scopeId }, |
| 789 | payload: OpenApiSourceBindingInput.make({ | 829 | payload: OpenApiSourceBindingInput.make({ |
| 790 | sourceId, | 830 | sourceId, |
| 791 | sourceScope, | 831 | sourceScope, |
| 792 | scope: bindingScope, | 832 | scope: sourceScope, |
| 793 | slot: configuredOAuth2.clientIdSlot, | 833 | slot: configuredOAuth2.clientIdSlot, |
| 794 | value: { | 834 | value: { |
| 795 | kind: "secret", | 835 | kind: "secret", |
| @@ -812,7 +852,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines | |||
| 812 | payload: OpenApiSourceBindingInput.make({ | 852 | payload: OpenApiSourceBindingInput.make({ |
| 813 | sourceId, | 853 | sourceId, |
| 814 | sourceScope, | 854 | sourceScope, |
| 815 | scope: bindingScope, | 855 | scope: sourceScope, |
| 816 | slot: configuredOAuth2.clientSecretSlot, | 856 | slot: configuredOAuth2.clientSecretSlot, |
| 817 | value: { | 857 | value: { |
| 818 | kind: "secret", | 858 | kind: "secret", |
| @@ -908,7 +948,10 @@ export default function AddOpenApiSource(props: { · 89 unmodified lines | |||
| 908 | onChange={setSpecFetchCredentials} | 948 | onChange={setSpecFetchCredentials} |
| 909 | existingSecrets={secretList} | 949 | existingSecrets={secretList} |
| 910 | sourceName={identity.name} | 950 | sourceName={identity.name} |
| 911 | targetScope={credentialTargetScope} | 951 | targetScope={sourceScope} |
| 952 | credentialScopeOptions={sourceCredentialScopeOptions} | ||
| 953 | bindingScopeOptions={sourceCredentialScopeOptions} | ||
| 954 | restrictSecretsToTargetScope | ||
| 912 | labels={{ | 955 | labels={{ |
| 913 | headers: "Spec fetch headers", | 956 | headers: "Spec fetch headers", |
| 914 | queryParams: "Spec fetch query parameters", | 957 | queryParams: "Spec fetch query parameters", |
| @@ -1044,9 +1087,9 @@ export default function AddOpenApiSource(props: { · 129 unmodified lines | |||
| 1044 | onHeadersChange={handleHeadersChange} | 1087 | onHeadersChange={handleHeadersChange} |
| 1045 | existingSecrets={secretList} | 1088 | existingSecrets={secretList} |
| 1046 | sourceName={identity.name} | 1089 | sourceName={identity.name} |
| 1047 | targetScope={credentialTargetScope} | 1090 | targetScope={sourceScope} |
| 1048 | credentialScopeOptions={initialCredentialScopeOptions} | 1091 | credentialScopeOptions={sourceCredentialScopeOptions} |
| 1049 | bindingScopeOptions={bindingScopeOptions} | 1092 | bindingScopeOptions={sourceCredentialScopeOptions} |
| 1050 | restrictSecretsToTargetScope | 1093 | restrictSecretsToTargetScope |
| 1051 | emptyLabel="No credentials yet. Add the header value this method should use." | 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 | onChange={setRuntimeCredentials} | 1101 | onChange={setRuntimeCredentials} |
| 1059 | existingSecrets={secretList} | 1102 | existingSecrets={secretList} |
| 1060 | sourceName={identity.name} | 1103 | sourceName={identity.name} |
| 1061 | targetScope={credentialTargetScope} | 1104 | targetScope={sourceScope} |
| 1062 | credentialScopeOptions={initialCredentialScopeOptions} | 1105 | credentialScopeOptions={sourceCredentialScopeOptions} |
| 1063 | bindingScopeOptions={bindingScopeOptions} | 1106 | bindingScopeOptions={sourceCredentialScopeOptions} |
| 1064 | restrictSecretsToTargetScope | 1107 | restrictSecretsToTargetScope |
| 1065 | sections={{ headers: false, queryParams: true }} | 1108 | sections={{ headers: false, queryParams: true }} |
| 1066 | labels={{ queryParams: "Runtime query parameters" }} | 1109 | labels={{ queryParams: "Runtime query parameters" }} |
| @@ -1096,22 +1139,17 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines | |||
| 1096 | value={oauth2ClientIdSecretId} | 1139 | value={oauth2ClientIdSecretId} |
| 1097 | onSelect={(id: string, secretScopeId?: ScopeId) => { | 1140 | onSelect={(id: string, secretScopeId?: ScopeId) => { |
| 1098 | setOauth2ClientIdSecretId(id); | 1141 | setOauth2ClientIdSecretId(id); |
| 1099 | setOauth2ClientIdScope(secretScopeId ?? credentialTargetScope); | 1142 | setOauth2ClientIdScope(secretScopeId ?? sourceScope); |
| 1100 | setOauth2AuthState(null); | 1143 | setOauth2AuthState(null); |
| 1101 | }} | 1144 | }} |
| 1102 | secrets={initialCredentialSecrets} | 1145 | secrets={sourceCredentialSecrets} |
| 1103 | sourceName={identity.name} | 1146 | sourceName={identity.name} |
| 1104 | secretLabel="Client ID" | 1147 | secretLabel="Client ID" |
| 1105 | targetScope={oauth2ClientIdScope ?? credentialTargetScope} | 1148 | targetScope={oauth2ClientIdScope ?? sourceScope} |
| 1106 | credentialScopeOptions={initialCredentialScopeOptions} | 1149 | credentialScopeOptions={sourceCredentialScopeOptions} |
| 1107 | onCreatedScope={setOauth2ClientIdScope} | 1150 | onCreatedScope={setOauth2ClientIdScope} |
| 1108 | /> | 1151 | /> |
| 1109 | </div> | 1152 | </div> |
| 1110 | <CredentialScopeDropdown | ||
| 1111 | value={credentialTargetScope} | ||
| 1112 | options={bindingScopeOptions} | ||
| 1113 | onChange={setInitialCredentialScope} | ||
| 1114 | /> | ||
| 1115 | </div> | 1153 | </div> |
| 1116 | </div> | 1154 | </div> |
| 1117 | <div className="space-y-1.5"> | 1155 | <div className="space-y-1.5"> |
| @@ -1133,22 +1171,17 @@ export default function AddOpenApiSource(props: { · 15 unmodified lines | |||
| 1133 | value={oauth2ClientSecretSecretId} | 1171 | value={oauth2ClientSecretSecretId} |
| 1134 | onSelect={(id: string, secretScopeId?: ScopeId) => { | 1172 | onSelect={(id: string, secretScopeId?: ScopeId) => { |
| 1135 | setOauth2ClientSecretSecretId(id); | 1173 | setOauth2ClientSecretSecretId(id); |
| 1136 | setOauth2ClientSecretScope(secretScopeId ?? credentialTargetScope); | 1174 | setOauth2ClientSecretScope(secretScopeId ?? sourceScope); |
| 1137 | setOauth2AuthState(null); | 1175 | setOauth2AuthState(null); |
| 1138 | }} | 1176 | }} |
| 1139 | secrets={initialCredentialSecrets} | 1177 | secrets={sourceCredentialSecrets} |
| 1140 | sourceName={identity.name} | 1178 | sourceName={identity.name} |
| 1141 | secretLabel="Client Secret" | 1179 | secretLabel="Client Secret" |
| 1142 | targetScope={oauth2ClientSecretScope ?? credentialTargetScope} | 1180 | targetScope={oauth2ClientSecretScope ?? sourceScope} |
| 1143 | credentialScopeOptions={initialCredentialScopeOptions} | 1181 | credentialScopeOptions={sourceCredentialScopeOptions} |
| 1144 | onCreatedScope={setOauth2ClientSecretScope} | 1182 | onCreatedScope={setOauth2ClientSecretScope} |
| 1145 | /> | 1183 | /> |
| 1146 | </div> | 1184 | </div> |
| 1147 | <CredentialScopeDropdown | ||
| 1148 | value={credentialTargetScope} | ||
| 1149 | options={bindingScopeOptions} | ||
| 1150 | onChange={setInitialCredentialScope} | ||
| 1151 | /> | ||
| 1152 | </div> | 1185 | </div> |
| 1153 | </div> | 1186 | </div> |
| 1154 | <div className="space-y-1.5"> | 1187 | <div className="space-y-1.5"> |
| @@ -43,7 +43,7 @@ export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => · 42 unmodified lines | |||
| 43 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | 43 | const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; |
| 44 | const source = { | 44 | const source = { |
| 45 | id, | 45 | id, |
| 46 | scopeId: arg.payload.targetScope, | 46 | scopeId, |
| 47 | kind: "openapi", | 47 | kind: "openapi", |
| 48 | pluginId: "openapi", | 48 | pluginId: "openapi", |
| 49 | name: arg.payload.name ?? id, | 49 | name: arg.payload.name ?? id, |
| @@ -110,7 +110,10 @@ const testApiSourceConfig = (options: TestApiSourceOptions = {}) => · 109 unmodified lines | |||
| 110 | ...options, | 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 | // Implement handlers | 119 | // Implement handlers |
| @@ -172,6 +175,9 @@ const serveSpecRequiringHeader = () => { · 55 unmodified lines | |||
| 172 | Effect.sync(() => { | 175 | Effect.sync(() => { |
| 173 | state.requests++; | 176 | state.requests++; |
| 174 | state.lastToken = request.headers["x-spec-token"] ?? null; | 177 | state.lastToken = request.headers["x-spec-token"] ?? null; |
| 178 | if (state.requests === 1) { | ||
| 179 | return null; | ||
| 180 | } | ||
| 175 | if (state.lastToken !== "org-token") { | 181 | if (state.lastToken !== "org-token") { |
| 176 | return HttpServerResponse.jsonUnsafe({ error: "missing token" }, { status: 401 }); | 182 | return HttpServerResponse.jsonUnsafe({ error: "missing token" }, { status: 401 }); |
| 177 | } | 183 | } |
| @@ -292,10 +298,10 @@ describe("OpenAPI Plugin", () => { · 114 unmodified lines | |||
| 292 | 298 | ||
| 293 | expect(schema).not.toBeNull(); | 299 | expect(schema).not.toBeNull(); |
| 294 | expect(schema!.inputTypeScript).toContain("scope: string"); | 300 | expect(schema!.inputTypeScript).toContain("scope: string"); |
| 295 | expect(schema!.inputTypeScript).toContain("spec: string"); | 301 | expect(schema!.inputTypeScript).toContain('kind: "url"'); |
| 296 | expect( | 302 | expect( |
| 297 | (schema!.inputSchema as { properties?: Record<string, unknown> }).properties, | 303 | (schema!.inputSchema as { properties?: Record<string, unknown> }).properties, |
| 298 | ).toHaveProperty("credentialTargetScope"); | 304 | ).not.toHaveProperty("credentialTargetScope"); |
| 299 | expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>"); | 305 | expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>"); |
| 300 | }), | 306 | }), |
| 301 | ); | 307 | ); |
| @@ -395,8 +401,7 @@ describe("OpenAPI Plugin", () => { · 93 unmodified lines | |||
| 395 | const input = testApiSourceConfig({ | 401 | const input = testApiSourceConfig({ |
| 396 | scope: String(orgScope), | 402 | scope: String(orgScope), |
| 397 | namespace: "org_direct_user_credential", | 403 | namespace: "org_direct_user_credential", |
| 398 | queryParams: { token: { secretId: "user-query-token" } }, | 404 | queryParams: { token: { kind: "secret" } }, |
| 399 | credentialTargetScope: String(userScope), | ||
| 400 | }); | 405 | }); |
| 401 | 406 | ||
| 402 | yield* executor.openapi.addSpec(input); | 407 | yield* executor.openapi.addSpec(input); |
| @@ -405,15 +410,7 @@ describe("OpenAPI Plugin", () => { · 2 unmodified lines | |||
| 405 | "org_direct_user_credential", | 410 | "org_direct_user_credential", |
| 406 | String(orgScope), | 411 | String(orgScope), |
| 407 | ); | 412 | ); |
| 408 | expect(bindings).toHaveLength(1); | 413 | expect(bindings).toEqual([]); |
| 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 | }); | ||
| 417 | }), | 414 | }), |
| 418 | ); | 415 | ); |
| 419 | 416 | ||
| @@ -443,12 +440,20 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines | |||
| 443 | testApiSourceConfig({ | 440 | testApiSourceConfig({ |
| 444 | namespace: "stale_binding", | 441 | namespace: "stale_binding", |
| 445 | baseUrl: "", | 442 | baseUrl: "", |
| 446 | credentialTargetScope: TEST_SCOPE, | ||
| 447 | headers: { | 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 | yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { | 458 | yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { |
| 454 | headers: {}, | 459 | headers: {}, |
| @@ -555,12 +560,20 @@ describe("OpenAPI Plugin", () => { · 100 unmodified lines | |||
| 555 | yield* addOpenApiTestSource(executor, server, { | 560 | yield* addOpenApiTestSource(executor, server, { |
| 556 | scope: TEST_SCOPE, | 561 | scope: TEST_SCOPE, |
| 557 | namespace: "authed", | 562 | namespace: "authed", |
| 558 | credentialTargetScope: TEST_SCOPE, | ||
| 559 | headers: { | 563 | headers: { |
| 560 | Authorization: { secretId: "test-api-token", prefix: "Bearer " }, | 564 | Authorization: { kind: "secret", prefix: "Bearer " }, |
| 561 | "X-Static": "hello", | 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 | const result = unwrapInvocation( | 578 | const result = unwrapInvocation( |
| 566 | yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), | 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", () => | 590 | it.effect("addSpec declares secret-backed header shape without a credential value", () => |
| 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. | ||
| 583 | Effect.gen(function* () { | 591 | Effect.gen(function* () { |
| 584 | const clientLayer = FetchHttpClient.layer; | 592 | const clientLayer = FetchHttpClient.layer; |
| 585 | 593 | ||
| @@ -607,7 +615,7 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines | |||
| 607 | baseUrl: "", | 615 | baseUrl: "", |
| 608 | headers: { | 616 | headers: { |
| 609 | Authorization: { | 617 | Authorization: { |
| 610 | secretId: "config-sync-token", | 618 | kind: "secret", |
| 611 | prefix: "Bearer ", | 619 | prefix: "Bearer ", |
| 612 | }, | 620 | }, |
| 613 | }, | 621 | }, |
| @@ -618,15 +626,7 @@ describe("OpenAPI Plugin", () => { · 4 unmodified lines | |||
| 618 | "default_target_scope", | 626 | "default_target_scope", |
| 619 | TEST_SCOPE, | 627 | TEST_SCOPE, |
| 620 | ); | 628 | ); |
| 621 | expect(bindings).toHaveLength(1); | 629 | expect(bindings).toEqual([]); |
| 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 | }); | ||
| 630 | }), | 630 | }), |
| 631 | ); | 631 | ); |
| 632 | 632 | ||
| @@ -975,6 +975,7 @@ describe("OpenAPI Plugin", () => { · 342 unmodified lines | |||
| 975 | testApiSourceConfig({ | 975 | testApiSourceConfig({ |
| 976 | scope: String(USER_SCOPE), | 976 | scope: String(USER_SCOPE), |
| 977 | namespace: "shared", | 977 | namespace: "shared", |
| 978 | baseUrl: null, | ||
| 978 | name: "User Source", | 979 | name: "User Source", |
| 979 | }), | 980 | }), |
| 980 | ); | 981 | ); |
| @@ -1017,6 +1018,7 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines | |||
| 1017 | testApiSourceConfig({ | 1018 | testApiSourceConfig({ |
| 1018 | scope: String(USER_SCOPE), | 1019 | scope: String(USER_SCOPE), |
| 1019 | namespace: "shared", | 1020 | namespace: "shared", |
| 1021 | baseUrl: null, | ||
| 1020 | name: "User Source", | 1022 | name: "User Source", |
| 1021 | }), | 1023 | }), |
| 1022 | ); | 1024 | ); |
| @@ -1057,7 +1059,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | |||
| 1057 | testApiSourceConfig({ | 1059 | testApiSourceConfig({ |
| 1058 | scope: String(USER_SCOPE), | 1060 | scope: String(USER_SCOPE), |
| 1059 | namespace: "shared", | 1061 | namespace: "shared", |
| 1060 | baseUrl: "", | 1062 | baseUrl: null, |
| 1061 | name: "User Source", | 1063 | name: "User Source", |
| 1062 | }), | 1064 | }), |
| 1063 | ); | 1065 | ); |
| @@ -1098,6 +1100,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | |||
| 1098 | testApiSourceConfig({ | 1100 | testApiSourceConfig({ |
| 1099 | scope: String(USER_SCOPE), | 1101 | scope: String(USER_SCOPE), |
| 1100 | namespace: "shared", | 1102 | namespace: "shared", |
| 1103 | baseUrl: null, | ||
| 1101 | name: "User Source", | 1104 | name: "User Source", |
| 1102 | }), | 1105 | }), |
| 1103 | ); | 1106 | ); |
| @@ -1149,19 +1152,19 @@ describe("OpenAPI Plugin", () => { · 45 unmodified lines | |||
| 1149 | ); | 1152 | ); |
| 1150 | 1153 | ||
| 1151 | yield* executor.openapi.addSpec({ | 1154 | yield* executor.openapi.addSpec({ |
| 1152 | spec: server.specJson, | 1155 | spec: { kind: "blob", value: server.specJson }, |
| 1153 | scope: String(ORG_SCOPE), | 1156 | scope: String(ORG_SCOPE), |
| 1157 | name: "Shadow Auth", | ||
| 1154 | namespace: "shadow_auth", | 1158 | namespace: "shadow_auth", |
| 1155 | baseUrl: "https://org.example.com", | 1159 | baseUrl: "https://org.example.com", |
| 1156 | credentialTargetScope: String(ORG_SCOPE), | ||
| 1157 | headers: { | 1160 | headers: { |
| 1158 | Authorization: { secretId: "org-api-token", prefix: "Bearer " }, | 1161 | Authorization: { kind: "secret", prefix: "Bearer " }, |
| 1159 | }, | 1162 | }, |
| 1160 | }); | 1163 | }); |
| 1161 | 1164 | ||
| 1162 | const addResult = yield* executor.openapi | 1165 | const addResult = yield* executor.openapi |
| 1163 | .addSpec({ | 1166 | .addSpec({ |
| 1164 | spec: server.specJson, | 1167 | spec: { kind: "blob", value: server.specJson }, |
| 1165 | scope: String(USER_SCOPE), | 1168 | scope: String(USER_SCOPE), |
| 1166 | namespace: "shadow_auth", | 1169 | namespace: "shadow_auth", |
| 1167 | baseUrl: server.baseUrl, | 1170 | baseUrl: server.baseUrl, |
| @@ -1202,20 +1205,31 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines | |||
| 1202 | ); | 1205 | ); |
| 1203 | 1206 | ||
| 1204 | yield* executor.openapi.addSpec({ | 1207 | yield* executor.openapi.addSpec({ |
| 1205 | spec: server.specUrl, | 1208 | spec: { kind: "url", url: server.specUrl }, |
| 1206 | scope: String(ORG_SCOPE), | 1209 | scope: String(ORG_SCOPE), |
| 1210 | name: "Shared Spec Fetch", | ||
| 1207 | namespace: "shared_spec_fetch", | 1211 | namespace: "shared_spec_fetch", |
| 1208 | credentialTargetScope: String(ORG_SCOPE), | 1212 | baseUrl: "https://api.example.test", |
| 1209 | specFetchCredentials: { | 1213 | specFetchCredentials: { |
| 1210 | headers: { | 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 | yield* executor.openapi.addSpec( | 1228 | yield* executor.openapi.addSpec( |
| 1216 | testApiSourceConfig({ | 1229 | testApiSourceConfig({ |
| 1217 | scope: String(USER_SCOPE), | 1230 | scope: String(USER_SCOPE), |
| 1218 | namespace: "shared_spec_fetch", | 1231 | namespace: "shared_spec_fetch", |
| 1232 | baseUrl: null, | ||
| 1219 | name: "User Shadow", | 1233 | name: "User Shadow", |
| 1220 | }), | 1234 | }), |
| 1221 | ); | 1235 | ); |
| @@ -1390,8 +1404,16 @@ describe("OpenAPI Plugin", () => { · 168 unmodified lines | |||
| 1390 | testApiSourceConfig({ | 1404 | testApiSourceConfig({ |
| 1391 | namespace: "with_secret", | 1405 | namespace: "with_secret", |
| 1392 | baseUrl: "http://example.com", | 1406 | baseUrl: "http://example.com", |
| 1393 | credentialTargetScope: TEST_SCOPE, | 1407 | queryParams: { token: { kind: "secret" } }, |
| 1394 | queryParams: { token: { secretId: "api-key" } }, | 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 | import { | 5 | import { |
| 6 | ScopeId, | 6 | ScopeId, |
| 7 | SecretId, | ||
| 8 | SourceDetectionResult, | 7 | SourceDetectionResult, |
| 9 | StorageError, | 8 | StorageError, |
| 10 | ToolResult, | 9 | ToolResult, |
| @@ -29,7 +28,6 @@ import { parse, resolveSpecText } from "./parse"; · 18 unmodified lines | |||
| 29 | import { extract } from "./extract"; | 28 | import { extract } from "./extract"; |
| 30 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; | 29 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| 31 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; | 30 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; |
| 32 | import { resolveBaseUrl } from "./openapi-utils"; | ||
| 33 | import { previewSpec, SpecPreview } from "./preview"; | 31 | import { previewSpec, SpecPreview } from "./preview"; |
| 34 | import { | 32 | import { |
| 35 | makeDefaultOpenapiStore, | 33 | makeDefaultOpenapiStore, |
| @@ -41,13 +39,10 @@ import { · 5 unmodified lines | |||
| 41 | } from "./store"; | 39 | } from "./store"; |
| 42 | import { | 40 | import { |
| 43 | HeaderValue as HeaderValueSchema, | 41 | HeaderValue as HeaderValueSchema, |
| 44 | ConfiguredHeaderValue as ConfiguredHeaderValueSchema, | ||
| 45 | ConfiguredHeaderBinding, | 42 | ConfiguredHeaderBinding, |
| 46 | OAuth2SourceConfig, | 43 | OAuth2SourceConfig, |
| 47 | OpenApiCredentialInput as OpenApiCredentialInputSchema, | ||
| 48 | OpenApiSourceBindingInput, | 44 | OpenApiSourceBindingInput, |
| 49 | OpenApiSourceBindingRef, | 45 | OpenApiSourceBindingRef, |
| 50 | type OpenApiCredentialInput as OpenApiCredentialInputValue, | ||
| 51 | type OpenApiSourceBindingValue, | 46 | type OpenApiSourceBindingValue, |
| 52 | OperationBinding, | 47 | OperationBinding, |
| 53 | type ConfiguredHeaderValue as ConfiguredHeaderValueValue, | 48 | type ConfiguredHeaderValue as ConfiguredHeaderValueValue, |
| @@ -131,22 +126,34 @@ const extractUpstreamMessage = (body: unknown, status: number): string => { · 77 unmodified lines | |||
| 131 | 126 | ||
| 132 | export type HeaderValue = HeaderValueValue; | 127 | export type HeaderValue = HeaderValueValue; |
| 133 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; | 128 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 134 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; | ||
| 135 | export type OpenApiCredentialInput = OpenApiCredentialInputValue; | ||
| 136 | export type OpenApiOAuthInput = OAuth2SourceConfig; | 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 | export interface OpenApiSpecFetchCredentialsInput { | 140 | export interface OpenApiSpecFetchCredentialsInput { |
| 141 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; | ||
| 142 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; | ||
| 143 | } | ||
| 144 | |||
| 145 | export interface OpenApiPreviewSpecFetchCredentialsInput { | ||
| 139 | readonly headers?: Record<string, HeaderValue>; | 146 | readonly headers?: Record<string, HeaderValue>; |
| 140 | readonly queryParams?: Record<string, HeaderValue>; | 147 | readonly queryParams?: Record<string, HeaderValue>; |
| 141 | } | 148 | } |
| 142 | 149 | ||
| 143 | export interface OpenApiPreviewInput { | 150 | export interface OpenApiPreviewInput { |
| 144 | readonly spec: string; | 151 | readonly spec: string; |
| 145 | readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; | 152 | readonly specFetchCredentials?: OpenApiPreviewSpecFetchCredentialsInput; |
| 146 | } | 153 | } |
| 147 | 154 | ||
| 148 | export interface OpenApiSpecConfig { | 155 | export interface OpenApiSpecConfig { |
| 149 | readonly spec: string; | 156 | readonly spec: OpenApiSpecInput; |
| 150 | readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; | 157 | readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; |
| 151 | /** | 158 | /** |
| 152 | * Executor scope id that owns this source row. Must be one of the | 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 | * every inner (per-user) scope via fall-through reads. | 162 | * every inner (per-user) scope via fall-through reads. |
| 156 | */ | 163 | */ |
| 157 | readonly scope: string; | 164 | readonly scope: string; |
| 158 | readonly name?: string; | 165 | readonly name: string; |
| 159 | readonly baseUrl?: string; | 166 | readonly baseUrl: string; |
| 160 | readonly namespace?: string; | 167 | readonly namespace: string; |
| 161 | readonly headers?: Record<string, OpenApiCredentialInput>; | 168 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; |
| 162 | readonly queryParams?: Record<string, OpenApiCredentialInput>; | 169 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; |
| 163 | readonly oauth2?: OpenApiOAuthInput; | 170 | readonly oauth2?: OpenApiOAuthInput; |
| 164 | readonly credentialTargetScope?: string; | ||
| 165 | } | 171 | } |
| 166 | 172 | ||
| 167 | export interface OpenApiUpdateSourceInput { | 173 | export interface OpenApiUpdateSourceInput { |
| 168 | readonly name?: string; | 174 | readonly name?: string; |
| 169 | readonly baseUrl?: string; | 175 | readonly baseUrl?: string; |
| 170 | readonly headers?: Record<string, OpenApiCredentialInput>; | 176 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; |
| 171 | readonly queryParams?: Record<string, OpenApiCredentialInput>; | 177 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; |
| 172 | readonly credentialTargetScope?: string; | ||
| 173 | /** Refresh the source's stored OAuth2 metadata after a successful | 178 | /** Refresh the source's stored OAuth2 metadata after a successful |
| 174 | * re-authenticate. */ | 179 | * re-authenticate. */ |
| 175 | readonly oauth2?: OpenApiOAuthInput; | 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 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; | 263 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; |
| 248 | 264 | ||
| 249 | const AddSourceInputSchema = Schema.Struct({ | 265 | const AddSourceInputSchema = Schema.Struct({ |
| 250 | scope: Schema.String, | 266 | scope: Schema.String, |
| 251 | spec: Schema.String, | 267 | spec: OpenApiSpecInputSchema, |
| 252 | name: Schema.optional(Schema.String), | 268 | name: Schema.String, |
| 253 | baseUrl: Schema.optional(Schema.String), | 269 | baseUrl: Schema.String, |
| 254 | namespace: Schema.optional(Schema.String), | 270 | namespace: Schema.String, |
| 255 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiHeaderInputSchema)), | 271 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), |
| 256 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputSchema)), | 272 | queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), |
| 257 | oauth2: Schema.optional(OpenApiOAuthInputSchema), | 273 | oauth2: Schema.optional(OpenApiOAuthInputSchema), |
| 258 | credentialTargetScope: Schema.optional(Schema.String), | ||
| 259 | specFetchCredentials: Schema.optional( | 274 | specFetchCredentials: Schema.optional( |
| 260 | Schema.Struct({ | 275 | Schema.Struct({ |
| 261 | headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), | 276 | headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), |
| 262 | queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), | 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 | `spec_fetch_query_param:${slotPart(name)}`; | 361 | `spec_fetch_query_param:${slotPart(name)}`; |
| 347 | 362 | ||
| 348 | const canonicalizeHeaders = ( | 363 | const canonicalizeHeaders = ( |
| 349 | headers: Record<string, OpenApiCredentialInput> | undefined, | 364 | headers: Record<string, OpenApiConfiguredValueInput> | undefined, |
| 350 | ): { | 365 | ): { |
| 351 | readonly headers: Record<string, ConfiguredHeaderValue>; | 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 | const nextHeaders: Record<string, ConfiguredHeaderValue> = {}; | 368 | const nextHeaders: Record<string, ConfiguredHeaderValue> = {}; |
| 359 | const bindings: Array<{ | ||
| 360 | slot: string; | ||
| 361 | value: OpenApiSourceBindingValue; | ||
| 362 | targetScope?: string; | ||
| 363 | }> = []; | ||
| 364 | for (const [name, value] of Object.entries(headers ?? {})) { | 369 | for (const [name, value] of Object.entries(headers ?? {})) { |
| 365 | if (typeof value === "string") { | 370 | if (typeof value === "string") { |
| 366 | nextHeaders[name] = value; | 371 | nextHeaders[name] = value; |
| 367 | continue; | 372 | continue; |
| 368 | } | 373 | } |
| 369 | if ("kind" in value) { | 374 | if (value.kind === "binding") { |
| 370 | nextHeaders[name] = value; | 375 | nextHeaders[name] = value; |
| 371 | continue; | 376 | continue; |
| 372 | } | 377 | } |
| @@ -376,44 +381,23 @@ const canonicalizeHeaders = ( · 3 unmodified lines | |||
| 376 | slot, | 381 | slot, |
| 377 | prefix: value.prefix, | 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 | const canonicalizeCredentialMap = ( | 388 | const canonicalizeCredentialMap = ( |
| 395 | values: Record<string, OpenApiCredentialInput> | undefined, | 389 | values: Record<string, OpenApiConfiguredValueInput> | undefined, |
| 396 | slotForName: (name: string) => string, | 390 | slotForName: (name: string) => string, |
| 397 | ): { | 391 | ): { |
| 398 | readonly values: Record<string, ConfiguredHeaderValue>; | 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 | const nextValues: Record<string, ConfiguredHeaderValue> = {}; | 394 | const nextValues: Record<string, ConfiguredHeaderValue> = {}; |
| 406 | const bindings: Array<{ | ||
| 407 | slot: string; | ||
| 408 | value: OpenApiSourceBindingValue; | ||
| 409 | targetScope?: string; | ||
| 410 | }> = []; | ||
| 411 | for (const [name, value] of Object.entries(values ?? {})) { | 395 | for (const [name, value] of Object.entries(values ?? {})) { |
| 412 | if (typeof value === "string") { | 396 | if (typeof value === "string") { |
| 413 | nextValues[name] = value; | 397 | nextValues[name] = value; |
| 414 | continue; | 398 | continue; |
| 415 | } | 399 | } |
| 416 | if ("kind" in value) { | 400 | if (value.kind === "binding") { |
| 417 | nextValues[name] = value; | 401 | nextValues[name] = value; |
| 418 | continue; | 402 | continue; |
| 419 | } | 403 | } |
| @@ -423,35 +407,19 @@ const canonicalizeCredentialMap = ( · 3 unmodified lines | |||
| 423 | slot, | 407 | slot, |
| 424 | prefix: value.prefix, | 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 | const canonicalizeSpecFetchCredentials = ( | 414 | const canonicalizeSpecFetchCredentials = ( |
| 442 | credentials: | 415 | credentials: |
| 443 | | { | 416 | | { |
| 444 | readonly headers?: Record<string, OpenApiCredentialInput>; | 417 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; |
| 445 | readonly queryParams?: Record<string, OpenApiCredentialInput>; | 418 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; |
| 446 | } | 419 | } |
| 447 | | undefined, | 420 | | undefined, |
| 448 | ): { | 421 | ): { |
| 449 | readonly credentials?: SourceConfig["specFetchCredentials"]; | 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 | const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); | 424 | const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); |
| 457 | const queryParams = canonicalizeCredentialMap( | 425 | const queryParams = canonicalizeCredentialMap( |
| @@ -469,7 +437,6 @@ const canonicalizeSpecFetchCredentials = ( · 11 unmodified lines | |||
| 469 | }; | 437 | }; |
| 470 | return { | 438 | return { |
| 471 | credentials: nextCredentials, | 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 | const findOuterSource = ( | 569 | const findOuterSource = ( |
| 616 | ctx: PluginCtx<OpenapiStore>, | 570 | ctx: PluginCtx<OpenapiStore>, |
| 617 | namespace: string, | 571 | namespace: string, |
| @@ -790,7 +744,7 @@ const resolveOAuthConnectionId = ( · 172 unmodified lines | |||
| 790 | 744 | ||
| 791 | const resolveSpecFetchInputCredentials = ( | 745 | const resolveSpecFetchInputCredentials = ( |
| 792 | ctx: PluginCtx<OpenapiStore>, | 746 | ctx: PluginCtx<OpenapiStore>, |
| 793 | credentials: OpenApiSpecFetchCredentialsInput | undefined, | 747 | credentials: OpenApiPreviewSpecFetchCredentialsInput | undefined, |
| 794 | ) => | 748 | ) => |
| 795 | Effect.gen(function* () { | 749 | Effect.gen(function* () { |
| 796 | if (!credentials) return undefined; | 750 | if (!credentials) return undefined; |
| @@ -850,13 +804,13 @@ const toOpenApiSourceConfig = ( · 53 unmodified lines | |||
| 850 | ): OpenApiSourceConfig => { | 804 | ): OpenApiSourceConfig => { |
| 851 | const configHeaders: Record<string, HeaderValueValue> = {}; | 805 | const configHeaders: Record<string, HeaderValueValue> = {}; |
| 852 | for (const [name, value] of Object.entries(config.headers ?? {})) { | 806 | for (const [name, value] of Object.entries(config.headers ?? {})) { |
| 853 | if (typeof value === "string" || !("kind" in value)) { | 807 | if (typeof value === "string") { |
| 854 | configHeaders[name] = value; | 808 | configHeaders[name] = value; |
| 855 | } | 809 | } |
| 856 | } | 810 | } |
| 857 | return { | 811 | return { |
| 858 | kind: "openapi", | 812 | kind: "openapi", |
| 859 | spec: config.spec, | 813 | spec: specInputToConfigString(config.spec), |
| 860 | baseUrl: config.baseUrl, | 814 | baseUrl: config.baseUrl, |
| 861 | namespace, | 815 | namespace, |
| 862 | headers: headersToConfigValues( | 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 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { | 825 | export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { |
| 871 | type RebuildInput = { | 826 | type RebuildInput = { |
| 872 | readonly specText: string; | 827 | readonly specText: string; |
| 873 | readonly scope: string; | 828 | readonly scope: string; |
| 874 | readonly sourceUrl?: string; | 829 | readonly sourceUrl?: string; |
| 875 | readonly name?: string; | 830 | readonly name: string; |
| 876 | readonly baseUrl?: string; | 831 | readonly baseUrl: string | undefined; |
| 877 | readonly namespace?: string; | 832 | readonly namespace: string; |
| 878 | readonly headers?: Record<string, OpenApiHeaderInput>; | 833 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; |
| 879 | readonly queryParams?: Record<string, OpenApiCredentialInput>; | 834 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; |
| 880 | readonly specFetchCredentials?: { | 835 | readonly specFetchCredentials?: { |
| 881 | readonly headers?: Record<string, OpenApiCredentialInput>; | 836 | readonly headers?: Record<string, OpenApiConfiguredValueInput>; |
| 882 | readonly queryParams?: Record<string, OpenApiCredentialInput>; | 837 | readonly queryParams?: Record<string, OpenApiConfiguredValueInput>; |
| 883 | }; | 838 | }; |
| 884 | readonly oauth2?: OpenApiOAuthInput; | 839 | readonly oauth2?: OpenApiOAuthInput; |
| 885 | readonly credentialTargetScope?: string; | ||
| 886 | }; | 840 | }; |
| 887 | 841 | ||
| 888 | // ctx comes from the plugin runtime — the same instance is passed to | 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 | const doc = yield* parse(input.specText); | 847 | const doc = yield* parse(input.specText); |
| 894 | const result = yield* extract(doc); | 848 | const result = yield* extract(doc); |
| 895 | 849 | ||
| 896 | const namespace = | 850 | const namespace = input.namespace; |
| 897 | input.namespace ?? | ||
| 898 | Option.getOrElse(result.title, () => "api") | ||
| 899 | .toLowerCase() | ||
| 900 | .replace(/[^a-z0-9]+/g, "_"); | ||
| 901 | const outerSource = yield* findOuterSource(ctx, namespace, input.scope); | 851 | const outerSource = yield* findOuterSource(ctx, namespace, input.scope); |
| 902 | if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") { | 852 | if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") { |
| 903 | return yield* new OpenApiOAuthError({ | 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 | const canonicalHeaders = canonicalizeHeaders(input.headers); | 866 | const canonicalHeaders = canonicalizeHeaders(input.headers); |
| 917 | const canonicalQueryParams = canonicalizeCredentialMap( | 867 | const canonicalQueryParams = canonicalizeCredentialMap( |
| 918 | input.queryParams, | 868 | input.queryParams, |
| @@ -922,25 +872,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines | |||
| 922 | input.specFetchCredentials, | 872 | input.specFetchCredentials, |
| 923 | ); | 873 | ); |
| 924 | const canonicalOAuth2 = canonicalizeOAuth2(input.oauth2); | 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 | const definitions = compileToolDefinitions(result.operations); | 875 | const definitions = compileToolDefinitions(result.operations); |
| 943 | const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace); | 876 | const sourceName = input.name; |
| 944 | 877 | ||
| 945 | const sourceConfig: SourceConfig = { | 878 | const sourceConfig: SourceConfig = { |
| 946 | spec: input.specText, | 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 | if (Object.keys(hoistedDefs).length > 0) { | 925 | if (Object.keys(hoistedDefs).length > 0) { |
| 1010 | yield* ctx.core.definitions.register({ | 926 | yield* ctx.core.definitions.register({ |
| 1011 | sourceId: namespace, | 927 | sourceId: namespace, |
| @@ -1046,13 +962,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 34 unmodified lines | |||
| 1046 | scope, | 962 | scope, |
| 1047 | sourceUrl, | 963 | sourceUrl, |
| 1048 | name: existing.name, | 964 | name: existing.name, |
| 1049 | baseUrl: resolvedConfig.baseUrl, | 965 | baseUrl: existing.config.baseUrl, |
| 1050 | namespace: existing.namespace, | 966 | namespace: existing.namespace, |
| 1051 | headers: existing.config.headers, | 967 | headers: existing.config.headers, |
| 1052 | queryParams: existing.config.queryParams, | 968 | queryParams: existing.config.queryParams, |
| 1053 | specFetchCredentials: existing.config.specFetchCredentials, | 969 | specFetchCredentials: existing.config.specFetchCredentials, |
| 1054 | oauth2: existing.config.oauth2, | 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 | // Resolve URL → text and parse BEFORE opening a transaction. | 984 | // Resolve URL → text and parse BEFORE opening a transaction. |
| 1070 | // Holding `BEGIN` on the pool=1 Postgres connection across a | 985 | // Holding `BEGIN` on the pool=1 Postgres connection across a |
| 1071 | // network fetch is the Hyperdrive deadlock path in production. | 986 | // network fetch is the Hyperdrive deadlock path in production. |
| 1072 | const credentials = yield* resolveSpecFetchInputCredentials( | 987 | const specText = |
| 1073 | ctx, | 988 | config.spec.kind === "url" |
| 1074 | config.specFetchCredentials, | 989 | ? yield* resolveSpecText(config.spec.url).pipe(Effect.provide(httpClientLayer)) |
| 1075 | ); | 990 | : config.spec.value; |
| 1076 | const specText = yield* resolveSpecText(config.spec, credentials).pipe( | ||
| 1077 | Effect.provide(httpClientLayer), | ||
| 1078 | ); | ||
| 1079 | return yield* rebuildSource(ctx, { | 991 | return yield* rebuildSource(ctx, { |
| 1080 | specText, | 992 | specText, |
| 1081 | scope: config.scope, | 993 | scope: config.scope, |
| 1082 | sourceUrl: isHttpUrl(config.spec) ? config.spec : undefined, | 994 | sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined, |
| 1083 | name: config.name, | 995 | name: config.name, |
| 1084 | baseUrl: config.baseUrl, | 996 | baseUrl: config.baseUrl, |
| 1085 | namespace: config.namespace, | 997 | namespace: config.namespace, |
| @@ -1087,11 +999,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line | |||
| 1087 | queryParams: config.queryParams, | 999 | queryParams: config.queryParams, |
| 1088 | specFetchCredentials: config.specFetchCredentials, | 1000 | specFetchCredentials: config.specFetchCredentials, |
| 1089 | oauth2: config.oauth2, | 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 | input.queryParams !== undefined | 1071 | input.queryParams !== undefined |
| 1165 | ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName) | 1072 | ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName) |
| 1166 | : null; | 1073 | : null; |
| 1167 | const directBindings = [ | ||
| 1168 | ...(canonicalHeaders?.bindings ?? []), | ||
| 1169 | ...(canonicalQueryParams?.bindings ?? []), | ||
| 1170 | ...(canonicalOAuth2?.bindings ?? []), | ||
| 1171 | ]; | ||
| 1172 | const affectedPrefixes = [ | 1074 | const affectedPrefixes = [ |
| 1173 | ...(input.headers !== undefined ? ["header:"] : []), | 1075 | ...(input.headers !== undefined ? ["header:"] : []), |
| 1174 | ...(input.queryParams !== undefined ? ["query_param:"] : []), | 1076 | ...(input.queryParams !== undefined ? ["query_param:"] : []), |
| 1175 | ...(input.oauth2 !== undefined ? ["oauth2:"] : []), | 1077 | ...(input.oauth2 !== undefined ? ["oauth2:"] : []), |
| 1176 | ]; | 1078 | ]; |
| 1177 | const targetScope = input.credentialTargetScope ?? scope; | 1079 | const targetScope = scope; |
| 1178 | if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { | 1080 | if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { |
| 1179 | const outerSource = yield* findOuterSource(ctx, namespace, scope); | 1081 | const outerSource = yield* findOuterSource(ctx, namespace, scope); |
| 1180 | if (outerSource) { | 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 | yield* validateOpenApiBindingTarget(ctx, { | 1089 | yield* validateOpenApiBindingTarget(ctx, { |
| 1188 | sourceId: namespace, | 1090 | sourceId: namespace, |
| 1189 | sourceScope: scope, | 1091 | sourceScope: scope, |
| @@ -1199,17 +1101,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 9 unmodified lines | |||
| 1199 | queryParams: canonicalQueryParams?.values, | 1101 | queryParams: canonicalQueryParams?.values, |
| 1200 | oauth2: canonicalOAuth2?.oauth2, | 1102 | oauth2: canonicalOAuth2?.oauth2, |
| 1201 | }); | 1103 | }); |
| 1202 | if (affectedPrefixes.length > 0 || directBindings.length > 0) { | 1104 | if (affectedPrefixes.length > 0) { |
| 1203 | yield* ctx.credentialBindings.replaceForSource({ | 1105 | yield* ctx.credentialBindings.replaceForSource({ |
| 1204 | targetScope: ScopeId.make(targetScope), | 1106 | targetScope: ScopeId.make(targetScope), |
| 1205 | pluginId: OPENAPI_PLUGIN_ID, | 1107 | pluginId: OPENAPI_PLUGIN_ID, |
| 1206 | sourceId: namespace, | 1108 | sourceId: namespace, |
| 1207 | sourceScope: ScopeId.make(scope), | 1109 | sourceScope: ScopeId.make(scope), |
| 1208 | slotPrefixes: affectedPrefixes, | 1110 | slotPrefixes: affectedPrefixes, |
| 1209 | bindings: directBindings.map((binding) => ({ | 1111 | bindings: [], |
| 1210 | slotKey: binding.slot, | ||
| 1211 | value: binding.value, | ||
| 1212 | })), | ||
| 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 | yield* executor.openapi.addSpec({ | 92 | yield* executor.openapi.addSpec({ |
| 93 | spec: specText, | 93 | spec: { kind: "blob", value: specText }, |
| 94 | scope: TEST_SCOPE, | 94 | scope: TEST_SCOPE, |
| 95 | name: namespace, | ||
| 95 | namespace, | 96 | namespace, |
| 96 | baseUrl: "", | 97 | baseUrl: "", |
| 97 | }); | 98 | }); |
| @@ -41,6 +41,12 @@ export const headerBindingSlot = (headerName: string): string => · 40 unmodified lines | |||
| 41 | export const queryParamBindingSlot = (name: string): string => | 41 | export const queryParamBindingSlot = (name: string): string => |
| 42 | `query_param:${slugifySlotPart(name)}`; | 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 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => | 50 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => |
| 45 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; | 51 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; |
| 46 | 52 | ||
| @@ -13,7 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | OpenApi, | 13 | OpenApi, |
| 14 | } from "effect/unstable/httpapi"; | 14 | } from "effect/unstable/httpapi"; |
| 15 | import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing"; | 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 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; | 17 | import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin"; |
| 18 | 18 | ||
| 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( | 19 | export class OpenApiTestServerAddressError extends Data.TaggedError( |
| @@ -80,22 +80,45 @@ export interface OpenApiEchoTestServerShape extends OpenApiTestServerShape { · 60 unmodified lines | |||
| 80 | readonly clearRequests: Effect.Effect<void>; | 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 | readonly baseUrl?: string | null; | 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 | readonly specBaseUrl?: string; | 99 | readonly specBaseUrl?: string; |
| 89 | readonly transformSpec?: (spec: Record<string, unknown>) => Record<string, unknown>; | 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 | export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit< | 115 | export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit< |
| 93 | OpenApiHttpApiTestSourceOptions, | 116 | OpenApiHttpApiTestSourceOptions, |
| 94 | "scope" | "credentialTargetScope" | 117 | "scope" | "headers" | "queryParams" | "specFetchCredentials" |
| 95 | > & { | 118 | > & |
| 96 | readonly targetScope: ScopeId; | 119 | OpenApiHttpApiAddSpecCredentialsInput & { |
| 97 | readonly credentialTargetScope?: ScopeId; | 120 | readonly specFetchCredentials?: OpenApiHttpApiAddSpecCredentialsInput; |
| 98 | }; | 121 | }; |
| 99 | 122 | ||
| 100 | export type OpenApiTestSourceExecutor = { | 123 | export type OpenApiTestSourceExecutor = { |
| 101 | readonly openapi: Pick<OpenApiPluginExtension, "addSpec">; | 124 | readonly openapi: Pick<OpenApiPluginExtension, "addSpec">; |
| @@ -125,9 +148,11 @@ export const makeOpenApiTestSourceConfig = ( · 23 unmodified lines | |||
| 125 | const { baseUrl, ...rest } = options; | 148 | const { baseUrl, ...rest } = options; |
| 126 | return { | 149 | return { |
| 127 | ...rest, | 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 | ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? server.baseUrl }), | 154 | ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? server.baseUrl }), |
| 130 | }; | 155 | } as OpenApiSpecConfig; |
| 131 | }; | 156 | }; |
| 132 | 157 | ||
| 133 | export const addOpenApiTestSource = ( | 158 | export const addOpenApiTestSource = ( |
| @@ -140,11 +165,17 @@ export const makeOpenApiHttpApiTestSourceConfig = ( · 6 unmodified lines | |||
| 140 | api: HttpApi.Any, | 165 | api: HttpApi.Any, |
| 141 | options: OpenApiHttpApiTestSourceOptions, | 166 | options: OpenApiHttpApiTestSourceOptions, |
| 142 | ): OpenApiSpecConfig => { | 167 | ): OpenApiSpecConfig => { |
| 143 | const { specBaseUrl, transformSpec, ...config } = options; | 168 | const { baseUrl, specBaseUrl, transformSpec, ...config } = options; |
| 144 | return { | 169 | return { |
| 145 | ...config, | 170 | ...config, |
| 146 | spec: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }), | 171 | spec: { |
| 147 | }; | 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 | export const addOpenApiHttpApiTestSource = ( | 181 | export const addOpenApiHttpApiTestSource = ( |
| @@ -157,26 +188,21 @@ export const makeOpenApiHttpApiTestAddSpecPayload = ( · 6 unmodified lines | |||
| 157 | api: HttpApi.Any, | 188 | api: HttpApi.Any, |
| 158 | options: OpenApiHttpApiTestAddSpecPayloadOptions, | 189 | options: OpenApiHttpApiTestAddSpecPayloadOptions, |
| 159 | ) => { | 190 | ) => { |
| 160 | const { targetScope, credentialTargetScope, ...sourceOptions } = options; | 191 | const { ...sourceOptions } = options; |
| 161 | const config = makeOpenApiHttpApiTestSourceConfig(api, { | 192 | const config = makeOpenApiHttpApiTestSourceConfig(api, { |
| 162 | ...sourceOptions, | 193 | ...sourceOptions, |
| 163 | scope: String(targetScope), | 194 | scope: "unused-http-helper-scope", |
| 164 | ...(credentialTargetScope !== undefined | ||
| 165 | ? { credentialTargetScope: String(credentialTargetScope) } | ||
| 166 | : {}), | ||
| 167 | }); | 195 | }); |
| 168 | return { | 196 | return { |
| 169 | targetScope, | ||
| 170 | spec: config.spec, | 197 | spec: config.spec, |
| 171 | namespace: config.namespace, | 198 | namespace: config.namespace, |
| 172 | ...(config.name !== undefined ? { name: config.name } : {}), | 199 | name: config.name, |
| 173 | ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}), | 200 | baseUrl: config.baseUrl, |
| 174 | ...(config.headers !== undefined ? { headers: config.headers } : {}), | 201 | ...(sourceOptions.headers !== undefined ? { headers: sourceOptions.headers } : {}), |
| 175 | ...(config.queryParams !== undefined ? { queryParams: config.queryParams } : {}), | 202 | ...(sourceOptions.queryParams !== undefined ? { queryParams: sourceOptions.queryParams } : {}), |
| 176 | ...(config.oauth2 !== undefined ? { oauth2: config.oauth2 } : {}), | 203 | ...(config.oauth2 !== undefined ? { oauth2: config.oauth2 } : {}), |
| 177 | ...(credentialTargetScope !== undefined ? { credentialTargetScope } : {}), | 204 | ...(sourceOptions.specFetchCredentials !== undefined |
| 178 | ...(config.specFetchCredentials !== undefined | 205 | ? { specFetchCredentials: sourceOptions.specFetchCredentials } |
| 179 | ? { specFetchCredentials: config.specFetchCredentials } | ||
| 180 | : {}), | 206 | : {}), |
| 181 | }; | 207 | }; |
| 182 | }; | 208 | }; |