History

[codex] Clean up OpenAPI add source API (#843)

* 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

Rhys Sullivan committed 5 months ago

Commit 529a54f1 parent 1d16b0b20 files · +1477 −467Browse files

Unified · Split

20 files
  1. apps/cloud/src/services/sources-api.node.test.ts +23 −29
  2. apps/cloud/src/services/sources-refresh.node.test.ts +6 −4
  3. apps/cloud/src/services/tenant-isolation.node.test.ts +10 −25
  4. apps/marketing/src/pages/api/detect.ts +3 −1
  5. examples/all-plugins/src/main.ts +2 −1
  6. examples/docs-sdk-quickstart/src/main.ts +6 −1
  7. examples/promise-sdk/src/main.ts +6 −1
  8. notes/openapi-add-source-api-cleanup.md +316 −0
  9. notes/openapi-sdk-credential-configuration.md +509 −0
  10. notes/source-binding-consolidation.md +209 −0
  11. packages/kernel/runtime-dynamic-worker/src/integration.test.ts +7 −1
  12. packages/plugins/openapi/src/api/group.ts +23 −31
  13. packages/plugins/openapi/src/api/handlers.ts +11 −14
  14. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +143 −110
  15. packages/plugins/openapi/src/react/atoms.ts +1 −1
  16. packages/plugins/openapi/src/sdk/plugin.test.ts +66 −44
  17. packages/plugins/openapi/src/sdk/plugin.ts +76 −177
  18. packages/plugins/openapi/src/sdk/real-specs.test.ts +2 −1
  19. packages/plugins/openapi/src/sdk/source-contracts.ts +6 −0
  20. packages/plugins/openapi/src/testing/index.ts +52 −26
apps/cloud/src/services/sources-api.node.test.ts+23−29
@@ -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
37const makeMinimalOpenApiSourcePayload = (37const 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 }),
apps/cloud/src/services/sources-refresh.node.test.ts+6−4
@@ -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 }),
apps/cloud/src/services/tenant-isolation.node.test.ts+10−25
@@ -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
22const makeTenantOpenApiSourcePayload = (22const 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 }),
apps/marketing/src/pages/api/detect.ts+3−1
@@ -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") {
examples/all-plugins/src/main.ts+2−1
@@ -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 });
examples/docs-sdk-quickstart/src/main.ts+6−1
@@ -81,7 +81,12 @@ const executor = await createExecutor({ · 80 unmodified lines
81await executor.openapi.addSpec({81await 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-source91// docs:end add-source
87 92
examples/promise-sdk/src/main.ts+6−1
@@ -44,9 +44,14 @@ await executor.mcp.addSource({ · 43 unmodified lines
44// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
45 45
46await executor.openapi.addSpec({46await 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)
notes/openapi-add-source-api-cleanup.md+316−0
@@ -0,0 +1,316 @@
1# OpenAPI Add Source API Cleanup Notes
2
3Date: 2026-05-17
4Status: planning
5
6## Summary
7
8The current OpenAPI add-source API mixes source ownership, source shape, and
9credential binding values in one payload. That makes the common org-shared
10source with per-user credentials hard to reason about. We want the immediate
11cleanup to make the OpenAPI add endpoint explicit and remove duplicated scope
12fields, without solving the larger per-user source/tool discovery model yet.
13
14The 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
29payload, duplicating the URL `scopeId` for web usage. The endpoint handler maps
30`payload.targetScope` to SDK `scope`.
31
32The payload also accepts `credentialTargetScope`, which is a fallback for direct
33secret inputs that do not carry their own binding scope. This is especially
34visible in `specFetchCredentials`, where the UI currently serializes direct
35secret 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
43derived from the spec. That is convenient for internal/programmatic callers, but
44the web/API boundary already has explicit user-facing values after preview.
45
46## Immediate Endpoint Direction
47
48For the HTTP/web add endpoint:
49
50```txt
51POST /scopes/:scopeId/openapi/specs
52```
53
54`scopeId` should be the source owner scope. Remove payload `targetScope`.
55
56Make 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
68Use a discriminated spec input instead of a string that guesses:
69
70```ts
71type OpenApiSpecInput = { kind: "url"; url: string } | { kind: "blob"; value: string };
72```
73
74Remove `credentialTargetScope` from the HTTP payload. Scope should always be
75explicit anywhere a concrete secret or connection value is written.
76
77Keep SDK-level `scope` on `OpenApiSpecConfig`; non-HTTP callers still need to
78choose where a source is added.
79
80## Source Shape vs Values
81
82The source shape is shared. Values can be bound per scope.
83
84Source shape examples:
85
86```ts
87headers: {
88 Authorization: {
89 kind: "secret",
90 prefix: "Bearer ",
91 },
92},
93queryParams: {
94 api_version: "2026-05-17",
95}
96```
97
98This 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
104Under the hood, secret-shaped headers/query params still normalize to internal
105source bindings:
106
107```ts
108headers: {
109 Authorization: {
110 kind: "binding",
111 slot: "header:authorization",
112 prefix: "Bearer ",
113 },
114}
115```
116
117Plain text values are not credentials. They can stay directly in source config.
118
119Concrete values should be set through source value binding APIs. The existing
120low-level operation is `setSourceBinding`; a later cleanup can add domain-level
121wrappers such as `setHeaderValue` or `setQueryParamValue`.
122
123## Example: Org Default With One User Override
124
125```ts
126const orgScope = ScopeId.make("org_123");
127const aliceScope = ScopeId.make("user-org:alice:org_123");
128
129const 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
149await 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
156await 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
167await 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
174await 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
186Resolution:
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
194OAuth 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
202Then 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
207Conceptually:
208
209```ts
210const 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
225await 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
232await executor.openapi.setOAuthConnection({
233 source,
234 scope: aliceScope,
235 connectionId: aliceConnection.id,
236});
237```
238
239The current implementation can continue using internal OAuth slots. The public
240SDK does not need to expose those slots for the simple flow.
241
242## Spec Fetch Credentials
243
244Spec fetch credentials differ from runtime credentials because they may be
245needed before the source exists.
246
247For 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
256Longer term, if we want all persisted values to use bindings only, preview may
257need either request-only credentials or callers must fetch private specs
258themselves and pass `{ kind: "blob", value }`.
259
260## Sharing Boundary
261
262One source has one shared credential structure. Users can provide different
263values for that structure, but they cannot change the structure per user.
264
265Supported:
266
267```txt
268Source shape: Authorization header with Bearer prefix
269Org value: shared token
270Alice value: Alice token override
271Bob value: Bob token override
272```
273
274Not supported as one source:
275
276```txt
277Alice uses Authorization: Bearer <token>
278Bob uses X-API-Key: <key>
279Carol uses OAuth
280```
281
282Those are different source instances, even if they point at the same OpenAPI
283spec and base URL.
284
285## MCP and Per-User Tool Discovery
286
287MCP exposes a larger issue: some servers may return different tools or tool
288descriptions based on the authenticated user. That means auth can affect
289discovery, not just invocation.
290
291This 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
298For now, keep OpenAPI on the stable shared tool surface model.
299
300## Deferred API Cleanup
301
302Leave `setSourceBinding` in place for edit/override flows for now. It is a
303lower-level source value binding API and already supports text, secret, and
304connection values.
305
306A later public SDK cleanup can add domain-level wrappers:
307
308- `setHeaderValue`;
309- `setQueryParamValue`;
310- `setSpecFetchHeaderValue`;
311- `setSpecFetchQueryParamValue`;
312- `setOAuthClientCredentials`;
313- `setOAuthConnection`.
314
315Those wrappers can map to internal binding slots without exposing slots to the
316consumer-facing simple flow.
notes/openapi-sdk-credential-configuration.md+509−0
@@ -0,0 +1,509 @@
1# OpenAPI SDK Credential Configuration Notes
2
3Date: 2026-05-17
4Status: planning
5
6## Summary
7
8The SDK should present OpenAPI source onboarding as two explicit phases:
9
101. Add/import a shared source shape.
112. Configure scoped credential values for that source.
12
13The internal implementation can still use source slots and scoped bindings,
14but normal SDK callers should not need to think about `setSourceBinding`,
15`connectionSlot`, `clientIdSlot`, `credentialTargetScope`, or plugin-specific
16binding row shapes.
17
18The product flow we want to preserve is:
19
20```txt
21Admin adds Stripe/Linear/GitHub once at org scope.
22Org may set default credentials.
23Each user can sign in or provide their own token.
24The source and tools stay shared.
25Credentials 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
46Source shape belongs to the source owner scope. Credential values belong to
47the scope where they are configured and can inherit/override through the scope
48stack.
49
50## Public SDK Shape
51
52The common bearer token flow should read like this:
53
54```ts
55const org = ScopeId.make("org_acme");
56const user = ScopeId.make("user_rhys");
57
58const 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
75await executor.openapi.configure(source, {
76 scope: user,
77 headers: {
78 Authorization: SecretId.make("stripe_api_key"),
79 },
80});
81```
82
83The caller should not pass:
84
85```ts
86kind: "binding";
87slot: "header:authorization";
88credentialTargetScope: user;
89```
90
91Those are internal implementation details.
92
93## Strict Configure Semantics
94
95`configure` fills declared holes in an existing source shape. It should not
96create new source shape.
97
98If the source was imported with:
99
100```ts
101headers: {
102 Authorization: {
103 prefix: "Bearer ",
104 },
105}
106```
107
108then this is valid:
109
110```ts
111await executor.openapi.configure(source, {
112 scope: user,
113 headers: {
114 Authorization: SecretId.make("stripe_api_key"),
115 },
116});
117```
118
119This should fail:
120
121```ts
122await executor.openapi.configure(source, {
123 scope: user,
124 headers: {
125 Username: SecretId.make("stripe_api_key"),
126 },
127});
128```
129
130because `Username` was not declared on the source. Allowing this would silently
131write a binding for a value the source never sends.
132
133Suggested error:
134
135```txt
136Unknown header "Username" for OpenAPI source "stripe".
137Declared headers: Authorization.
138```
139
140The same rule applies to query params:
141
142```ts
143await executor.openapi.configure(source, {
144 scope: user,
145 queryParams: {
146 api_key: SecretId.make("example_api_key"),
147 },
148});
149```
150
151should only succeed if `api_key` was declared in the source shape.
152
153Adding or changing source shape should be a different operation:
154
155```ts
156await executor.openapi.updateSource(source, {
157 headers: {
158 Authorization: { prefix: "Bearer " },
159 Username: {},
160 },
161});
162
163await 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
174values internally.
175
176```ts
177type 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
191Examples:
192
193```ts
194await 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
206Internal binding writes:
207
208```ts
209setBinding({
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
216setBinding({
217 source: { pluginId: "openapi", id: "stripe", scope: org },
218 scope: user,
219 slot: "header:x-workspace",
220 value: { kind: "text", text: "acme" },
221});
222
223setBinding({
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
234const 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
250await executor.openapi.configure(source, {
251 scope: org,
252 headers: {
253 Authorization: SecretId.make("example_org_token"),
254 },
255});
256
257await executor.openapi.configure(source, {
258 scope: user,
259 headers: {
260 Authorization: SecretId.make("example_user_token"),
261 },
262});
263```
264
265Resolution:
266
267```txt
268User with user binding:
269 Authorization: Bearer <example_user_token>
270
271User without user binding:
272 Authorization: Bearer <example_org_token>
273```
274
275The source and tools remain org-scoped. Only values vary by scope.
276
277## OAuth Is Not Just A Header
278
279OAuth eventually produces an `Authorization` header, but the setup is not just
280"set the Authorization header value." OAuth has distinct source shape and
281credential 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
294The source should choose one OAuth configuration at import time. After that,
295credential operations should not ask the caller to repeat the security scheme
296name in the common case.
297
298## Current OpenAPI OAuth Model
299
300Today preview returns `oauth2Presets`, one per supported OAuth option derived
301from the spec. The add UI chooses one preset and persists one
302`OAuth2SourceConfig` on the source:
303
304```ts
305oauth2: {
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
319Client ID and client secret are stored as scoped bindings:
320
321```ts
322setBinding({
323 slot: oauth2.clientIdSlot,
324 scope: org,
325 value: {
326 kind: "secret",
327 secretId: SecretId.make("linear_client_id"),
328 },
329});
330
331setBinding({
332 slot: oauth2.clientSecretSlot,
333 scope: org,
334 value: {
335 kind: "secret",
336 secretId: SecretId.make("linear_client_secret"),
337 },
338});
339```
340
341The user connection is also stored as a scoped binding:
342
343```ts
344setBinding({
345 slot: oauth2.connectionSlot,
346 scope: user,
347 value: {
348 kind: "connection",
349 connectionId: ConnectionId.make("linear_user_connection"),
350 },
351});
352```
353
354That underlying model is sound. The clunky part is exposing the slot names and
355raw binding operations as the normal SDK path.
356
357## Proposed OAuth SDK Flow
358
359If the spec has exactly one supported OAuth option, `addSpec` can infer it. If
360there are multiple supported OAuth options, `addSpec` should require an
361import-time selection.
362
363Single OAuth option:
364
365```ts
366const 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
379Multiple OAuth options:
380
381```ts
382const 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
397Configure app credentials:
398
399```ts
400await 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
409Connect a user:
410
411```ts
412await executor.openapi.connect(source, {
413 scope: user,
414});
415```
416
417Client credentials flow should use the same public concepts:
418
419```ts
420await 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
428await executor.openapi.connect(source, {
429 scope: org,
430});
431```
432
433Internally this still writes:
434
435```txt
436oauth2:<selected-security-scheme>:client-id
437oauth2:<selected-security-scheme>:client-secret
438oauth2:<selected-security-scheme>:connection
439```
440
441but SDK callers do not see those slots.
442
443## OAuth Validation
444
445`configure(source, { oauth: ... })` should fail if the source has no selected
446OAuth config:
447
448```txt
449OpenAPI source "stripe" does not declare OAuth credentials.
450```
451
452`connect(source, { scope })` should fail if the source has no selected OAuth
453config:
454
455```txt
456OpenAPI source "stripe" does not support OAuth connect.
457```
458
459`connect` should validate required client credentials before starting OAuth:
460
461```txt
462Client ID must be configured before connecting.
463Client secret must be configured before connecting.
464```
465
466For authorization-code flows, client secret may be optional for public PKCE
467clients. For client-credentials flows, client secret is required.
468
469## Configure Should Be A Facade
470
471The public OpenAPI SDK can expose:
472
473```ts
474executor.openapi.addSpec(...)
475executor.openapi.configure(...)
476executor.openapi.connect(...)
477```
478
479The lower-level core API can still expose generic bindings for advanced or UI
480infrastructure cases:
481
482```ts
483executor.sources.setBinding(...)
484executor.sources.listBindings(...)
485executor.sources.removeBinding(...)
486executor.sources.getSlotManifest(...)
487```
488
489But the common OpenAPI SDK path should not require users to manually map:
490
491```txt
492Authorization -> header:authorization
493OAuth client ID -> oauth2:<scheme>:client-id
494OAuth connection -> oauth2:<scheme>:connection
495```
496
497The 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
506configuration.
507
508Bindings and slots are the internal storage/resolution mechanism behind those
509operations.
notes/source-binding-consolidation.md+209−0
@@ -0,0 +1,209 @@
1# Source Binding Consolidation Notes
2
3Date: 2026-05-17
4Status: exploring
5
6## Context
7
8The branch `codex/fix-openapi-add-flow` shipped a focused cleanup of the
9OpenAPI add-source API (see `openapi-add-source-api-cleanup.md`). Looking
10across the OpenAPI, GraphQL, and MCP plugins while reviewing that work
11turned up a much larger duplication problem. This note captures the
12direction we're considering before drafting a real plan.
13
14## What's duplicated today
15
16Each of OpenAPI, GraphQL, and MCP independently reimplements the same
17source-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
38Storage-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
46All eight tables share the exact same columns:
47`(source_id, scope_id, name, kind, text_value, slot_key, prefix)`.
48They're discriminated only by which logical compartment the plugin is
49modeling. They're written and read wholesale per source (bulk delete +
50bulk 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
56We expect to support source types beyond HTTP-ish protocols: CLI
57sources (argv + env), database connection strings (templated URIs like
58`mysql://user:{{password_slot}}@host/db`), and others we haven't named.
59
60That 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
70The only protocol-agnostic primitive is: _"named slot at
71(plugin, source, source_scope, scope) resolving to a value of kind
72text | secret | connection."_ That's already what core
73`credential_binding` provides.
74
75## Revised consolidation thesis
76
77Core 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
121The existing `openapi-add-source-api-cleanup.md` proposes deferred
122domain wrappers (`setHeaderValue`, `setQueryParamValue`,
123`setOAuthClientCredentials`, `setOAuthConnection`). Under the
124consolidated model those wrappers stop making sense — they pre-suppose
125HTTP-ish compartments. The consumer-facing API becomes "set this slot
126on this source," parameterised by a slot key the plugin's manifest
127defines. UX affordances like header preset pickers move into the UI
128layer, driven by the manifest.
129
130The plan note's "source shape vs values" split is still right; it just
131applies more aggressively. Shape lives in plugin source config (JSON
132or columns the plugin actually queries on); values live in core
133bindings.
134
135## On `prefix`
136
137`prefix` is a UI/UX affordance for the `Authorization: Bearer <token>`
138pattern, generalised to "any prepended literal string." It is stored
139in three places (core `ConfiguredCredentialBinding`,
140`ScopedSecretCredentialInput`, plugin child-table columns) and
141consumed at exactly one site per plugin:
142
143```ts
144resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
145```
146
147No escaping, no suffix, no conditional logic. It exists so the secret
148itself doesn't have to bake `Bearer ` into the value and so the UI can
149render a separate prefix input next to the secret picker.
150
151It does not generalise to connection strings or argv. The right move
152is to drop `prefix` from core entirely and either:
153
1541. 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
1572. 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
161Either way the resolver stops doing string concatenation.
162
163## On the OpenAPI normalized child tables
164
165In the local-cleanup framing they look wrong because the same four
166columns are duplicated four times. In the arbitrary-source-types
167framing they look wrong for a deeper reason: they bake HTTP
168compartment vocabulary into the schema. They should go away rather
169than be consolidated.
170
171Two 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
1821. **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.
1852. **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.
1903. **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.
1934. **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.
packages/kernel/runtime-dynamic-worker/src/integration.test.ts+7−1
@@ -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 }),
packages/plugins/openapi/src/api/group.ts+23−31
@@ -1,11 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import {3import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared";
4 InternalError,
5 ScopeId,
6 ScopedSecretCredentialInput,
7 SecretBackedValue,
8} from "@executor-js/sdk/shared";
9 4
10import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";5import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
11import { SpecPreview } from "../sdk/preview";6import { SpecPreview } from "../sdk/preview";
@@ -42,57 +37,54 @@ const SourceBindingParams = { · 30 unmodified lines
42 sourceScopeId: ScopeId,37 sourceScopeId: ScopeId,
43};38};
44 39
45const SpecFetchCredentialsPayload = Schema.Struct({40const 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
45const 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
50const ConfiguredCredentialBindingPayload = Schema.Struct({50const 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
56const ConfiguredCredentialValuePayload = Schema.Union([55const OpenApiConfiguredValuePayload = Schema.Union([Schema.String, OpenApiSecretShapePayload]);
57 Schema.String,
58 ConfiguredCredentialBindingPayload,
59]);
60 56
61const OpenApiCredentialInputPayload = Schema.Union([57const 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// Payloads63// Payloads
69// ---------------------------------------------------------------------------64// ---------------------------------------------------------------------------
70 65
71const AddSpecPayload = Schema.Struct({66const 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
84const PreviewSpecPayload = Schema.Struct({77const 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
89const UpdateSourcePayload = Schema.Struct({82const 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's88 // 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),
packages/plugins/openapi/src/api/handlers.ts+11−14
@@ -3,10 +3,9 @@ import { Context, Effect } from "effect"; · 2 unmodified lines
3 3
4import { addGroup, capture } from "@executor-js/api";4import { addGroup, capture } from "@executor-js/api";
5import type {5import 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 as56 specFetchCredentials: payload.specFetchCredentials as
58 | OpenApiSpecFetchCredentialsInput57 | 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 as69 specFetchCredentials: payload.specFetchCredentials as
71 | OpenApiSpecFetchCredentialsInput70 | 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 as77 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 as120 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 };
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+143−110
@@ -1,5 +1,6 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
4import * as Match from "effect/Match";5import * as Match from "effect/Match";
5import * as Option from "effect/Option";6import * 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";
70import {73import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types";
71 ConfiguredHeaderBinding,
72 OAuth2SourceConfig,
73 OpenApiSourceBindingInput,
74 type ServerInfo,
75} from "../sdk/types";
76import { expandServerUrlOptions } from "../sdk/openapi-utils";74import { expandServerUrlOptions } from "../sdk/openapi-utils";
77 75
78export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";76export 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
129const 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
131type StrategySelection =142type 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 > 0739 ...(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 restrictSecretsToTargetScope1093 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 restrictSecretsToTargetScope1107 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">
packages/plugins/openapi/src/react/atoms.ts+1−1
@@ -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,
packages/plugins/openapi/src/sdk/plugin.test.ts+66−44
@@ -110,7 +110,10 @@ const testApiSourceConfig = (options: TestApiSourceOptions = {}) => · 109 unmodified lines
110 ...options,110 ...options,
111 });111 });
112 112
113const testApiSpec = () => testApiSourceConfig().spec;113const testApiSpec = () => {
114 const spec = testApiSourceConfig().spec;
115 return spec.kind === "blob" ? spec.value : spec.url;
116};
114 117
115// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
116// Implement handlers119// 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.openapi1165 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
packages/plugins/openapi/src/sdk/plugin.ts+76−177
@@ -4,7 +4,6 @@ import { HttpClient } from "effect/unstable/http"; · 3 unmodified lines
4 4
5import {5import {
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
29import { extract } from "./extract";28import { extract } from "./extract";
30import { compileToolDefinitions, type ToolDefinition } from "./definitions";29import { compileToolDefinitions, type ToolDefinition } from "./definitions";
31import { annotationsForOperation, invokeWithLayer } from "./invoke";30import { annotationsForOperation, invokeWithLayer } from "./invoke";
32import { resolveBaseUrl } from "./openapi-utils";
33import { previewSpec, SpecPreview } from "./preview";31import { previewSpec, SpecPreview } from "./preview";
34import {32import {
35 makeDefaultOpenapiStore,33 makeDefaultOpenapiStore,
@@ -41,13 +39,10 @@ import { · 5 unmodified lines
41} from "./store";39} from "./store";
42import {40import {
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
132export type HeaderValue = HeaderValueValue;127export type HeaderValue = HeaderValueValue;
133export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;128export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
134export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
135export type OpenApiCredentialInput = OpenApiCredentialInputValue;
136export type OpenApiOAuthInput = OAuth2SourceConfig;129export type OpenApiOAuthInput = OAuth2SourceConfig;
137 130
131export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type;
132
133export type OpenApiSecretShapeInput = typeof OpenApiSecretShapeInputSchema.Type;
134
135export type OpenApiConfiguredValueInput =
136 | string
137 | OpenApiSecretShapeInput
138 | ConfiguredHeaderValueValue;
139
138export interface OpenApiSpecFetchCredentialsInput {140export interface OpenApiSpecFetchCredentialsInput {
141 readonly headers?: Record<string, OpenApiConfiguredValueInput>;
142 readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
143}
144
145export 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
143export interface OpenApiPreviewInput {150export interface OpenApiPreviewInput {
144 readonly spec: string;151 readonly spec: string;
145 readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput;152 readonly specFetchCredentials?: OpenApiPreviewSpecFetchCredentialsInput;
146}153}
147 154
148export interface OpenApiSpecConfig {155export 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 the159 * 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
167export interface OpenApiUpdateSourceInput {173export 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 successful178 /** 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
246const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);251const OpenApiSpecInputSchema = Schema.Union([
252 Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
253 Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
254]);
255const OpenApiSecretShapeInputSchema = Schema.Struct({
256 kind: Schema.Literal("secret"),
257 prefix: Schema.optional(Schema.String),
258});
259const OpenApiConfiguredValueInputSchema = Schema.Union([
260 Schema.String,
261 OpenApiSecretShapeInputSchema,
262]);
247const OpenApiOAuthInputSchema = OAuth2SourceConfig;263const OpenApiOAuthInputSchema = OAuth2SourceConfig;
248 264
249const AddSourceInputSchema = Schema.Struct({265const 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
348const canonicalizeHeaders = (363const 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
394const canonicalizeCredentialMap = (388const 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
441const canonicalizeSpecFetchCredentials = (414const 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
602const 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
615const findOuterSource = (569const 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
791const resolveSpecFetchInputCredentials = (745const 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
868const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://");822const specInputToConfigString = (spec: OpenApiSpecInput): string =>
823 spec.kind === "url" ? spec.url : spec.value;
869 824
870export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {825export 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 to842 // 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 a985 // 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 !== undefined1071 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 }),
packages/plugins/openapi/src/sdk/real-specs.test.ts+2−1
@@ -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 });
packages/plugins/openapi/src/sdk/source-contracts.ts+6−0
@@ -41,6 +41,12 @@ export const headerBindingSlot = (headerName: string): string => · 40 unmodified lines
41export const queryParamBindingSlot = (name: string): string =>41export const queryParamBindingSlot = (name: string): string =>
42 `query_param:${slugifySlotPart(name)}`;42 `query_param:${slugifySlotPart(name)}`;
43 43
44export const specFetchHeaderBindingSlot = (headerName: string): string =>
45 `spec_fetch_header:${slugifySlotPart(headerName)}`;
46
47export const specFetchQueryParamBindingSlot = (name: string): string =>
48 `spec_fetch_query_param:${slugifySlotPart(name)}`;
49
44export const oauth2ClientIdSlot = (securitySchemeName: string): string =>50export const oauth2ClientIdSlot = (securitySchemeName: string): string =>
45 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;51 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;
46 52
packages/plugins/openapi/src/testing/index.ts+52−26
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
13 OpenApi,13 OpenApi,
14} from "effect/unstable/httpapi";14} from "effect/unstable/httpapi";
15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";15import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";
16import { isToolResult, type ScopeId } from "@executor-js/sdk/core";16import { isToolResult } from "@executor-js/sdk/core";
17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";17import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
18 18
19export class OpenApiTestServerAddressError extends Data.TaggedError(19export 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
83export type OpenApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec" | "baseUrl"> & {83export 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
87export type OpenApiHttpApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec"> & {92export 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
103type OpenApiHttpApiAddSpecCredentialInput =
104 | string
105 | {
106 readonly kind: "secret";
107 readonly prefix?: string;
108 };
109
110type OpenApiHttpApiAddSpecCredentialsInput = {
111 readonly headers?: Record<string, OpenApiHttpApiAddSpecCredentialInput>;
112 readonly queryParams?: Record<string, OpenApiHttpApiAddSpecCredentialInput>;
113};
114
92export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit<115export 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
100export type OpenApiTestSourceExecutor = {123export 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
133export const addOpenApiTestSource = (158export 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
150export const addOpenApiHttpApiTestSource = (181export 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 !== undefined205 ? { specFetchCredentials: sourceOptions.specFetchCredentials }
179 ? { specFetchCredentials: config.specFetchCredentials }
180 : {}),206 : {}),
181 };207 };
182};208};