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
3535 .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" }));
3636
3737 const makeMinimalOpenApiSourcePayload = (
38− targetScope: ScopeId,
3938 namespace: string,
40− options: Omit<
41− Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1],
42− "targetScope" | "namespace"
43− > = {},
39+ options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {},
4440 ) =>
4541 makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, {
46− targetScope,
4742 namespace,
4843 ...options,
4944 });
@@ -72,7 +67,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines
7267 Effect.gen(function* () {
7368 const result = yield* client.openapi.addSpec({
7469 params: { scopeId: ScopeId.make(org) },
75− payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace),
70+ payload: makeMinimalOpenApiSourcePayload(namespace),
7671 });
7772 expect(result.namespace).toBe(namespace);
7873 expect(result.toolCount).toBeGreaterThan(0);
@@ -94,7 +89,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines
9489 yield* asOrg(org, (client) =>
9590 client.openapi.addSpec({
9691 params: { scopeId: ScopeId.make(org) },
97− payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace),
92+ payload: makeMinimalOpenApiSourcePayload(namespace),
9893 }),
9994 );
10095
@@ -136,11 +131,9 @@ describe("sources api (HTTP)", () => { · 35 unmodified lines
136131 const result = yield* asOrg(org, (client) =>
137132 client.openapi.addSpec({
138133 params: { scopeId: ScopeId.make(org) },
139− payload: makeMinimalOpenApiSourcePayload(
140− ScopeId.make(org),
141− `ns_${crypto.randomUUID().replace(/-/g, "_")}`,
142− { baseUrl: "http://example.com" },
143− ),
134+ payload: makeMinimalOpenApiSourcePayload(`ns_${crypto.randomUUID().replace(/-/g, "_")}`, {
135+ baseUrl: "http://example.com",
136+ }),
144137 }),
145138 );
146139
@@ -167,8 +160,9 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines
167160 client.openapi.addSpec({
168161 params: { scopeId },
169162 payload: {
170− targetScope: scopeId,
171− spec: server.specJson,
163+ spec: { kind: "blob", value: server.specJson },
164+ name: "Invocable Source API",
165+ baseUrl: server.baseUrl,
172166 namespace,
173167 },
174168 }),
@@ -414,7 +408,7 @@ describe("sources api (HTTP)", () => { · 239 unmodified lines
414408 Effect.gen(function* () {
415409 yield* client.openapi.addSpec({
416410 params: { scopeId: ScopeId.make(org) },
417− payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace),
411+ payload: makeMinimalOpenApiSourcePayload(namespace),
418412 });
419413 yield* client.sources.remove({
420414 params: { scopeId: ScopeId.make(org), sourceId: namespace },
@@ -468,7 +462,7 @@ describe("sources api (HTTP)", () => { · 47 unmodified lines
468462 Effect.gen(function* () {
469463 yield* client.openapi.addSpec({
470464 params: { scopeId: ScopeId.make(org) },
471− payload: makeMinimalOpenApiSourcePayload(ScopeId.make(org), namespace),
465+ payload: makeMinimalOpenApiSourcePayload(namespace),
472466 });
473467 yield* client.openapi.updateSource({
474468 params: { scopeId: ScopeId.make(org), namespace },
@@ -502,11 +496,10 @@ describe("sources api (HTTP)", () => { · 27 unmodified lines
502496 client.openapi.addSpec({
503497 params: { scopeId: ScopeId.make(orgId) },
504498 payload: {
505− ...makeMinimalOpenApiSourcePayload(ScopeId.make(orgId), namespace),
499+ ...makeMinimalOpenApiSourcePayload(namespace),
506500 headers: {
507501 Authorization: {
508− kind: "binding",
509− slot: "auth:personal-token",
502+ kind: "secret",
510503 prefix: "Bearer ",
511504 },
512505 },
@@ -530,7 +523,7 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines
530523 sourceId: namespace,
531524 sourceScope: ScopeId.make(orgId),
532525 scope: ScopeId.make(aliceScope),
533− slot: "auth:personal-token",
526+ slot: "header:authorization",
534527 value: {
535528 kind: "secret",
536529 secretId: SecretId.make("alice_pat"),
@@ -541,7 +534,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines
541534 sourceId: namespace,
542535 sourceScopeId: ScopeId.make(orgId),
543536 scopeId: ScopeId.make(aliceScope),
544− slot: "auth:personal-token",
537+ slot: "header:authorization",
545538 value: {
546539 kind: "secret",
547540 secretId: SecretId.make("alice_pat"),
@@ -568,7 +561,7 @@ describe("sources api (HTTP)", () => { · 20 unmodified lines
568561 sourceId: namespace,
569562 sourceScope: ScopeId.make(orgId),
570563 scope: ScopeId.make(bobScope),
571− slot: "auth:personal-token",
564+ slot: "header:authorization",
572565 value: {
573566 kind: "secret",
574567 secretId: SecretId.make("bob_pat"),
@@ -590,7 +583,7 @@ describe("sources api (HTTP)", () => { · 15 unmodified lines
590583 expect(aliceBindings).toContainEqual(
591584 expect.objectContaining({
592585 scopeId: ScopeId.make(aliceScope),
593− slot: "auth:personal-token",
586+ slot: "header:authorization",
594587 value: {
595588 kind: "secret",
596589 secretId: SecretId.make("alice_pat"),
@@ -601,7 +594,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines
601594 expect(
602595 aliceBindings.some(
603596 (binding) =>
604− binding.slot === "auth:personal-token" &&
597+ binding.slot === "header:authorization" &&
605598 binding.value.kind === "secret" &&
606599 binding.value.secretId === SecretId.make("bob_pat"),
607600 ),
@@ -619,7 +612,7 @@ describe("sources api (HTTP)", () => { · 11 unmodified lines
619612 expect(bobBindings).toContainEqual(
620613 expect.objectContaining({
621614 scopeId: ScopeId.make(bobScope),
622− slot: "auth:personal-token",
615+ slot: "header:authorization",
623616 value: {
624617 kind: "secret",
625618 secretId: SecretId.make("bob_pat"),
@@ -630,7 +623,7 @@ describe("sources api (HTTP)", () => { · 4 unmodified lines
630623 expect(
631624 bobBindings.some(
632625 (binding) =>
633− binding.slot === "auth:personal-token" &&
626+ binding.slot === "header:authorization" &&
634627 binding.value.kind === "secret" &&
635628 binding.value.secretId === SecretId.make("alice_pat"),
636629 ),
@@ -654,8 +647,9 @@ describe("sources api (HTTP)", () => { · 17 unmodified lines
654647 client.openapi.addSpec({
655648 params: { scopeId: ScopeId.make(org) },
656649 payload: {
657− targetScope: ScopeId.make(org),
658− spec: CLOUDFLARE_SPEC,
650+ spec: { kind: "blob", value: CLOUDFLARE_SPEC },
651+ name: namespace,
652+ baseUrl: "https://api.cloudflare.com/client/v4",
659653 namespace,
660654 },
661655 }),
apps/cloud/src/services/sources-refresh.node.test.ts+6−4
@@ -45,8 +45,9 @@ describe("sources.refresh (HTTP)", () => { · 44 unmodified lines
4545 client.openapi.addSpec({
4646 params: { scopeId: ScopeId.make(org) },
4747 payload: {
48− targetScope: ScopeId.make(org),
49− spec: server.specUrl,
48+ spec: { kind: "url", url: server.specUrl },
49+ name: namespace,
50+ baseUrl: server.baseUrl,
5051 namespace,
5152 },
5253 }),
@@ -107,8 +108,9 @@ describe("sources.refresh (HTTP)", () => { · 54 unmodified lines
107108 client.openapi.addSpec({
108109 params: { scopeId: ScopeId.make(org) },
109110 payload: {
110− targetScope: ScopeId.make(org),
111− spec: makeRefreshSpecText(),
111+ spec: { kind: "blob", value: makeRefreshSpecText() },
112+ name: namespace,
113+ baseUrl: "https://api.example.test",
112114 namespace,
113115 },
114116 }),
apps/cloud/src/services/tenant-isolation.node.test.ts+10−25
@@ -20,15 +20,10 @@ const TenantIsolationApi = HttpApi.make("tenantIsolationTest") · 19 unmodified lines
2020 .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" }));
2121
2222 const makeTenantOpenApiSourcePayload = (
23− targetScope: ScopeId,
2423 namespace: string,
25− options: Omit<
26− Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1],
27− "targetScope" | "namespace"
28− > = {},
24+ options: Omit<Parameters<typeof makeOpenApiHttpApiTestAddSpecPayload>[1], "namespace"> = {},
2925 ) =>
3026 makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, {
31− targetScope,
3227 namespace,
3328 ...options,
3429 });
@@ -116,7 +111,7 @@ describe("tenant isolation (HTTP)", () => { · 81 unmodified lines
116111 yield* asOrg(orgA, (client) =>
117112 client.openapi.addSpec({
118113 params: { scopeId: ScopeId.make(orgA) },
119− payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA),
114+ payload: makeTenantOpenApiSourcePayload(namespaceA),
120115 }),
121116 );
122117
@@ -136,7 +131,7 @@ describe("tenant isolation (HTTP)", () => { · 13 unmodified lines
136131 yield* asOrg(orgA, (client) =>
137132 client.openapi.addSpec({
138133 params: { scopeId: ScopeId.make(orgA) },
139− payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA),
134+ payload: makeTenantOpenApiSourcePayload(namespaceA),
140135 }),
141136 );
142137
@@ -159,7 +154,7 @@ describe("tenant isolation (HTTP)", () => { · 16 unmodified lines
159154 yield* asOrg(orgA, (client) =>
160155 client.openapi.addSpec({
161156 params: { scopeId: ScopeId.make(orgA) },
162− payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA),
157+ payload: makeTenantOpenApiSourcePayload(namespaceA),
163158 }),
164159 );
165160
@@ -259,11 +254,10 @@ describe("tenant isolation (HTTP)", () => { · 93 unmodified lines
259254 yield* client.openapi.addSpec({
260255 params: { scopeId: ScopeId.make(orgA) },
261256 payload: {
262− ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA),
257+ ...makeTenantOpenApiSourcePayload(namespaceA),
263258 headers: {
264259 Authorization: {
265− kind: "binding",
266− slot: "auth:token",
260+ kind: "secret",
267261 prefix: "Bearer ",
268262 },
269263 },
@@ -275,7 +269,7 @@ describe("tenant isolation (HTTP)", () => { · 5 unmodified lines
275269 sourceId: namespaceA,
276270 sourceScope: ScopeId.make(orgA),
277271 scope: ScopeId.make(orgA),
278− slot: "auth:token",
272+ slot: "header:authorization",
279273 value: { kind: "secret", secretId: secretIdA },
280274 },
281275 });
@@ -303,16 +297,7 @@ describe("tenant isolation (HTTP)", () => { · 21 unmodified lines
303297 Effect.gen(function* () {
304298 yield* client.openapi.addSpec({
305299 params: { scopeId: ScopeId.make(orgA) },
306− payload: {
307− ...makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespaceA),
308− headers: {
309− Authorization: {
310− kind: "binding",
311− slot: "auth:conn",
312− prefix: "Bearer ",
313− },
314− },
315− },
300+ payload: makeTenantOpenApiSourcePayload(namespaceA),
316301 });
317302 yield* client.openapi.setSourceBinding({
318303 params: { scopeId: ScopeId.make(orgA) },
@@ -346,7 +331,7 @@ describe("tenant isolation (HTTP)", () => { · 27 unmodified lines
346331 yield* asOrg(orgA, (client) =>
347332 client.openapi.addSpec({
348333 params: { scopeId: ScopeId.make(orgA) },
349− payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgA), namespace, {
334+ payload: makeTenantOpenApiSourcePayload(namespace, {
350335 name: "Org A API",
351336 baseUrl: "https://org-a.example.com",
352337 }),
@@ -355,7 +340,7 @@ describe("tenant isolation (HTTP)", () => { · 2 unmodified lines
355340 yield* asOrg(orgB, (client) =>
356341 client.openapi.addSpec({
357342 params: { scopeId: ScopeId.make(orgB) },
358− payload: makeTenantOpenApiSourcePayload(ScopeId.make(orgB), namespace, {
343+ payload: makeTenantOpenApiSourcePayload(namespace, {
359344 name: "Org B API",
360345 baseUrl: "https://org-b.example.com",
361346 }),
apps/marketing/src/pages/api/detect.ts+3−1
@@ -85,8 +85,10 @@ export const POST: APIRoute = async ({ request }) => { · 84 unmodified lines
8585 // Add source to register its tools (Google Discovery needs auth so skip)
8686 if (match.kind === "openapi") {
8787 yield* executor.openapi.addSpec({
88− spec: match.endpoint,
88+ spec: { kind: "url", url: match.endpoint },
89+ name: match.name,
8990 namespace: match.namespace,
91+ baseUrl: match.endpoint,
9092 scope: "test-scope",
9193 });
9294 } 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
244244 console.log("-".repeat(72));
245245
246246 const addSpecResult = yield* executor.openapi.addSpec({
247− spec: exampleOpenApiSpec,
247+ spec: { kind: "blob", value: exampleOpenApiSpec },
248248 namespace: "example-api",
249+ name: "Example API",
249250 baseUrl: "https://example.com/api",
250251 scope: "example-scope",
251252 });
examples/docs-sdk-quickstart/src/main.ts+6−1
@@ -81,7 +81,12 @@ const executor = await createExecutor({ · 80 unmodified lines
8181 await executor.openapi.addSpec({
8282 namespace: "inventory",
8383 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+ },
8590 });
8691 // docs:end add-source
8792
examples/promise-sdk/src/main.ts+6−1
@@ -44,9 +44,14 @@ await executor.mcp.addSource({ · 43 unmodified lines
4444 // ---------------------------------------------------------------------------
4545
4646 await executor.openapi.addSpec({
47− spec: "https://petstore3.swagger.io/api/v3/openapi.json",
47+ spec: {
48+ kind: "url",
49+ url: "https://petstore3.swagger.io/api/v3/openapi.json",
50+ },
4851 namespace: "petstore",
4952 scope: "my-app",
53+ name: "Petstore",
54+ baseUrl: "https://petstore3.swagger.io/api/v3",
5055 });
5156
5257 // 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+
3+Date: 2026-05-17
4+Status: planning
5+
6+## Summary
7+
8+The current OpenAPI add-source API mixes source ownership, source shape, and
9+credential binding values in one payload. That makes the common org-shared
10+source with per-user credentials hard to reason about. We want the immediate
11+cleanup to make the OpenAPI add endpoint explicit and remove duplicated scope
12+fields, without solving the larger per-user source/tool discovery model yet.
13+
14+The product model for this pass is:
15+
16+- OpenAPI source rows and tool definitions are shared at the org/workspace
17+ level in the web UI.
18+- Runtime credential values can vary by user or org, but they must match the
19+ shared source credential shape.
20+- Spec fetch credentials are source-definition credentials. If the shared spec
21+ URL requires auth, those credentials are part of maintaining the shared tool
22+ surface.
23+- Different auth structures should be different source instances, not
24+ different per-user modes inside one source.
25+
26+## Current Problems
27+
28+`POST /scopes/:scopeId/openapi/specs` currently accepts `targetScope` in the
29+payload, duplicating the URL `scopeId` for web usage. The endpoint handler maps
30+`payload.targetScope` to SDK `scope`.
31+
32+The payload also accepts `credentialTargetScope`, which is a fallback for direct
33+secret inputs that do not carry their own binding scope. This is especially
34+visible in `specFetchCredentials`, where the UI currently serializes direct
35+secret refs without per-entry scope.
36+
37+`spec` is a plain string that can mean either:
38+
39+- an HTTP URL to fetch, or
40+- raw OpenAPI JSON/YAML text.
41+
42+`name`, `namespace`, and `baseUrl` are optional in the SDK because they can be
43+derived from the spec. That is convenient for internal/programmatic callers, but
44+the web/API boundary already has explicit user-facing values after preview.
45+
46+## Immediate Endpoint Direction
47+
48+For the HTTP/web add endpoint:
49+
50+```txt
51+POST /scopes/:scopeId/openapi/specs
52+```
53+
54+`scopeId` should be the source owner scope. Remove payload `targetScope`.
55+
56+Make the identity and request base explicit:
57+
58+```ts
59+{
60+ spec: OpenApiSpecInput;
61+ name: string;
62+ namespace: string;
63+ baseUrl: string;
64+ // source shape fields...
65+}
66+```
67+
68+Use a discriminated spec input instead of a string that guesses:
69+
70+```ts
71+type OpenApiSpecInput = { kind: "url"; url: string } | { kind: "blob"; value: string };
72+```
73+
74+Remove `credentialTargetScope` from the HTTP payload. Scope should always be
75+explicit anywhere a concrete secret or connection value is written.
76+
77+Keep SDK-level `scope` on `OpenApiSpecConfig`; non-HTTP callers still need to
78+choose where a source is added.
79+
80+## Source Shape vs Values
81+
82+The source shape is shared. Values can be bound per scope.
83+
84+Source shape examples:
85+
86+```ts
87+headers: {
88+ Authorization: {
89+ kind: "secret",
90+ prefix: "Bearer ",
91+ },
92+},
93+queryParams: {
94+ api_version: "2026-05-17",
95+}
96+```
97+
98+This declares:
99+
100+- every user of the source uses an `Authorization` header with a bearer prefix;
101+- the concrete token value is supplied separately;
102+- `api_version` is a shared literal source config value.
103+
104+Under the hood, secret-shaped headers/query params still normalize to internal
105+source bindings:
106+
107+```ts
108+headers: {
109+ Authorization: {
110+ kind: "binding",
111+ slot: "header:authorization",
112+ prefix: "Bearer ",
113+ },
114+}
115+```
116+
117+Plain text values are not credentials. They can stay directly in source config.
118+
119+Concrete values should be set through source value binding APIs. The existing
120+low-level operation is `setSourceBinding`; a later cleanup can add domain-level
121+wrappers such as `setHeaderValue` or `setQueryParamValue`.
122+
123+## Example: Org Default With One User Override
124+
125+```ts
126+const orgScope = ScopeId.make("org_123");
127+const aliceScope = ScopeId.make("user-org:alice:org_123");
128+
129+const source = await executor.openapi.addSpec({
130+ scope: orgScope,
131+ spec: {
132+ kind: "url",
133+ url: "https://api.example.com/openapi.json",
134+ },
135+ name: "Example API",
136+ namespace: "example",
137+ baseUrl: "https://api.example.com",
138+ headers: {
139+ Authorization: {
140+ kind: "secret",
141+ prefix: "Bearer ",
142+ },
143+ },
144+ queryParams: {
145+ api_version: "2026-05-17",
146+ },
147+});
148+
149+await executor.secrets.set({
150+ id: SecretId.make("example-org-token"),
151+ scope: orgScope,
152+ name: "Example org token",
153+ value: process.env.EXAMPLE_ORG_TOKEN!,
154+});
155+
156+await executor.openapi.setHeaderValue({
157+ source,
158+ scope: orgScope,
159+ name: "Authorization",
160+ value: {
161+ kind: "secret",
162+ secretId: SecretId.make("example-org-token"),
163+ secretScope: orgScope,
164+ },
165+});
166+
167+await executor.secrets.set({
168+ id: SecretId.make("alice-example-token"),
169+ scope: aliceScope,
170+ name: "Alice Example token",
171+ value: process.env.ALICE_EXAMPLE_TOKEN!,
172+});
173+
174+await executor.openapi.setHeaderValue({
175+ source,
176+ scope: aliceScope,
177+ name: "Authorization",
178+ value: {
179+ kind: "secret",
180+ secretId: SecretId.make("alice-example-token"),
181+ secretScope: aliceScope,
182+ },
183+});
184+```
185+
186+Resolution:
187+
188+- Alice gets `Authorization: Bearer <alice token>`.
189+- Other users fall back to `Authorization: Bearer <org token>`.
190+- The source/tool definition remains org-scoped and shared.
191+
192+## OAuth Shape
193+
194+OAuth follows the same split. The source declares shared OAuth metadata:
195+
196+- security scheme name;
197+- flow;
198+- authorization/token URLs;
199+- scopes;
200+- where client id/client secret/connection values are expected.
201+
202+Then values are bound separately:
203+
204+- OAuth app client id/secret can be org-level values.
205+- OAuth access/refresh token connection is commonly per user.
206+
207+Conceptually:
208+
209+```ts
210+const source = await executor.openapi.addSpec({
211+ scope: orgScope,
212+ spec: { kind: "url", url: "https://api.example.com/openapi.json" },
213+ name: "Example API",
214+ namespace: "example",
215+ baseUrl: "https://api.example.com",
216+ oauth2: {
217+ securitySchemeName: "oauth2",
218+ flow: "authorizationCode",
219+ authorizationUrl: "https://api.example.com/oauth/authorize",
220+ tokenUrl: "https://api.example.com/oauth/token",
221+ scopes: ["read", "write"],
222+ },
223+});
224+
225+await executor.openapi.setOAuthClientCredentials({
226+ source,
227+ scope: orgScope,
228+ clientId: { kind: "secret", secretId: "client-id", secretScope: orgScope },
229+ clientSecret: { kind: "secret", secretId: "client-secret", secretScope: orgScope },
230+});
231+
232+await executor.openapi.setOAuthConnection({
233+ source,
234+ scope: aliceScope,
235+ connectionId: aliceConnection.id,
236+});
237+```
238+
239+The current implementation can continue using internal OAuth slots. The public
240+SDK does not need to expose those slots for the simple flow.
241+
242+## Spec Fetch Credentials
243+
244+Spec fetch credentials differ from runtime credentials because they may be
245+needed before the source exists.
246+
247+For this immediate pass:
248+
249+- keep spec fetch credentials on `addSpec`;
250+- treat them as source-definition credentials;
251+- make the UI use the same secret scope selection semantics as other secret
252+ inputs;
253+- remove `credentialTargetScope` by making any concrete spec fetch secret refs
254+ carry explicit scope information.
255+
256+Longer term, if we want all persisted values to use bindings only, preview may
257+need either request-only credentials or callers must fetch private specs
258+themselves and pass `{ kind: "blob", value }`.
259+
260+## Sharing Boundary
261+
262+One source has one shared credential structure. Users can provide different
263+values for that structure, but they cannot change the structure per user.
264+
265+Supported:
266+
267+```txt
268+Source shape: Authorization header with Bearer prefix
269+Org value: shared token
270+Alice value: Alice token override
271+Bob value: Bob token override
272+```
273+
274+Not supported as one source:
275+
276+```txt
277+Alice uses Authorization: Bearer <token>
278+Bob uses X-API-Key: <key>
279+Carol uses OAuth
280+```
281+
282+Those are different source instances, even if they point at the same OpenAPI
283+spec and base URL.
284+
285+## MCP and Per-User Tool Discovery
286+
287+MCP exposes a larger issue: some servers may return different tools or tool
288+descriptions based on the authenticated user. That means auth can affect
289+discovery, not just invocation.
290+
291+This does not need to be solved in the OpenAPI add-source cleanup. Defer:
292+
293+- per-user source rows;
294+- per-user tool discovery;
295+- MCP auth-dependent tool descriptions;
296+- template/materialization flows for user-bound discovery sources.
297+
298+For now, keep OpenAPI on the stable shared tool surface model.
299+
300+## Deferred API Cleanup
301+
302+Leave `setSourceBinding` in place for edit/override flows for now. It is a
303+lower-level source value binding API and already supports text, secret, and
304+connection values.
305+
306+A later public SDK cleanup can add domain-level wrappers:
307+
308+- `setHeaderValue`;
309+- `setQueryParamValue`;
310+- `setSpecFetchHeaderValue`;
311+- `setSpecFetchQueryParamValue`;
312+- `setOAuthClientCredentials`;
313+- `setOAuthConnection`.
314+
315+Those wrappers can map to internal binding slots without exposing slots to the
316+consumer-facing simple flow.
notes/openapi-sdk-credential-configuration.md+509−0
@@ -0,0 +1,509 @@
1+# OpenAPI SDK Credential Configuration Notes
2+
3+Date: 2026-05-17
4+Status: planning
5+
6+## Summary
7+
8+The SDK should present OpenAPI source onboarding as two explicit phases:
9+
10+1. Add/import a shared source shape.
11+2. Configure scoped credential values for that source.
12+
13+The internal implementation can still use source slots and scoped bindings,
14+but normal SDK callers should not need to think about `setSourceBinding`,
15+`connectionSlot`, `clientIdSlot`, `credentialTargetScope`, or plugin-specific
16+binding row shapes.
17+
18+The product flow we want to preserve is:
19+
20+```txt
21+Admin adds Stripe/Linear/GitHub once at org scope.
22+Org may set default credentials.
23+Each user can sign in or provide their own token.
24+The source and tools stay shared.
25+Credentials remain scoped and overridable.
26+```
27+
28+## Core Vocabulary
29+
30+**Source shape** is the shared definition:
31+
32+- OpenAPI spec and extracted operations.
33+- Base URL.
34+- Declared headers and query params the source knows how to send.
35+- Selected OAuth method, if any.
36+- OAuth flow metadata such as authorization URL, token URL, and scopes.
37+
38+**Scoped credential values** are the concrete values attached later:
39+
40+- Bearer/API key token values.
41+- Header/query param text values.
42+- OAuth client ID and client secret.
43+- OAuth connection IDs.
44+- Spec-fetch credential values.
45+
46+Source shape belongs to the source owner scope. Credential values belong to
47+the scope where they are configured and can inherit/override through the scope
48+stack.
49+
50+## Public SDK Shape
51+
52+The common bearer token flow should read like this:
53+
54+```ts
55+const org = ScopeId.make("org_acme");
56+const user = ScopeId.make("user_rhys");
57+
58+const source = await executor.openapi.addSpec({
59+ scope: org,
60+ name: "Stripe",
61+ namespace: "stripe",
62+ baseUrl: "https://api.stripe.com",
63+ spec: {
64+ kind: "url",
65+ url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
66+ },
67+
68+ headers: {
69+ Authorization: {
70+ prefix: "Bearer ",
71+ },
72+ },
73+});
74+
75+await executor.openapi.configure(source, {
76+ scope: user,
77+ headers: {
78+ Authorization: SecretId.make("stripe_api_key"),
79+ },
80+});
81+```
82+
83+The caller should not pass:
84+
85+```ts
86+kind: "binding";
87+slot: "header:authorization";
88+credentialTargetScope: user;
89+```
90+
91+Those are internal implementation details.
92+
93+## Strict Configure Semantics
94+
95+`configure` fills declared holes in an existing source shape. It should not
96+create new source shape.
97+
98+If the source was imported with:
99+
100+```ts
101+headers: {
102+ Authorization: {
103+ prefix: "Bearer ",
104+ },
105+}
106+```
107+
108+then this is valid:
109+
110+```ts
111+await executor.openapi.configure(source, {
112+ scope: user,
113+ headers: {
114+ Authorization: SecretId.make("stripe_api_key"),
115+ },
116+});
117+```
118+
119+This should fail:
120+
121+```ts
122+await executor.openapi.configure(source, {
123+ scope: user,
124+ headers: {
125+ Username: SecretId.make("stripe_api_key"),
126+ },
127+});
128+```
129+
130+because `Username` was not declared on the source. Allowing this would silently
131+write a binding for a value the source never sends.
132+
133+Suggested error:
134+
135+```txt
136+Unknown header "Username" for OpenAPI source "stripe".
137+Declared headers: Authorization.
138+```
139+
140+The same rule applies to query params:
141+
142+```ts
143+await executor.openapi.configure(source, {
144+ scope: user,
145+ queryParams: {
146+ api_key: SecretId.make("example_api_key"),
147+ },
148+});
149+```
150+
151+should only succeed if `api_key` was declared in the source shape.
152+
153+Adding or changing source shape should be a different operation:
154+
155+```ts
156+await executor.openapi.updateSource(source, {
157+ headers: {
158+ Authorization: { prefix: "Bearer " },
159+ Username: {},
160+ },
161+});
162+
163+await executor.openapi.configure(source, {
164+ scope: user,
165+ headers: {
166+ Username: SecretId.make("basic_username"),
167+ },
168+});
169+```
170+
171+## Values Accepted By Configure
172+
173+`configure` should accept ergonomic value inputs and normalize them to binding
174+values internally.
175+
176+```ts
177+type OpenApiConfiguredCredentialValue =
178+ | string
179+ | SecretId
180+ | {
181+ kind: "secret";
182+ secretId: SecretId;
183+ secretScope?: ScopeId;
184+ }
185+ | {
186+ kind: "text";
187+ text: string;
188+ };
189+```
190+
191+Examples:
192+
193+```ts
194+await executor.openapi.configure(source, {
195+ scope: user,
196+ headers: {
197+ Authorization: SecretId.make("stripe_api_key"),
198+ "X-Workspace": "acme",
199+ },
200+ queryParams: {
201+ version: "2026-05-17",
202+ },
203+});
204+```
205+
206+Internal binding writes:
207+
208+```ts
209+setBinding({
210+ source: { pluginId: "openapi", id: "stripe", scope: org },
211+ scope: user,
212+ slot: "header:authorization",
213+ value: { kind: "secret", secretId: SecretId.make("stripe_api_key") },
214+});
215+
216+setBinding({
217+ source: { pluginId: "openapi", id: "stripe", scope: org },
218+ scope: user,
219+ slot: "header:x-workspace",
220+ value: { kind: "text", text: "acme" },
221+});
222+
223+setBinding({
224+ source: { pluginId: "openapi", id: "stripe", scope: org },
225+ scope: user,
226+ slot: "query_param:version",
227+ value: { kind: "text", text: "2026-05-17" },
228+});
229+```
230+
231+## Org Default With User Override
232+
233+```ts
234+const source = await executor.openapi.addSpec({
235+ scope: org,
236+ name: "Example API",
237+ namespace: "example",
238+ baseUrl: "https://api.example.com",
239+ spec: {
240+ kind: "url",
241+ url: "https://api.example.com/openapi.json",
242+ },
243+ headers: {
244+ Authorization: {
245+ prefix: "Bearer ",
246+ },
247+ },
248+});
249+
250+await executor.openapi.configure(source, {
251+ scope: org,
252+ headers: {
253+ Authorization: SecretId.make("example_org_token"),
254+ },
255+});
256+
257+await executor.openapi.configure(source, {
258+ scope: user,
259+ headers: {
260+ Authorization: SecretId.make("example_user_token"),
261+ },
262+});
263+```
264+
265+Resolution:
266+
267+```txt
268+User with user binding:
269+ Authorization: Bearer <example_user_token>
270+
271+User without user binding:
272+ Authorization: Bearer <example_org_token>
273+```
274+
275+The source and tools remain org-scoped. Only values vary by scope.
276+
277+## OAuth Is Not Just A Header
278+
279+OAuth eventually produces an `Authorization` header, but the setup is not just
280+"set the Authorization header value." OAuth has distinct source shape and
281+credential value concerns:
282+
283+- The selected OpenAPI security scheme.
284+- OAuth flow.
285+- Authorization URL.
286+- Token URL.
287+- Issuer URL when relevant.
288+- Scopes.
289+- Client ID.
290+- Client secret.
291+- Connection ID.
292+- Token refresh lifecycle.
293+
294+The source should choose one OAuth configuration at import time. After that,
295+credential operations should not ask the caller to repeat the security scheme
296+name in the common case.
297+
298+## Current OpenAPI OAuth Model
299+
300+Today preview returns `oauth2Presets`, one per supported OAuth option derived
301+from the spec. The add UI chooses one preset and persists one
302+`OAuth2SourceConfig` on the source:
303+
304+```ts
305+oauth2: {
306+ kind: "oauth2",
307+ securitySchemeName: selectedOAuth2Preset.securitySchemeName,
308+ flow: selectedOAuth2Preset.flow,
309+ authorizationUrl: selectedOAuth2Preset.authorizationUrl,
310+ tokenUrl: selectedOAuth2Preset.tokenUrl,
311+ issuerUrl: selectedOAuth2Preset.issuerUrl ?? null,
312+ clientIdSlot: oauth2ClientIdSlot(selectedOAuth2Preset.securitySchemeName),
313+ clientSecretSlot: oauth2ClientSecretSlot(selectedOAuth2Preset.securitySchemeName),
314+ connectionSlot: oauth2ConnectionSlot(selectedOAuth2Preset.securitySchemeName),
315+ scopes: [...oauth2SelectedScopes],
316+}
317+```
318+
319+Client ID and client secret are stored as scoped bindings:
320+
321+```ts
322+setBinding({
323+ slot: oauth2.clientIdSlot,
324+ scope: org,
325+ value: {
326+ kind: "secret",
327+ secretId: SecretId.make("linear_client_id"),
328+ },
329+});
330+
331+setBinding({
332+ slot: oauth2.clientSecretSlot,
333+ scope: org,
334+ value: {
335+ kind: "secret",
336+ secretId: SecretId.make("linear_client_secret"),
337+ },
338+});
339+```
340+
341+The user connection is also stored as a scoped binding:
342+
343+```ts
344+setBinding({
345+ slot: oauth2.connectionSlot,
346+ scope: user,
347+ value: {
348+ kind: "connection",
349+ connectionId: ConnectionId.make("linear_user_connection"),
350+ },
351+});
352+```
353+
354+That underlying model is sound. The clunky part is exposing the slot names and
355+raw binding operations as the normal SDK path.
356+
357+## Proposed OAuth SDK Flow
358+
359+If the spec has exactly one supported OAuth option, `addSpec` can infer it. If
360+there are multiple supported OAuth options, `addSpec` should require an
361+import-time selection.
362+
363+Single OAuth option:
364+
365+```ts
366+const source = await executor.openapi.addSpec({
367+ scope: org,
368+ name: "Linear",
369+ namespace: "linear",
370+ baseUrl: "https://api.linear.app",
371+ spec: {
372+ kind: "url",
373+ url: "https://example.com/linear-openapi.json",
374+ },
375+ oauth: true,
376+});
377+```
378+
379+Multiple OAuth options:
380+
381+```ts
382+const source = await executor.openapi.addSpec({
383+ scope: org,
384+ name: "Linear",
385+ namespace: "linear",
386+ baseUrl: "https://api.linear.app",
387+ spec: {
388+ kind: "url",
389+ url: "https://example.com/linear-openapi.json",
390+ },
391+ oauth: {
392+ securityScheme: "linearOAuth",
393+ },
394+});
395+```
396+
397+Configure app credentials:
398+
399+```ts
400+await executor.openapi.configure(source, {
401+ scope: org,
402+ oauth: {
403+ clientId: SecretId.make("linear_client_id"),
404+ clientSecret: SecretId.make("linear_client_secret"),
405+ },
406+});
407+```
408+
409+Connect a user:
410+
411+```ts
412+await executor.openapi.connect(source, {
413+ scope: user,
414+});
415+```
416+
417+Client credentials flow should use the same public concepts:
418+
419+```ts
420+await executor.openapi.configure(source, {
421+ scope: org,
422+ oauth: {
423+ clientId: SecretId.make("service_client_id"),
424+ clientSecret: SecretId.make("service_client_secret"),
425+ },
426+});
427+
428+await executor.openapi.connect(source, {
429+ scope: org,
430+});
431+```
432+
433+Internally this still writes:
434+
435+```txt
436+oauth2:<selected-security-scheme>:client-id
437+oauth2:<selected-security-scheme>:client-secret
438+oauth2:<selected-security-scheme>:connection
439+```
440+
441+but SDK callers do not see those slots.
442+
443+## OAuth Validation
444+
445+`configure(source, { oauth: ... })` should fail if the source has no selected
446+OAuth config:
447+
448+```txt
449+OpenAPI source "stripe" does not declare OAuth credentials.
450+```
451+
452+`connect(source, { scope })` should fail if the source has no selected OAuth
453+config:
454+
455+```txt
456+OpenAPI source "stripe" does not support OAuth connect.
457+```
458+
459+`connect` should validate required client credentials before starting OAuth:
460+
461+```txt
462+Client ID must be configured before connecting.
463+Client secret must be configured before connecting.
464+```
465+
466+For authorization-code flows, client secret may be optional for public PKCE
467+clients. For client-credentials flows, client secret is required.
468+
469+## Configure Should Be A Facade
470+
471+The public OpenAPI SDK can expose:
472+
473+```ts
474+executor.openapi.addSpec(...)
475+executor.openapi.configure(...)
476+executor.openapi.connect(...)
477+```
478+
479+The lower-level core API can still expose generic bindings for advanced or UI
480+infrastructure cases:
481+
482+```ts
483+executor.sources.setBinding(...)
484+executor.sources.listBindings(...)
485+executor.sources.removeBinding(...)
486+executor.sources.getSlotManifest(...)
487+```
488+
489+But the common OpenAPI SDK path should not require users to manually map:
490+
491+```txt
492+Authorization -> header:authorization
493+OAuth client ID -> oauth2:<scheme>:client-id
494+OAuth connection -> oauth2:<scheme>:connection
495+```
496+
497+The SDK should derive that mapping from the selected source shape.
498+
499+## Design Rule
500+
501+`addSpec` defines what this source can use.
502+
503+`configure` assigns values to things this source already declared.
504+
505+`connect` creates or refreshes OAuth connection values for the selected OAuth
506+configuration.
507+
508+Bindings and slots are the internal storage/resolution mechanism behind those
509+operations.
notes/source-binding-consolidation.md+209−0
@@ -0,0 +1,209 @@
1+# Source Binding Consolidation Notes
2+
3+Date: 2026-05-17
4+Status: exploring
5+
6+## Context
7+
8+The branch `codex/fix-openapi-add-flow` shipped a focused cleanup of the
9+OpenAPI add-source API (see `openapi-add-source-api-cleanup.md`). Looking
10+across the OpenAPI, GraphQL, and MCP plugins while reviewing that work
11+turned up a much larger duplication problem. This note captures the
12+direction we're considering before drafting a real plan.
13+
14+## What's duplicated today
15+
16+Each of OpenAPI, GraphQL, and MCP independently reimplements the same
17+source-binding machinery on top of the core `credential_binding` table:
18+
19+- A plugin-specific `*SourceBindingInput` / `*SourceBindingRef` type that
20+ is a thin re-skin of core `CredentialBindingInput` / `CredentialBindingRef`.
21+- A `resolve*SourceBinding` helper that filters bindings by
22+ `slotKey === slot` and picks the innermost-visible binding whose scope
23+ rank ≤ the source's owner-scope rank. Identical across plugins.
24+- A `list*SourceBindings` helper applying the same scope-ceiling filter.
25+- A `validate*BindingTarget` helper that re-checks the same outer-scope
26+ rule core's `assertCredentialBindingTargetNotOuter` already enforces.
27+- A `coreBindingToXxxBinding` adapter that exists only because the
28+ per-plugin Ref type is a re-skin.
29+- Per-plugin HTTP endpoints: `setSourceBinding`, `listSourceBindings`,
30+ `removeSourceBinding`. Same payloads, same semantics, mounted under
31+ different paths.
32+- A `canonicalizeCredentialMap` / `canonicalizeAuth` that splits an input
33+ "shape with secret refs" into a stored shape (with binding sentinels)
34+ plus a list of bindings to write. Same algorithm per plugin.
35+- React glue: each plugin re-wires `secret-header-auth`,
36+ `credential-target-scope`, and `oauth-sign-in` into its own add flow.
37+
38+Storage-side, each plugin owns nearly-identical child tables:
39+
40+- OpenAPI: `openapi_source_header`, `openapi_source_query_param`,
41+ `openapi_source_spec_fetch_header`,
42+ `openapi_source_spec_fetch_query_param`.
43+- GraphQL: `graphql_source_header`, `graphql_source_query_param`.
44+- MCP: `mcp_source_header`, `mcp_source_query_param`.
45+
46+All eight tables share the exact same columns:
47+`(source_id, scope_id, name, kind, text_value, slot_key, prefix)`.
48+They're discriminated only by which logical compartment the plugin is
49+modeling. They're written and read wholesale per source (bulk delete +
50+bulk insert; bulk findMany). No query filters by `name`, `slot_key`,
51+`kind`, or `prefix`. No join uses them. The Drizzle mirrors in
52+`apps/cloud` and `apps/local` are the only non-store consumers.
53+
54+## The arbitrary-source-types constraint
55+
56+We expect to support source types beyond HTTP-ish protocols: CLI
57+sources (argv + env), database connection strings (templated URIs like
58+`mysql://user:{{password_slot}}@host/db`), and others we haven't named.
59+
60+That changes the design center of any shared abstraction:
61+
62+- The HTTP-flavored vocabulary baked into the current shared bits
63+ (`header:`, `query_param:`, `prefix`) doesn't generalize.
64+- A DB source doesn't have headers; a CLI source doesn't have query
65+ params. The notion of "compartment" is plugin-specific.
66+- A connection-string source needs interpolation, not just whole-field
67+ substitution. `prefix` is a degenerate one-hole template; argv and
68+ URIs want full templating.
69+
70+The only protocol-agnostic primitive is: _"named slot at
71+(plugin, source, source_scope, scope) resolving to a value of kind
72+text | secret | connection."_ That's already what core
73+`credential_binding` provides.
74+
75+## Revised consolidation thesis
76+
77+Core should own the binding primitive, not just the storage.
78+
79+**Core owns:**
80+
81+- The `credential_binding` table (today).
82+- The resolver: given `(pluginId, sourceId, sourceScope, slot, scope)`,
83+ return the innermost-visible binding subject to the source-scope
84+ ceiling. Today this is duplicated per plugin.
85+- Validation: scope-stack membership, "binding target not outer than
86+ source," secret/connection reachability. The facade already has the
87+ inner checks; the plugin-side `validate*BindingTarget` helpers can go.
88+- HTTP endpoints for `setBinding` / `listBindings` / `removeBinding`,
89+ parameterised by `pluginId` in the path (or payload).
90+- A small _slot manifest_ concept: each plugin declares, for a given
91+ source, which slot keys exist, their human-readable labels, expected
92+ value kind, required/optional, and any rendering hints. The UI uses
93+ this to render a credential editor for any plugin without bespoke
94+ React code.
95+
96+**Plugins own:**
97+
98+- Their source-shape storage. Whatever's plugin-specific (OpenAPI:
99+ spec/baseUrl/operations; GraphQL: endpoint/operations; MCP:
100+ transport/config; DB: connection-string template; CLI: argv/env).
101+ The nearly-identical child tables go away; shape moves to JSON on
102+ the source row or whatever structural columns the plugin actually
103+ needs.
104+- The wire-format glue: how a resolved slot value becomes an HTTP
105+ header, an MCP transport handshake parameter, a DB connection
106+ string, a CLI argv entry.
107+- Their slot manifest content. OpenAPI generates it from the parsed
108+ spec; MCP from connect-time metadata; DB from the URI template's
109+ holes.
110+
111+**Plugins do not own:**
112+
113+- Re-skinned binding types.
114+- Their own resolver / lister / validator.
115+- Their own `setSourceBinding` HTTP endpoint.
116+- Hand-written credential-editor React glue, beyond plugin-specific
117+ manifest hints.
118+
119+## What this changes vs the current plan
120+
121+The existing `openapi-add-source-api-cleanup.md` proposes deferred
122+domain wrappers (`setHeaderValue`, `setQueryParamValue`,
123+`setOAuthClientCredentials`, `setOAuthConnection`). Under the
124+consolidated model those wrappers stop making sense — they pre-suppose
125+HTTP-ish compartments. The consumer-facing API becomes "set this slot
126+on this source," parameterised by a slot key the plugin's manifest
127+defines. UX affordances like header preset pickers move into the UI
128+layer, driven by the manifest.
129+
130+The plan note's "source shape vs values" split is still right; it just
131+applies more aggressively. Shape lives in plugin source config (JSON
132+or columns the plugin actually queries on); values live in core
133+bindings.
134+
135+## On `prefix`
136+
137+`prefix` is a UI/UX affordance for the `Authorization: Bearer <token>`
138+pattern, generalised to "any prepended literal string." It is stored
139+in three places (core `ConfiguredCredentialBinding`,
140+`ScopedSecretCredentialInput`, plugin child-table columns) and
141+consumed at exactly one site per plugin:
142+
143+```ts
144+resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
145+```
146+
147+No escaping, no suffix, no conditional logic. It exists so the secret
148+itself doesn't have to bake `Bearer ` into the value and so the UI can
149+render a separate prefix input next to the secret picker.
150+
151+It does not generalise to connection strings or argv. The right move
152+is to drop `prefix` from core entirely and either:
153+
154+1. Have it live in the plugin's slot manifest as a pure UI hint, with
155+ the resolver still returning the raw secret value and the plugin
156+ doing the concatenation in its wire-format layer; or
157+2. Subsume it into a plugin-owned template ("this slot's value is
158+ interpolated into this string"), which is the same idea generalised
159+ to multi-hole shapes.
160+
161+Either way the resolver stops doing string concatenation.
162+
163+## On the OpenAPI normalized child tables
164+
165+In the local-cleanup framing they look wrong because the same four
166+columns are duplicated four times. In the arbitrary-source-types
167+framing they look wrong for a deeper reason: they bake HTTP
168+compartment vocabulary into the schema. They should go away rather
169+than be consolidated.
170+
171+Two reasonable destinations:
172+
173+- **JSON on the source row.** Same place OAuth2 config already lives.
174+ Access pattern (read all, replace all) already matches what a JSON
175+ blob gives you.
176+- **Plugin-owned structural columns where they matter.** If a plugin
177+ truly benefits from a normalized shape table for its own queries,
178+ it can have one; OpenAPI/GraphQL/MCP currently don't.
179+
180+## Open questions
181+
182+1. **Third-party plugins.** Do we expect plugin authors outside this
183+ repo? That sets the stability bar for the slot manifest contract
184+ and the resolver API.
185+2. **Templating layer.** Is there one core mini-template language
186+ (`{{slot}}`) that plugins opt into, or is interpolation strictly
187+ plugin-owned? Shared templating gives consistent UX (preview the
188+ resolved value in the UI, redact in logs uniformly); per-plugin
189+ templating gives more freedom but reinvents.
190+3. **Migration order.** Sketch the core API first and prove it on
191+ OpenAPI as the proving ground, then port GraphQL and MCP in
192+ follow-ups. Each step independently mergeable.
193+4. **Existing PR.** Land or shelve `codex/fix-openapi-add-flow` as-is
194+ before the bigger consolidation opens; do not swallow the focused
195+ cleanup into the larger refactor.
196+
197+## Suggested next steps
198+
199+- Close out the existing PR.
200+- Draft a real plan note that specifies the core API surface (resolver
201+ signature, manifest shape, HTTP endpoints) before touching code.
202+- Once the shape is agreed, port OpenAPI first: delete the four child
203+ tables, move shape to source-row storage, replace the plugin-level
204+ binding helpers with core calls, replace the plugin-level HTTP group
205+ with a core one.
206+- Port GraphQL and MCP to match.
207+- Pick the first arbitrary source type (likely DB connection string or
208+ CLI) as a forcing function to validate the abstraction works beyond
209+ HTTP-ish shapes.
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
219219 plugins,
220220 onElicitation: "accept-all",
221221 });
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+ });
223229 const invoker = makeExecutorToolInvoker(executor, { invokeOptions: autoApprove });
224230 return { executor, invoker, captured: recording.captured, sql };
225231 }),
packages/plugins/openapi/src/api/group.ts+23−31
@@ -1,11 +1,6 @@
11 import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
22 import { Schema } from "effect";
3−import {
4− InternalError,
5− ScopeId,
6− ScopedSecretCredentialInput,
7− SecretBackedValue,
8−} from "@executor-js/sdk/shared";
3+import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared";
94
105 import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
116 import { SpecPreview } from "../sdk/preview";
@@ -42,57 +37,54 @@ const SourceBindingParams = { · 30 unmodified lines
4237 sourceScopeId: ScopeId,
4338 };
4439
45−const SpecFetchCredentialsPayload = Schema.Struct({
40+const OpenApiSpecInputPayload = Schema.Union([
41+ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
42+ Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
43+]);
44+
45+const PreviewSpecFetchCredentialsPayload = Schema.Struct({
4646 headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
4747 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
4848 });
4949
50−const ConfiguredCredentialBindingPayload = Schema.Struct({
51− kind: Schema.Literal("binding"),
52− slot: Schema.String,
50+const OpenApiSecretShapePayload = Schema.Struct({
51+ kind: Schema.Literal("secret"),
5352 prefix: Schema.optional(Schema.String),
5453 });
5554
56−const ConfiguredCredentialValuePayload = Schema.Union([
57− Schema.String,
58− ConfiguredCredentialBindingPayload,
59−]);
55+const OpenApiConfiguredValuePayload = Schema.Union([Schema.String, OpenApiSecretShapePayload]);
6056
61−const OpenApiCredentialInputPayload = Schema.Union([
62− ScopedSecretCredentialInput,
63− SecretBackedValue,
64− ConfiguredCredentialValuePayload,
65−]);
57+const SpecFetchCredentialsPayload = Schema.Struct({
58+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
59+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
60+});
6661
6762 // ---------------------------------------------------------------------------
6863 // Payloads
6964 // ---------------------------------------------------------------------------
7065
7166 const AddSpecPayload = Schema.Struct({
72− targetScope: ScopeId,
73− credentialTargetScope: Schema.optional(ScopeId),
74− spec: Schema.String,
67+ spec: OpenApiSpecInputPayload,
7568 specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload),
76− name: Schema.optional(Schema.String),
77− baseUrl: Schema.optional(Schema.String),
78− namespace: Schema.optional(Schema.String),
79− headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
80− queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
69+ name: Schema.String,
70+ baseUrl: Schema.String,
71+ namespace: Schema.String,
72+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
73+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
8174 oauth2: Schema.optional(OAuth2SourceConfig),
8275 });
8376
8477 const PreviewSpecPayload = Schema.Struct({
8578 spec: Schema.String,
86− specFetchCredentials: Schema.optional(SpecFetchCredentialsPayload),
79+ specFetchCredentials: Schema.optional(PreviewSpecFetchCredentialsPayload),
8780 });
8881
8982 const UpdateSourcePayload = Schema.Struct({
9083 sourceScope: ScopeId,
9184 name: Schema.optional(Schema.String),
9285 baseUrl: Schema.optional(Schema.String),
93− headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
94− queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
95− credentialTargetScope: Schema.optional(ScopeId),
86+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
87+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)),
9688 // Set after a successful re-authenticate to refresh the source's
9789 // stored OAuth2 metadata.
9890 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
33
44 import { addGroup, capture } from "@executor-js/api";
55 import type {
6− ConfiguredHeaderValue,
6+ OpenApiConfiguredValueInput,
77 OpenApiPluginExtension,
8− HeaderValue,
9− OpenApiCredentialInput,
8+ OpenApiPreviewSpecFetchCredentialsInput,
109 OpenApiSpecFetchCredentialsInput,
1110 OpenApiUpdateSourceInput,
1211 } from "../sdk/plugin";
@@ -55,13 +54,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 42 unmodified lines
5554 return yield* ext.previewSpec({
5655 spec: payload.spec,
5756 specFetchCredentials: payload.specFetchCredentials as
58− | OpenApiSpecFetchCredentialsInput
57+ | OpenApiPreviewSpecFetchCredentialsInput
5958 | undefined,
6059 });
6160 }),
6261 ),
6362 )
64− .handle("addSpec", ({ payload }) =>
63+ .handle("addSpec", ({ params: path, payload }) =>
6564 capture(
6665 Effect.gen(function* () {
6766 const ext = yield* OpenApiExtensionService;
@@ -70,15 +69,14 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 2 unmodified lines
7069 specFetchCredentials: payload.specFetchCredentials as
7170 | OpenApiSpecFetchCredentialsInput
7271 | undefined,
73− scope: payload.targetScope,
72+ scope: path.scopeId,
7473 name: payload.name,
7574 baseUrl: payload.baseUrl,
7675 namespace: payload.namespace,
77− credentialTargetScope: payload.credentialTargetScope,
78− headers: payload.headers as
79− | Record<string, HeaderValue | ConfiguredHeaderValue>
76+ headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined,
77+ queryParams: payload.queryParams as
78+ | Record<string, OpenApiConfiguredValueInput>
8079 | undefined,
81− queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined,
8280 oauth2: payload.oauth2,
8381 });
8482 return {
@@ -119,11 +117,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 34 unmodified lines
119117 yield* ext.updateSource(path.namespace, payload.sourceScope, {
120118 name: payload.name,
121119 baseUrl: payload.baseUrl,
122− headers: payload.headers as
123− | Record<string, HeaderValue | ConfiguredHeaderValue>
120+ headers: payload.headers as Record<string, OpenApiConfiguredValueInput> | undefined,
121+ queryParams: payload.queryParams as
122+ | Record<string, OpenApiConfiguredValueInput>
124123 | undefined,
125− queryParams: payload.queryParams as Record<string, OpenApiCredentialInput> | undefined,
126− credentialTargetScope: payload.credentialTargetScope,
127124 oauth2: payload.oauth2,
128125 } as OpenApiUpdateSourceInput);
129126 return { updated: true };
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+143−110
@@ -1,5 +1,6 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
3+import * as Effect from "effect/Effect";
34 import * as Exit from "effect/Exit";
45 import * as Match from "effect/Match";
56 import * as Option from "effect/Option";
@@ -66,13 +67,10 @@ import { · 60 unmodified lines
6667 oauth2ClientSecretSlot,
6768 oauth2ConnectionSlot,
6869 queryParamBindingSlot,
70+ specFetchHeaderBindingSlot,
71+ specFetchQueryParamBindingSlot,
6972 } from "../sdk/source-contracts";
70−import {
71− ConfiguredHeaderBinding,
72− OAuth2SourceConfig,
73− OpenApiSourceBindingInput,
74− type ServerInfo,
75−} from "../sdk/types";
73+import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types";
7674 import { expandServerUrlOptions } from "../sdk/openapi-utils";
7775
7876 export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth";
@@ -128,6 +126,19 @@ export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { · 49 unmodified lines
128126 }
129127 }
130128
129+const specInputForAdd = (input: string) => {
130+ const value = input.trim();
131+ const parsed = Effect.runSyncExit(
132+ Effect.try({
133+ try: () => new URL(value),
134+ catch: () => null,
135+ }),
136+ );
137+ return Exit.isSuccess(parsed)
138+ ? { kind: "url" as const, url: value }
139+ : { kind: "blob" as const, value };
140+};
141+
131142 type StrategySelection =
132143 | { readonly kind: "none" }
133144 | { readonly kind: "custom" }
@@ -256,35 +267,19 @@ export default function AddOpenApiSource(props: { · 122 unmodified lines
256267 })),
257268 [scopeStack],
258269 );
259− const defaultCredentialTargetScope =
260− credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId;
261270 const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId;
262− const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>(
263− defaultCredentialTargetScope,
264− );
265271 const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>(
266272 defaultOAuthTokenTargetScope,
267273 );
268− const bindingScopeOptions = useMemo(
269− () => credentialScopeOptions.map((option) => ({ ...option })),
270− [credentialScopeOptions],
274+ const sourceCredentialScopeOptions = useMemo(
275+ () => credentialScopeOptions.filter((option) => option.scopeId === scopeId),
276+ [credentialScopeOptions, scopeId],
271277 );
272− useEffect(() => {
273− if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) {
274− setCredentialTargetScope(defaultCredentialTargetScope);
275− }
276− }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]);
277278 useEffect(() => {
278279 if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) {
279280 setOAuthTokenTargetScope(defaultOAuthTokenTargetScope);
280281 }
281282 }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]);
282− const initialCredentialScopeOption =
283− credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ??
284− credentialScopeOptions[0];
285− const initialCredentialScopeOptions = initialCredentialScopeOption
286− ? [initialCredentialScopeOption]
287− : [];
288283 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
289284 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {
290285 mode: "promiseExit",
@@ -294,9 +289,9 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
294289 mode: "promiseExit",
295290 });
296291 const secretList = useSecretPickerSecrets();
297− const initialCredentialSecrets = useMemo(
298− () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)),
299− [credentialTargetScope, secretList],
292+ const sourceCredentialSecrets = useMemo(
293+ () => secretList.filter((secret) => secret.scopeId === String(scopeId)),
294+ [scopeId, secretList],
300295 );
301296 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
302297 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -336,35 +331,29 @@ export default function AddOpenApiSource(props: { · 33 unmodified lines
336331 );
337332
338333 const resolvedBaseUrl = baseUrl.trim();
334+ const sourceScope = ScopeId.make(scopeId);
339335
340− const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};
341− const headerBindings: Array<{
342− slot: string;
343− secretId: string;
344− scope: ScopeId;
345− secretScope: ScopeId;
346− }> = [];
347− const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {};
348− const queryParamBindings: Array<{
349− slot: string;
350− secretId: string;
351− scope: ScopeId;
352− secretScope: ScopeId;
353− }> = [];
336+ type PendingSecretBinding = {
337+ readonly slot: string;
338+ readonly secretId: string;
339+ readonly scope: ScopeId;
340+ readonly secretScope: ScopeId;
341+ };
342+
343+ const configuredHeaders: Record<string, { kind: "secret"; prefix?: string }> = {};
344+ const headerBindings: PendingSecretBinding[] = [];
345+ const configuredQueryParams: Record<string, string | { kind: "secret"; prefix?: string }> = {};
346+ const queryParamBindings: PendingSecretBinding[] = [];
354347 for (const ch of customHeaders) {
355348 if (!ch.name.trim()) continue;
356349 const slot = headerBindingSlot(ch.name.trim());
357− configuredHeaders[ch.name.trim()] = ConfiguredHeaderBinding.make({
358− kind: "binding",
359− slot,
360− prefix: ch.prefix,
361− });
350+ configuredHeaders[ch.name.trim()] = { kind: "secret", prefix: ch.prefix };
362351 if (ch.secretId) {
363352 headerBindings.push({
364353 slot,
365354 secretId: ch.secretId,
366− scope: ch.targetScope ?? credentialTargetScope,
367− secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope,
355+ scope: sourceScope,
356+ secretScope: ch.secretScope ?? sourceScope,
368357 });
369358 }
370359 }
@@ -373,16 +362,12 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
373362 if (!name) continue;
374363 if (param.secretId) {
375364 const slot = queryParamBindingSlot(name);
376− configuredQueryParams[name] = ConfiguredHeaderBinding.make({
377− kind: "binding",
378− slot,
379− prefix: param.prefix,
380− });
365+ configuredQueryParams[name] = { kind: "secret", prefix: param.prefix };
381366 queryParamBindings.push({
382367 slot,
383368 secretId: param.secretId,
384− scope: param.targetScope ?? credentialTargetScope,
385− secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope,
369+ scope: sourceScope,
370+ secretScope: param.secretScope ?? sourceScope,
386371 });
387372 continue;
388373 }
@@ -390,6 +375,52 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
390375 configuredQueryParams[name] = param.literalValue.trim();
391376 }
392377 }
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;
393424
394425 const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? [];
395426 const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH);
@@ -447,8 +478,14 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines
447478 const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some(
448479 (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(),
449480 );
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+ );
450486 const willAddWithoutInitialCredentials =
451487 Boolean(selectedOAuth2Preset && !oauth2Auth) ||
488+ hasIncompleteSpecFetchCredentials ||
452489 hasIncompleteHeaderCredentials ||
453490 hasIncompleteQueryCredentials;
454491
@@ -539,24 +576,6 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines
539576 }
540577 };
541578
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−
560579 const toggleOAuth2Scope = (scope: string) => {
561580 setOauth2SelectedScopes((prev) => {
562581 const copy = new Set(prev);
@@ -709,13 +728,13 @@ export default function AddOpenApiSource(props: { · 146 unmodified lines
709728 const exit = await doAdd({
710729 params: { scopeId },
711730 payload: {
712− targetScope: scopeId,
713− credentialTargetScope,
714− spec: specUrl,
715− specFetchCredentials: serializeHttpCredentials(specFetchCredentials),
731+ spec: specInputForAdd(specUrl),
716732 name: displayName,
717733 namespace,
718− baseUrl: resolvedBaseUrl || undefined,
734+ baseUrl: resolvedBaseUrl,
735+ ...(configuredSpecFetchCredentials
736+ ? { specFetchCredentials: configuredSpecFetchCredentials }
737+ : {}),
719738 ...(hasHeaders ? { headers: configuredHeaders } : {}),
720739 ...(Object.keys(configuredQueryParams).length > 0
721740 ? { queryParams: configuredQueryParams }
@@ -731,11 +750,9 @@ export default function AddOpenApiSource(props: { · 9 unmodified lines
731750 }
732751
733752 const sourceId = exit.value.namespace;
734− const sourceScope = ScopeId.make(scopeId);
735− const bindingScope = ScopeId.make(credentialTargetScope);
736753 const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope);
737− const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope;
738− const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope;
754+ const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope;
755+ const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope;
739756
740757 for (const binding of headerBindings) {
741758 const bindingExit = await doSetBinding({
@@ -783,13 +800,36 @@ export default function AddOpenApiSource(props: { · 41 unmodified lines
783800 }
784801 }
785802
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+
786826 if (configuredOAuth2 && oauth2ClientIdSecretId) {
787827 const bindingExit = await doSetBinding({
788828 params: { scopeId },
789829 payload: OpenApiSourceBindingInput.make({
790830 sourceId,
791831 sourceScope,
792− scope: bindingScope,
832+ scope: sourceScope,
793833 slot: configuredOAuth2.clientIdSlot,
794834 value: {
795835 kind: "secret",
@@ -812,7 +852,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
812852 payload: OpenApiSourceBindingInput.make({
813853 sourceId,
814854 sourceScope,
815− scope: bindingScope,
855+ scope: sourceScope,
816856 slot: configuredOAuth2.clientSecretSlot,
817857 value: {
818858 kind: "secret",
@@ -908,7 +948,10 @@ export default function AddOpenApiSource(props: { · 89 unmodified lines
908948 onChange={setSpecFetchCredentials}
909949 existingSecrets={secretList}
910950 sourceName={identity.name}
911− targetScope={credentialTargetScope}
951+ targetScope={sourceScope}
952+ credentialScopeOptions={sourceCredentialScopeOptions}
953+ bindingScopeOptions={sourceCredentialScopeOptions}
954+ restrictSecretsToTargetScope
912955 labels={{
913956 headers: "Spec fetch headers",
914957 queryParams: "Spec fetch query parameters",
@@ -1044,9 +1087,9 @@ export default function AddOpenApiSource(props: { · 129 unmodified lines
10441087 onHeadersChange={handleHeadersChange}
10451088 existingSecrets={secretList}
10461089 sourceName={identity.name}
1047− targetScope={credentialTargetScope}
1048− credentialScopeOptions={initialCredentialScopeOptions}
1049− bindingScopeOptions={bindingScopeOptions}
1090+ targetScope={sourceScope}
1091+ credentialScopeOptions={sourceCredentialScopeOptions}
1092+ bindingScopeOptions={sourceCredentialScopeOptions}
10501093 restrictSecretsToTargetScope
10511094 emptyLabel="No credentials yet. Add the header value this method should use."
10521095 />
@@ -1058,9 +1101,9 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
10581101 onChange={setRuntimeCredentials}
10591102 existingSecrets={secretList}
10601103 sourceName={identity.name}
1061− targetScope={credentialTargetScope}
1062− credentialScopeOptions={initialCredentialScopeOptions}
1063− bindingScopeOptions={bindingScopeOptions}
1104+ targetScope={sourceScope}
1105+ credentialScopeOptions={sourceCredentialScopeOptions}
1106+ bindingScopeOptions={sourceCredentialScopeOptions}
10641107 restrictSecretsToTargetScope
10651108 sections={{ headers: false, queryParams: true }}
10661109 labels={{ queryParams: "Runtime query parameters" }}
@@ -1096,22 +1139,17 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
10961139 value={oauth2ClientIdSecretId}
10971140 onSelect={(id: string, secretScopeId?: ScopeId) => {
10981141 setOauth2ClientIdSecretId(id);
1099− setOauth2ClientIdScope(secretScopeId ?? credentialTargetScope);
1142+ setOauth2ClientIdScope(secretScopeId ?? sourceScope);
11001143 setOauth2AuthState(null);
11011144 }}
1102− secrets={initialCredentialSecrets}
1145+ secrets={sourceCredentialSecrets}
11031146 sourceName={identity.name}
11041147 secretLabel="Client ID"
1105− targetScope={oauth2ClientIdScope ?? credentialTargetScope}
1106− credentialScopeOptions={initialCredentialScopeOptions}
1148+ targetScope={oauth2ClientIdScope ?? sourceScope}
1149+ credentialScopeOptions={sourceCredentialScopeOptions}
11071150 onCreatedScope={setOauth2ClientIdScope}
11081151 />
11091152 </div>
1110− <CredentialScopeDropdown
1111− value={credentialTargetScope}
1112− options={bindingScopeOptions}
1113− onChange={setInitialCredentialScope}
1114− />
11151153 </div>
11161154 </div>
11171155 <div className="space-y-1.5">
@@ -1133,22 +1171,17 @@ export default function AddOpenApiSource(props: { · 15 unmodified lines
11331171 value={oauth2ClientSecretSecretId}
11341172 onSelect={(id: string, secretScopeId?: ScopeId) => {
11351173 setOauth2ClientSecretSecretId(id);
1136− setOauth2ClientSecretScope(secretScopeId ?? credentialTargetScope);
1174+ setOauth2ClientSecretScope(secretScopeId ?? sourceScope);
11371175 setOauth2AuthState(null);
11381176 }}
1139− secrets={initialCredentialSecrets}
1177+ secrets={sourceCredentialSecrets}
11401178 sourceName={identity.name}
11411179 secretLabel="Client Secret"
1142− targetScope={oauth2ClientSecretScope ?? credentialTargetScope}
1143− credentialScopeOptions={initialCredentialScopeOptions}
1180+ targetScope={oauth2ClientSecretScope ?? sourceScope}
1181+ credentialScopeOptions={sourceCredentialScopeOptions}
11441182 onCreatedScope={setOauth2ClientSecretScope}
11451183 />
11461184 </div>
1147− <CredentialScopeDropdown
1148− value={credentialTargetScope}
1149− options={bindingScopeOptions}
1150− onChange={setInitialCredentialScope}
1151− />
11521185 </div>
11531186 </div>
11541187 <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
4343 const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`;
4444 const source = {
4545 id,
46− scopeId: arg.payload.targetScope,
46+ scopeId,
4747 kind: "openapi",
4848 pluginId: "openapi",
4949 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
110110 ...options,
111111 });
112112
113−const testApiSpec = () => testApiSourceConfig().spec;
113+const testApiSpec = () => {
114+ const spec = testApiSourceConfig().spec;
115+ return spec.kind === "blob" ? spec.value : spec.url;
116+};
114117
115118 // ---------------------------------------------------------------------------
116119 // Implement handlers
@@ -172,6 +175,9 @@ const serveSpecRequiringHeader = () => { · 55 unmodified lines
172175 Effect.sync(() => {
173176 state.requests++;
174177 state.lastToken = request.headers["x-spec-token"] ?? null;
178+ if (state.requests === 1) {
179+ return null;
180+ }
175181 if (state.lastToken !== "org-token") {
176182 return HttpServerResponse.jsonUnsafe({ error: "missing token" }, { status: 401 });
177183 }
@@ -292,10 +298,10 @@ describe("OpenAPI Plugin", () => { · 114 unmodified lines
292298
293299 expect(schema).not.toBeNull();
294300 expect(schema!.inputTypeScript).toContain("scope: string");
295− expect(schema!.inputTypeScript).toContain("spec: string");
301+ expect(schema!.inputTypeScript).toContain('kind: "url"');
296302 expect(
297303 (schema!.inputSchema as { properties?: Record<string, unknown> }).properties,
298− ).toHaveProperty("credentialTargetScope");
304+ ).not.toHaveProperty("credentialTargetScope");
299305 expect(schema!.inputTypeScript).not.toBe("Record<string, unknown>");
300306 }),
301307 );
@@ -395,8 +401,7 @@ describe("OpenAPI Plugin", () => { · 93 unmodified lines
395401 const input = testApiSourceConfig({
396402 scope: String(orgScope),
397403 namespace: "org_direct_user_credential",
398− queryParams: { token: { secretId: "user-query-token" } },
399− credentialTargetScope: String(userScope),
404+ queryParams: { token: { kind: "secret" } },
400405 });
401406
402407 yield* executor.openapi.addSpec(input);
@@ -405,15 +410,7 @@ describe("OpenAPI Plugin", () => { · 2 unmodified lines
405410 "org_direct_user_credential",
406411 String(orgScope),
407412 );
408− expect(bindings).toHaveLength(1);
409− expect(bindings[0]).toMatchObject({
410− scopeId: userScope,
411− slot: "query_param:token",
412− value: {
413− kind: "secret",
414− secretId: SecretId.make("user-query-token"),
415− },
416− });
413+ expect(bindings).toEqual([]);
417414 }),
418415 );
419416
@@ -443,12 +440,20 @@ describe("OpenAPI Plugin", () => { · 23 unmodified lines
443440 testApiSourceConfig({
444441 namespace: "stale_binding",
445442 baseUrl: "",
446− credentialTargetScope: TEST_SCOPE,
447443 headers: {
448− "X-Old": { secretId: "old-token" },
444+ "X-Old": { kind: "secret" },
449445 },
450446 }),
451447 );
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+ );
452457
453458 yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, {
454459 headers: {},
@@ -555,12 +560,20 @@ describe("OpenAPI Plugin", () => { · 100 unmodified lines
555560 yield* addOpenApiTestSource(executor, server, {
556561 scope: TEST_SCOPE,
557562 namespace: "authed",
558− credentialTargetScope: TEST_SCOPE,
559563 headers: {
560− Authorization: { secretId: "test-api-token", prefix: "Bearer " },
564+ Authorization: { kind: "secret", prefix: "Bearer " },
561565 "X-Static": "hello",
562566 },
563567 });
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+ );
564577
565578 const result = unwrapInvocation(
566579 yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove),
@@ -574,12 +587,7 @@ describe("OpenAPI Plugin", () => { · 7 unmodified lines
574587 ),
575588 );
576589
577− it.effect("addSpec without credentialTargetScope defaults to the source's scope", () =>
578− // Regression: config-sync calls addSpec without ever setting
579− // credentialTargetScope. Before the fix, any source with a
580− // header secret in executor.jsonc errored with
581− // "credentialTargetScope is required when adding direct OpenAPI
582− // credentials" the moment the daemon started.
590+ it.effect("addSpec declares secret-backed header shape without a credential value", () =>
583591 Effect.gen(function* () {
584592 const clientLayer = FetchHttpClient.layer;
585593
@@ -607,7 +615,7 @@ describe("OpenAPI Plugin", () => { · 21 unmodified lines
607615 baseUrl: "",
608616 headers: {
609617 Authorization: {
610− secretId: "config-sync-token",
618+ kind: "secret",
611619 prefix: "Bearer ",
612620 },
613621 },
@@ -618,15 +626,7 @@ describe("OpenAPI Plugin", () => { · 4 unmodified lines
618626 "default_target_scope",
619627 TEST_SCOPE,
620628 );
621− expect(bindings).toHaveLength(1);
622− expect(bindings[0]).toMatchObject({
623− scopeId: ScopeId.make(TEST_SCOPE),
624− slot: "header:authorization",
625− value: {
626− kind: "secret",
627− secretId: SecretId.make("config-sync-token"),
628− },
629− });
629+ expect(bindings).toEqual([]);
630630 }),
631631 );
632632
@@ -975,6 +975,7 @@ describe("OpenAPI Plugin", () => { · 342 unmodified lines
975975 testApiSourceConfig({
976976 scope: String(USER_SCOPE),
977977 namespace: "shared",
978+ baseUrl: null,
978979 name: "User Source",
979980 }),
980981 );
@@ -1017,6 +1018,7 @@ describe("OpenAPI Plugin", () => { · 36 unmodified lines
10171018 testApiSourceConfig({
10181019 scope: String(USER_SCOPE),
10191020 namespace: "shared",
1021+ baseUrl: null,
10201022 name: "User Source",
10211023 }),
10221024 );
@@ -1057,7 +1059,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines
10571059 testApiSourceConfig({
10581060 scope: String(USER_SCOPE),
10591061 namespace: "shared",
1060− baseUrl: "",
1062+ baseUrl: null,
10611063 name: "User Source",
10621064 }),
10631065 );
@@ -1098,6 +1100,7 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines
10981100 testApiSourceConfig({
10991101 scope: String(USER_SCOPE),
11001102 namespace: "shared",
1103+ baseUrl: null,
11011104 name: "User Source",
11021105 }),
11031106 );
@@ -1149,19 +1152,19 @@ describe("OpenAPI Plugin", () => { · 45 unmodified lines
11491152 );
11501153
11511154 yield* executor.openapi.addSpec({
1152− spec: server.specJson,
1155+ spec: { kind: "blob", value: server.specJson },
11531156 scope: String(ORG_SCOPE),
1157+ name: "Shadow Auth",
11541158 namespace: "shadow_auth",
11551159 baseUrl: "https://org.example.com",
1156− credentialTargetScope: String(ORG_SCOPE),
11571160 headers: {
1158− Authorization: { secretId: "org-api-token", prefix: "Bearer " },
1161+ Authorization: { kind: "secret", prefix: "Bearer " },
11591162 },
11601163 });
11611164
11621165 const addResult = yield* executor.openapi
11631166 .addSpec({
1164− spec: server.specJson,
1167+ spec: { kind: "blob", value: server.specJson },
11651168 scope: String(USER_SCOPE),
11661169 namespace: "shadow_auth",
11671170 baseUrl: server.baseUrl,
@@ -1202,20 +1205,31 @@ describe("OpenAPI Plugin", () => { · 34 unmodified lines
12021205 );
12031206
12041207 yield* executor.openapi.addSpec({
1205− spec: server.specUrl,
1208+ spec: { kind: "url", url: server.specUrl },
12061209 scope: String(ORG_SCOPE),
1210+ name: "Shared Spec Fetch",
12071211 namespace: "shared_spec_fetch",
1208− credentialTargetScope: String(ORG_SCOPE),
1212+ baseUrl: "https://api.example.test",
12091213 specFetchCredentials: {
12101214 headers: {
1211− "X-Spec-Token": { secretId: "org-spec-token" },
1215+ "X-Spec-Token": { kind: "secret" },
12121216 },
12131217 },
12141218 });
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+ );
12151228 yield* executor.openapi.addSpec(
12161229 testApiSourceConfig({
12171230 scope: String(USER_SCOPE),
12181231 namespace: "shared_spec_fetch",
1232+ baseUrl: null,
12191233 name: "User Shadow",
12201234 }),
12211235 );
@@ -1390,8 +1404,16 @@ describe("OpenAPI Plugin", () => { · 168 unmodified lines
13901404 testApiSourceConfig({
13911405 namespace: "with_secret",
13921406 baseUrl: "http://example.com",
1393− credentialTargetScope: TEST_SCOPE,
1394− queryParams: { token: { secretId: "api-key" } },
1407+ queryParams: { token: { kind: "secret" } },
1408+ }),
1409+ );
1410+ yield* executor.openapi.setSourceBinding(
1411+ OpenApiSourceBindingInput.make({
1412+ sourceId: "with_secret",
1413+ sourceScope: ScopeId.make(TEST_SCOPE),
1414+ scope: ScopeId.make(TEST_SCOPE),
1415+ slot: "query_param:token",
1416+ value: { kind: "secret", secretId: SecretId.make("api-key") },
13951417 }),
13961418 );
13971419
packages/plugins/openapi/src/sdk/plugin.ts+76−177
@@ -4,7 +4,6 @@ import { HttpClient } from "effect/unstable/http"; · 3 unmodified lines
44
55 import {
66 ScopeId,
7− SecretId,
87 SourceDetectionResult,
98 StorageError,
109 ToolResult,
@@ -29,7 +28,6 @@ import { parse, resolveSpecText } from "./parse"; · 18 unmodified lines
2928 import { extract } from "./extract";
3029 import { compileToolDefinitions, type ToolDefinition } from "./definitions";
3130 import { annotationsForOperation, invokeWithLayer } from "./invoke";
32−import { resolveBaseUrl } from "./openapi-utils";
3331 import { previewSpec, SpecPreview } from "./preview";
3432 import {
3533 makeDefaultOpenapiStore,
@@ -41,13 +39,10 @@ import { · 5 unmodified lines
4139 } from "./store";
4240 import {
4341 HeaderValue as HeaderValueSchema,
44− ConfiguredHeaderValue as ConfiguredHeaderValueSchema,
4542 ConfiguredHeaderBinding,
4643 OAuth2SourceConfig,
47− OpenApiCredentialInput as OpenApiCredentialInputSchema,
4844 OpenApiSourceBindingInput,
4945 OpenApiSourceBindingRef,
50− type OpenApiCredentialInput as OpenApiCredentialInputValue,
5146 type OpenApiSourceBindingValue,
5247 OperationBinding,
5348 type ConfiguredHeaderValue as ConfiguredHeaderValueValue,
@@ -131,22 +126,34 @@ const extractUpstreamMessage = (body: unknown, status: number): string => { · 77 unmodified lines
131126
132127 export type HeaderValue = HeaderValueValue;
133128 export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
134−export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
135−export type OpenApiCredentialInput = OpenApiCredentialInputValue;
136129 export type OpenApiOAuthInput = OAuth2SourceConfig;
137130
131+export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type;
132+
133+export type OpenApiSecretShapeInput = typeof OpenApiSecretShapeInputSchema.Type;
134+
135+export type OpenApiConfiguredValueInput =
136+ | string
137+ | OpenApiSecretShapeInput
138+ | ConfiguredHeaderValueValue;
139+
138140 export interface OpenApiSpecFetchCredentialsInput {
141+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
142+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
143+}
144+
145+export interface OpenApiPreviewSpecFetchCredentialsInput {
139146 readonly headers?: Record<string, HeaderValue>;
140147 readonly queryParams?: Record<string, HeaderValue>;
141148 }
142149
143150 export interface OpenApiPreviewInput {
144151 readonly spec: string;
145− readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput;
152+ readonly specFetchCredentials?: OpenApiPreviewSpecFetchCredentialsInput;
146153 }
147154
148155 export interface OpenApiSpecConfig {
149− readonly spec: string;
156+ readonly spec: OpenApiSpecInput;
150157 readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput;
151158 /**
152159 * Executor scope id that owns this source row. Must be one of the
@@ -155,21 +162,19 @@ export interface OpenApiSpecConfig { · 2 unmodified lines
155162 * every inner (per-user) scope via fall-through reads.
156163 */
157164 readonly scope: string;
158− readonly name?: string;
159− readonly baseUrl?: string;
160− readonly namespace?: string;
161− readonly headers?: Record<string, OpenApiCredentialInput>;
162− readonly queryParams?: Record<string, OpenApiCredentialInput>;
165+ readonly name: string;
166+ readonly baseUrl: string;
167+ readonly namespace: string;
168+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
169+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
163170 readonly oauth2?: OpenApiOAuthInput;
164− readonly credentialTargetScope?: string;
165171 }
166172
167173 export interface OpenApiUpdateSourceInput {
168174 readonly name?: string;
169175 readonly baseUrl?: string;
170− readonly headers?: Record<string, OpenApiCredentialInput>;
171− readonly queryParams?: Record<string, OpenApiCredentialInput>;
172− readonly credentialTargetScope?: string;
176+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
177+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
173178 /** Refresh the source's stored OAuth2 metadata after a successful
174179 * re-authenticate. */
175180 readonly oauth2?: OpenApiOAuthInput;
@@ -243,23 +248,33 @@ const PreviewSpecInputSchema = Schema.Struct({ · 67 unmodified lines
243248 ),
244249 });
245250
246−const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
251+const OpenApiSpecInputSchema = Schema.Union([
252+ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }),
253+ Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }),
254+]);
255+const OpenApiSecretShapeInputSchema = Schema.Struct({
256+ kind: Schema.Literal("secret"),
257+ prefix: Schema.optional(Schema.String),
258+});
259+const OpenApiConfiguredValueInputSchema = Schema.Union([
260+ Schema.String,
261+ OpenApiSecretShapeInputSchema,
262+]);
247263 const OpenApiOAuthInputSchema = OAuth2SourceConfig;
248264
249265 const AddSourceInputSchema = Schema.Struct({
250266 scope: Schema.String,
251− spec: Schema.String,
252− name: Schema.optional(Schema.String),
253− baseUrl: Schema.optional(Schema.String),
254− namespace: Schema.optional(Schema.String),
255− headers: Schema.optional(Schema.Record(Schema.String, OpenApiHeaderInputSchema)),
256− queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputSchema)),
267+ spec: OpenApiSpecInputSchema,
268+ name: Schema.String,
269+ baseUrl: Schema.String,
270+ namespace: Schema.String,
271+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)),
272+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)),
257273 oauth2: Schema.optional(OpenApiOAuthInputSchema),
258− credentialTargetScope: Schema.optional(Schema.String),
259274 specFetchCredentials: Schema.optional(
260275 Schema.Struct({
261− headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),
262− queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)),
276+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)),
277+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)),
263278 }),
264279 ),
265280 });
@@ -346,27 +361,17 @@ const specFetchQueryParamSlotFromName = (name: string): string => · 80 unmodified lines
346361 `spec_fetch_query_param:${slotPart(name)}`;
347362
348363 const canonicalizeHeaders = (
349− headers: Record<string, OpenApiCredentialInput> | undefined,
364+ headers: Record<string, OpenApiConfiguredValueInput> | undefined,
350365 ): {
351366 readonly headers: Record<string, ConfiguredHeaderValue>;
352− readonly bindings: ReadonlyArray<{
353− readonly slot: string;
354− readonly value: OpenApiSourceBindingValue;
355− readonly targetScope?: string;
356− }>;
357367 } => {
358368 const nextHeaders: Record<string, ConfiguredHeaderValue> = {};
359− const bindings: Array<{
360− slot: string;
361− value: OpenApiSourceBindingValue;
362− targetScope?: string;
363− }> = [];
364369 for (const [name, value] of Object.entries(headers ?? {})) {
365370 if (typeof value === "string") {
366371 nextHeaders[name] = value;
367372 continue;
368373 }
369− if ("kind" in value) {
374+ if (value.kind === "binding") {
370375 nextHeaders[name] = value;
371376 continue;
372377 }
@@ -376,44 +381,23 @@ const canonicalizeHeaders = ( · 3 unmodified lines
376381 slot,
377382 prefix: value.prefix,
378383 });
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− });
390384 }
391− return { headers: nextHeaders, bindings };
385+ return { headers: nextHeaders };
392386 };
393387
394388 const canonicalizeCredentialMap = (
395− values: Record<string, OpenApiCredentialInput> | undefined,
389+ values: Record<string, OpenApiConfiguredValueInput> | undefined,
396390 slotForName: (name: string) => string,
397391 ): {
398392 readonly values: Record<string, ConfiguredHeaderValue>;
399− readonly bindings: ReadonlyArray<{
400− readonly slot: string;
401− readonly value: OpenApiSourceBindingValue;
402− readonly targetScope?: string;
403− }>;
404393 } => {
405394 const nextValues: Record<string, ConfiguredHeaderValue> = {};
406− const bindings: Array<{
407− slot: string;
408− value: OpenApiSourceBindingValue;
409− targetScope?: string;
410− }> = [];
411395 for (const [name, value] of Object.entries(values ?? {})) {
412396 if (typeof value === "string") {
413397 nextValues[name] = value;
414398 continue;
415399 }
416− if ("kind" in value) {
400+ if (value.kind === "binding") {
417401 nextValues[name] = value;
418402 continue;
419403 }
@@ -423,35 +407,19 @@ const canonicalizeCredentialMap = ( · 3 unmodified lines
423407 slot,
424408 prefix: value.prefix,
425409 });
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− });
437410 }
438− return { values: nextValues, bindings };
411+ return { values: nextValues };
439412 };
440413
441414 const canonicalizeSpecFetchCredentials = (
442415 credentials:
443416 | {
444− readonly headers?: Record<string, OpenApiCredentialInput>;
445− readonly queryParams?: Record<string, OpenApiCredentialInput>;
417+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
418+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
446419 }
447420 | undefined,
448421 ): {
449422 readonly credentials?: SourceConfig["specFetchCredentials"];
450− readonly bindings: ReadonlyArray<{
451− readonly slot: string;
452− readonly value: OpenApiSourceBindingValue;
453− readonly targetScope?: string;
454− }>;
455423 } => {
456424 const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName);
457425 const queryParams = canonicalizeCredentialMap(
@@ -469,7 +437,6 @@ const canonicalizeSpecFetchCredentials = ( · 11 unmodified lines
469437 };
470438 return {
471439 credentials: nextCredentials,
472− bindings: [...headers.bindings, ...queryParams.bindings],
473440 };
474441 };
475442
@@ -599,19 +566,6 @@ const validateOpenApiBindingTarget = ( · 123 unmodified lines
599566 }
600567 });
601568
602−const targetScopeForBinding = (
603− fallbackTargetScope: string | undefined,
604− binding: { readonly slot: string; readonly targetScope?: string },
605−): Effect.Effect<string, OpenApiOAuthError> => {
606− const targetScope = binding.targetScope ?? fallbackTargetScope;
607− if (targetScope) return Effect.succeed(targetScope);
608− return Effect.fail(
609− new OpenApiOAuthError({
610− message: "credentialTargetScope is required when adding direct OpenAPI credentials",
611− }),
612− );
613−};
614−
615569 const findOuterSource = (
616570 ctx: PluginCtx<OpenapiStore>,
617571 namespace: string,
@@ -790,7 +744,7 @@ const resolveOAuthConnectionId = ( · 172 unmodified lines
790744
791745 const resolveSpecFetchInputCredentials = (
792746 ctx: PluginCtx<OpenapiStore>,
793− credentials: OpenApiSpecFetchCredentialsInput | undefined,
747+ credentials: OpenApiPreviewSpecFetchCredentialsInput | undefined,
794748 ) =>
795749 Effect.gen(function* () {
796750 if (!credentials) return undefined;
@@ -850,13 +804,13 @@ const toOpenApiSourceConfig = ( · 53 unmodified lines
850804 ): OpenApiSourceConfig => {
851805 const configHeaders: Record<string, HeaderValueValue> = {};
852806 for (const [name, value] of Object.entries(config.headers ?? {})) {
853− if (typeof value === "string" || !("kind" in value)) {
807+ if (typeof value === "string") {
854808 configHeaders[name] = value;
855809 }
856810 }
857811 return {
858812 kind: "openapi",
859− spec: config.spec,
813+ spec: specInputToConfigString(config.spec),
860814 baseUrl: config.baseUrl,
861815 namespace,
862816 headers: headersToConfigValues(
@@ -865,24 +819,24 @@ const toOpenApiSourceConfig = ( · 2 unmodified lines
865819 };
866820 };
867821
868−const isHttpUrl = (s: string): boolean => s.startsWith("http://") || s.startsWith("https://");
822+const specInputToConfigString = (spec: OpenApiSpecInput): string =>
823+ spec.kind === "url" ? spec.url : spec.value;
869824
870825 export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => {
871826 type RebuildInput = {
872827 readonly specText: string;
873828 readonly scope: string;
874829 readonly sourceUrl?: string;
875− readonly name?: string;
876− readonly baseUrl?: string;
877− readonly namespace?: string;
878− readonly headers?: Record<string, OpenApiHeaderInput>;
879− readonly queryParams?: Record<string, OpenApiCredentialInput>;
830+ readonly name: string;
831+ readonly baseUrl: string | undefined;
832+ readonly namespace: string;
833+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
834+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
880835 readonly specFetchCredentials?: {
881− readonly headers?: Record<string, OpenApiCredentialInput>;
882− readonly queryParams?: Record<string, OpenApiCredentialInput>;
836+ readonly headers?: Record<string, OpenApiConfiguredValueInput>;
837+ readonly queryParams?: Record<string, OpenApiConfiguredValueInput>;
883838 };
884839 readonly oauth2?: OpenApiOAuthInput;
885− readonly credentialTargetScope?: string;
886840 };
887841
888842 // 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
893847 const doc = yield* parse(input.specText);
894848 const result = yield* extract(doc);
895849
896− const namespace =
897− input.namespace ??
898− Option.getOrElse(result.title, () => "api")
899− .toLowerCase()
900− .replace(/[^a-z0-9]+/g, "_");
850+ const namespace = input.namespace;
901851 const outerSource = yield* findOuterSource(ctx, namespace, input.scope);
902852 if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") {
903853 return yield* new OpenApiOAuthError({
@@ -912,7 +862,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 8 unmodified lines
912862 }
913863 }
914864
915− const baseUrl = outerSource ? undefined : (input.baseUrl ?? resolveBaseUrl(result.servers));
865+ const baseUrl = outerSource ? undefined : input.baseUrl;
916866 const canonicalHeaders = canonicalizeHeaders(input.headers);
917867 const canonicalQueryParams = canonicalizeCredentialMap(
918868 input.queryParams,
@@ -922,25 +872,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 3 unmodified lines
922872 input.specFetchCredentials,
923873 );
924874 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− }
942875 const definitions = compileToolDefinitions(result.operations);
943− const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace);
876+ const sourceName = input.name;
944877
945878 const sourceConfig: SourceConfig = {
946879 spec: input.specText,
@@ -989,23 +922,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 42 unmodified lines
989922 })),
990923 });
991924
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−
1009925 if (Object.keys(hoistedDefs).length > 0) {
1010926 yield* ctx.core.definitions.register({
1011927 sourceId: namespace,
@@ -1046,13 +962,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 34 unmodified lines
1046962 scope,
1047963 sourceUrl,
1048964 name: existing.name,
1049− baseUrl: resolvedConfig.baseUrl,
965+ baseUrl: existing.config.baseUrl,
1050966 namespace: existing.namespace,
1051967 headers: existing.config.headers,
1052968 queryParams: existing.config.queryParams,
1053969 specFetchCredentials: existing.config.specFetchCredentials,
1054970 oauth2: existing.config.oauth2,
1055− credentialTargetScope: scope,
1056971 });
1057972 });
1058973
@@ -1069,17 +984,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 10 unmodified lines
1069984 // Resolve URL → text and parse BEFORE opening a transaction.
1070985 // Holding `BEGIN` on the pool=1 Postgres connection across a
1071986 // network fetch is the Hyperdrive deadlock path in production.
1072− const credentials = yield* resolveSpecFetchInputCredentials(
1073− ctx,
1074− config.specFetchCredentials,
1075− );
1076− const specText = yield* resolveSpecText(config.spec, credentials).pipe(
1077− Effect.provide(httpClientLayer),
1078− );
987+ const specText =
988+ config.spec.kind === "url"
989+ ? yield* resolveSpecText(config.spec.url).pipe(Effect.provide(httpClientLayer))
990+ : config.spec.value;
1079991 return yield* rebuildSource(ctx, {
1080992 specText,
1081993 scope: config.scope,
1082− sourceUrl: isHttpUrl(config.spec) ? config.spec : undefined,
994+ sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined,
1083995 name: config.name,
1084996 baseUrl: config.baseUrl,
1085997 namespace: config.namespace,
@@ -1087,11 +999,6 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 1 unmodified line
1087999 queryParams: config.queryParams,
10881000 specFetchCredentials: config.specFetchCredentials,
10891001 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,
10951002 });
10961003 });
10971004
@@ -1164,17 +1071,12 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 66 unmodified lines
11641071 input.queryParams !== undefined
11651072 ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName)
11661073 : null;
1167− const directBindings = [
1168− ...(canonicalHeaders?.bindings ?? []),
1169− ...(canonicalQueryParams?.bindings ?? []),
1170− ...(canonicalOAuth2?.bindings ?? []),
1171− ];
11721074 const affectedPrefixes = [
11731075 ...(input.headers !== undefined ? ["header:"] : []),
11741076 ...(input.queryParams !== undefined ? ["query_param:"] : []),
11751077 ...(input.oauth2 !== undefined ? ["oauth2:"] : []),
11761078 ];
1177− const targetScope = input.credentialTargetScope ?? scope;
1079+ const targetScope = scope;
11781080 if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") {
11791081 const outerSource = yield* findOuterSource(ctx, namespace, scope);
11801082 if (outerSource) {
@@ -1183,7 +1085,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 2 unmodified lines
11831085 });
11841086 }
11851087 }
1186− if (affectedPrefixes.length > 0 || directBindings.length > 0) {
1088+ if (affectedPrefixes.length > 0) {
11871089 yield* validateOpenApiBindingTarget(ctx, {
11881090 sourceId: namespace,
11891091 sourceScope: scope,
@@ -1199,17 +1101,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 9 unmodified lines
11991101 queryParams: canonicalQueryParams?.values,
12001102 oauth2: canonicalOAuth2?.oauth2,
12011103 });
1202− if (affectedPrefixes.length > 0 || directBindings.length > 0) {
1104+ if (affectedPrefixes.length > 0) {
12031105 yield* ctx.credentialBindings.replaceForSource({
12041106 targetScope: ScopeId.make(targetScope),
12051107 pluginId: OPENAPI_PLUGIN_ID,
12061108 sourceId: namespace,
12071109 sourceScope: ScopeId.make(scope),
12081110 slotPrefixes: affectedPrefixes,
1209− bindings: directBindings.map((binding) => ({
1210− slotKey: binding.slot,
1211− value: binding.value,
1212− })),
1111+ bindings: [],
12131112 });
12141113 }
12151114 }),
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
9090 );
9191
9292 yield* executor.openapi.addSpec({
93− spec: specText,
93+ spec: { kind: "blob", value: specText },
9494 scope: TEST_SCOPE,
95+ name: namespace,
9596 namespace,
9697 baseUrl: "",
9798 });
packages/plugins/openapi/src/sdk/source-contracts.ts+6−0
@@ -41,6 +41,12 @@ export const headerBindingSlot = (headerName: string): string => · 40 unmodified lines
4141 export const queryParamBindingSlot = (name: string): string =>
4242 `query_param:${slugifySlotPart(name)}`;
4343
44+export const specFetchHeaderBindingSlot = (headerName: string): string =>
45+ `spec_fetch_header:${slugifySlotPart(headerName)}`;
46+
47+export const specFetchQueryParamBindingSlot = (name: string): string =>
48+ `spec_fetch_query_param:${slugifySlotPart(name)}`;
49+
4450 export const oauth2ClientIdSlot = (securitySchemeName: string): string =>
4551 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;
4652
packages/plugins/openapi/src/testing/index.ts+52−26
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
1313 OpenApi,
1414 } from "effect/unstable/httpapi";
1515 import { OAuthTestServer, serveTestHttpServerLayer } from "@executor-js/sdk/testing";
16−import { isToolResult, type ScopeId } from "@executor-js/sdk/core";
16+import { isToolResult } from "@executor-js/sdk/core";
1717 import type { OpenApiPluginExtension, OpenApiSpecConfig } from "../sdk/plugin";
1818
1919 export class OpenApiTestServerAddressError extends Data.TaggedError(
@@ -80,22 +80,45 @@ export interface OpenApiEchoTestServerShape extends OpenApiTestServerShape { · 60 unmodified lines
8080 readonly clearRequests: Effect.Effect<void>;
8181 }
8282
83−export type OpenApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec" | "baseUrl"> & {
83+export type OpenApiTestSourceOptions = Omit<
84+ OpenApiSpecConfig,
85+ "spec" | "baseUrl" | "name" | "namespace"
86+> & {
8487 readonly baseUrl?: string | null;
88+ readonly name?: string;
89+ readonly namespace?: string;
8590 };
8691
87−export type OpenApiHttpApiTestSourceOptions = Omit<OpenApiSpecConfig, "spec"> & {
92+export type OpenApiHttpApiTestSourceOptions = Omit<
93+ OpenApiSpecConfig,
94+ "spec" | "name" | "namespace" | "baseUrl"
95+> & {
96+ readonly name?: string;
97+ readonly namespace?: string;
98+ readonly baseUrl?: string | null;
8899 readonly specBaseUrl?: string;
89100 readonly transformSpec?: (spec: Record<string, unknown>) => Record<string, unknown>;
90101 };
91102
103+type OpenApiHttpApiAddSpecCredentialInput =
104+ | string
105+ | {
106+ readonly kind: "secret";
107+ readonly prefix?: string;
108+ };
109+
110+type OpenApiHttpApiAddSpecCredentialsInput = {
111+ readonly headers?: Record<string, OpenApiHttpApiAddSpecCredentialInput>;
112+ readonly queryParams?: Record<string, OpenApiHttpApiAddSpecCredentialInput>;
113+};
114+
92115 export type OpenApiHttpApiTestAddSpecPayloadOptions = Omit<
93116 OpenApiHttpApiTestSourceOptions,
94− "scope" | "credentialTargetScope"
95−> & {
96− readonly targetScope: ScopeId;
97− readonly credentialTargetScope?: ScopeId;
98−};
117+ "scope" | "headers" | "queryParams" | "specFetchCredentials"
118+> &
119+ OpenApiHttpApiAddSpecCredentialsInput & {
120+ readonly specFetchCredentials?: OpenApiHttpApiAddSpecCredentialsInput;
121+ };
99122
100123 export type OpenApiTestSourceExecutor = {
101124 readonly openapi: Pick<OpenApiPluginExtension, "addSpec">;
@@ -125,9 +148,11 @@ export const makeOpenApiTestSourceConfig = ( · 23 unmodified lines
125148 const { baseUrl, ...rest } = options;
126149 return {
127150 ...rest,
128− spec: server.specJson,
151+ spec: { kind: "blob", value: server.specJson },
152+ name: rest.name ?? "Test API",
153+ namespace: rest.namespace ?? "test_api",
129154 ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? server.baseUrl }),
130− };
155+ } as OpenApiSpecConfig;
131156 };
132157
133158 export const addOpenApiTestSource = (
@@ -140,11 +165,17 @@ export const makeOpenApiHttpApiTestSourceConfig = ( · 6 unmodified lines
140165 api: HttpApi.Any,
141166 options: OpenApiHttpApiTestSourceOptions,
142167 ): OpenApiSpecConfig => {
143− const { specBaseUrl, transformSpec, ...config } = options;
168+ const { baseUrl, specBaseUrl, transformSpec, ...config } = options;
144169 return {
145170 ...config,
146− spec: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }),
147− };
171+ spec: {
172+ kind: "blob",
173+ value: makeOpenApiTestSpecJson(api, { baseUrl: specBaseUrl, transformSpec }),
174+ },
175+ name: config.name ?? "Test API",
176+ namespace: config.namespace ?? "test_api",
177+ ...(baseUrl === null ? {} : { baseUrl: baseUrl ?? specBaseUrl ?? "https://api.example.test" }),
178+ } as OpenApiSpecConfig;
148179 };
149180
150181 export const addOpenApiHttpApiTestSource = (
@@ -157,26 +188,21 @@ export const makeOpenApiHttpApiTestAddSpecPayload = ( · 6 unmodified lines
157188 api: HttpApi.Any,
158189 options: OpenApiHttpApiTestAddSpecPayloadOptions,
159190 ) => {
160− const { targetScope, credentialTargetScope, ...sourceOptions } = options;
191+ const { ...sourceOptions } = options;
161192 const config = makeOpenApiHttpApiTestSourceConfig(api, {
162193 ...sourceOptions,
163− scope: String(targetScope),
164− ...(credentialTargetScope !== undefined
165− ? { credentialTargetScope: String(credentialTargetScope) }
166− : {}),
194+ scope: "unused-http-helper-scope",
167195 });
168196 return {
169− targetScope,
170197 spec: config.spec,
171198 namespace: config.namespace,
172− ...(config.name !== undefined ? { name: config.name } : {}),
173− ...(config.baseUrl !== undefined ? { baseUrl: config.baseUrl } : {}),
174− ...(config.headers !== undefined ? { headers: config.headers } : {}),
175− ...(config.queryParams !== undefined ? { queryParams: config.queryParams } : {}),
199+ name: config.name,
200+ baseUrl: config.baseUrl,
201+ ...(sourceOptions.headers !== undefined ? { headers: sourceOptions.headers } : {}),
202+ ...(sourceOptions.queryParams !== undefined ? { queryParams: sourceOptions.queryParams } : {}),
176203 ...(config.oauth2 !== undefined ? { oauth2: config.oauth2 } : {}),
177− ...(credentialTargetScope !== undefined ? { credentialTargetScope } : {}),
178− ...(config.specFetchCredentials !== undefined
179− ? { specFetchCredentials: config.specFetchCredentials }
204+ ...(sourceOptions.specFetchCredentials !== undefined
205+ ? { specFetchCredentials: sourceOptions.specFetchCredentials }
180206 : {}),
181207 };
182208 };