History

Merge pull request #703 from RhysSullivan/codex/openapi-credential-scope-ui

Improve scope add flow

Rhys Sullivan committed 6 months ago

Commit 088fb432 parents cbe85fe 36c806841 files · +2079 −830Browse files

Unified · Split

41 files
  1. apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql +2 −0
  2. apps/cloud/drizzle/meta/_journal.json +7 −0
  3. apps/cloud/src/services/executor-schema.ts +2 −0
  4. apps/cloud/src/services/sources-api.node.test.ts +2 −0
  5. apps/local/drizzle/0010_add_credential_binding_secret_scope.sql +2 −0
  6. apps/local/drizzle/meta/_journal.json +7 −0
  7. apps/local/src/server/executor-schema.ts +2 −0
  8. apps/local/src/server/migrate-oauth-connections.test.ts +1 −3
  9. notes/product-scope-language.md +47 −0
  10. packages/core/sdk/src/core-schema.ts +3 −1
  11. packages/core/sdk/src/credential-bindings.test.ts +127 −1
  12. packages/core/sdk/src/credential-bindings.ts +11 −1
  13. packages/core/sdk/src/executor.ts +46 −10
  14. packages/core/sdk/src/index.ts +1 −0
  15. packages/core/sdk/src/oauth-helpers.ts +94 −103
  16. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +67 −49
  17. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +2 −1
  18. packages/plugins/graphql/src/sdk/plugin.test.ts +62 −0
  19. packages/plugins/graphql/src/sdk/plugin.ts +45 −7
  20. packages/plugins/graphql/src/sdk/types.ts +6 −1
  21. packages/plugins/mcp/src/react/AddMcpSource.tsx +122 −146
  22. packages/plugins/mcp/src/sdk/plugin.ts +67 −33
  23. packages/plugins/mcp/src/sdk/types.ts +8 −1
  24. packages/plugins/openapi/src/api/group.ts +22 −5
  25. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +312 −79
  26. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +177 −116
  27. packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +120 −6
  28. packages/plugins/openapi/src/sdk/plugin.test.ts +1 −0
  29. packages/plugins/openapi/src/sdk/plugin.ts +58 −27
  30. packages/plugins/openapi/src/sdk/store.ts +3 −0
  31. packages/plugins/openapi/src/sdk/types.ts +15 −1
  32. packages/react/src/components/help-tooltip.tsx +32 −0
  33. packages/react/src/pages/secrets.tsx +11 −0
  34. packages/react/src/plugins/credential-target-scope.tsx +91 −3
  35. packages/react/src/plugins/headers-list.tsx +54 −6
  36. packages/react/src/plugins/http-credentials.tsx +84 −166
  37. packages/react/src/plugins/secret-form.tsx +3 −2
  38. packages/react/src/plugins/secret-header-auth.tsx +280 −55
  39. packages/react/src/plugins/secret-picker.tsx +33 −7
  40. packages/react/src/plugins/source-identity.test.ts +24 −0
  41. packages/react/src/plugins/source-identity.tsx +26 −0
apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql+2−0
@@ -0,0 +1,2 @@
1+ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint
2+CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id");
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -106,6 +106,13 @@ · 105 unmodified lines
106106 "when": 1778192434062,
107107 "tag": "0014_repair_openapi_oauth_cutover_residue",
108108 "breakpoints": true
109+ },
110+ {
111+ "idx": 15,
112+ "version": "7",
113+ "when": 1778192434063,
114+ "tag": "0015_add_credential_binding_secret_scope",
115+ "breakpoints": true
109116 }
110117 ]
111118 }
apps/cloud/src/services/executor-schema.ts+2−0
@@ -148,6 +148,7 @@ export const credential_binding = pgTable( · 147 unmodified lines
148148 kind: text("kind").notNull(),
149149 text_value: text("text_value"),
150150 secret_id: text("secret_id"),
151+ secret_scope_id: text("secret_scope_id"),
151152 connection_id: text("connection_id"),
152153 created_at: timestamp("created_at").notNull(),
153154 updated_at: timestamp("updated_at").notNull(),
@@ -161,6 +162,7 @@ export const credential_binding = pgTable( · 7 unmodified lines
161162 index("credential_binding_slot_key_idx").on(table.slot_key),
162163 index("credential_binding_kind_idx").on(table.kind),
163164 index("credential_binding_secret_id_idx").on(table.secret_id),
165+ index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
164166 index("credential_binding_connection_id_idx").on(table.connection_id),
165167 ],
166168 );
apps/cloud/src/services/sources-api.node.test.ts+2−0
@@ -830,6 +830,7 @@ describe("sources api (HTTP)", () => { · 829 unmodified lines
830830 value: {
831831 kind: "secret",
832832 secretId: SecretId.make("alice_pat"),
833+ secretScopeId: ScopeId.make(aliceScope),
833834 },
834835 }),
835836 );
@@ -858,6 +859,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines
858859 value: {
859860 kind: "secret",
860861 secretId: SecretId.make("bob_pat"),
862+ secretScopeId: ScopeId.make(bobScope),
861863 },
862864 }),
863865 );
apps/local/drizzle/0010_add_credential_binding_secret_scope.sql+2−0
@@ -0,0 +1,2 @@
1+ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint
2+CREATE INDEX `credential_binding_secret_scope_id_idx` ON `credential_binding` (`secret_scope_id`);
apps/local/drizzle/meta/_journal.json+7−0
@@ -71,6 +71,13 @@ · 70 unmodified lines
7171 "when": 1778192434062,
7272 "tag": "0009_repair_openapi_oauth_cutover_residue",
7373 "breakpoints": true
74+ },
75+ {
76+ "idx": 10,
77+ "version": "6",
78+ "when": 1778192434063,
79+ "tag": "0010_add_credential_binding_secret_scope",
80+ "breakpoints": true
7481 }
7582 ]
7683 }
apps/local/src/server/executor-schema.ts+2−0
@@ -137,6 +137,7 @@ export const credential_binding = sqliteTable( · 136 unmodified lines
137137 kind: text("kind").notNull(),
138138 text_value: text("text_value"),
139139 secret_id: text("secret_id"),
140+ secret_scope_id: text("secret_scope_id"),
140141 connection_id: text("connection_id"),
141142 created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
142143 updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
@@ -150,6 +151,7 @@ export const credential_binding = sqliteTable( · 7 unmodified lines
150151 index("credential_binding_slot_key_idx").on(table.slot_key),
151152 index("credential_binding_kind_idx").on(table.kind),
152153 index("credential_binding_secret_id_idx").on(table.secret_id),
154+ index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
153155 index("credential_binding_connection_id_idx").on(table.connection_id),
154156 ],
155157 );
apps/local/src/server/migrate-oauth-connections.test.ts+1−3
@@ -158,9 +158,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { · 157 unmodified lines
158158 );
159159 `);
160160
161− db.prepare(
162− "INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)",
163− ).run(
161+ db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run(
164162 "dealcloud_api",
165163 "org-1",
166164 JSON.stringify({
notes/product-scope-language.md+47−0
@@ -0,0 +1,47 @@
1+# Product Scope Language
2+
3+Executor has a real scope model, but the product should mostly avoid saying
4+"scope" to users. Use ownership and usage language instead.
5+
6+## Product Terms
7+
8+- **Personal**: only this user can use or update the credential/connection.
9+- **Organization**: everyone with access to the source can use the shared
10+ credential/connection.
11+- **Source owner**: where the source definition and shared auth method live.
12+ This is usually implicit from the current page/context and should not be
13+ shown as debug information.
14+- **Used by**: who uses a specific credential value for a shared auth slot.
15+- **Saved to**: where a newly created secret or OAuth token/connection is
16+ stored.
17+
18+## UI Rules
19+
20+- Communicate source auth as two separate choices:
21+ - the shared authentication method for the source, such as bearer header,
22+ query parameter, or OAuth;
23+ - the credential/connection value used for that method.
24+- Do not imply users can change the auth method per person when the backend only
25+ allows credential values to vary per scope.
26+- Put secret storage choices in the new-secret flow, because choosing Personal
27+ or Organization there creates a reusable secret at that ownership level.
28+- Put credential usage choices next to the credential picker as **Used by**,
29+ because attaching a secret to a source slot is separate from where the secret
30+ itself is stored.
31+- Put OAuth token/connection storage next to **Connect via OAuth** as **Token
32+ saved to**. This is independent from OAuth client ID/client secret storage.
33+- Secret lists should show secrets from all visible ownership levels with a
34+ Personal/Organization badge.
35+
36+## Preferred Copy
37+
38+- Use "Personal" and "Organization" for selectors.
39+- Use "Used by" for source credential bindings.
40+- Use "Save secret to" in secret creation.
41+- Use "Token saved to" for OAuth sign-in results.
42+- Use "Add without credentials" whenever the source can be added with missing
43+ initial credential values, not only for OAuth.
44+
45+Avoid copy like "scope", "target scope", "source scope", "binding scope", or
46+"credential target scope" in product UI unless it is explicitly a developer or
47+debug surface.
packages/core/sdk/src/core-schema.ts+3−1
@@ -182,6 +182,7 @@ export const coreSchema = { · 181 unmodified lines
182182 kind: { type: credentialBindingKinds, required: true, index: true },
183183 text_value: { type: "string", required: false },
184184 secret_id: { type: "string", required: false, index: true },
185+ secret_scope_id: { type: "string", required: false, index: true },
185186 connection_id: { type: "string", required: false, index: true },
186187 created_at: { type: "date", required: true },
187188 updated_at: { type: "date", required: true },
@@ -244,7 +245,7 @@ export type ConnectionRow = InferDBFieldsOutput<CoreSchema["connection"]["fields · 56 unmodified lines
244245 type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>;
245246 type CredentialBindingRowBase = Omit<
246247 CredentialBindingRowFields,
247− "kind" | "text_value" | "secret_id" | "connection_id"
248+ "kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id"
248249 >;
249250
250251 export type CredentialBindingRow = CredentialBindingRowBase &
@@ -256,6 +257,7 @@ export type CredentialBindingRow = CredentialBindingRowBase & · 5 unmodified lines
256257 | {
257258 kind: "secret";
258259 secret_id: string;
260+ secret_scope_id?: string;
259261 }
260262 | {
261263 kind: "connection";
packages/core/sdk/src/credential-bindings.test.ts+127−1
@@ -437,7 +437,10 @@ describe("credential bindings", () => { · 436 unmodified lines
437437 sourceId: TEST_SOURCE_ID,
438438 sourceScope: harness.scopes.org.id,
439439 slotKey: "oauth.connection",
440− value: { kind: "connection", connectionId: ConnectionId.make("oauth-connection") },
440+ value: {
441+ kind: "connection",
442+ connectionId: ConnectionId.make("oauth-connection"),
443+ },
441444 });
442445
443446 const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]);
@@ -608,4 +611,127 @@ describe("credential bindings", () => { · 164 unmodified lines
608611 expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);
609612 }),
610613 );
614+
615+ it.effect("a personal binding can point at an organization-owned secret", () =>
616+ Effect.gen(function* () {
617+ const harness = makeHarness();
618+ const orgExecutor = yield* harness.create([harness.scopes.org]);
619+ yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);
620+ yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org");
621+
622+ const userExecutor = yield* harness.create([
623+ harness.scopes.userWorkspaceA,
624+ harness.scopes.org,
625+ ]);
626+
627+ const binding = yield* userExecutor.credentialBindings.set({
628+ targetScope: harness.scopes.userWorkspaceA.id,
629+ pluginId: TEST_PLUGIN_ID,
630+ sourceId: TEST_SOURCE_ID,
631+ sourceScope: harness.scopes.org.id,
632+ slotKey: TEST_SLOT,
633+ value: {
634+ kind: "secret",
635+ secretId: SecretId.make("shared-token-id"),
636+ secretScopeId: harness.scopes.org.id,
637+ },
638+ });
639+
640+ expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id);
641+ expect(binding.value).toMatchObject({
642+ kind: "secret",
643+ secretId: SecretId.make("shared-token-id"),
644+ secretScopeId: harness.scopes.org.id,
645+ });
646+
647+ const resolved = yield* userExecutor.credentialBindings.resolve({
648+ pluginId: TEST_PLUGIN_ID,
649+ sourceId: TEST_SOURCE_ID,
650+ sourceScope: harness.scopes.org.id,
651+ slotKey: TEST_SLOT,
652+ });
653+
654+ expect(resolved.status).toBe("resolved");
655+ expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id);
656+ }),
657+ );
658+
659+ it.effect(
660+ "removing an organization secret is blocked when a personal binding references it",
661+ () =>
662+ Effect.gen(function* () {
663+ const harness = makeHarness();
664+ const orgExecutor = yield* harness.create([harness.scopes.org]);
665+ yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);
666+ yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org");
667+
668+ const userExecutor = yield* harness.create([
669+ harness.scopes.userWorkspaceA,
670+ harness.scopes.org,
671+ ]);
672+ yield* userExecutor.credentialBindings.set({
673+ targetScope: harness.scopes.userWorkspaceA.id,
674+ pluginId: TEST_PLUGIN_ID,
675+ sourceId: TEST_SOURCE_ID,
676+ sourceScope: harness.scopes.org.id,
677+ slotKey: TEST_SLOT,
678+ value: {
679+ kind: "secret",
680+ secretId: SecretId.make("shared-token-id"),
681+ secretScopeId: harness.scopes.org.id,
682+ },
683+ });
684+
685+ const result = yield* Effect.result(
686+ userExecutor.secrets.remove(
687+ new RemoveSecretInput({
688+ id: SecretId.make("shared-token-id"),
689+ targetScope: harness.scopes.org.id,
690+ }),
691+ ),
692+ );
693+
694+ expect(Result.isFailure(result)).toBe(true);
695+ if (!Result.isFailure(result)) return;
696+ expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);
697+ }),
698+ );
699+
700+ it.effect("rejects an organization binding to a personal secret", () =>
701+ Effect.gen(function* () {
702+ const harness = makeHarness();
703+ const orgExecutor = yield* harness.create([harness.scopes.org]);
704+ yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id);
705+
706+ const userExecutor = yield* harness.create([
707+ harness.scopes.userWorkspaceA,
708+ harness.scopes.org,
709+ ]);
710+ yield* setSecret(
711+ userExecutor,
712+ harness.scopes.userWorkspaceA.id,
713+ "personal-token",
714+ "sk-user-a",
715+ );
716+
717+ const result = yield* Effect.result(
718+ userExecutor.credentialBindings.set({
719+ targetScope: harness.scopes.org.id,
720+ pluginId: TEST_PLUGIN_ID,
721+ sourceId: TEST_SOURCE_ID,
722+ sourceScope: harness.scopes.org.id,
723+ slotKey: TEST_SLOT,
724+ value: {
725+ kind: "secret",
726+ secretId: SecretId.make("personal-token"),
727+ secretScopeId: harness.scopes.userWorkspaceA.id,
728+ },
729+ }),
730+ );
731+
732+ expect(Result.isFailure(result)).toBe(true);
733+ if (!Result.isFailure(result)) return;
734+ expect(Predicate.isTagged("StorageError")(result.failure)).toBe(true);
735+ }),
736+ );
611737 });
packages/core/sdk/src/credential-bindings.ts+11−1
@@ -17,6 +17,7 @@ export const CredentialBindingValue = Schema.Union([ · 16 unmodified lines
1717 Schema.Struct({
1818 kind: Schema.Literal("secret"),
1919 secretId: SecretId,
20+ secretScopeId: Schema.optional(ScopeId),
2021 }),
2122 Schema.Struct({
2223 kind: Schema.Literal("connection"),
@@ -36,6 +37,14 @@ export class ConfiguredCredentialBinding extends Schema.Class<ConfiguredCredenti · 13 unmodified lines
3637 export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]);
3738 export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type;
3839
40+export const ScopedSecretCredentialInput = Schema.Struct({
41+ secretId: Schema.String,
42+ prefix: Schema.optional(Schema.String),
43+ targetScope: ScopeId,
44+ secretScopeId: Schema.optional(ScopeId),
45+});
46+export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type;
47+
3948 export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>(
4049 "CredentialBindingRef",
4150 )({
@@ -172,9 +181,10 @@ export const credentialBindingValueFromRow = (row: CredentialBindingRow): Creden · 130 unmodified lines
172181 kind: "text" as const,
173182 text: text_value,
174183 })),
175− Match.when({ kind: "secret" }, ({ secret_id }) => ({
184+ Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({
176185 kind: "secret" as const,
177186 secretId: SecretId.make(secret_id),
187+ secretScopeId: ScopeId.make(secret_scope_id ?? scope_id),
178188 })),
179189 Match.when({ kind: "connection" }, ({ connection_id }) => ({
180190 kind: "connection" as const,
packages/core/sdk/src/executor.ts+46−10
@@ -900,7 +900,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 899 unmodified lines
900900 ): Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure> =>
901901 Effect.gen(function* () {
902902 yield* assertScopeInStack("secret get scope", scope);
903− const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope });
903+ const row = yield* findSecretRowAtScope({
904+ secretId: id,
905+ scopeId: scope,
906+ });
904907 if (row?.owned_by_connection_id) {
905908 return yield* new SecretOwnedByConnectionError({
906909 secretId: SecretId.make(id),
@@ -922,7 +925,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 15 unmodified lines
922925 ): Effect.Effect<string | null, StorageFailure> =>
923926 Effect.gen(function* () {
924927 yield* assertScopeInStack("connection secret get scope", scope);
925− const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope });
928+ const row = yield* findSecretRowAtScope({
929+ secretId: id,
930+ scopeId: scope,
931+ });
926932 return yield* resolveSecretValueAtScope(row, id);
927933 });
928934
@@ -1423,7 +1429,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 494 unmodified lines
14231429 ): Effect.Effect<ConnectionRef | null, StorageFailure> =>
14241430 Effect.gen(function* () {
14251431 yield* assertScopeInStack("connection get scope", scope);
1426− const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope });
1432+ const row = yield* findConnectionRowAtScope({
1433+ connectionId: id,
1434+ scopeId: scope,
1435+ });
14271436 return row ? rowToConnection(row) : null;
14281437 });
14291438
@@ -1962,7 +1971,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 532 unmodified lines
19621971 ): Effect.Effect<string, AccessTokenError> =>
19631972 Effect.gen(function* () {
19641973 yield* assertScopeInStack("connection accessToken scope", scope);
1965− const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope });
1974+ const row = yield* findConnectionRowAtScope({
1975+ connectionId: id,
1976+ scopeId: scope,
1977+ });
19661978 if (!row) {
19671979 return yield* new ConnectionNotFoundError({
19681980 connectionId: ConnectionId.make(id),
@@ -2139,15 +2151,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 170 unmodified lines
21392151 }
21402152
21412153 if (input.value.kind === "secret") {
2154+ const secretScope = input.value.secretScopeId ?? input.targetScope;
2155+ yield* assertScopeInStack("credential binding secretScope", secretScope);
2156+ if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) {
2157+ return yield* new StorageError({
2158+ message:
2159+ `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}" ` +
2160+ `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`,
2161+ cause: undefined,
2162+ });
2163+ }
21422164 const secret = yield* findSecretRowAtScope({
21432165 secretId: input.value.secretId,
2144− scopeId: input.targetScope,
2166+ scopeId: secretScope,
21452167 });
21462168 if (!secret) {
21472169 return yield* new StorageError({
21482170 message:
2149− `Cannot bind secret "${input.value.secretId}" at scope "${input.targetScope}": ` +
2150− `the secret must be owned by the same scope as the binding.`,
2171+ `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}": ` +
2172+ `the secret must be visible and owned by that scope.`,
21512173 cause: undefined,
21522174 });
21532175 }
@@ -2192,6 +2214,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 38 unmodified lines
21922214 kind: input.value.kind,
21932215 text_value: input.value.kind === "text" ? input.value.text : undefined,
21942216 secret_id: input.value.kind === "secret" ? input.value.secretId : undefined,
2217+ secret_scope_id:
2218+ input.value.kind === "secret"
2219+ ? (input.value.secretScopeId ?? input.targetScope)
2220+ : undefined,
21952221 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,
21962222 created_at: now,
21972223 updated_at: now,
@@ -2208,6 +2234,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 10 unmodified lines
22082234 kind: input.value.kind,
22092235 text_value: input.value.kind === "text" ? input.value.text : undefined,
22102236 secret_id: input.value.kind === "secret" ? input.value.secretId : undefined,
2237+ secret_scope_id:
2238+ input.value.kind === "secret"
2239+ ? (input.value.secretScopeId ?? input.targetScope)
2240+ : undefined,
22112241 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,
22122242 created_at: now,
22132243 updated_at: now,
@@ -2347,7 +2377,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 133 unmodified lines
23472377 if (!row.secret_id) return "missing";
23482378 const secret = yield* findSecretRowAtScope({
23492379 secretId: row.secret_id,
2350− scopeId: row.scope_id,
2380+ scopeId: row.secret_scope_id ?? row.scope_id,
23512381 });
23522382 if (!secret) return "missing";
23532383 return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing";
@@ -2416,7 +2446,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 62 unmodified lines
24162446 (row) =>
24172447 new Usage({
24182448 pluginId: row.plugin_id,
2419− scopeId: ScopeId.make(row.scope_id),
2449+ scopeId: ScopeId.make(
2450+ row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id,
2451+ ),
24202452 ownerKind: "credential-binding",
24212453 ownerId: row.source_id,
24222454 ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null,
@@ -2882,7 +2914,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 459 unmodified lines
28822914
28832915 const defsMap = new Map<string, unknown>(Object.entries(defs));
28842916 const preview = yield* Effect.sync(() =>
2885− buildToolTypeScriptPreview({ inputSchema, outputSchema, defs: defsMap }),
2917+ buildToolTypeScriptPreview({
2918+ inputSchema,
2919+ outputSchema,
2920+ defs: defsMap,
2921+ }),
28862922 ).pipe(
28872923 Effect.withSpan("schema.compile.preview", {
28882924 attributes: {
packages/core/sdk/src/index.ts+1−0
@@ -129,6 +129,7 @@ export { · 128 unmodified lines
129129 CredentialBindingValue,
130130 ConfiguredCredentialBinding,
131131 ConfiguredCredentialValue,
132+ ScopedSecretCredentialInput,
132133 CredentialBindingRef,
133134 SetCredentialBindingInput,
134135 CredentialBindingSourceInput,
packages/core/sdk/src/oauth-helpers.ts+94−103
@@ -16,7 +16,7 @@ · 15 unmodified lines
1616 // construction keeps the call sync and lets callers opt out of PAR
1717 // ---------------------------------------------------------------------------
1818
19−import { Data, Effect } from "effect";
19+import { Data, Effect, Predicate } from "effect";
2020 import * as oauth from "oauth4webapi";
2121
2222 // ---------------------------------------------------------------------------
@@ -121,11 +121,7 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 98 unmodified lines
121121 // to reauth-required) across wrappers.
122122 // ---------------------------------------------------------------------------
123123
124−const isOAuth2Error = (cause: unknown): cause is OAuth2Error =>
125− typeof cause === "object" &&
126− cause !== null &&
127− "_tag" in cause &&
128− (cause as { readonly _tag?: unknown })._tag === "OAuth2Error";
124+const isOAuth2Error = Predicate.isTagged("OAuth2Error") as (cause: unknown) => cause is OAuth2Error;
129125
130126 const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => {
131127 if (cause instanceof Response) return cause;
@@ -200,19 +196,26 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 68 unmodified lines
200196 });
201197 };
202198
203−const toOAuth2ErrorWithHttpSummary = async (cause: unknown): Promise<OAuth2Error> => {
204− if (isOAuth2Error(cause)) return cause;
199+const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect<OAuth2Error> => {
200+ if (isOAuth2Error(cause)) return Effect.succeed(cause);
205201 const base = toOAuth2Error(cause);
206202 const response = responseFromOAuthErrorCause(cause);
207− if (!response) return base;
208− const summary = await tokenEndpointHttpSummary(response);
209− return new OAuth2Error({
210− message: `${base.message} (${summary})`,
211− error: base.error,
212− cause,
213− });
203+ if (!response) return Effect.succeed(base);
204+ return Effect.promise(() => tokenEndpointHttpSummary(response)).pipe(
205+ Effect.map(
206+ (summary) =>
207+ new OAuth2Error({
208+ message: `${base.message} (${summary})`,
209+ error: base.error,
210+ cause,
211+ }),
212+ ),
213+ );
214214 };
215215
216+const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>
217+ toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));
218+
216219 // ---------------------------------------------------------------------------
217220 // oauth4webapi adapter helpers
218221 // ---------------------------------------------------------------------------
@@ -348,40 +351,36 @@ export const exchangeAuthorizationCode = ( · 129 unmodified lines
348351 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
349352 Effect.tryPromise({
350353 try: async () => {
351− try {
352− const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
353− idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
354− });
355− const client: oauth.Client = { client_id: input.clientId };
356− const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
357− // `authorizationCodeGrantRequest` requires its `callbackParameters`
358− // to have been returned from `validateAuthResponse`. Our public API
359− // takes the `code` directly (the UI already validated `state` by
360− // looking up the session), so skip the library's state-validation
361− // rail and go through the generic grant request instead.
362− const params = new URLSearchParams({
363− code: input.code,
364− redirect_uri: input.redirectUrl,
365− code_verifier: input.codeVerifier,
366− });
367− if (input.resource) {
368− params.set("resource", input.resource);
369− }
370− const response = await oauth.genericTokenEndpointRequest(
371− as,
372− client,
373− clientAuth,
374− "authorization_code",
375− params,
376− oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
377− );
378− return await processTokenEndpointResponse(as, client, response);
379− } catch (cause) {
380− throw await toOAuth2ErrorWithHttpSummary(cause);
354+ const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
355+ idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
356+ });
357+ const client: oauth.Client = { client_id: input.clientId };
358+ const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
359+ // `authorizationCodeGrantRequest` requires its `callbackParameters`
360+ // to have been returned from `validateAuthResponse`. Our public API
361+ // takes the `code` directly (the UI already validated `state` by
362+ // looking up the session), so skip the library's state-validation
363+ // rail and go through the generic grant request instead.
364+ const params = new URLSearchParams({
365+ code: input.code,
366+ redirect_uri: input.redirectUrl,
367+ code_verifier: input.codeVerifier,
368+ });
369+ if (input.resource) {
370+ params.set("resource", input.resource);
381371 }
372+ const response = await oauth.genericTokenEndpointRequest(
373+ as,
374+ client,
375+ clientAuth,
376+ "authorization_code",
377+ params,
378+ oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
379+ );
380+ return await processTokenEndpointResponse(as, client, response);
382381 },
383− catch: toOAuth2Error,
384− });
382+ catch: (cause) => cause,
383+ }).pipe(Effect.catch(failOAuth2WithHttpSummary));
385384
386385 // ---------------------------------------------------------------------------
387386 // Exchange client credentials → tokens (RFC 6749 §4.4)
@@ -402,29 +401,25 @@ export const exchangeClientCredentials = ( · 14 unmodified lines
402401 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
403402 Effect.tryPromise({
404403 try: async () => {
405− try {
406− const as = asFromTokenUrl(input.tokenUrl);
407− const client: oauth.Client = { client_id: input.clientId };
408− const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
409− const params = new URLSearchParams();
410− if (input.scopes && input.scopes.length > 0) {
411− params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
412− }
413− const response = await oauth.clientCredentialsGrantRequest(
414− as,
415− client,
416− clientAuth,
417− params,
418− oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
419− );
420− const result = await oauth.processClientCredentialsResponse(as, client, response);
421− return tokenResponseFrom(result);
422− } catch (cause) {
423− throw await toOAuth2ErrorWithHttpSummary(cause);
404+ const as = asFromTokenUrl(input.tokenUrl);
405+ const client: oauth.Client = { client_id: input.clientId };
406+ const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
407+ const params = new URLSearchParams();
408+ if (input.scopes && input.scopes.length > 0) {
409+ params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
424410 }
411+ const response = await oauth.clientCredentialsGrantRequest(
412+ as,
413+ client,
414+ clientAuth,
415+ params,
416+ oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
417+ );
418+ const result = await oauth.processClientCredentialsResponse(as, client, response);
419+ return tokenResponseFrom(result);
425420 },
426− catch: toOAuth2Error,
427− });
421+ catch: (cause) => cause,
422+ }).pipe(Effect.catch(failOAuth2WithHttpSummary));
428423
429424 // ---------------------------------------------------------------------------
430425 // Refresh access token
@@ -452,43 +447,39 @@ export const refreshAccessToken = ( · 21 unmodified lines
452447 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
453448 Effect.tryPromise({
454449 try: async () => {
455− try {
456− const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
457− idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
458− });
459− const client: oauth.Client = { client_id: input.clientId };
460− const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
461− const extraParams = new URLSearchParams();
462− if (input.scopes && input.scopes.length > 0) {
463− extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
464− }
465− if (input.resource) {
466− extraParams.set("resource", input.resource);
467− }
468− const additionalParameters =
469− Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
470− const response = await oauth.refreshTokenGrantRequest(
471− as,
472− client,
473− clientAuth,
474− input.refreshToken,
475− {
476− ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
477− additionalParameters,
478− },
479− );
480− const result = await oauth.processRefreshTokenResponse(
481− as,
482− client,
483− await stripIdToken(response),
484− );
485− return tokenResponseFrom(result);
486− } catch (cause) {
487− throw await toOAuth2ErrorWithHttpSummary(cause);
450+ const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
451+ idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
452+ });
453+ const client: oauth.Client = { client_id: input.clientId };
454+ const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
455+ const extraParams = new URLSearchParams();
456+ if (input.scopes && input.scopes.length > 0) {
457+ extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
488458 }
459+ if (input.resource) {
460+ extraParams.set("resource", input.resource);
461+ }
462+ const additionalParameters =
463+ Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
464+ const response = await oauth.refreshTokenGrantRequest(
465+ as,
466+ client,
467+ clientAuth,
468+ input.refreshToken,
469+ {
470+ ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs),
471+ additionalParameters,
472+ },
473+ );
474+ const result = await oauth.processRefreshTokenResponse(
475+ as,
476+ client,
477+ await stripIdToken(response),
478+ );
479+ return tokenResponseFrom(result);
489480 },
490− catch: toOAuth2Error,
491− });
481+ catch: (cause) => cause,
482+ }).pipe(Effect.catch(failOAuth2WithHttpSummary));
492483
493484 // ---------------------------------------------------------------------------
494485 // Refresh-needed predicate
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+67−49
@@ -9,11 +9,12 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 8 unmodified lines
99 import {
1010 HttpCredentialsEditor,
1111 httpCredentialsValid,
12+ serializeScopedHttpCredentials,
1213 serializeHttpCredentials,
1314 type HttpCredentialsState,
1415 } from "@executor-js/react/plugins/http-credentials";
1516 import {
16− displayNameFromUrl,
17+ sourceDisplayNameFromUrl,
1718 slugifyNamespace,
1819 SourceIdentityFieldRows,
1920 useSourceIdentity,
@@ -25,7 +26,8 @@ import { · 5 unmodified lines
2526 type OAuthCompletionPayload,
2627 } from "@executor-js/react/plugins/oauth-sign-in";
2728 import {
28− CredentialScopeSection,
29+ CredentialControlField,
30+ CredentialUsageRow,
2931 useCredentialTargetScope,
3032 } from "@executor-js/react/plugins/credential-target-scope";
3133 import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -61,7 +63,7 @@ export default function AddGraphqlSource(props: { · 29 unmodified lines
6163 }) {
6264 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
6365 const identity = useSourceIdentity({
64− fallbackName: displayNameFromUrl(endpoint) ?? "",
66+ fallbackName: sourceDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
6567 });
6668 const [credentials, setCredentials] = useState<HttpCredentialsState>(initialGraphqlCredentials);
6769 const [adding, setAdding] = useState(false);
@@ -70,8 +72,12 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
7072 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
7173
7274 const scopeId = useScope();
73− const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
74− useCredentialTargetScope();
75+ const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
76+ const {
77+ credentialTargetScope: oauthCredentialTargetScope,
78+ setCredentialTargetScope: setOAuthCredentialTargetScope,
79+ credentialScopeOptions,
80+ } = useCredentialTargetScope();
7581 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), {
7682 mode: "promiseExit",
7783 });
@@ -91,9 +97,10 @@ export default function AddGraphqlSource(props: { · 13 unmodified lines
9197 const trimmedEndpoint = endpoint.trim();
9298 const namespace =
9399 slugifyNamespace(identity.namespace) ||
94− slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") ||
100+ slugifyNamespace(sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") ?? "") ||
95101 "graphql";
96− const displayName = identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace;
102+ const displayName =
103+ identity.name.trim() || sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") || namespace;
97104 return { trimmedEndpoint, namespace, displayName };
98105 }, [endpoint, identity.name, identity.namespace]);
99106
@@ -109,7 +116,7 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
109116 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
110117 redirectUrl: oauthCallbackUrl(),
111118 connectionId: oauthConnectionId({ pluginId: "graphql", namespace }),
112− tokenScope: credentialTargetScope,
119+ tokenScope: oauthCredentialTargetScope,
113120 strategy: { kind: "dynamic-dcr" },
114121 pluginId: "graphql",
115122 identityLabel: `${displayName} OAuth`,
@@ -123,12 +130,15 @@ export default function AddGraphqlSource(props: { · 7 unmodified lines
123130 },
124131 onError: setAddError,
125132 });
126− }, [endpoint, credentials, oauth, sourceIdentity, credentialTargetScope]);
133+ }, [endpoint, credentials, oauth, sourceIdentity, oauthCredentialTargetScope]);
127134
128135 const handleAdd = async () => {
129136 setAdding(true);
130137 setAddError(null);
131− const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);
138+ const { headers: headerMap, queryParams } = serializeScopedHttpCredentials(
139+ credentials,
140+ requestCredentialTargetScope,
141+ );
132142
133143 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();
134144 const exit = await doAdd({
@@ -144,7 +154,10 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
144154 queryParams: queryParams as Record<string, GraphqlCredentialInput>,
145155 }
146156 : {}),
147− credentialTargetScope,
157+ credentialTargetScope:
158+ authMode === "oauth2" && tokens
159+ ? oauthCredentialTargetScope
160+ : requestCredentialTargetScope,
148161 ...(authMode === "oauth2" && tokens
149162 ? {
150163 auth: {
@@ -185,24 +198,18 @@ export default function AddGraphqlSource(props: { · 34 unmodified lines
185198 </CardStackContent>
186199 </CardStack>
187200
188− <CredentialScopeSection
189− value={credentialTargetScope}
190− options={credentialScopeOptions}
191− onChange={(targetScope) => {
192− setCredentialTargetScope(targetScope);
193− setTokens(null);
194− }}
195− >
196− <HttpCredentialsEditor
197− credentials={credentials}
198− onChange={setCredentials}
199− existingSecrets={secretList}
200− sourceName={identity.name}
201− targetScope={credentialTargetScope}
202− />
203− </CredentialScopeSection>
201+ <HttpCredentialsEditor
202+ credentials={credentials}
203+ onChange={setCredentials}
204+ existingSecrets={secretList}
205+ sourceName={identity.name}
206+ targetScope={requestCredentialTargetScope}
207+ credentialScopeOptions={credentialScopeOptions}
208+ bindingScopeOptions={credentialScopeOptions}
209+ />
204210
205− <section className="space-y-2.5">
211+ {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
212+ <section className="hidden space-y-2.5">
206213 <div className="flex items-center justify-between gap-3">
207214 <span className="text-sm font-medium text-foreground">Authentication</span>
208215 <FilterTabs<AuthMode>
@@ -219,27 +226,38 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
219226 </div>
220227
221228 {authMode === "oauth2" && (
222− <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
223− {tokens ? (
224− <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
225− Authenticated
226− </span>
227− ) : (
228− <span className="text-xs text-muted-foreground">
229− Sign in before adding so Executor can introspect the schema.
230− </span>
231− )}
232− <Button
233− type="button"
234− variant="outline"
235− size="sm"
236− className="ml-auto h-7 px-2 text-xs"
237− onClick={() => void handleOAuth()}
238− disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy}
239− >
240− {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"}
241− </Button>
242− </div>
229+ <CredentialUsageRow
230+ value={oauthCredentialTargetScope}
231+ options={credentialScopeOptions}
232+ onChange={(targetScope) => {
233+ setOAuthCredentialTargetScope(targetScope);
234+ setTokens(null);
235+ }}
236+ label="Connection saved to"
237+ help="Choose who can use the OAuth connection."
238+ >
239+ <CredentialControlField label="Connect via OAuth" help="Start the provider OAuth flow.">
240+ <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
241+ {tokens ? (
242+ <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
243+ Authenticated
244+ </span>
245+ ) : (
246+ <span className="text-xs text-muted-foreground">Not connected</span>
247+ )}
248+ <Button
249+ type="button"
250+ variant="outline"
251+ size="sm"
252+ className="ml-auto h-7 px-2 text-xs"
253+ onClick={() => void handleOAuth()}
254+ disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy}
255+ >
256+ {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"}
257+ </Button>
258+ </div>
259+ </CredentialControlField>
260+ </CredentialUsageRow>
243261 )}
244262 </section>
245263
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+2−1
@@ -222,7 +222,8 @@ function EditForm(props: { · 221 unmodified lines
222222 targetScope={credentialTargetScope}
223223 />
224224
225− <section className="space-y-2.5">
225+ {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
226+ <section className="hidden space-y-2.5">
226227 <div className="flex items-center justify-between gap-3">
227228 <span className="text-sm font-medium text-foreground">Authentication</span>
228229 <FilterTabs<AuthMode>
packages/plugins/graphql/src/sdk/plugin.test.ts+62−0
@@ -604,6 +604,68 @@ describe("graphqlPlugin", () => { · 603 unmodified lines
604604 }),
605605 );
606606
607+ it.effect("addSource stores direct GraphQL credential bindings at each row scope", () =>
608+ Effect.gen(function* () {
609+ const executor = yield* createExecutor(
610+ makeTestConfig({
611+ scopes: stackedScopes,
612+ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,
613+ }),
614+ );
615+
616+ yield* executor.secrets.set({
617+ id: SecretId.make("row-user-token"),
618+ scope: ScopeId.make(USER_SCOPE),
619+ name: "User token",
620+ value: "user-secret",
621+ provider: "memory",
622+ });
623+ yield* executor.secrets.set({
624+ id: SecretId.make("row-org-query"),
625+ scope: ScopeId.make(ORG_SCOPE),
626+ name: "Org query",
627+ value: "org-secret",
628+ provider: "memory",
629+ });
630+
631+ yield* executor.graphql.addSource({
632+ endpoint: "https://example.com/graphql",
633+ scope: ORG_SCOPE,
634+ namespace: "row_scoped_credentials",
635+ introspectionJson,
636+ headers: {
637+ Authorization: {
638+ secretId: "row-user-token",
639+ prefix: "Bearer ",
640+ targetScope: USER_SCOPE,
641+ },
642+ },
643+ queryParams: {
644+ token: {
645+ secretId: "row-org-query",
646+ targetScope: ORG_SCOPE,
647+ },
648+ },
649+ });
650+
651+ const bindings = yield* executor.graphql.listSourceBindings(
652+ "row_scoped_credentials",
653+ ORG_SCOPE,
654+ );
655+
656+ expect(bindings.map((binding) => binding.slot).sort()).toEqual([
657+ graphqlHeaderSlot("Authorization"),
658+ graphqlQueryParamSlot("token"),
659+ ]);
660+ expect(
661+ bindings.find((binding) => binding.slot === graphqlHeaderSlot("Authorization"))?.scopeId,
662+ ).toBe(ScopeId.make(USER_SCOPE));
663+ expect(
664+ bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId,
665+ ).toBe(ScopeId.make(ORG_SCOPE));
666+ }),
667+ );
668+
607669 it.effect("org header binding resolves the org secret when a user has the same secret id", () =>
608670 Effect.gen(function* () {
609671 const server = yield* serveGreetingServer;
packages/plugins/graphql/src/sdk/plugin.ts+45−7
@@ -424,6 +424,19 @@ const bindingTargetScope = ( · 423 unmodified lines
424424 );
425425 };
426426
427+const targetScopeForBinding = (
428+ fallbackTargetScope: string | undefined,
429+ binding: { readonly targetScope?: string },
430+): Effect.Effect<string, GraphqlIntrospectionError> => {
431+ const targetScope = binding.targetScope ?? fallbackTargetScope;
432+ if (targetScope) return Effect.succeed(targetScope);
433+ return Effect.fail(
434+ new GraphqlIntrospectionError({
435+ message: "credentialTargetScope is required when adding direct GraphQL credentials",
436+ }),
437+ );
438+};
439+
427440 const canonicalizeCredentialMap = (
428441 values: Record<string, GraphqlCredentialInput> | undefined,
429442 slotForName: (name: string) => string,
@@ -432,10 +445,15 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines
432445 readonly bindings: ReadonlyArray<{
433446 readonly slot: string;
434447 readonly value: GraphqlSourceBindingValue;
448+ readonly targetScope?: string;
435449 }>;
436450 } => {
437451 const nextValues: Record<string, ConfiguredGraphqlCredentialValue> = {};
438− const bindings: Array<{ slot: string; value: GraphqlSourceBindingValue }> = [];
452+ const bindings: Array<{
453+ slot: string;
454+ value: GraphqlSourceBindingValue;
455+ targetScope?: string;
456+ }> = [];
439457 for (const [name, value] of Object.entries(values ?? {})) {
440458 if (typeof value === "string") {
441459 nextValues[name] = value;
@@ -453,9 +471,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
453471 });
454472 bindings.push({
455473 slot,
474+ targetScope: "targetScope" in value ? value.targetScope : undefined,
456475 value: {
457476 kind: "secret",
458477 secretId: SecretId.make(value.secretId),
478+ ...("secretScopeId" in value && value.secretScopeId
479+ ? { secretScopeId: value.secretScopeId }
480+ : {}),
459481 },
460482 });
461483 }
@@ -469,6 +491,7 @@ const canonicalizeAuth = ( · 7 unmodified lines
469491 readonly bindings: ReadonlyArray<{
470492 readonly slot: string;
471493 readonly value: GraphqlSourceBindingValue;
494+ readonly targetScope?: string;
472495 }>;
473496 } => {
474497 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };
@@ -606,8 +629,12 @@ const makeGraphqlExtension = ( · 131 unmodified lines
606629 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];
607630 continue;
608631 }
632+ const secretScope =
633+ "secretScopeId" in value
634+ ? (value.secretScopeId ?? value.targetScope)
635+ : (params.targetScope ?? params.sourceScope);
609636 const secret = yield* ctx.secrets
610− .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope)
637+ .getAtScope(SecretId.make(value.secretId), secretScope)
611638 .pipe(
612639 Effect.catchTag("SecretOwnedByConnectionError", () =>
613640 Effect.fail(
@@ -682,14 +709,21 @@ const makeGraphqlExtension = ( · 68 unmodified lines
682709 ...canonicalQueryParams.bindings,
683710 ...canonicalAuth.bindings,
684711 ];
685− const targetScope = yield* bindingTargetScope(config.credentialTargetScope, directBindings);
686− if (targetScope) {
712+ for (const binding of directBindings) {
713+ const bindingTargetScope = yield* targetScopeForBinding(
714+ config.credentialTargetScope,
715+ binding,
716+ );
687717 yield* validateGraphqlBindingTarget(ctx, {
688718 sourceId: namespace,
689719 sourceScope: config.scope,
690− targetScope,
720+ targetScope: bindingTargetScope,
691721 });
692722 }
723+ const targetScope =
724+ directBindings[0] !== undefined
725+ ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0])
726+ : undefined;
693727
694728 let introspectionResult: IntrospectionResult;
695729 if (config.introspectionJson) {
@@ -769,10 +803,14 @@ const makeGraphqlExtension = ( · 73 unmodified lines
769803 });
770804 }
771805
772− if (targetScope) {
806+ if (directBindings.length > 0) {
773807 for (const binding of directBindings) {
808+ const bindingTargetScope = yield* targetScopeForBinding(
809+ config.credentialTargetScope,
810+ binding,
811+ );
774812 yield* ctx.credentialBindings.set({
775− targetScope: ScopeId.make(targetScope),
813+ targetScope: ScopeId.make(bindingTargetScope),
776814 pluginId: GRAPHQL_PLUGIN_ID,
777815 sourceId: namespace,
778816 sourceScope: ScopeId.make(config.scope),
packages/plugins/graphql/src/sdk/types.ts+6−1
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,
44 CredentialBindingValue,
55 credentialSlotKey,
6+ ScopedSecretCredentialInput,
67 SecretBackedValue,
78 ScopeId,
89 } from "@executor-js/sdk/core";
@@ -68,7 +69,11 @@ export type QueryParamValue = typeof QueryParamValue.Type; · 59 unmodified lines
6869
6970 export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema;
7071 export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type;
71−export const GraphqlCredentialInput = Schema.Union([HeaderValue, ConfiguredGraphqlCredentialValue]);
72+export const GraphqlCredentialInput = Schema.Union([
73+ ScopedSecretCredentialInput,
74+ HeaderValue,
75+ ConfiguredGraphqlCredentialValue,
76+]);
7277 export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type;
7378
7479 export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name);
packages/plugins/mcp/src/react/AddMcpSource.tsx+122−146
@@ -27,17 +27,15 @@ import { Skeleton } from "@executor-js/react/components/skeleton"; · 26 unmodified lines
2727 import { SourceFavicon } from "@executor-js/react/components/source-favicon";
2828 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
2929 import { Textarea } from "@executor-js/react/components/textarea";
30−import { HeadersList } from "@executor-js/react/plugins/headers-list";
3130 import {
3231 emptyHttpCredentials,
3332 httpCredentialsValid,
3433 HttpCredentialsEditor,
34+ serializeScopedHttpCredentials,
3535 serializeHttpCredentials,
36− type SecretBackedValue,
3736 } from "@executor-js/react/plugins/http-credentials";
38−import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth";
3937 import {
40− displayNameFromUrl,
38+ sourceDisplayNameFromUrl,
4139 slugifyNamespace,
4240 SourceIdentityFieldRows,
4341 SourceIdentityFields,
@@ -51,15 +49,16 @@ import { · 7 unmodified lines
5149 type OAuthCompletionPayload,
5250 } from "@executor-js/react/plugins/oauth-sign-in";
5351 import {
54− CredentialScopeSection,
52+ CredentialControlField,
53+ CredentialUsageRow,
5554 useCredentialTargetScope,
5655 } from "@executor-js/react/plugins/credential-target-scope";
5756
58−type RemoteAuthMode = "none" | "header" | "oauth2";
57+type RemoteAuthMode = "none" | "oauth2";
5958 import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
6059 import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
6160 import { mcpPresets, type McpPreset } from "../sdk/presets";
62−import { MCP_OAUTH_CONNECTION_SLOT } from "../sdk/types";
61+import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
6362
6463 const ErrorMessage = Schema.Struct({ message: Schema.String });
6564 const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
@@ -101,7 +100,7 @@ type PlainHeader = { · 35 unmodified lines
101100
102101 type State =
103102 | { step: "url"; url: string }
104− | { step: "probing"; url: string }
103+ | { step: "probing"; url: string; probe: ProbeResult | null }
105104 | { step: "probed"; url: string; probe: ProbeResult }
106105 | { step: "oauth-starting"; url: string; probe: ProbeResult }
107106 | {
@@ -148,7 +147,7 @@ function reducer(state: State, action: Action): State { · 40 unmodified lines
148147 return { step: "url", url: action.url };
149148
150149 case "probe-start":
151− return { step: "probing", url: state.url };
150+ return { step: "probing", url: state.url, probe: "probe" in state ? state.probe : null };
152151
153152 case "probe-ok":
154153 return { step: "probed", url: state.url, probe: action.probe };
@@ -292,8 +291,12 @@ export default function AddMcpSource(props: { · 137 unmodified lines
292291 );
293292
294293 const scopeId = useScope();
295− const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
296− useCredentialTargetScope();
294+ const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
295+ const {
296+ credentialTargetScope: oauthCredentialTargetScope,
297+ setCredentialTargetScope: setOAuthCredentialTargetScope,
298+ credentialScopeOptions,
299+ } = useCredentialTargetScope();
297300 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
298301 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
299302 mode: "promiseExit",
@@ -306,14 +309,6 @@ export default function AddMcpSource(props: { · 6 unmodified lines
306309 });
307310
308311 const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");
309− const [remoteAuthHeaders, setRemoteAuthHeaders] = useState<HeaderState[]>([
310− {
311− name: "Authorization",
312− prefix: "Bearer ",
313− presetKey: "bearer",
314− secretId: null,
315− },
316− ]);
317312 const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]);
318313 const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials());
319314
@@ -321,25 +316,19 @@ export default function AddMcpSource(props: { · 1 unmodified line
321316 const tokens = "tokens" in state ? state.tokens : null;
322317
323318 const remoteIdentity = useSourceIdentity({
324− fallbackName: probe?.serverName ?? probe?.name ?? displayNameFromUrl(state.url) ?? "",
319+ fallbackName:
320+ sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "",
325321 });
326322 const isProbing = state.step === "probing";
327323 const isAdding = state.step === "adding";
328324 const isOAuthBusy =
329325 state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy;
330326 const canUseNone = probe?.requiresOAuth !== true;
331− const remoteAuthHeader = remoteAuthHeaders[0];
332− const headerAuthComplete = Boolean(remoteAuthHeader?.name.trim() && remoteAuthHeader?.secretId);
333327 const remoteHeadersComplete = remoteHeaders.every(
334328 (header) => header.name.trim() && header.value.trim(),
335329 );
336330 const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials);
337− const authReady =
338− remoteAuthMode === "none"
339− ? canUseNone
340− : remoteAuthMode === "header"
341− ? headerAuthComplete
342− : tokens !== null;
331+ const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null;
343332 const canAdd =
344333 Boolean(probe) &&
345334 authReady &&
@@ -392,17 +381,11 @@ export default function AddMcpSource(props: { · 46 unmodified lines
392381 handleProbeRef.current();
393382 }, 400);
394383 return () => clearTimeout(handle);
395− }, [transport, state.step, state.url, remoteCredentials]);
384+ }, [transport, state.step, state.url]);
396385
397− const handleRemoteCredentialsChange = useCallback(
398− (next: typeof remoteCredentials) => {
399− setRemoteCredentials(next);
400− if (state.step === "error" || state.step === "probed" || state.step === "oauth-done") {
401− dispatch({ type: "set-url", url: state.url });
402− }
403− },
404− [state],
405− );
386+ const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => {
387+ setRemoteCredentials(next);
388+ }, []);
406389
407390 const handleOAuth = useCallback(async () => {
408391 dispatch({ type: "oauth-start" });
@@ -421,7 +404,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines
421404 pluginId: "mcp",
422405 namespace: namespaceSlug,
423406 }),
424− tokenScope: credentialTargetScope,
407+ tokenScope: oauthCredentialTargetScope,
425408 strategy: { kind: "dynamic-dcr" },
426409 pluginId: "mcp",
427410 identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`,
@@ -440,7 +423,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines
440423 dispatch({ type: "oauth-waiting", sessionId: result.sessionId }),
441424 onError: (error) => dispatch({ type: "oauth-fail", error }),
442425 });
443− }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, credentialTargetScope]);
426+ }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]);
444427
445428 const handleCancelOAuth = useCallback(() => {
446429 oauth.cancel();
@@ -450,33 +433,28 @@ export default function AddMcpSource(props: { · 3 unmodified lines
450433 const handleAddRemote = useCallback(async () => {
451434 if (!probe) return;
452435 dispatch({ type: "add-start" });
453− const headerAuth = remoteAuthHeaders[0];
454436 const auth =
455− remoteAuthMode === "header" && headerAuth?.secretId
456− ? {
457− kind: "header" as const,
458− headerName: headerAuth.name.trim(),
459− secretId: headerAuth.secretId,
460− ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}),
461− }
462− : remoteAuthMode === "oauth2"
463− ? tokens
464− ? {
465− kind: "oauth2" as const,
466− connectionId: tokens.connectionId,
467− }
468− : {
469− kind: "oauth2" as const,
470− connectionSlot: MCP_OAUTH_CONNECTION_SLOT,
471− }
472− : { kind: "none" as const };
437+ remoteAuthMode === "oauth2"
438+ ? tokens
439+ ? {
440+ kind: "oauth2" as const,
441+ connectionId: tokens.connectionId,
442+ }
443+ : {
444+ kind: "oauth2" as const,
445+ connectionSlot: MCP_OAUTH_CONNECTION_SLOT,
446+ }
447+ : { kind: "none" as const };
473448 const headers = Object.fromEntries(
474449 remoteHeaders
475450 .map((header) => [header.name.trim(), header.value.trim()] as const)
476451 .filter(([name, value]) => name && value),
477452 );
478− const credentials = serializeHttpCredentials(remoteCredentials);
479− const remoteRequestHeaders: Record<string, SecretBackedValue> = {
453+ const credentials = serializeScopedHttpCredentials(
454+ remoteCredentials,
455+ requestCredentialTargetScope,
456+ );
457+ const remoteRequestHeaders: Record<string, McpCredentialInput> = {
480458 ...headers,
481459 ...credentials.headers,
482460 };
@@ -491,7 +469,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines
491469 namespace: slugNamespace || undefined,
492470 endpoint: state.url.trim(),
493471 auth,
494− credentialTargetScope,
472+ credentialTargetScope:
473+ remoteAuthMode === "oauth2" && tokens
474+ ? oauthCredentialTargetScope
475+ : requestCredentialTargetScope,
495476 ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}),
496477 ...(Object.keys(credentials.queryParams).length > 0
497478 ? { queryParams: credentials.queryParams }
@@ -510,7 +491,6 @@ export default function AddMcpSource(props: { · 12 unmodified lines
510491 }, [
511492 probe,
512493 remoteAuthMode,
513− remoteAuthHeaders,
514494 remoteHeaders,
515495 remoteCredentials,
516496 remoteIdentity,
@@ -519,7 +499,8 @@ export default function AddMcpSource(props: { · 2 unmodified lines
519499 doAdd,
520500 props,
521501 scopeId,
522− credentialTargetScope,
502+ requestCredentialTargetScope,
503+ oauthCredentialTargetScope,
523504 ]);
524505
525506 // ---- Stdio actions ----
@@ -714,6 +695,7 @@ export default function AddMcpSource(props: { · 188 unmodified lines
714695 <div className="mt-2 space-y-2">
715696 <FieldError>{probeError}</FieldError>
716697 <Button
698+ type="button"
717699 variant="outline"
718700 size="sm"
719701 onClick={handleProbe}
@@ -733,7 +715,9 @@ export default function AddMcpSource(props: { · 13 unmodified lines
733715 onChange={handleRemoteCredentialsChange}
734716 existingSecrets={secretList}
735717 sourceName={remoteIdentity.name}
736− targetScope={credentialTargetScope}
718+ targetScope={requestCredentialTargetScope}
719+ credentialScopeOptions={credentialScopeOptions}
720+ bindingScopeOptions={credentialScopeOptions}
737721 labels={{
738722 headers: "Request headers",
739723 queryParams: "Query parameters",
@@ -748,13 +732,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines
748732 <FilterTabs<RemoteAuthMode>
749733 tabs={
750734 probe.requiresOAuth
751− ? [
752− { value: "header", label: "Header" },
753− { value: "oauth2", label: "OAuth" },
754− ]
735+ ? [{ value: "oauth2", label: "OAuth" }]
755736 : [
756737 { value: "none", label: "None" },
757− { value: "header", label: "Header" },
738+ { value: "oauth2", label: "OAuth" },
758739 ]
759740 }
760741 value={remoteAuthMode}
@@ -762,88 +743,77 @@ export default function AddMcpSource(props: { · 1 unmodified line
762743 />
763744 </div>
764745
765− {remoteAuthMode === "header" && (
766− <CredentialScopeSection
767− value={credentialTargetScope}
768− options={credentialScopeOptions}
769− onChange={(targetScope) => {
770− setCredentialTargetScope(targetScope);
771− dispatch({ type: "oauth-reset" });
772− }}
773− >
774− <HeadersList
775− headers={remoteAuthHeaders}
776− onHeadersChange={setRemoteAuthHeaders}
777− existingSecrets={secretList}
778− singleHeader
779− sourceName={remoteIdentity.name}
780− targetScope={credentialTargetScope}
781− />
782− </CredentialScopeSection>
783− )}
784−
785746 {remoteAuthMode === "oauth2" && (
786− <CredentialScopeSection
787− value={credentialTargetScope}
747+ <CredentialUsageRow
748+ value={oauthCredentialTargetScope}
788749 options={credentialScopeOptions}
789750 onChange={(targetScope) => {
790− setCredentialTargetScope(targetScope);
751+ setOAuthCredentialTargetScope(targetScope);
791752 dispatch({ type: "oauth-reset" });
792753 }}
793− description="Choose who can use the OAuth connection."
754+ label="Connection saved to"
755+ help="Choose who can use the OAuth connection."
794756 >
795− {!tokens && state.step === "probed" && (
796− <div className="flex flex-col gap-2">
797− <Button onClick={handleOAuth} variant="outline">
757+ <CredentialControlField
758+ label="Connect via OAuth"
759+ help="Start the provider OAuth flow."
760+ >
761+ {!tokens && state.step === "probed" && (
762+ <Button
763+ type="button"
764+ onClick={handleOAuth}
765+ variant="outline"
766+ className="w-full"
767+ >
798768 Sign in
799769 </Button>
800− <p className="text-[11px] text-muted-foreground">
801− Sign in before adding so Executor can discover and save the tool catalog.
802− </p>
803− </div>
804− )}
770+ )}
805771
806− {!tokens && state.step === "oauth-starting" && (
807− <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">
808− <Spinner className="size-3.5" />
809− <span className="text-xs text-muted-foreground">Starting authorization…</span>
810− </div>
811− )}
772+ {!tokens && state.step === "oauth-starting" && (
773+ <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
774+ <Spinner className="size-3.5" />
775+ <span className="text-xs text-muted-foreground">
776+ Starting authorization...
777+ </span>
778+ </div>
779+ )}
812780
813− {!tokens && state.step === "oauth-waiting" && (
814− <div className="flex items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">
815− <Spinner className="size-3.5 text-blue-500" />
816− <span className="text-xs text-blue-600 dark:text-blue-400">
817− Waiting for authorization in popup…
818− </span>
819− <Button
820− variant="ghost"
821− size="sm"
822− onClick={handleCancelOAuth}
823− className="ml-auto h-7 px-2 text-xs"
824− >
825− Cancel
826− </Button>
827− </div>
828− )}
781+ {!tokens && state.step === "oauth-waiting" && (
782+ <div className="flex min-h-9 items-center gap-2 rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2">
783+ <Spinner className="size-3.5 text-blue-500" />
784+ <span className="text-xs text-blue-600 dark:text-blue-400">
785+ Waiting for authorization...
786+ </span>
787+ <Button
788+ type="button"
789+ variant="ghost"
790+ size="sm"
791+ onClick={handleCancelOAuth}
792+ className="ml-auto h-7 px-2 text-xs"
793+ >
794+ Cancel
795+ </Button>
796+ </div>
797+ )}
829798
830− {tokens && (
831− <div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">
832− <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
833− <path
834− d="M3 8.5l3 3 7-7"
835− stroke="currentColor"
836− strokeWidth="1.5"
837− strokeLinecap="round"
838− strokeLinejoin="round"
839− />
840− </svg>
841− <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
842− Authenticated
843− </span>
844− </div>
845− )}
846− </CredentialScopeSection>
799+ {tokens && (
800+ <div className="flex min-h-9 items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2">
801+ <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
802+ <path
803+ d="M3 8.5l3 3 7-7"
804+ stroke="currentColor"
805+ strokeWidth="1.5"
806+ strokeLinecap="round"
807+ strokeLinejoin="round"
808+ />
809+ </svg>
810+ <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
811+ Authenticated
812+ </span>
813+ </div>
814+ )}
815+ </CredentialControlField>
816+ </CredentialUsageRow>
847817 )}
848818 </section>
849819 )}
@@ -854,8 +824,8 @@ export default function AddMcpSource(props: { · 4 unmodified lines
854824 <div>
855825 <Label>Additional headers</Label>
856826 <p className="mt-1 text-[12px] text-muted-foreground">
857− Plaintext headers sent with every request. Use authentication for secret-backed
858− auth headers.
827+ Plaintext headers sent with every request. Use request headers above for
828+ secret-backed values.
859829 </p>
860830 </div>
861831
@@ -957,6 +927,7 @@ export default function AddMcpSource(props: { · 95 unmodified lines
957927 <p className="text-[12px] text-destructive">{otherError}</p>
958928 </div>
959929 <Button
930+ type="button"
960931 variant="outline"
961932 size="sm"
962933 onClick={() => dispatch({ type: "retry" })}
@@ -969,6 +940,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
969940
970941 <FloatActions>
971942 <Button
943+ type="button"
972944 variant="ghost"
973945 onClick={() => {
974946 oauth.cancel();
@@ -979,7 +951,7 @@ export default function AddMcpSource(props: { · 4 unmodified lines
979951 Cancel
980952 </Button>
981953 {(probe || isProbing) && (
982− <Button onClick={handleAddRemote} disabled={!canAdd}>
954+ <Button type="button" onClick={handleAddRemote} disabled={!canAdd}>
983955 {isAdding ? (
984956 <>
985957 <Spinner className="size-3.5" /> Adding…
@@ -1046,10 +1018,14 @@ export default function AddMcpSource(props: { · 60 unmodified lines
10461018 )}
10471019
10481020 <FloatActions>
1049− <Button variant="ghost" onClick={props.onCancel} disabled={stdioAdding}>
1021+ <Button type="button" variant="ghost" onClick={props.onCancel} disabled={stdioAdding}>
10501022 Cancel
10511023 </Button>
1052− <Button onClick={handleAddStdio} disabled={!stdioCommand.trim() || stdioAdding}>
1024+ <Button
1025+ type="button"
1026+ onClick={handleAddStdio}
1027+ disabled={!stdioCommand.trim() || stdioAdding}
1028+ >
10531029 {stdioAdding ? (
10541030 <>
10551031 <Spinner className="size-3.5" /> Adding…
packages/plugins/mcp/src/sdk/plugin.ts+67−33
@@ -308,6 +308,20 @@ const bindingTargetScope = ( · 307 unmodified lines
308308 );
309309 };
310310
311+const targetScopeForBinding = (
312+ fallbackTargetScope: string | undefined,
313+ binding: { readonly targetScope?: string },
314+): Effect.Effect<string, McpConnectionError> => {
315+ const targetScope = binding.targetScope ?? fallbackTargetScope;
316+ if (targetScope) return Effect.succeed(targetScope);
317+ return Effect.fail(
318+ new McpConnectionError({
319+ transport: "remote",
320+ message: "credentialTargetScope is required when adding direct MCP credentials",
321+ }),
322+ );
323+};
324+
311325 const canonicalizeCredentialMap = (
312326 values: Record<string, McpCredentialInput> | undefined,
313327 slotForName: (name: string) => string,
@@ -316,10 +330,11 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines
316330 readonly bindings: ReadonlyArray<{
317331 readonly slot: string;
318332 readonly value: McpSourceBindingValue;
333+ readonly targetScope?: string;
319334 }>;
320335 } => {
321336 const nextValues: Record<string, ConfiguredMcpCredentialValue> = {};
322− const bindings: Array<{ slot: string; value: McpSourceBindingValue }> = [];
337+ const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [];
323338 for (const [name, value] of Object.entries(values ?? {})) {
324339 if (typeof value === "string") {
325340 nextValues[name] = value;
@@ -337,9 +352,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
337352 });
338353 bindings.push({
339354 slot,
355+ targetScope: "targetScope" in value ? value.targetScope : undefined,
340356 value: {
341357 kind: "secret",
342358 secretId: SecretId.make(value.secretId),
359+ ...("secretScopeId" in value && value.secretScopeId
360+ ? { secretScopeId: value.secretScopeId }
361+ : {}),
343362 },
344363 });
345364 }
@@ -353,6 +372,7 @@ const canonicalizeAuth = ( · 7 unmodified lines
353372 readonly bindings: ReadonlyArray<{
354373 readonly slot: string;
355374 readonly value: McpSourceBindingValue;
375+ readonly targetScope?: string;
356376 }>;
357377 } => {
358378 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };
@@ -368,13 +388,18 @@ const canonicalizeAuth = ( · 9 unmodified lines
368388 bindings: [
369389 {
370390 slot: MCP_HEADER_AUTH_SLOT,
371− value: { kind: "secret", secretId: SecretId.make(auth.secretId) },
391+ targetScope: auth.targetScope,
392+ value: {
393+ kind: "secret",
394+ secretId: SecretId.make(auth.secretId),
395+ ...(auth.secretScopeId ? { secretScopeId: auth.secretScopeId } : {}),
396+ },
372397 },
373398 ],
374399 };
375400 }
376401 if ("connectionSlot" in auth) return { auth, bindings: [] };
377− const bindings: Array<{ slot: string; value: McpSourceBindingValue }> = [
402+ const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [
378403 {
379404 slot: MCP_OAUTH_CONNECTION_SLOT,
380405 value: {
@@ -572,18 +597,20 @@ const resolveMcpCredentialInputMap = ( · 191 unmodified lines
572597 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];
573598 continue;
574599 }
575− const secret = yield* ctx.secrets
576− .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope)
577− .pipe(
578− Effect.catchTag("SecretOwnedByConnectionError", () =>
579− Effect.fail(
580− new McpConnectionError({
581− transport: "remote",
582− message: `Failed to resolve secret for ${params.missingLabel} "${name}"`,
583− }),
584− ),
600+ const secretScope =
601+ "secretScopeId" in value
602+ ? (value.secretScopeId ?? value.targetScope)
603+ : (params.targetScope ?? params.sourceScope);
604+ const secret = yield* ctx.secrets.getAtScope(SecretId.make(value.secretId), secretScope).pipe(
605+ Effect.catchTag("SecretOwnedByConnectionError", () =>
606+ Effect.fail(
607+ new McpConnectionError({
608+ transport: "remote",
609+ message: `Failed to resolve secret for ${params.missingLabel} "${name}"`,
610+ }),
585611 ),
586− );
612+ ),
613+ );
587614 if (secret === null) {
588615 return yield* new McpConnectionError({
589616 transport: "remote",
@@ -681,18 +708,17 @@ const resolveMcpInputAuth = ( · 91 unmodified lines
681708 const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth);
682709 return { headers };
683710 }
684− const secret = yield* ctx.secrets
685− .getAtScope(SecretId.make(auth.secretId), targetScope ?? sourceScope)
686− .pipe(
687− Effect.catchTag("SecretOwnedByConnectionError", () =>
688− Effect.fail(
689− new McpConnectionError({
690− transport: "remote",
691− message: `Failed to resolve secret "${auth.secretId}"`,
692− }),
693− ),
711+ const secretScope = auth.secretScopeId ?? auth.targetScope ?? targetScope ?? sourceScope;
712+ const secret = yield* ctx.secrets.getAtScope(SecretId.make(auth.secretId), secretScope).pipe(
713+ Effect.catchTag("SecretOwnedByConnectionError", () =>
714+ Effect.fail(
715+ new McpConnectionError({
716+ transport: "remote",
717+ message: `Failed to resolve secret "${auth.secretId}"`,
718+ }),
694719 ),
695− );
720+ ),
721+ );
696722 if (secret === null) {
697723 return yield* new McpConnectionError({
698724 transport: "remote",
@@ -1074,17 +1100,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 375 unmodified lines
10741100 ...canonicalRemote.auth.bindings,
10751101 ]
10761102 : [];
1077− const targetScope =
1078− config.transport === "remote"
1079− ? yield* bindingTargetScope(config.credentialTargetScope, directBindings)
1080− : undefined;
1081− if (targetScope) {
1103+ for (const binding of directBindings) {
1104+ const bindingTargetScope = yield* targetScopeForBinding(
1105+ config.transport === "remote" ? config.credentialTargetScope : undefined,
1106+ binding,
1107+ );
10821108 yield* validateMcpBindingTarget(ctx, {
10831109 sourceId: namespace,
10841110 sourceScope: config.scope,
1085− targetScope,
1111+ targetScope: bindingTargetScope,
10861112 });
10871113 }
1114+ const targetScope =
1115+ config.transport === "remote" && directBindings[0]
1116+ ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0])
1117+ : undefined;
10881118 const sd = toStoredSourceData(
10891119 config,
10901120 canonicalRemote
@@ -1227,10 +1257,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 136 unmodified lines
12271257 })),
12281258 });
12291259
1230− if (targetScope) {
1260+ if (directBindings.length > 0) {
12311261 for (const binding of directBindings) {
1262+ const bindingTargetScope = yield* targetScopeForBinding(
1263+ config.transport === "remote" ? config.credentialTargetScope : undefined,
1264+ binding,
1265+ );
12321266 yield* ctx.credentialBindings.set({
1233− targetScope: ScopeId.make(targetScope),
1267+ targetScope: ScopeId.make(bindingTargetScope),
12341268 pluginId: MCP_PLUGIN_ID,
12351269 sourceId: namespace,
12361270 sourceScope: ScopeId.make(config.scope),
packages/plugins/mcp/src/sdk/types.ts+8−1
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
33 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,
44 CredentialBindingValue,
55 credentialSlotKey,
6+ ScopedSecretCredentialInput,
67 ScopeId,
78 SecretBackedMap,
89 SecretBackedValue,
@@ -24,7 +25,11 @@ export type McpTransport = typeof McpTransport.Type; · 15 unmodified lines
2425 export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema;
2526 export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type;
2627
27−export const McpCredentialInput = Schema.Union([SecretBackedValue, ConfiguredMcpCredentialValue]);
28+export const McpCredentialInput = Schema.Union([
29+ ScopedSecretCredentialInput,
30+ SecretBackedValue,
31+ ConfiguredMcpCredentialValue,
32+]);
2833 export type McpCredentialInput = typeof McpCredentialInput.Type;
2934
3035 export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name);
@@ -69,6 +74,8 @@ export const McpConnectionAuthInput = Schema.Union([ · 38 unmodified lines
6974 headerName: Schema.String,
7075 secretId: Schema.String,
7176 prefix: Schema.optional(Schema.String),
77+ targetScope: Schema.optional(ScopeId),
78+ secretScopeId: Schema.optional(ScopeId),
7279 }),
7380 Schema.Struct({
7481 kind: Schema.Literal("oauth2"),
packages/plugins/openapi/src/api/group.ts+22−5
@@ -1,6 +1,6 @@
11 import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
22 import { Schema } from "effect";
3−import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core";
3+import { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core";
44 import { InternalError } from "@executor-js/api";
55
66 import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
@@ -43,6 +43,23 @@ const SpecFetchCredentialsPayload = Schema.Struct({ · 36 unmodified lines
4343 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
4444 });
4545
46+const ConfiguredCredentialBindingPayload = Schema.Struct({
47+ kind: Schema.Literal("binding"),
48+ slot: Schema.String,
49+ prefix: Schema.optional(Schema.String),
50+});
51+
52+const ConfiguredCredentialValuePayload = Schema.Union([
53+ Schema.String,
54+ ConfiguredCredentialBindingPayload,
55+]);
56+
57+const OpenApiCredentialInputPayload = Schema.Union([
58+ ScopedSecretCredentialInput,
59+ SecretBackedValue,
60+ ConfiguredCredentialValuePayload,
61+]);
62+
4663 // ---------------------------------------------------------------------------
4764 // Payloads
4865 // ---------------------------------------------------------------------------
@@ -55,8 +72,8 @@ const AddSpecPayload = Schema.Struct({ · 6 unmodified lines
5572 name: Schema.optional(Schema.String),
5673 baseUrl: Schema.optional(Schema.String),
5774 namespace: Schema.optional(Schema.String),
58− headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
59− queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
75+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
76+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
6077 oauth2: Schema.optional(OAuth2SourceConfig),
6178 });
6279
@@ -69,8 +86,8 @@ const UpdateSourcePayload = Schema.Struct({ · 6 unmodified lines
6986 sourceScope: ScopeId,
7087 name: Schema.optional(Schema.String),
7188 baseUrl: Schema.optional(Schema.String),
72− headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
73− queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
89+ headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
90+ queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
7491 credentialTargetScope: Schema.optional(ScopeId),
7592 // Set after a successful re-authenticate to refresh the source's
7693 // stored OAuth2 metadata.
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+312−79
@@ -1,4 +1,4 @@
1−import { useCallback, useEffect, useRef, useState } from "react";
1+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
44 import * as Option from "effect/Option";
@@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line
66
77 import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";
88 import { startOAuth } from "@executor-js/react/api/atoms";
9−import { useScope } from "@executor-js/react/api/scope-context";
9+import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
1010 import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
1111
1212 // `addSpec` with an oauth2 payload persists a source row AND (for
@@ -28,10 +28,6 @@ import { · 15 unmodified lines
2828 useOAuthPopupFlow,
2929 type OAuthCompletionPayload,
3030 } from "@executor-js/react/plugins/oauth-sign-in";
31−import {
32− CredentialScopeSection,
33− useCredentialTargetScope,
34−} from "@executor-js/react/plugins/credential-target-scope";
3531 import {
3632 CreatableSecretPicker,
3733 matchPresetKey,
@@ -62,12 +58,20 @@ import { · 24 unmodified lines
6258 } from "@executor-js/react/components/card-stack";
6359 import { FieldLabel } from "@executor-js/react/components/field";
6460 import { FloatActions } from "@executor-js/react/components/float-actions";
61+import { HelpTooltip } from "@executor-js/react/components/help-tooltip";
6562 import { Input } from "@executor-js/react/components/input";
6663 import { Label } from "@executor-js/react/components/label";
6764 import { Textarea } from "@executor-js/react/components/textarea";
6865 import { Checkbox } from "@executor-js/react/components/checkbox";
6966 import { SourceFavicon } from "@executor-js/react/components/source-favicon";
7067 import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
68+import {
69+ Select,
70+ SelectContent,
71+ SelectItem,
72+ SelectTrigger,
73+ SelectValue,
74+} from "@executor-js/react/components/select";
7175 import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
7276 import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
7377 import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
@@ -76,6 +80,7 @@ import { · 2 unmodified lines
7680 oauth2ClientIdSlot,
7781 oauth2ClientSecretSlot,
7882 oauth2ConnectionSlot,
83+ queryParamBindingSlot,
7984 } from "../sdk/store";
8085 import {
8186 ConfiguredHeaderBinding,
@@ -200,6 +205,49 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 118 unmodified lines
200205 });
201206 }
202207
208+const secretStorageDescription = (label: string): string =>
209+ label === "Personal"
210+ ? "Only you can use this secret."
211+ : "Everyone in the organization can use this secret.";
212+
213+function CredentialScopeDropdown(props: {
214+ readonly value: ScopeId;
215+ readonly options: readonly {
216+ readonly scopeId: ScopeId;
217+ readonly label: string;
218+ }[];
219+ readonly onChange: (scopeId: ScopeId) => void;
220+ readonly label?: string;
221+ readonly help?: ReactNode;
222+}) {
223+ const label = props.label ?? "Used by";
224+ return (
225+ <div className="space-y-1.5">
226+ <div className="flex items-center gap-1.5">
227+ <FieldLabel className="text-[11px]">{label}</FieldLabel>
228+ <HelpTooltip label={label}>
229+ {props.help ?? "Choose who uses this credential value."}
230+ </HelpTooltip>
231+ </div>
232+ <Select
233+ value={String(props.value)}
234+ onValueChange={(value) => props.onChange(ScopeId.make(value))}
235+ >
236+ <SelectTrigger className="w-full">
237+ <SelectValue placeholder={label} />
238+ </SelectTrigger>
239+ <SelectContent>
240+ {props.options.map((option) => (
241+ <SelectItem key={option.scopeId} value={option.scopeId}>
242+ {option.label}
243+ </SelectItem>
244+ ))}
245+ </SelectContent>
246+ </Select>
247+ </div>
248+ );
249+}
250+
203251 // ---------------------------------------------------------------------------
204252 // Main component — single progressive form
205253 // ---------------------------------------------------------------------------
@@ -237,6 +285,8 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines
237285 // OAuth2 state (only populated while an oauth2 preset is selected)
238286 const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null);
239287 const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null);
288+ const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null);
289+ const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null);
240290 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());
241291 const [oauth2AuthState, setOauth2AuthState] = useState<{
242292 readonly fingerprint: string;
@@ -250,8 +300,47 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
250300 const [addError, setAddError] = useState<string | null>(null);
251301
252302 const scopeId = useScope();
253− const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =
254− useCredentialTargetScope();
303+ const scopeStack = useScopeStack();
304+ const credentialScopeOptions = useMemo(
305+ () =>
306+ scopeStack.map((entry, index) => ({
307+ scopeId: entry.id,
308+ label: index === 0 ? "Personal" : entry.name || "Organization",
309+ description: secretStorageDescription(
310+ index === 0 ? "Personal" : entry.name || "Organization",
311+ ),
312+ })),
313+ [scopeStack],
314+ );
315+ const defaultCredentialTargetScope =
316+ credentialScopeOptions[credentialScopeOptions.length - 1]?.scopeId ?? scopeId;
317+ const defaultOAuthTokenTargetScope = credentialScopeOptions[0]?.scopeId ?? scopeId;
318+ const [credentialTargetScope, setCredentialTargetScope] = useState<ScopeId>(
319+ defaultCredentialTargetScope,
320+ );
321+ const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState<ScopeId>(
322+ defaultOAuthTokenTargetScope,
323+ );
324+ const bindingScopeOptions = useMemo(
325+ () => credentialScopeOptions.map((option) => ({ ...option })),
326+ [credentialScopeOptions],
327+ );
328+ useEffect(() => {
329+ if (!credentialScopeOptions.some((option) => option.scopeId === credentialTargetScope)) {
330+ setCredentialTargetScope(defaultCredentialTargetScope);
331+ }
332+ }, [credentialScopeOptions, credentialTargetScope, defaultCredentialTargetScope]);
333+ useEffect(() => {
334+ if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) {
335+ setOAuthTokenTargetScope(defaultOAuthTokenTargetScope);
336+ }
337+ }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]);
338+ const initialCredentialScopeOption =
339+ credentialScopeOptions.find((option) => option.scopeId === credentialTargetScope) ??
340+ credentialScopeOptions[0];
341+ const initialCredentialScopeOptions = initialCredentialScopeOption
342+ ? [initialCredentialScopeOption]
343+ : [];
255344 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
256345 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {
257346 mode: "promiseExit",
@@ -261,6 +350,10 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
261350 mode: "promiseExit",
262351 });
263352 const secretList = useSecretPickerSecrets();
353+ const initialCredentialSecrets = useMemo(
354+ () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)),
355+ [credentialTargetScope, secretList],
356+ );
264357 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
265358 popupName: OPENAPI_OAUTH_POPUP_NAME,
266359 popupBlockedMessage: "OAuth popup was blocked by the browser",
@@ -318,7 +411,19 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines
318411 const resolvedBaseUrl = baseUrl.trim();
319412
320413 const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};
321− const headerBindings: Array<{ slot: string; secretId: string }> = [];
414+ const headerBindings: Array<{
415+ slot: string;
416+ secretId: string;
417+ scope: ScopeId;
418+ secretScope: ScopeId;
419+ }> = [];
420+ const configuredQueryParams: Record<string, string | ConfiguredHeaderBinding> = {};
421+ const queryParamBindings: Array<{
422+ slot: string;
423+ secretId: string;
424+ scope: ScopeId;
425+ secretScope: ScopeId;
426+ }> = [];
322427 for (const ch of customHeaders) {
323428 if (!ch.name.trim()) continue;
324429 const slot = headerBindingSlot(ch.name.trim());
@@ -328,7 +433,34 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
328433 prefix: ch.prefix,
329434 });
330435 if (ch.secretId) {
331− headerBindings.push({ slot, secretId: ch.secretId });
436+ headerBindings.push({
437+ slot,
438+ secretId: ch.secretId,
439+ scope: ch.targetScope ?? credentialTargetScope,
440+ secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope,
441+ });
442+ }
443+ }
444+ for (const param of runtimeCredentials.queryParams) {
445+ const name = param.name.trim();
446+ if (!name) continue;
447+ if (param.secretId) {
448+ const slot = queryParamBindingSlot(name);
449+ configuredQueryParams[name] = new ConfiguredHeaderBinding({
450+ kind: "binding",
451+ slot,
452+ prefix: param.prefix,
453+ });
454+ queryParamBindings.push({
455+ slot,
456+ secretId: param.secretId,
457+ scope: param.targetScope ?? credentialTargetScope,
458+ secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope,
459+ });
460+ continue;
461+ }
462+ if (param.literalValue?.trim()) {
463+ configuredQueryParams[name] = param.literalValue.trim();
332464 }
333465 }
334466
@@ -380,6 +512,18 @@ export default function AddOpenApiSource(props: { · 45 unmodified lines
380512 : null;
381513 const hasHeaders = Object.keys(configuredHeaders).length > 0;
382514 const oauth2Busy = startingOAuth || oauth.busy;
515+ const canConnectOAuth2 = Boolean(oauth2ClientIdSecretId) && resolvedBaseUrl.length > 0;
516+ const hasIncompleteHeaderCredentials =
517+ strategy.kind !== "none" &&
518+ strategy.kind !== "oauth2" &&
519+ customHeaders.some((header) => header.name.trim() && !header.secretId);
520+ const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some(
521+ (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(),
522+ );
523+ const willAddWithoutInitialCredentials =
524+ Boolean(selectedOAuth2Preset && !oauth2Auth) ||
525+ hasIncompleteHeaderCredentials ||
526+ hasIncompleteQueryCredentials;
383527
384528 const canAdd = preview !== null && resolvedBaseUrl.length > 0;
385529
@@ -470,6 +614,24 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines
470614 }
471615 };
472616
617+ const setInitialCredentialScope = (targetScope: ScopeId) => {
618+ setCredentialTargetScope(targetScope);
619+ setCustomHeaders((headers) =>
620+ headers.map((header) => ({
621+ ...header,
622+ targetScope,
623+ ...(header.secretScope && header.secretScope !== targetScope
624+ ? { secretId: null, secretScope: undefined }
625+ : {}),
626+ })),
627+ );
628+ setOauth2ClientIdSecretId(null);
629+ setOauth2ClientSecretSecretId(null);
630+ setOauth2ClientIdScope(null);
631+ setOauth2ClientSecretScope(null);
632+ setOauth2AuthState(null);
633+ };
634+
473635 const toggleOAuth2Scope = (scope: string) => {
474636 setOauth2SelectedScopes((prev) => {
475637 const copy = new Set(prev);
@@ -505,7 +667,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
505667 endpoint: tokenUrl,
506668 redirectUrl: tokenUrl,
507669 connectionId,
508− tokenScope: credentialTargetScope,
670+ tokenScope: oauthTokenTargetScope,
509671 strategy: {
510672 kind: "client-credentials",
511673 tokenEndpoint: tokenUrl,
@@ -542,14 +704,14 @@ export default function AddOpenApiSource(props: { · 30 unmodified lines
542704 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
543705
544706 await oauth.openAuthorization({
545− tokenScope: credentialTargetScope,
707+ tokenScope: oauthTokenTargetScope,
546708 run: async () => {
547709 const exit = await doStartOAuth({
548710 params: { scopeId },
549711 payload: {
550712 endpoint: authorizationUrl,
551713 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
552− tokenScope: credentialTargetScope,
714+ tokenScope: oauthTokenTargetScope,
553715 redirectUrl: oauth2RedirectUrl,
554716 strategy: {
555717 kind: "authorization-code",
@@ -604,7 +766,7 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines
604766 resolvedSourceId,
605767 selectedOAuth2Fingerprint,
606768 oauth,
607− credentialTargetScope,
769+ oauthTokenTargetScope,
608770 ]);
609771
610772 const handleCancelOAuth2 = useCallback(() => {
@@ -631,10 +793,8 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
631793 namespace,
632794 baseUrl: resolvedBaseUrl || undefined,
633795 ...(hasHeaders ? { headers: configuredHeaders } : {}),
634− ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0
635− ? {
636− queryParams: serializeHttpCredentials(runtimeCredentials).queryParams,
637− }
796+ ...(Object.keys(configuredQueryParams).length > 0
797+ ? { queryParams: configuredQueryParams }
638798 : {}),
639799 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
640800 },
@@ -649,6 +809,9 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
649809 const sourceId = exit.value.namespace;
650810 const sourceScope = ScopeId.make(scopeId);
651811 const bindingScope = ScopeId.make(credentialTargetScope);
812+ const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope);
813+ const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope;
814+ const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope;
652815
653816 for (const binding of headerBindings) {
654817 const bindingExit = await doSetBinding({
@@ -656,11 +819,35 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
656819 payload: {
657820 sourceId,
658821 sourceScope,
659− scope: bindingScope,
822+ scope: binding.scope,
660823 slot: binding.slot,
661824 value: {
662825 kind: "secret",
663826 secretId: SecretId.make(binding.secretId),
827+ secretScopeId: binding.secretScope,
828+ },
829+ },
830+ reactivityKeys: bindingWriteKeys,
831+ });
832+ if (Exit.isFailure(bindingExit)) {
833+ setAddError(errorMessageFromExit(bindingExit, "Failed to add source"));
834+ setAdding(false);
835+ return;
836+ }
837+ }
838+
839+ for (const binding of queryParamBindings) {
840+ const bindingExit = await doSetBinding({
841+ params: { scopeId },
842+ payload: {
843+ sourceId,
844+ sourceScope,
845+ scope: binding.scope,
846+ slot: binding.slot,
847+ value: {
848+ kind: "secret",
849+ secretId: SecretId.make(binding.secretId),
850+ secretScopeId: binding.secretScope,
664851 },
665852 },
666853 reactivityKeys: bindingWriteKeys,
@@ -683,6 +870,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
683870 value: {
684871 kind: "secret",
685872 secretId: SecretId.make(oauth2ClientIdSecretId),
873+ secretScopeId: clientIdSecretScope,
686874 },
687875 },
688876 reactivityKeys: bindingWriteKeys,
@@ -705,6 +893,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
705893 value: {
706894 kind: "secret",
707895 secretId: SecretId.make(oauth2ClientSecretSecretId),
896+ secretScopeId: clientSecretSecretScope,
708897 },
709898 },
710899 reactivityKeys: bindingWriteKeys,
@@ -722,7 +911,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
722911 payload: {
723912 sourceId,
724913 sourceScope,
725− scope: bindingScope,
914+ scope: oauthTokenBindingScope,
726915 slot: configuredOAuth2.connectionSlot,
727916 value: {
728917 kind: "connection",
@@ -747,10 +936,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
747936 <div className="flex flex-1 flex-col gap-6">
748937 <div>
749938 <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1>
750− <p className="mt-1 text-[13px] text-muted-foreground">
751− Save the source now, then add personal or organization credentials where the API needs
752− them.
753− </p>
754939 </div>
755940
756941 {!preview && (
@@ -880,7 +1065,7 @@ export default function AddOpenApiSource(props: { · 123 unmodified lines
8801065 {preview && (
8811066 <>
8821067 <section className="space-y-2.5">
883− <FieldLabel>Authentication</FieldLabel>
1068+ <FieldLabel>Authentication method</FieldLabel>
8841069 {/* RadioGroup always renders so the static Custom + None radios
8851070 stay visible for specs with no security schemes (e.g. MS Graph).
8861071 The preset .map() blocks below render nothing when their arrays
@@ -959,22 +1144,19 @@ export default function AddOpenApiSource(props: { · 72 unmodified lines
9591144
9601145 {/* Header-based auth input */}
9611146 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (
962− <CredentialScopeSection
963− value={credentialTargetScope}
964− options={credentialScopeOptions}
965− onChange={(targetScope) => {
966− setCredentialTargetScope(targetScope);
967− setOauth2AuthState(null);
968− }}
969− >
1147+ <div className="space-y-3">
9701148 <HeadersList
9711149 headers={customHeaders}
9721150 onHeadersChange={handleHeadersChange}
9731151 existingSecrets={secretList}
9741152 sourceName={identity.name}
9751153 targetScope={credentialTargetScope}
1154+ credentialScopeOptions={initialCredentialScopeOptions}
1155+ bindingScopeOptions={bindingScopeOptions}
1156+ restrictSecretsToTargetScope
1157+ emptyLabel="No credentials yet. Add the header value this method should use."
9761158 />
977− </CredentialScopeSection>
1159+ </div>
9781160 )}
9791161
9801162 <HttpCredentialsEditor
@@ -983,6 +1165,9 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
9831165 existingSecrets={secretList}
9841166 sourceName={identity.name}
9851167 targetScope={credentialTargetScope}
1168+ credentialScopeOptions={initialCredentialScopeOptions}
1169+ bindingScopeOptions={bindingScopeOptions}
1170+ restrictSecretsToTargetScope
9861171 sections={{ headers: false, queryParams: true }}
9871172 labels={{ queryParams: "Runtime query parameters" }}
9881173 />
@@ -990,15 +1175,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
9901175 {/* OAuth2 configuration */}
9911176 {selectedOAuth2Preset && (
9921177 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
993− <CredentialScopeSection
994− value={credentialTargetScope}
995− options={credentialScopeOptions}
996− onChange={(targetScope) => {
997− setCredentialTargetScope(targetScope);
998− setOauth2AuthState(null);
999− }}
1000− description="Choose who can use the OAuth client secrets and connection."
1001− >
1178+ <div className="space-y-3">
10021179 <div className="space-y-1.5">
10031180 <FieldLabel className="text-[11px]">
10041181 Redirect URL{" "}
@@ -1013,17 +1190,35 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
10131190 </div>
10141191 <div className="space-y-1.5">
10151192 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1016− <CreatableSecretPicker
1017− value={oauth2ClientIdSecretId}
1018− onSelect={(id: string) => {
1019− setOauth2ClientIdSecretId(id);
1020− setOauth2AuthState(null);
1021− }}
1022− secrets={secretList}
1023− sourceName={identity.name}
1024− secretLabel="Client ID"
1025− targetScope={credentialTargetScope}
1026− />
1193+ <div className="grid gap-2 md:grid-cols-2">
1194+ <div className="space-y-1.5">
1195+ <div className="flex items-center gap-1.5">
1196+ <FieldLabel className="text-[11px]">Secret</FieldLabel>
1197+ <HelpTooltip label="Client ID secret">
1198+ Select or create the OAuth client ID secret.
1199+ </HelpTooltip>
1200+ </div>
1201+ <CreatableSecretPicker
1202+ value={oauth2ClientIdSecretId}
1203+ onSelect={(id: string, secretScopeId?: ScopeId) => {
1204+ setOauth2ClientIdSecretId(id);
1205+ setOauth2ClientIdScope(secretScopeId ?? credentialTargetScope);
1206+ setOauth2AuthState(null);
1207+ }}
1208+ secrets={initialCredentialSecrets}
1209+ sourceName={identity.name}
1210+ secretLabel="Client ID"
1211+ targetScope={oauth2ClientIdScope ?? credentialTargetScope}
1212+ credentialScopeOptions={initialCredentialScopeOptions}
1213+ onCreatedScope={setOauth2ClientIdScope}
1214+ />
1215+ </div>
1216+ <CredentialScopeDropdown
1217+ value={credentialTargetScope}
1218+ options={bindingScopeOptions}
1219+ onChange={setInitialCredentialScope}
1220+ />
1221+ </div>
10271222 </div>
10281223 <div className="space-y-1.5">
10291224 <FieldLabel className="text-[11px]">
@@ -1032,17 +1227,35 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
10321227 · optional for public clients with PKCE
10331228 </span>
10341229 </FieldLabel>
1035− <CreatableSecretPicker
1036− value={oauth2ClientSecretSecretId}
1037− onSelect={(id: string) => {
1038− setOauth2ClientSecretSecretId(id);
1039− setOauth2AuthState(null);
1040− }}
1041− secrets={secretList}
1042− sourceName={identity.name}
1043− secretLabel="Client Secret"
1044− targetScope={credentialTargetScope}
1045− />
1230+ <div className="grid gap-2 md:grid-cols-2">
1231+ <div className="space-y-1.5">
1232+ <div className="flex items-center gap-1.5">
1233+ <FieldLabel className="text-[11px]">Secret</FieldLabel>
1234+ <HelpTooltip label="Client secret">
1235+ Select or create the OAuth client secret.
1236+ </HelpTooltip>
1237+ </div>
1238+ <CreatableSecretPicker
1239+ value={oauth2ClientSecretSecretId}
1240+ onSelect={(id: string, secretScopeId?: ScopeId) => {
1241+ setOauth2ClientSecretSecretId(id);
1242+ setOauth2ClientSecretScope(secretScopeId ?? credentialTargetScope);
1243+ setOauth2AuthState(null);
1244+ }}
1245+ secrets={initialCredentialSecrets}
1246+ sourceName={identity.name}
1247+ secretLabel="Client Secret"
1248+ targetScope={oauth2ClientSecretScope ?? credentialTargetScope}
1249+ credentialScopeOptions={initialCredentialScopeOptions}
1250+ onCreatedScope={setOauth2ClientSecretScope}
1251+ />
1252+ </div>
1253+ <CredentialScopeDropdown
1254+ value={credentialTargetScope}
1255+ options={bindingScopeOptions}
1256+ onChange={setInitialCredentialScope}
1257+ />
1258+ </div>
10461259 </div>
10471260 <div className="space-y-1.5">
10481261 <FieldLabel className="text-[11px]">Scopes</FieldLabel>
@@ -1097,18 +1310,38 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines
10971310 </div>
10981311 ) : (
10991312 <div className="flex flex-col gap-1.5">
1100− <Button
1101− variant="secondary"
1102− onClick={handleConnectOAuth2}
1103− disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}
1104− className="w-full"
1105− >
1106− Connect via OAuth
1107− </Button>
1108− <p className="text-[11px] text-muted-foreground">
1109− You can add the source without this OAuth connection. Secured operations
1110− will ask for credentials on the source detail page.
1111− </p>
1313+ <div className="grid gap-2 md:grid-cols-2">
1314+ <div className="space-y-1.5">
1315+ <div className="flex items-center gap-1.5">
1316+ <FieldLabel className="text-[11px]">OAuth sign-in</FieldLabel>
1317+ <HelpTooltip label="OAuth sign-in">
1318+ Start the provider OAuth flow.
1319+ </HelpTooltip>
1320+ </div>
1321+ <Button
1322+ variant="secondary"
1323+ onClick={handleConnectOAuth2}
1324+ disabled={!canConnectOAuth2}
1325+ className={
1326+ canConnectOAuth2
1327+ ? "w-full border border-green-500/30 bg-green-600 text-white hover:bg-green-700 focus-visible:ring-green-500/30 dark:bg-green-500 dark:text-white dark:hover:bg-green-600"
1328+ : "w-full"
1329+ }
1330+ >
1331+ Connect via OAuth
1332+ </Button>
1333+ </div>
1334+ <CredentialScopeDropdown
1335+ value={oauthTokenTargetScope}
1336+ options={credentialScopeOptions}
1337+ onChange={(targetScope) => {
1338+ setOAuthTokenTargetScope(targetScope);
1339+ setOauth2AuthState(null);
1340+ }}
1341+ label="Token saved to"
1342+ help="Choose who can use the signed-in OAuth token."
1343+ />
1344+ </div>
11121345 </div>
11131346 )}
11141347
@@ -1117,7 +1350,7 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
11171350 <p className="text-[11px] text-destructive">{oauth2Error}</p>
11181351 </div>
11191352 )}
1120− </CredentialScopeSection>
1353+ </div>
11211354 </div>
11221355 )}
11231356 </section>
@@ -1140,7 +1373,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
11401373 {adding && <Spinner className="size-3.5" />}
11411374 {adding
11421375 ? "Adding…"
1143− : selectedOAuth2Preset && !oauth2Auth
1376+ : willAddWithoutInitialCredentials
11441377 ? "Add without credentials"
11451378 : "Add source"}
11461379 </Button>
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+177−116
@@ -170,8 +170,12 @@ export default function EditOpenApiSource(props: { · 169 unmodified lines
170170 const secretList = useSecretPickerSecrets();
171171
172172 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" });
173− const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });
174− const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promiseExit" });
173+ const doSetBinding = useAtomSet(setOpenApiSourceBinding, {
174+ mode: "promiseExit",
175+ });
176+ const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, {
177+ mode: "promiseExit",
178+ });
175179 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
176180 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
177181 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -196,11 +200,15 @@ export default function EditOpenApiSource(props: { · 18 unmodified lines
196200 readonly connectionId: string;
197201 } | null>(null);
198202 const [loadedSourceKey, setLoadedSourceKey] = useState<string | null>(null);
199− const [selectedCredentialScope, setSelectedCredentialScope] = useState<string>(
203+ const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState<string>(
200204 userScope !== sourceScopeId ? userScope : sourceScopeId,
201205 );
202206 const sourceSaveSeq = useRef(0);
203207
208+ useEffect(() => {
209+ setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId);
210+ }, [sourceScopeId, userScope]);
211+
204212 useEffect(() => {
205213 if (!source) return;
206214 const sourceKey = `${sourceScopeId}:${source.namespace}`;
@@ -211,10 +219,6 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
211219 setLoadedSourceKey(sourceKey);
212220 }, [loadedSourceKey, source, sourceScopeId]);
213221
214− useEffect(() => {
215− setSelectedCredentialScope(userScope !== sourceScopeId ? userScope : sourceScopeId);
216− }, [sourceScopeId, userScope]);
217−
218222 useEffect(() => {
219223 if (!source) return;
220224 const sourceKey = `${sourceScopeId}:${source.namespace}`;
@@ -313,15 +317,39 @@ export default function EditOpenApiSource(props: { · 92 unmodified lines
313317 if (userScope !== sourceScopeId) {
314318 entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" });
315319 } else {
316− entries[0] = { scopeId: ScopeId.make(sourceScopeId), label: "Credentials" };
320+ entries[0] = {
321+ scopeId: ScopeId.make(sourceScopeId),
322+ label: "Credentials",
323+ };
317324 }
318325 return entries;
319326 }, [sourceScopeId, userScope]);
320− const activeCredentialScope =
321− credentialScopes.find((entry) => entry.scopeId === selectedCredentialScope) ??
327+ const credentialScopeOptions = useMemo(
328+ () =>
329+ credentialScopes.map((entry) => ({
330+ scopeId: entry.scopeId,
331+ label: entry.label,
332+ description:
333+ entry.label === "Personal"
334+ ? "Saved only for your account."
335+ : "Shared with everyone who can use this source.",
336+ })),
337+ [credentialScopes],
338+ );
339+ const organizationCredentialScope =
340+ credentialScopes.find((entry) => entry.label === "Organization") ?? credentialScopes[0]!;
341+ const personalCredentialScope =
342+ credentialScopes.find((entry) => entry.label === "Personal") ?? null;
343+ const secretBindingScopes =
344+ personalCredentialScope &&
345+ personalCredentialScope.scopeId !== organizationCredentialScope.scopeId
346+ ? [organizationCredentialScope, personalCredentialScope]
347+ : [organizationCredentialScope];
348+ const activeOAuthTokenScope =
349+ credentialScopes.find((entry) => entry.scopeId === selectedOAuthTokenScope) ??
322350 credentialScopes[0]!;
323− const activeCredentialScopeId = activeCredentialScope.scopeId;
324− const activeCredentialScopeLabel = activeCredentialScope.label;
351+ const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId;
352+ const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label;
325353
326354 if (!source) {
327355 return (
@@ -332,7 +360,12 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
332360 );
333361 }
334362
335− const setSecretBinding = async (targetScope: ScopeId, slot: string, secretId: string) => {
363+ const setSecretBinding = async (
364+ targetScope: ScopeId,
365+ slot: string,
366+ secretId: string,
367+ secretScope: ScopeId,
368+ ) => {
336369 const inputKey = `${targetScope}:${slot}`;
337370 const trimmed = secretId.trim();
338371 if (!trimmed) return;
@@ -345,7 +378,11 @@ export default function EditOpenApiSource(props: { · 6 unmodified lines
345378 sourceScope,
346379 scope: targetScope,
347380 slot,
348− value: { kind: "secret", secretId: SecretId.make(trimmed) },
381+ value: {
382+ kind: "secret",
383+ secretId: SecretId.make(trimmed),
384+ secretScopeId: secretScope,
385+ },
349386 },
350387 reactivityKeys: sourceWriteKeys,
351388 });
@@ -562,10 +599,6 @@ export default function EditOpenApiSource(props: { · 210 unmodified lines
562599 <div className="space-y-6">
563600 <div>
564601 <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1>
565− <p className="mt-1 text-sm text-muted-foreground">
566− Shared source settings stay on the source. Credentials can be saved personally or shared
567− with the organization.
568− </p>
569602 </div>
570603
571604 <CardStack>
@@ -612,101 +645,110 @@ export default function EditOpenApiSource(props: { · 40 unmodified lines
612645
613646 <CardStack>
614647 <CardStackContent className="border-t-0">
615− {credentialScopes.length > 1 && (
616− <CardStackEntry>
617− <CardStackEntryContent>
618− <CardStackEntryTitle>Credentials</CardStackEntryTitle>
619− <CardStackEntryDescription>
620− Choose whether each credential is personal or shared with the organization.
621− </CardStackEntryDescription>
622− </CardStackEntryContent>
623− <FilterTabs
624− tabs={credentialScopes.map((entry) => ({
625− value: entry.scopeId,
626− label: entry.label,
627− }))}
628− value={activeCredentialScopeId}
629− onChange={setSelectedCredentialScope}
630− />
631− </CardStackEntry>
632− )}
648+ <CardStackEntry>
649+ <CardStackEntryContent>
650+ <CardStackEntryTitle>Secrets</CardStackEntryTitle>
651+ </CardStackEntryContent>
652+ </CardStackEntry>
633653
634654 {secretSlots
635655 .filter((slot) => slot.kind === "secret")
636656 .map((slot) => {
637− const exact = exactBindingForScope(bindingRows, slot.slot, activeCredentialScopeId);
638− const effective = effectiveBindingForScope(
639− bindingRows,
640− slot.slot,
641− activeCredentialScopeId,
642− scopeRanks,
643− );
644− const inputKey = `${activeCredentialScopeId}:${slot.slot}`;
645− const savedHere = !!(exact && isSecretBindingValue(exact.value));
646− const inherited =
647− !savedHere &&
648− effective &&
649− effective.scopeId !== activeCredentialScopeId &&
650− isSecretBindingValue(effective.value);
651− const currentSecretId =
652− exact && isSecretBindingValue(exact.value)
653− ? exact.value.secretId
654− : inherited && effective && isSecretBindingValue(effective.value)
655− ? effective.value.secretId
656− : null;
657657 return (
658− <CardStackEntryField
659− key={`${slot.slot}:${activeCredentialScopeId}`}
660− label={slot.label}
661− >
662− <div className="space-y-2">
663− <CreatableSecretPicker
664− value={currentSecretId}
665− onSelect={(secretId) =>
666− void setSecretBinding(activeCredentialScopeId, slot.slot, secretId)
667− }
668− secrets={secretList}
669− placeholder={
670− savedHere
671− ? `Selected in ${activeCredentialScopeLabel.toLowerCase()}`
672− : inherited
673− ? "Using organization default"
674− : "Select or create a secret"
675− }
676− targetScope={activeCredentialScopeId}
677− suggestedId={bindingSecretId(
678− props.sourceId,
658+ <CardStackEntryField key={slot.slot} label={slot.label}>
659+ <div className="space-y-3">
660+ {secretBindingScopes.map((bindingScope) => {
661+ const exact = exactBindingForScope(
662+ bindingRows,
679663 slot.slot,
680− activeCredentialScopeId,
681− )}
682− sourceName={source.name}
683− secretLabel={slot.label}
684− />
685− <div className="flex items-center gap-2">
686− {savedHere && (
687− <Button
688− variant="outline"
689− size="sm"
690− onClick={() => void clearBinding(activeCredentialScopeId, slot.slot)}
691− disabled={busyKey === `${activeCredentialScopeId}:${slot.slot}:clear`}
664+ bindingScope.scopeId,
665+ );
666+ const exactSecretId =
667+ exact && isSecretBindingValue(exact.value) ? exact.value.secretId : null;
668+ const inherited =
669+ bindingScope.label === "Personal"
670+ ? effectiveBindingForScope(
671+ bindingRows,
672+ slot.slot,
673+ bindingScope.scopeId,
674+ scopeRanks,
675+ )
676+ : null;
677+ const inheritedSecretId =
678+ inherited &&
679+ inherited.scopeId !== bindingScope.scopeId &&
680+ isSecretBindingValue(inherited.value)
681+ ? inherited.value.secretId
682+ : null;
683+ const inputKey = `${bindingScope.scopeId}:${slot.slot}`;
684+ const clearKey = `${bindingScope.scopeId}:${slot.slot}:clear`;
685+ const rowTitle =
686+ bindingScope.label === "Personal"
687+ ? "My override"
688+ : secretBindingScopes.length === 1
689+ ? "Source credential"
690+ : "Organization default";
691+
692+ return (
693+ <div
694+ key={bindingScope.scopeId}
695+ className="space-y-2 rounded-md border border-border bg-background/40 p-3"
692696 >
693− Clear
694− </Button>
695− )}
696− {busyKey === inputKey && (
697− <span className="text-xs text-muted-foreground">Saving…</span>
698− )}
699− {slot.hint && (
700− <span className="text-xs text-muted-foreground">{slot.hint}</span>
701− )}
702− </div>
703− <p className="text-xs text-muted-foreground">
704− {savedHere
705− ? `Saved in ${activeCredentialScopeLabel.toLowerCase()}.`
706− : inherited
707− ? "No value saved here. Using the organization default."
708− : `No ${activeCredentialScopeLabel.toLowerCase()} value saved yet.`}
709− </p>
697+ <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
698+ <div>
699+ <div className="text-sm font-medium text-foreground">{rowTitle}</div>
700+ </div>
701+ {bindingScope.label === "Personal" &&
702+ !exactSecretId &&
703+ inheritedSecretId && (
704+ <span className="text-xs text-muted-foreground">
705+ Using organization default
706+ </span>
707+ )}
708+ </div>
709+ <CreatableSecretPicker
710+ value={exactSecretId}
711+ onSelect={(secretId, secretScopeId) => {
712+ void setSecretBinding(
713+ bindingScope.scopeId,
714+ slot.slot,
715+ secretId,
716+ secretScopeId ?? bindingScope.scopeId,
717+ );
718+ }}
719+ secrets={secretList}
720+ placeholder="Select or create a secret"
721+ targetScope={bindingScope.scopeId}
722+ credentialScopeOptions={credentialScopeOptions}
723+ suggestedId={bindingSecretId(
724+ props.sourceId,
725+ slot.slot,
726+ bindingScope.scopeId,
727+ )}
728+ sourceName={source.name}
729+ secretLabel={slot.label}
730+ />
731+ <div className="flex flex-wrap items-center gap-2">
732+ {exactSecretId && (
733+ <Button
734+ variant="outline"
735+ size="sm"
736+ onClick={() => void clearBinding(bindingScope.scopeId, slot.slot)}
737+ disabled={busyKey === clearKey}
738+ >
739+ Clear
740+ </Button>
741+ )}
742+ {busyKey === inputKey && (
743+ <span className="text-xs text-muted-foreground">Saving…</span>
744+ )}
745+ {slot.hint && (
746+ <span className="text-xs text-muted-foreground">{slot.hint}</span>
747+ )}
748+ </div>
749+ </div>
750+ );
751+ })}
710752 </div>
711753 </CardStackEntryField>
712754 );
@@ -725,19 +767,37 @@ export default function EditOpenApiSource(props: { · 12 unmodified lines
725767 </p>
726768 </div>
727769 </CardStackEntryField>
770+ {credentialScopes.length > 1 && (
771+ <CardStackEntry>
772+ <CardStackEntryContent>
773+ <CardStackEntryTitle>OAuth token</CardStackEntryTitle>
774+ <CardStackEntryDescription>
775+ Choose where the signed-in OAuth token is saved.
776+ </CardStackEntryDescription>
777+ </CardStackEntryContent>
778+ <FilterTabs
779+ tabs={credentialScopes.map((entry) => ({
780+ value: entry.scopeId,
781+ label: entry.label,
782+ }))}
783+ value={activeOAuthTokenScopeId}
784+ onChange={setSelectedOAuthTokenScope}
785+ />
786+ </CardStackEntry>
787+ )}
728788 <CardStackEntryField label="OAuth Connection">
729789 {(() => {
730790 const exact = exactBindingForScope(
731791 bindingRows,
732792 source.config.oauth2!.connectionSlot,
733− activeCredentialScopeId,
793+ activeOAuthTokenScopeId,
734794 );
735795 const binding =
736796 exact ??
737797 effectiveBindingForScope(
738798 bindingRows,
739799 source.config.oauth2!.connectionSlot,
740− activeCredentialScopeId,
800+ activeOAuthTokenScopeId,
741801 scopeRanks,
742802 );
743803 const connectionBinding =
@@ -748,9 +808,10 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
748808 const bindingScopeId = connectionBinding && binding ? binding.scopeId : null;
749809 const isConnecting =
750810 busyKey ===
751− `${activeCredentialScopeId}:${source.config.oauth2.connectionSlot}:connect`;
811+ `${activeOAuthTokenScopeId}:${source.config.oauth2.connectionSlot}:connect`;
752812 const isPendingOAuthConnection =
753− pendingOAuthConnection?.scopeId === activeCredentialScopeId &&
813+ pendingOAuthConnection?.scopeId === activeOAuthTokenScopeId &&
814+ pendingOAuthConnection !== null &&
754815 pendingOAuthConnection.slot === source.config.oauth2.connectionSlot;
755816 const isConnected = connection !== null && connection !== undefined;
756817 const statusText =
@@ -758,24 +819,24 @@ export default function EditOpenApiSource(props: { · 1 unmodified line
758819 ? "Saving OAuth connection..."
759820 : connectionBinding && bindingScopeId
760821 ? connection
761− ? bindingScopeId === activeCredentialScopeId
762− ? `Connected in ${activeCredentialScopeLabel.toLowerCase()} as ${
822+ ? bindingScopeId === activeOAuthTokenScopeId
823+ ? `Connected in ${activeOAuthTokenScopeLabel.toLowerCase()} as ${
763824 connection.identityLabel ?? connection.id
764825 }`
765826 : `Using organization connection ${
766827 connection.identityLabel ?? connection.id
767828 }`
768− : bindingScopeId === activeCredentialScopeId
769− ? `Saved connection is missing in ${activeCredentialScopeLabel.toLowerCase()}`
829+ : bindingScopeId === activeOAuthTokenScopeId
830+ ? `Saved connection is missing in ${activeOAuthTokenScopeLabel.toLowerCase()}`
770831 : "Organization connection is missing"
771− : `No ${activeCredentialScopeLabel.toLowerCase()} connection`;
832+ : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`;
772833
773834 return (
774835 <div className="space-y-2">
775836 <div className="text-sm text-muted-foreground">{statusText}</div>
776837 <Button
777838 size="sm"
778− onClick={() => void connectOAuth(activeCredentialScopeId)}
839+ onClick={() => void connectOAuth(activeOAuthTokenScopeId)}
779840 disabled={isConnecting}
780841 >
781842 {isConnecting ? "Connecting…" : isConnected ? "Reconnect" : "Connect"}
packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts+120−6
@@ -81,9 +81,10 @@ const ProjectsGroupLive = HttpApiBuilder.group(VercelApi, "projects", (handlers) · 80 unmodified lines
8181
8282 const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive));
8383
84−const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe(
85− Layer.provideMerge(NodeHttpServer.layerTest),
86−);
84+const TestLayer = HttpRouter.serve(ApiLive, {
85+ disableListenLog: true,
86+ disableLogger: true,
87+}).pipe(Layer.provideMerge(NodeHttpServer.layerTest));
8788
8889 // ---------------------------------------------------------------------------
8990 // Tests
@@ -296,7 +297,10 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 206 unmodified lines
296297 "vercel.projects.list",
297298 {},
298299 autoApprove,
299− )) as { data: { authorization?: string; token?: string } | null; error: unknown };
300+ )) as {
301+ data: { authorization?: string; token?: string } | null;
302+ error: unknown;
303+ };
300304 expect(aliceResult.error).toBeNull();
301305 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");
302306 expect(aliceResult.data?.token).toBe("alice-team");
@@ -882,8 +886,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 579 unmodified lines
882886 const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) });
883887 const blobs = makeInMemoryBlobStore();
884888 const now = new Date();
885− const orgScope = new Scope({ id: ScopeId.make("org"), name: "org", createdAt: now });
886− const userScope = new Scope({ id: ScopeId.make("user"), name: "user", createdAt: now });
889+ const orgScope = new Scope({
890+ id: ScopeId.make("org"),
891+ name: "org",
892+ createdAt: now,
893+ });
894+ const userScope = new Scope({
895+ id: ScopeId.make("user"),
896+ name: "user",
897+ createdAt: now,
898+ });
887899
888900 const adminExec = yield* createExecutor({
889901 scopes: [orgScope],
@@ -949,4 +961,106 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 59 unmodified lines
949961 expect(result.data?.authorization).toBe("Bearer org-token");
950962 }),
951963 );
964+
965+ it.effect("personal binding can override the source with an org-owned secret", () =>
966+ Effect.gen(function* () {
967+ const secretStore = new Map<string, string>();
968+ const key = (scope: string, id: string) => `${scope}\u0000${id}`;
969+ const memoryProvider: SecretProvider = {
970+ key: "memory",
971+ writable: true,
972+ get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null),
973+ set: (id, value, scope) =>
974+ Effect.sync(() => {
975+ secretStore.set(key(scope, id), value);
976+ }),
977+ delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))),
978+ };
979+ const memorySecretsPlugin = definePlugin(() => ({
980+ id: "memory-secrets" as const,
981+ storage: () => ({}),
982+ secretProviders: [memoryProvider],
983+ }));
984+
985+ const httpClient = yield* HttpClient.HttpClient;
986+ const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
987+ const plugins = [
988+ openApiPlugin({ httpClientLayer: clientLayer }),
989+ memorySecretsPlugin(),
990+ ] as const;
991+
992+ const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) });
993+ const blobs = makeInMemoryBlobStore();
994+ const now = new Date();
995+ const orgScope = new Scope({
996+ id: ScopeId.make("org"),
997+ name: "org",
998+ createdAt: now,
999+ });
1000+ const userScope = new Scope({
1001+ id: ScopeId.make("user"),
1002+ name: "user",
1003+ createdAt: now,
1004+ });
1005+
1006+ const adminExec = yield* createExecutor({
1007+ scopes: [orgScope],
1008+ adapter,
1009+ blobs,
1010+ plugins,
1011+ onElicitation: "accept-all",
1012+ });
1013+ const userExec = yield* createExecutor({
1014+ scopes: [userScope, orgScope],
1015+ adapter,
1016+ blobs,
1017+ plugins,
1018+ onElicitation: "accept-all",
1019+ });
1020+
1021+ yield* adminExec.openapi.addSpec({
1022+ spec: specJson,
1023+ scope: String(orgScope.id),
1024+ namespace: "vercel",
1025+ baseUrl: "",
1026+ headers: {
1027+ Authorization: new ConfiguredHeaderBinding({
1028+ kind: "binding",
1029+ slot: "auth:personal-choice",
1030+ prefix: "Bearer ",
1031+ }),
1032+ },
1033+ });
1034+ yield* adminExec.secrets.set(
1035+ new SetSecretInput({
1036+ id: SecretId.make("org-choice-token"),
1037+ scope: orgScope.id,
1038+ name: "Org choice token",
1039+ value: "org-choice",
1040+ }),
1041+ );
1042+
1043+ yield* userExec.openapi.setSourceBinding(
1044+ new OpenApiSourceBindingInput({
1045+ sourceId: "vercel",
1046+ sourceScope: orgScope.id,
1047+ scope: userScope.id,
1048+ slot: "auth:personal-choice",
1049+ value: {
1050+ kind: "secret",
1051+ secretId: SecretId.make("org-choice-token"),
1052+ secretScopeId: orgScope.id,
1053+ },
1054+ }),
1055+ );
1056+
1057+ const result = (yield* userExec.tools.invoke("vercel.projects.list", {}, autoApprove)) as {
1058+ data: { authorization?: string } | null;
1059+ error: unknown;
1060+ };
1061+
1062+ expect(result.error).toBeNull();
1063+ expect(result.data?.authorization).toBe("Bearer org-choice");
1064+ }),
1065+ );
9521066 });
packages/plugins/openapi/src/sdk/plugin.test.ts+1−0
@@ -1122,6 +1122,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 1121 unmodified lines
11221122 expect(clientIdBinding?.value).toEqual({
11231123 kind: "secret",
11241124 secretId: SecretId.make("acme-client-id"),
1125+ secretScopeId: ScopeId.make(TEST_SCOPE),
11251126 });
11261127
11271128 const connectionBinding = yield* executor.openapi
packages/plugins/openapi/src/sdk/plugin.ts+58−27
@@ -45,8 +45,10 @@ import { · 44 unmodified lines
4545 ConfiguredHeaderValue as ConfiguredHeaderValueSchema,
4646 ConfiguredHeaderBinding,
4747 OAuth2SourceConfig,
48+ OpenApiCredentialInput as OpenApiCredentialInputSchema,
4849 OpenApiSourceBindingInput,
4950 OpenApiSourceBindingRef,
51+ type OpenApiCredentialInput as OpenApiCredentialInputValue,
5052 type OpenApiSourceBindingValue,
5153 OperationBinding,
5254 type ConfiguredHeaderValue as ConfiguredHeaderValueValue,
@@ -60,7 +62,7 @@ import { · 7 unmodified lines
6062 export type HeaderValue = HeaderValueValue;
6163 export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
6264 export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
63−export type OpenApiCredentialInput = HeaderValue | ConfiguredHeaderValue;
65+export type OpenApiCredentialInput = OpenApiCredentialInputValue;
6466 export type OpenApiOAuthInput = OAuth2SourceConfig;
6567
6668 export interface OpenApiSpecFetchCredentialsInput {
@@ -86,7 +88,7 @@ export interface OpenApiSpecConfig { · 19 unmodified lines
8688 readonly name?: string;
8789 readonly baseUrl?: string;
8890 readonly namespace?: string;
89− readonly headers?: Record<string, OpenApiHeaderInput>;
91+ readonly headers?: Record<string, OpenApiCredentialInput>;
9092 readonly queryParams?: Record<string, OpenApiCredentialInput>;
9193 readonly oauth2?: OpenApiOAuthInput;
9294 readonly credentialTargetScope?: string;
@@ -95,7 +97,7 @@ export interface OpenApiSpecConfig { · 2 unmodified lines
9597 export interface OpenApiUpdateSourceInput {
9698 readonly name?: string;
9799 readonly baseUrl?: string;
98− readonly headers?: Record<string, OpenApiHeaderInput>;
100+ readonly headers?: Record<string, OpenApiCredentialInput>;
99101 readonly queryParams?: Record<string, OpenApiCredentialInput>;
100102 readonly credentialTargetScope?: string;
101103 /** Refresh the source's stored OAuth2 metadata after a successful
@@ -173,7 +175,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 71 unmodified lines
173175 type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
174176
175177 const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
176−const OpenApiCredentialInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
177178 const OpenApiOAuthInputSchema = OAuth2SourceConfig;
178179
179180 const AddSourceInputSchema = Schema.Struct({
@@ -262,16 +263,21 @@ const specFetchQueryParamSlotFromName = (name: string): string => · 82 unmodified lines
262263 `spec_fetch_query_param:${slotPart(name)}`;
263264
264265 const canonicalizeHeaders = (
265− headers: Record<string, OpenApiHeaderInput> | undefined,
266+ headers: Record<string, OpenApiCredentialInput> | undefined,
266267 ): {
267268 readonly headers: Record<string, ConfiguredHeaderValue>;
268269 readonly bindings: ReadonlyArray<{
269270 readonly slot: string;
270271 readonly value: OpenApiSourceBindingValue;
272+ readonly targetScope?: string;
271273 }>;
272274 } => {
273275 const nextHeaders: Record<string, ConfiguredHeaderValue> = {};
274− const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = [];
276+ const bindings: Array<{
277+ slot: string;
278+ value: OpenApiSourceBindingValue;
279+ targetScope?: string;
280+ }> = [];
275281 for (const [name, value] of Object.entries(headers ?? {})) {
276282 if (typeof value === "string") {
277283 nextHeaders[name] = value;
@@ -289,9 +295,13 @@ const canonicalizeHeaders = ( · 11 unmodified lines
289295 });
290296 bindings.push({
291297 slot,
298+ targetScope: "targetScope" in value ? value.targetScope : undefined,
292299 value: {
293300 kind: "secret",
294301 secretId: SecretId.make(value.secretId),
302+ ...("secretScopeId" in value && value.secretScopeId
303+ ? { secretScopeId: value.secretScopeId }
304+ : {}),
295305 },
296306 });
297307 }
@@ -306,10 +316,15 @@ const canonicalizeCredentialMap = ( · 8 unmodified lines
306316 readonly bindings: ReadonlyArray<{
307317 readonly slot: string;
308318 readonly value: OpenApiSourceBindingValue;
319+ readonly targetScope?: string;
309320 }>;
310321 } => {
311322 const nextValues: Record<string, ConfiguredHeaderValue> = {};
312− const bindings: Array<{ slot: string; value: OpenApiSourceBindingValue }> = [];
323+ const bindings: Array<{
324+ slot: string;
325+ value: OpenApiSourceBindingValue;
326+ targetScope?: string;
327+ }> = [];
313328 for (const [name, value] of Object.entries(values ?? {})) {
314329 if (typeof value === "string") {
315330 nextValues[name] = value;
@@ -327,9 +342,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
327342 });
328343 bindings.push({
329344 slot,
345+ targetScope: "targetScope" in value ? value.targetScope : undefined,
330346 value: {
331347 kind: "secret",
332348 secretId: SecretId.make(value.secretId),
349+ ...("secretScopeId" in value && value.secretScopeId
350+ ? { secretScopeId: value.secretScopeId }
351+ : {}),
333352 },
334353 });
335354 }
@@ -348,6 +367,7 @@ const canonicalizeSpecFetchCredentials = ( · 12 unmodified lines
348367 readonly bindings: ReadonlyArray<{
349368 readonly slot: string;
350369 readonly value: OpenApiSourceBindingValue;
370+ readonly targetScope?: string;
351371 }>;
352372 } => {
353373 const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName);
@@ -496,11 +516,11 @@ const validateOpenApiBindingTarget = ( · 142 unmodified lines
496516 }
497517 });
498518
499−const bindingTargetScope = (
500− targetScope: string | undefined,
501− bindings: readonly unknown[],
502−): Effect.Effect<string | undefined, OpenApiOAuthError> => {
503− if (bindings.length === 0) return Effect.succeed(undefined);
519+const targetScopeForBinding = (
520+ fallbackTargetScope: string | undefined,
521+ binding: { readonly slot: string; readonly targetScope?: string },
522+): Effect.Effect<string, OpenApiOAuthError> => {
523+ const targetScope = binding.targetScope ?? fallbackTargetScope;
504524 if (targetScope) return Effect.succeed(targetScope);
505525 return Effect.fail(
506526 new OpenApiOAuthError({
@@ -579,15 +599,17 @@ const resolveConfiguredValueMap = ( · 72 unmodified lines
579599 value.slot,
580600 );
581601 if (binding?.value.kind === "secret") {
582− const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe(
583− Effect.catchTag("SecretOwnedByConnectionError", () =>
584− Effect.fail(
585− new OpenApiOAuthError({
586− message: `Secret not found for header "${name}"`,
587− }),
602+ const secret = yield* ctx.secrets
603+ .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId)
604+ .pipe(
605+ Effect.catchTag("SecretOwnedByConnectionError", () =>
606+ Effect.fail(
607+ new OpenApiOAuthError({
608+ message: `Secret not found for header "${name}"`,
609+ }),
610+ ),
588611 ),
589− ),
590− );
612+ );
591613 if (secret === null) {
592614 return yield* new OpenApiOAuthError({
593615 message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`,
@@ -808,15 +830,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 214 unmodified lines
808830 ...canonicalSpecFetchCredentials.bindings,
809831 ...canonicalOAuth2.bindings,
810832 ];
811− const targetScope = yield* bindingTargetScope(input.credentialTargetScope, directBindings);
812− if (targetScope) {
833+ for (const binding of directBindings) {
834+ const bindingTargetScope = yield* targetScopeForBinding(
835+ input.credentialTargetScope,
836+ binding,
837+ );
813838 yield* validateOpenApiBindingTarget(ctx, {
814839 sourceId: namespace,
815840 sourceScope: input.scope,
816− targetScope,
841+ targetScope: bindingTargetScope,
817842 });
818843 }
819−
820844 const definitions = compileToolDefinitions(result.operations);
821845 const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace);
822846
@@ -867,10 +891,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 44 unmodified lines
867891 })),
868892 });
869893
870− if (targetScope) {
894+ if (directBindings.length > 0) {
871895 for (const binding of directBindings) {
896+ const bindingTargetScope = yield* targetScopeForBinding(
897+ input.credentialTargetScope,
898+ binding,
899+ );
872900 yield* ctx.credentialBindings.set({
873− targetScope: ScopeId.make(targetScope),
901+ targetScope: ScopeId.make(bindingTargetScope),
874902 pluginId: OPENAPI_PLUGIN_ID,
875903 sourceId: namespace,
876904 sourceScope: ScopeId.make(input.scope),
@@ -1000,7 +1028,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 123 unmodified lines
10001028 sourceScope: ScopeId.make(scope),
10011029 });
10021030 yield* ctx.storage.removeSource(namespace, scope);
1003− yield* ctx.core.sources.unregister({ id: namespace, targetScope: scope });
1031+ yield* ctx.core.sources.unregister({
1032+ id: namespace,
1033+ targetScope: scope,
1034+ });
10041035 }),
10051036 );
10061037 if (configFile) {
packages/plugins/openapi/src/sdk/store.ts+3−0
@@ -304,6 +304,9 @@ const slugifySlotPart = (value: string): string => · 303 unmodified lines
304304 export const headerBindingSlot = (headerName: string): string =>
305305 `header:${slugifySlotPart(headerName)}`;
306306
307+export const queryParamBindingSlot = (name: string): string =>
308+ `query_param:${slugifySlotPart(name)}`;
309+
307310 export const oauth2ClientIdSlot = (securitySchemeName: string): string =>
308311 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;
309312
packages/plugins/openapi/src/sdk/types.ts+15−1
@@ -1,5 +1,11 @@
11 import { Schema } from "effect";
2−import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor-js/sdk/core";
2+import {
3+ ConnectionId,
4+ ScopeId,
5+ ScopedSecretCredentialInput,
6+ SecretBackedValue,
7+ SecretId,
8+} from "@executor-js/sdk/core";
39
410 // ---------------------------------------------------------------------------
511 // Branded IDs
@@ -146,10 +152,18 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 140 unmodified lines
146152 export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]);
147153 export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;
148154
155+export const OpenApiCredentialInput = Schema.Union([
156+ ScopedSecretCredentialInput,
157+ HeaderValue,
158+ ConfiguredHeaderValue,
159+]);
160+export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type;
161+
149162 export const OpenApiSourceBindingValue = Schema.Union([
150163 Schema.Struct({
151164 kind: Schema.Literal("secret"),
152165 secretId: SecretId,
166+ secretScopeId: Schema.optional(ScopeId),
153167 }),
154168 Schema.Struct({
155169 kind: Schema.Literal("connection"),
packages/react/src/components/help-tooltip.tsx+32−0
@@ -0,0 +1,32 @@
1+import { InfoIcon } from "lucide-react";
2+
3+import { cn } from "../lib/utils";
4+import { Button } from "./button";
5+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
6+
7+export function HelpTooltip(props: {
8+ readonly label: string;
9+ readonly children: React.ReactNode;
10+ readonly className?: string;
11+}) {
12+ return (
13+ <TooltipProvider>
14+ <Tooltip>
15+ <TooltipTrigger asChild>
16+ <Button
17+ type="button"
18+ variant="ghost"
19+ size="icon-xs"
20+ className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
21+ aria-label={`${props.label} help`}
22+ >
23+ <InfoIcon className="size-3.5" aria-hidden />
24+ </Button>
25+ </TooltipTrigger>
26+ <TooltipContent className={cn("w-max max-w-72 text-left text-wrap", props.className)}>
27+ {props.children}
28+ </TooltipContent>
29+ </Tooltip>
30+ </TooltipProvider>
31+ );
32+}
packages/react/src/pages/secrets.tsx+11−0
@@ -11,6 +11,7 @@ import { useSecretProviderPlugins } from "@executor-js/sdk/client"; · 10 unmodified lines
1111 import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk";
1212 import { SecretForm } from "../plugins/secret-form";
1313 import { useScope } from "../hooks/use-scope";
14+import { useScopeStack } from "../api/scope-context";
1415 import {
1516 Dialog,
1617 DialogContent,
@@ -169,6 +170,7 @@ function SecretRow(props: { · 152 unmodified lines
169170 usageScopeId: ScopeId;
170171 showProvider: boolean;
171172 secret: { id: string; scopeId: ScopeId; name: string; provider?: string };
173+ scopeLabel: string;
172174 onRemove: () => void;
173175 }) {
174176 const { secret, showProvider } = props;
@@ -192,6 +194,7 @@ function SecretRow(props: { · 17 unmodified lines
192194 </Suspense>
193195 </CardStackEntryContent>
194196 <CardStackEntryActions>
197+ <Badge variant="secondary">{props.scopeLabel}</Badge>
195198 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}
196199 <DropdownMenu>
197200 <DropdownMenuTrigger asChild>
@@ -238,7 +241,14 @@ export function SecretsPage(props: { · 40 unmodified lines
238241 const secretProviderPlugins = useSecretProviderPlugins();
239242 const [addOpen, setAddOpen] = useState(false);
240243 const scopeId = useScope();
244+ const scopeStack = useScopeStack();
241245 const secrets = useAtomValue(secretsOptimisticAtom(scopeId));
246+ const scopeLabel = (secretScopeId: ScopeId): string => {
247+ const index = scopeStack.findIndex((entry) => entry.id === secretScopeId);
248+ if (index === 0) return "Personal";
249+ if (index > 0) return scopeStack[index]?.name || "Organization";
250+ return "Scoped";
251+ };
242252 const existingSecretIds = useMemo(
243253 () =>
244254 AsyncResult.match(secrets, {
@@ -367,6 +377,7 @@ export function SecretsPage(props: { · 122 unmodified lines
367377 name: s.name,
368378 provider: s.provider ? String(s.provider) : undefined,
369379 }}
380+ scopeLabel={scopeLabel(s.scopeId)}
370381 onRemove={() => handleRemove(s)}
371382 />
372383 ),
packages/react/src/plugins/credential-target-scope.tsx+91−3
@@ -1,7 +1,7 @@
11 import { useEffect, useMemo, useState } from "react";
22 import type { ReactNode } from "react";
33
4−import type { ScopeId } from "@executor-js/sdk";
4+import { ScopeId } from "@executor-js/sdk";
55
66 import { useScope, useUserScope } from "../api/scope-context";
77 import {
@@ -13,6 +13,15 @@ import { · 5 unmodified lines
1313 CardStackEntryTitle,
1414 } from "../components/card-stack";
1515 import { FilterTabs } from "../components/filter-tabs";
16+import { FieldLabel } from "../components/field";
17+import { HelpTooltip } from "../components/help-tooltip";
18+import {
19+ Select,
20+ SelectContent,
21+ SelectItem,
22+ SelectTrigger,
23+ SelectValue,
24+} from "../components/select";
1625
1726 export interface CredentialTargetScopeOption {
1827 readonly scopeId: ScopeId;
@@ -147,10 +156,89 @@ export function CredentialScopeSection(props: { · 128 unmodified lines
147156 value={props.value}
148157 options={props.options}
149158 onChange={props.onChange}
150− title={props.title ?? "Save credentials to"}
151− description={props.description ?? "Choose who can use the credentials attached below."}
159+ title={props.title ?? "Used by"}
160+ description={props.description ?? "Choose who can use these credentials."}
152161 />
153162 {props.children}
154163 </div>
155164 );
156165 }
166+
167+export function CredentialScopeDropdown(props: {
168+ readonly value: ScopeId;
169+ readonly options: readonly CredentialTargetScopeOption[];
170+ readonly onChange: (scope: ScopeId) => void;
171+ readonly label?: string;
172+ readonly help?: ReactNode;
173+}) {
174+ const label = props.label ?? "Used by";
175+ if (props.options.length <= 1) return null;
176+
177+ return (
178+ <div className="space-y-1.5">
179+ <div className="flex items-center gap-1.5">
180+ <FieldLabel className="text-[11px]">{label}</FieldLabel>
181+ <HelpTooltip label={label}>
182+ {props.help ?? "Choose who can use these credentials."}
183+ </HelpTooltip>
184+ </div>
185+ <Select
186+ value={String(props.value)}
187+ onValueChange={(value) => props.onChange(ScopeId.make(value))}
188+ >
189+ <SelectTrigger className="w-full">
190+ <SelectValue placeholder={label} />
191+ </SelectTrigger>
192+ <SelectContent>
193+ {props.options.map((option) => (
194+ <SelectItem key={option.scopeId} value={option.scopeId}>
195+ {option.label}
196+ </SelectItem>
197+ ))}
198+ </SelectContent>
199+ </Select>
200+ </div>
201+ );
202+}
203+
204+export function CredentialControlField(props: {
205+ readonly label: string;
206+ readonly children: ReactNode;
207+ readonly help?: ReactNode;
208+}) {
209+ return (
210+ <div className="space-y-1.5">
211+ <div className="flex items-center gap-1.5">
212+ <FieldLabel className="text-[11px]">{props.label}</FieldLabel>
213+ {props.help ? <HelpTooltip label={props.label}>{props.help}</HelpTooltip> : null}
214+ </div>
215+ {props.children}
216+ </div>
217+ );
218+}
219+
220+export function CredentialUsageRow(props: {
221+ readonly value: ScopeId;
222+ readonly options: readonly CredentialTargetScopeOption[];
223+ readonly onChange: (scope: ScopeId) => void;
224+ readonly children: ReactNode;
225+ readonly label?: string;
226+ readonly help?: ReactNode;
227+}) {
228+ if (props.options.length <= 1) {
229+ return <div className="space-y-2.5">{props.children}</div>;
230+ }
231+
232+ return (
233+ <div className="grid gap-2 md:grid-cols-2">
234+ <div className="min-w-0 space-y-2.5">{props.children}</div>
235+ <CredentialScopeDropdown
236+ value={props.value}
237+ options={props.options}
238+ onChange={props.onChange}
239+ label={props.label}
240+ help={props.help}
241+ />
242+ </div>
243+ );
244+}
packages/react/src/plugins/headers-list.tsx+54−6
@@ -14,7 +14,10 @@ import { · 13 unmodified lines
1414 type HeaderAuthPreset,
1515 type HeaderState,
1616 SecretHeaderAuthRow,
17+ type SecretCredentialPreviewComponent,
18+ type SecretCredentialRowCopy,
1719 } from "./secret-header-auth";
20+import type { CredentialTargetScopeOption } from "./credential-target-scope";
1821 import type { SecretPickerSecret } from "./secret-picker";
1922
2023 export interface HeadersListProps {
@@ -27,6 +30,10 @@ export interface HeadersListProps { · 6 unmodified lines
2730 readonly singleHeader?: boolean;
2831 /** Text shown in the empty state. */
2932 readonly emptyLabel?: ReactNode;
33+ readonly addLabel?: ReactNode;
34+ readonly addAriaLabel?: string;
35+ readonly rowCopy?: Partial<SecretCredentialRowCopy>;
36+ readonly rowPreviewComponent?: SecretCredentialPreviewComponent;
3037 /**
3138 * Display name of the source that owns these headers (e.g. "Axiom"). Used
3239 * to derive unique default secret labels/IDs like `axiom-authorization`.
@@ -34,6 +41,11 @@ export interface HeadersListProps { · 1 unmodified line
3441 readonly sourceName?: string;
3542 /** Inline-created secrets are written to this explicit scope. */
3643 readonly targetScope: ScopeId;
44+ /** Scope choices shown only inside the inline "+ New secret" form. */
45+ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
46+ /** Scope choices for where this source credential is used. */
47+ readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[];
48+ readonly restrictSecretsToTargetScope?: boolean;
3749 }
3850
3951 export function HeadersList({
@@ -43,11 +55,26 @@ export function HeadersList({ · 3 unmodified lines
4355 presets = defaultHeaderAuthPresets,
4456 singleHeader = false,
4557 emptyLabel = "No headers",
58+ addLabel,
59+ addAriaLabel = "Add header",
60+ rowCopy,
61+ rowPreviewComponent,
4662 sourceName,
4763 targetScope,
64+ credentialScopeOptions,
65+ bindingScopeOptions,
66+ restrictSecretsToTargetScope,
4867 }: HeadersListProps) {
4968 const [picking, setPicking] = useState(false);
5069 const canAddMore = !singleHeader || headers.length === 0;
70+ const addFirstPreset = () => {
71+ const preset = presets[0];
72+ if (presets.length === 1 && preset) {
73+ addHeaderFromPreset(preset);
74+ return;
75+ }
76+ setPicking(true);
77+ };
5178
5279 const addHeaderFromPreset = (preset: HeaderAuthPreset) => {
5380 onHeadersChange([
@@ -57,6 +84,7 @@ export function HeadersList({ · 3 unmodified lines
5784 prefix: preset.prefix,
5885 presetKey: preset.key,
5986 secretId: null,
87+ targetScope,
6088 },
6189 ]);
6290 setPicking(false);
@@ -69,6 +97,8 @@ export function HeadersList({ · 6 unmodified lines
6997 secretId: string | null;
7098 prefix?: string;
7199 presetKey?: string;
100+ targetScope?: ScopeId;
101+ secretScope?: ScopeId;
72102 }>,
73103 ) => {
74104 onHeadersChange(headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)));
@@ -89,7 +119,11 @@ export function HeadersList({ · 14 unmodified lines
89119 />
90120 ) : headers.length === 0 ? (
91121 canAddMore ? (
92− <AddHeaderRow leading={<span>{emptyLabel}</span>} onClick={() => setPicking(true)} />
122+ <AddHeaderRow
123+ leading={<span>{emptyLabel}</span>}
124+ onClick={addFirstPreset}
125+ ariaLabel={addAriaLabel}
126+ />
93127 ) : (
94128 <CardStackEmpty>
95129 <span>{emptyLabel}</span>
@@ -104,15 +138,28 @@ export function HeadersList({ · 8 unmodified lines
104138 prefix={header.prefix}
105139 presetKey={header.presetKey}
106140 secretId={header.secretId}
141+ secretScope={header.secretScope}
107142 onChange={(update) => updateHeader(index, update)}
108− onSelectSecret={(secretId) => updateHeader(index, { secretId })}
143+ onSelectSecret={(secretId, scopeId) =>
144+ updateHeader(index, {
145+ secretId,
146+ ...(scopeId ? { secretScope: scopeId } : {}),
147+ })
148+ }
109149 onRemove={singleHeader ? undefined : () => removeHeader(index)}
110150 existingSecrets={existingSecrets}
111151 sourceName={sourceName}
112− targetScope={targetScope}
152+ targetScope={header.targetScope ?? targetScope}
153+ credentialScopeOptions={credentialScopeOptions}
154+ bindingScopeOptions={bindingScopeOptions}
155+ restrictSecretsToTargetScope={restrictSecretsToTargetScope}
156+ copy={rowCopy}
157+ previewComponent={rowPreviewComponent}
113158 />
114159 ))}
115− {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />}
160+ {canAddMore && (
161+ <AddHeaderRow leading={addLabel} onClick={addFirstPreset} ariaLabel={addAriaLabel} />
162+ )}
116163 </>
117164 )}
118165 </CardStackContent>
@@ -123,9 +170,10 @@ export function HeadersList({ · 4 unmodified lines
123170 interface AddHeaderRowProps {
124171 readonly onClick: () => void;
125172 readonly leading?: ReactNode;
173+ readonly ariaLabel: string;
126174 }
127175
128−function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) {
176+function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) {
129177 return (
130178 // oxlint-disable-next-line react/forbid-elements
131179 <button
@@ -134,7 +182,7 @@ function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { · 2 unmodified lines
134182 event.stopPropagation();
135183 onClick();
136184 }}
137− aria-label="Add header"
185+ aria-label={ariaLabel}
138186 className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40"
139187 >
140188 <span className="min-w-0 flex-1 text-left">{leading}</span>
packages/react/src/plugins/http-credentials.tsx+84−166
@@ -1,18 +1,15 @@
1−import { useId } from "react";
2−import { PlusIcon } from "lucide-react";
3−import type { ScopeId, SecretBackedValue } from "@executor-js/sdk";
1+import type { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk";
42
5−import { Button } from "../components/button";
6−import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack";
7−import { Field, FieldGroup, FieldLabel } from "../components/field";
8−import { Input } from "../components/input";
3+import { FieldLabel } from "../components/field";
94 import { HeadersList } from "./headers-list";
105 import {
11− CreatableSecretPicker,
126 headerValueToState,
137 headersFromState,
8+ QueryParamCredentialValuePreview,
9+ type HeaderAuthPreset,
1410 type HeaderState,
1511 } from "./secret-header-auth";
12+import type { CredentialTargetScopeOption } from "./credential-target-scope";
1613 import type { SecretPickerSecret } from "./secret-picker";
1714
1815 export type { SecretBackedValue };
@@ -22,8 +19,14 @@ export type QueryParamState = { · 3 unmodified lines
2219 secretId: string | null;
2320 prefix?: string;
2421 literalValue?: string;
22+ targetScope?: ScopeId;
23+ secretScope?: ScopeId;
2524 };
2625
26+const queryParamPresets: readonly HeaderAuthPreset[] = [
27+ { key: "custom", label: "Query parameter", name: "" },
28+];
29+
2730 export type HttpCredentialsState = {
2831 headers: HeaderState[];
2932 queryParams: QueryParamState[];
@@ -84,6 +87,58 @@ export const serializeHttpCredentials = ( · 54 unmodified lines
8487 queryParams: serializeQueryCredentials(credentials.queryParams),
8588 });
8689
90+export const serializeScopedHeaderCredentials = (
91+ headers: readonly HeaderState[],
92+ fallbackTargetScope: ScopeId,
93+): Record<string, ScopedSecretCredentialInput> => {
94+ const result: Record<string, ScopedSecretCredentialInput> = {};
95+ for (const header of headers) {
96+ const name = header.name.trim();
97+ if (!name || !header.secretId) continue;
98+ const targetScope = header.targetScope ?? fallbackTargetScope;
99+ result[name] = {
100+ secretId: header.secretId,
101+ targetScope,
102+ ...(header.secretScope ? { secretScopeId: header.secretScope } : {}),
103+ ...(header.prefix ? { prefix: header.prefix } : {}),
104+ };
105+ }
106+ return result;
107+};
108+
109+export const serializeScopedQueryCredentials = (
110+ queryParams: readonly QueryParamState[],
111+ fallbackTargetScope: ScopeId,
112+): Record<string, string | ScopedSecretCredentialInput> => {
113+ const result: Record<string, string | ScopedSecretCredentialInput> = {};
114+ for (const param of queryParams) {
115+ const name = param.name.trim();
116+ if (!name) continue;
117+ if (param.secretId) {
118+ const targetScope = param.targetScope ?? fallbackTargetScope;
119+ result[name] = {
120+ secretId: param.secretId,
121+ targetScope,
122+ ...(param.secretScope ? { secretScopeId: param.secretScope } : {}),
123+ ...(param.prefix ? { prefix: param.prefix } : {}),
124+ };
125+ continue;
126+ }
127+ if (param.literalValue?.trim()) {
128+ result[name] = param.literalValue.trim();
129+ }
130+ }
131+ return result;
132+};
133+
134+export const serializeScopedHttpCredentials = (
135+ credentials: HttpCredentialsState,
136+ fallbackTargetScope: ScopeId,
137+) => ({
138+ headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope),
139+ queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope),
140+});
141+
87142 export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean =>
88143 credentials.headers.every((header) => header.name.trim() && header.secretId) &&
89144 credentials.queryParams.every((param) => {
@@ -97,6 +152,9 @@ export function HttpCredentialsEditor(props: { · 7 unmodified lines
97152 readonly existingSecrets: readonly SecretPickerSecret[];
98153 readonly sourceName?: string;
99154 readonly targetScope: ScopeId;
155+ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
156+ readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[];
157+ readonly restrictSecretsToTargetScope?: boolean;
100158 readonly sections?: {
101159 readonly headers?: boolean;
102160 readonly queryParams?: boolean;
@@ -120,6 +178,9 @@ export function HttpCredentialsEditor(props: { · 17 unmodified lines
120178 existingSecrets={props.existingSecrets}
121179 sourceName={props.sourceName}
122180 targetScope={props.targetScope}
181+ credentialScopeOptions={props.credentialScopeOptions}
182+ bindingScopeOptions={props.bindingScopeOptions}
183+ restrictSecretsToTargetScope={props.restrictSecretsToTargetScope}
123184 />
124185 </section>
125186 )}
@@ -127,170 +188,27 @@ export function HttpCredentialsEditor(props: { · 1 unmodified line
127188 {showQueryParams && (
128189 <section className="space-y-2.5">
129190 <FieldLabel>{props.labels?.queryParams ?? "Query parameters"}</FieldLabel>
130− <QueryParamsList
131− queryParams={props.credentials.queryParams}
132− onQueryParamsChange={(queryParams) =>
133− props.onChange({ ...props.credentials, queryParams })
134− }
191+ <HeadersList
192+ headers={props.credentials.queryParams}
193+ onHeadersChange={(queryParams) => props.onChange({ ...props.credentials, queryParams })}
135194 existingSecrets={props.existingSecrets}
136195 sourceName={props.sourceName}
137196 targetScope={props.targetScope}
197+ credentialScopeOptions={props.credentialScopeOptions}
198+ bindingScopeOptions={props.bindingScopeOptions}
199+ restrictSecretsToTargetScope={props.restrictSecretsToTargetScope}
200+ presets={queryParamPresets}
201+ emptyLabel="No query parameters"
202+ addLabel="Add query parameter"
203+ addAriaLabel="Add query parameter"
204+ rowCopy={{
205+ rowLabel: "Query parameter",
206+ namePlaceholder: "token",
207+ }}
208+ rowPreviewComponent={QueryParamCredentialValuePreview}
138209 />
139210 </section>
140211 )}
141212 </div>
142213 );
143214 }
144−
145−function QueryParamsList(props: {
146− readonly queryParams: readonly QueryParamState[];
147− readonly onQueryParamsChange: (queryParams: QueryParamState[]) => void;
148− readonly existingSecrets: readonly SecretPickerSecret[];
149− readonly sourceName?: string;
150− readonly targetScope: ScopeId;
151−}) {
152− const addParam = () => {
153− props.onQueryParamsChange([...props.queryParams, { name: "", secretId: null }]);
154− };
155− const updateParam = (index: number, update: Partial<QueryParamState>) => {
156− props.onQueryParamsChange(
157− props.queryParams.map((param, i) => (i === index ? { ...param, ...update } : param)),
158− );
159− };
160− const removeParam = (index: number) => {
161− props.onQueryParamsChange(props.queryParams.filter((_, i) => i !== index));
162− };
163−
164− return (
165− <CardStack>
166− <CardStackContent className="[&>*+*]:before:inset-x-0">
167− {props.queryParams.length === 0 ? (
168− <AddQueryParamRow leading={<span>No query parameters</span>} onClick={addParam} />
169− ) : (
170− <>
171− {props.queryParams.map((param, index) => (
172− <QueryParamRow
173− key={index}
174− param={param}
175− existingSecrets={props.existingSecrets}
176− sourceName={props.sourceName}
177− targetScope={props.targetScope}
178− onChange={(update) => updateParam(index, update)}
179− onRemove={() => removeParam(index)}
180− />
181− ))}
182− <AddQueryParamRow onClick={addParam} />
183− </>
184− )}
185− </CardStackContent>
186− </CardStack>
187− );
188−}
189−
190−function QueryParamRow(props: {
191− readonly param: QueryParamState;
192− readonly existingSecrets: readonly SecretPickerSecret[];
193− readonly sourceName?: string;
194− readonly targetScope: ScopeId;
195− readonly onChange: (update: Partial<QueryParamState>) => void;
196− readonly onRemove: () => void;
197−}) {
198− const nameInputId = useId();
199− const prefixInputId = useId();
200− const literalInputId = useId();
201− const name = props.param.name.trim();
202− const secretLabel = name ? `${name} query parameter` : "Query parameter";
203−
204− return (
205− <div className="space-y-2.5 px-4 py-3">
206− <div className="flex w-full items-center justify-between gap-4">
207− <span className="text-[10px] uppercase tracking-wider text-muted-foreground">
208− Query parameter
209− </span>
210− <Button
211− type="button"
212− variant="ghost"
213− size="xs"
214− className="text-muted-foreground hover:text-destructive"
215− onClick={props.onRemove}
216− >
217− Remove
218− </Button>
219− </div>
220−
221− <FieldGroup className="grid grid-cols-2 gap-3">
222− <Field>
223− <FieldLabel htmlFor={nameInputId}>Name</FieldLabel>
224− <Input
225− id={nameInputId}
226− value={props.param.name}
227− onChange={(event) => props.onChange({ name: event.currentTarget.value })}
228− placeholder="token"
229− className="font-mono"
230− />
231− </Field>
232− <Field>
233− <FieldLabel htmlFor={prefixInputId}>
234− Prefix <span className="font-normal text-muted-foreground/60">(optional)</span>
235− </FieldLabel>
236− <Input
237− id={prefixInputId}
238− value={props.param.prefix ?? ""}
239− onChange={(event) => props.onChange({ prefix: event.currentTarget.value || undefined })}
240− placeholder="Bearer "
241− className="font-mono"
242− />
243− </Field>
244− </FieldGroup>
245−
246− <CreatableSecretPicker
247− value={props.param.secretId}
248− onSelect={(secretId) => props.onChange({ secretId, literalValue: undefined })}
249− secrets={props.existingSecrets}
250− placeholder="Select a secret"
251− sourceName={props.sourceName}
252− secretLabel={secretLabel}
253− targetScope={props.targetScope}
254− />
255−
256− {!props.param.secretId && props.param.literalValue !== undefined && (
257− <Field>
258− <FieldLabel htmlFor={literalInputId}>Literal value</FieldLabel>
259− <Input
260− id={literalInputId}
261− value={props.param.literalValue}
262− onChange={(event) => props.onChange({ literalValue: event.currentTarget.value })}
263− placeholder="value"
264− className="font-mono"
265− />
266− </Field>
267− )}
268− </div>
269− );
270−}
271−
272−function AddQueryParamRow(props: {
273− readonly onClick: () => void;
274− readonly leading?: React.ReactNode;
275−}) {
276− return (
277− <CardStackEntry
278− asChild
279− className="justify-between gap-4 px-0 py-0 text-sm text-muted-foreground"
280− >
281− <Button
282− type="button"
283− variant="ghost"
284− onClick={(event) => {
285− event.stopPropagation();
286− props.onClick();
287− }}
288− aria-label="Add query parameter"
289− className="h-auto w-full justify-between rounded-none px-4 py-3 text-left text-muted-foreground hover:bg-accent/40 focus-visible:bg-accent/40"
290− >
291− <span className="min-w-0 flex-1">{props.leading}</span>
292− <PlusIcon aria-hidden className="size-4 shrink-0" />
293− </Button>
294− </CardStackEntry>
295− );
296−}
packages/react/src/plugins/secret-form.tsx+3−2
@@ -109,7 +109,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { · 108 unmodified lines
109109 const doSet = useAtomSet(setSecret, { mode: "promiseExit" });
110110
111111 const [state, setState] = useState<SecretFormState>(() => ({
112− name: "",
112+ name: suggestedName,
113113 value: "",
114114 idOverride: null,
115115 provider: initialProvider,
@@ -215,7 +215,7 @@ function IdField(props: { placeholder?: string }) { · 99 unmodified lines
215215 );
216216 }
217217
218−function ValueField(props: { revealable?: boolean; placeholder?: string }) {
218+function ValueField(props: { revealable?: boolean; placeholder?: string; autoFocus?: boolean }) {
219219 const { state, actions } = useSecretForm();
220220 const inputId = useId();
221221 const revealable = props.revealable ?? false;
@@ -231,6 +231,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string }) { · 9 unmodified lines
231231 value={state.value}
232232 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}
233233 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}
234+ autoFocus={props.autoFocus}
234235 className={revealable ? "pr-9 font-mono" : "font-mono"}
235236 style={
236237 revealable && !state.revealed
packages/react/src/plugins/secret-header-auth.tsx+280−55
@@ -1,11 +1,30 @@
1−import { useId, useState } from "react";
1+import { useId, useState, type ReactNode } from "react";
22
3−import { type ScopeId } from "@executor-js/sdk";
3+import { ScopeId } from "@executor-js/sdk";
44 import { Button } from "../components/button";
55 import { Field, FieldGroup, FieldLabel } from "../components/field";
6+import { HelpTooltip } from "../components/help-tooltip";
67 import { Input } from "../components/input";
8+import {
9+ Dialog,
10+ DialogContent,
11+ DialogDescription,
12+ DialogHeader,
13+ DialogTitle,
14+} from "../components/dialog";
15+import {
16+ Select,
17+ SelectContent,
18+ SelectItem,
19+ SelectTrigger,
20+ SelectValue,
21+} from "../components/select";
722 import { SecretForm } from "./secret-form";
823 import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
24+import {
25+ CredentialTargetScopeSelector,
26+ type CredentialTargetScopeOption,
27+} from "./credential-target-scope";
928
1029 export const secretsForCredentialTarget = (
1130 secrets: readonly SecretPickerSecret[],
@@ -21,8 +40,18 @@ export interface HeaderAuthPreset { · 9 unmodified lines
2140 }
2241
2342 export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [
24− { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },
25− { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },
43+ {
44+ key: "bearer",
45+ label: "Bearer Token",
46+ name: "Authorization",
47+ prefix: "Bearer ",
48+ },
49+ {
50+ key: "basic",
51+ label: "Basic Auth",
52+ name: "Authorization",
53+ prefix: "Basic ",
54+ },
2655 { key: "api-key", label: "API Key", name: "X-API-Key" },
2756 { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },
2857 { key: "access-token", label: "Access Token", name: "X-Access-Token" },
@@ -30,51 +59,141 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ · 1 unmodified line
3059 { key: "custom", label: "Custom", name: "" },
3160 ];
3261
33−export function InlineCreateSecret(props: {
62+function CreateSecretContent(props: {
3463 suggestedName: string;
3564 existingSecretIds: readonly string[];
36− onCreated: (secretId: string) => void;
37− onCancel: () => void;
65+ onCreated: (secretId: string, scopeId: ScopeId) => void;
66+ onCancel?: () => void;
3867 fallbackId?: string;
3968 targetScope: ScopeId;
69+ credentialScopeOptions?: readonly CredentialTargetScopeOption[];
4070 }) {
71+ const [scopeId, setScopeId] = useState(props.targetScope);
72+ const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId);
73+
4174 return (
4275 <SecretForm.Provider
4376 existingSecretIds={props.existingSecretIds}
4477 suggestedName={props.suggestedName}
4578 fallbackId={props.fallbackId ?? "custom-header"}
46− scopeId={props.targetScope}
47− onCreated={props.onCreated}
79+ scopeId={scopeId}
80+ onCreated={(secretId) => props.onCreated(secretId, scopeId)}
4881 >
49− <div className="bg-primary/[0.03] px-4 py-3 space-y-3">
50− <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>
82+ <div className="space-y-3">
83+ {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && (
84+ <CredentialTargetScopeSelector
85+ value={scopeId}
86+ options={props.credentialScopeOptions}
87+ onChange={setScopeId}
88+ title="Save secret to"
89+ description={activeScope?.description ?? "Choose where this secret is saved."}
90+ />
91+ )}
5192 <FieldGroup className="gap-3">
5293 <div className="grid grid-cols-2 gap-3">
5394 <SecretForm.NameField label="Label" placeholder="API Token" />
5495 <SecretForm.IdField placeholder="my-api-token" />
5596 </div>
56− <SecretForm.ValueField revealable placeholder="paste your token or key…" />
97+ <SecretForm.ValueField revealable autoFocus placeholder="paste your token or key…" />
5798 </FieldGroup>
58− <div className="flex justify-end gap-1.5 pt-0.5">
59− <Button variant="outline" size="xs" onClick={props.onCancel}>
60− Cancel
61− </Button>
62− <SecretForm.SubmitButton size="xs">Create and use</SecretForm.SubmitButton>
99+ <div className="flex justify-end gap-2 pt-0.5">
100+ {props.onCancel && (
101+ <Button type="button" variant="outline" size="sm" onClick={props.onCancel}>
102+ Cancel
103+ </Button>
104+ )}
105+ <SecretForm.SubmitButton size="sm">Create and use</SecretForm.SubmitButton>
63106 </div>
64107 </div>
65108 </SecretForm.Provider>
66109 );
67110 }
68111
69−function HeaderValuePreview(props: { headerName: string; secretId: string; prefix?: string }) {
70− const { headerName, prefix } = props;
112+export function InlineCreateSecret(props: {
113+ suggestedName: string;
114+ existingSecretIds: readonly string[];
115+ onCreated: (secretId: string, scopeId: ScopeId) => void;
116+ onCancel: () => void;
117+ fallbackId?: string;
118+ targetScope: ScopeId;
119+ credentialScopeOptions?: readonly CredentialTargetScopeOption[];
120+}) {
121+ return (
122+ <div className="bg-primary/[0.03] px-4 py-3">
123+ <p className="mb-3 text-[11px] font-semibold tracking-wide text-primary uppercase">
124+ New secret
125+ </p>
126+ <CreateSecretContent {...props} />
127+ </div>
128+ );
129+}
130+
131+function CreateSecretDialog(props: {
132+ readonly open: boolean;
133+ readonly onOpenChange: (open: boolean) => void;
134+ readonly suggestedName: string;
135+ readonly existingSecretIds: readonly string[];
136+ readonly onCreated: (secretId: string, scopeId: ScopeId) => void;
137+ readonly fallbackId?: string;
138+ readonly targetScope: ScopeId;
139+ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
140+}) {
141+ return (
142+ <Dialog open={props.open} onOpenChange={props.onOpenChange}>
143+ <DialogContent>
144+ <DialogHeader>
145+ <DialogTitle>New secret</DialogTitle>
146+ <DialogDescription>
147+ Create a reusable secret, then use it for this credential.
148+ </DialogDescription>
149+ </DialogHeader>
150+ <CreateSecretContent
151+ suggestedName={props.suggestedName}
152+ existingSecretIds={props.existingSecretIds}
153+ fallbackId={props.fallbackId}
154+ onCreated={props.onCreated}
155+ onCancel={() => props.onOpenChange(false)}
156+ targetScope={props.targetScope}
157+ credentialScopeOptions={props.credentialScopeOptions}
158+ />
159+ </DialogContent>
160+ </Dialog>
161+ );
162+}
163+
164+export type SecretCredentialPreviewProps = {
165+ readonly name: string;
166+ readonly secretId: string;
167+ readonly prefix?: string;
168+};
169+
170+export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode;
171+
172+export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) {
173+ const { name, prefix } = props;
174+ const maskedValue = "•".repeat(12);
71175
72176 return (
73177 <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">
74− <span className="text-muted-foreground shrink-0">{headerName}:</span>
178+ <span className="text-muted-foreground shrink-0">{name}:</span>
75179 <span className="text-foreground truncate">
76180 {prefix && <span className="text-muted-foreground">{prefix}</span>}
77− {"•".repeat(12)}
181+ {maskedValue}
182+ </span>
183+ </div>
184+ );
185+}
186+
187+export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewProps) {
188+ const { name, prefix } = props;
189+ const maskedValue = "•".repeat(12);
190+
191+ return (
192+ <div className="rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">
193+ <span className="text-muted-foreground">?{name}=</span>
194+ <span className="text-foreground">
195+ {prefix && <span className="text-muted-foreground">{prefix}</span>}
196+ {maskedValue}
78197 </span>
79198 </div>
80199 );
@@ -90,6 +209,10 @@ export type HeaderState = { · 9 unmodified lines
90209 prefix?: string;
91210 presetKey?: string;
92211 fromPreset?: boolean;
212+ /** Scope where this source credential value is used. */
213+ targetScope?: ScopeId;
214+ /** Scope that owns the selected reusable secret. */
215+ secretScope?: ScopeId;
93216 };
94217
95218 export function matchPresetKey(name: string, prefix?: string): string {
@@ -99,6 +222,39 @@ export function matchPresetKey(name: string, prefix?: string): string { · 3 unmodified lines
99222 return preset?.key ?? "custom";
100223 }
101224
225+function InfoLabel(props: { readonly children: string; readonly tooltip: string }) {
226+ return (
227+ <div className="flex items-center gap-1.5">
228+ <FieldLabel>{props.children}</FieldLabel>
229+ <HelpTooltip label={props.children}>{props.tooltip}</HelpTooltip>
230+ </div>
231+ );
232+}
233+
234+export type SecretCredentialRowCopy = {
235+ readonly rowLabel: string;
236+ readonly nameLabel: string;
237+ readonly namePlaceholder: string;
238+ readonly prefixLabel: string;
239+ readonly prefixPlaceholder: string;
240+ readonly secretLabel: string;
241+ readonly secretHelp: string;
242+ readonly usedByLabel: string;
243+ readonly usedByHelp: string;
244+};
245+
246+const defaultSecretCredentialRowCopy: SecretCredentialRowCopy = {
247+ rowLabel: "Header",
248+ nameLabel: "Name",
249+ namePlaceholder: "Authorization",
250+ prefixLabel: "Prefix",
251+ prefixPlaceholder: "Bearer ",
252+ secretLabel: "Secret",
253+ secretHelp: "Select or create a reusable secret.",
254+ usedByLabel: "Used by",
255+ usedByHelp: "Choose who uses this credential value.",
256+};
257+
102258 export function headerValueToState(
103259 name: string,
104260 value: { secretId: string; prefix?: string } | string,
@@ -138,12 +294,21 @@ export function SecretHeaderAuthRow(props: { · 33 unmodified lines
138294 prefix?: string;
139295 presetKey?: string;
140296 secretId: string | null;
141− onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;
142− onSelectSecret: (secretId: string) => void;
297+ secretScope?: ScopeId;
298+ onChange: (update: {
299+ name: string;
300+ secretId?: string | null;
301+ prefix?: string;
302+ presetKey?: string;
303+ targetScope?: ScopeId;
304+ secretScope?: ScopeId;
305+ }) => void;
306+ onSelectSecret: (secretId: string, scopeId?: ScopeId) => void;
143307 existingSecrets: readonly SecretPickerSecret[];
144308 onRemove?: () => void;
145309 removeLabel?: string;
146− label?: string;
310+ copy?: Partial<SecretCredentialRowCopy>;
311+ previewComponent?: SecretCredentialPreviewComponent;
147312 /**
148313 * Display name of the source this header belongs to (e.g. "Axiom"). Used
149314 * to prefix the suggested secret label and ID so tokens from different
@@ -151,6 +316,9 @@ export function SecretHeaderAuthRow(props: { · 1 unmodified line
151316 */
152317 sourceName?: string;
153318 targetScope: ScopeId;
319+ credentialScopeOptions?: readonly CredentialTargetScopeOption[];
320+ bindingScopeOptions?: readonly CredentialTargetScopeOption[];
321+ restrictSecretsToTargetScope?: boolean;
154322 }) {
155323 const [creating, setCreating] = useState(false);
156324 const nameInputId = useId();
@@ -160,42 +328,49 @@ export function SecretHeaderAuthRow(props: { · 3 unmodified lines
160328 prefix,
161329 presetKey,
162330 secretId,
331+ secretScope,
163332 onChange,
164333 onSelectSecret,
165334 existingSecrets,
166335 onRemove,
167336 removeLabel = "Remove",
168− label = "Header",
337+ copy: copyOverride,
338+ previewComponent: PreviewComponent = HeaderCredentialValuePreview,
169339 sourceName,
170340 targetScope,
341+ credentialScopeOptions,
342+ bindingScopeOptions,
343+ restrictSecretsToTargetScope = false,
171344 } = props;
172345
173346 const isCustom = presetKey === "custom" || presetKey === undefined;
347+ const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride };
174348 const headerLabel = name.trim() || "Custom Header";
175349 const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" ");
176350 const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope);
351+ const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets;
177352
178− if (creating) {
179− return (
180− <InlineCreateSecret
353+ return (
354+ <div className="space-y-2.5 px-4 py-3">
355+ <CreateSecretDialog
356+ open={creating}
357+ onOpenChange={setCreating}
181358 suggestedName={suggestedName}
182359 existingSecretIds={scopedSecrets.map((secret) => secret.id)}
183− onCreated={(id) => {
184− onSelectSecret(id);
360+ onCreated={(id, scopeId) => {
361+ onSelectSecret(id, scopeId);
185362 setCreating(false);
186363 }}
187− onCancel={() => setCreating(false)}
188364 targetScope={targetScope}
365+ credentialScopeOptions={credentialScopeOptions}
189366 />
190− );
191− }
192−
193− return (
194− <div className="space-y-2.5 px-4 py-3">
195367 <div className="flex w-full items-center justify-between gap-4">
196− <span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
368+ <span className="text-[10px] uppercase tracking-wider text-muted-foreground">
369+ {copy.rowLabel}
370+ </span>
197371 {onRemove && (
198372 <Button
373+ type="button"
199374 variant="ghost"
200375 size="xs"
201376 className="text-muted-foreground hover:text-destructive"
@@ -208,7 +383,7 @@ export function SecretHeaderAuthRow(props: { · 6 unmodified lines
208383
209384 <FieldGroup className="grid grid-cols-2 gap-3">
210385 <Field>
211− <FieldLabel htmlFor={nameInputId}>Name</FieldLabel>
386+ <FieldLabel htmlFor={nameInputId}>{copy.nameLabel}</FieldLabel>
212387 <Input
213388 id={nameInputId}
214389 value={name}
@@ -219,13 +394,14 @@ export function SecretHeaderAuthRow(props: { · 4 unmodified lines
219394 presetKey: isCustom ? "custom" : presetKey,
220395 })
221396 }
222− placeholder="Authorization"
397+ placeholder={copy.namePlaceholder}
223398 className="font-mono"
224399 />
225400 </Field>
226401 <Field>
227402 <FieldLabel htmlFor={prefixInputId}>
228− Prefix <span className="font-normal text-muted-foreground/60">(optional)</span>
403+ {copy.prefixLabel}{" "}
404+ <span className="font-normal text-muted-foreground/60">(optional)</span>
229405 </FieldLabel>
230406 <Input
231407 id={prefixInputId}
@@ -237,21 +413,62 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines
237413 presetKey: isCustom ? "custom" : presetKey,
238414 })
239415 }
240− placeholder="Bearer "
416+ placeholder={copy.prefixPlaceholder}
241417 className="font-mono"
242418 />
243419 </Field>
244420 </FieldGroup>
245421
246− <SecretPicker
247− value={secretId}
248− onSelect={onSelectSecret}
249− secrets={scopedSecrets}
250− onCreateNew={() => setCreating(true)}
251− />
422+ <div
423+ className={
424+ bindingScopeOptions && bindingScopeOptions.length > 1
425+ ? "grid gap-2 md:grid-cols-2"
426+ : undefined
427+ }
428+ >
429+ <div className="space-y-1.5">
430+ <InfoLabel tooltip={copy.secretHelp}>{copy.secretLabel}</InfoLabel>
431+ <SecretPicker
432+ value={secretId}
433+ valueScopeId={secretScope ? String(secretScope) : undefined}
434+ onSelect={(id, scopeId) => onSelectSecret(id, ScopeId.make(scopeId))}
435+ secrets={selectableSecrets}
436+ onCreateNew={() => setCreating(true)}
437+ />
438+ </div>
439+ {bindingScopeOptions && bindingScopeOptions.length > 1 && (
440+ <div className="space-y-1.5">
441+ <InfoLabel tooltip={copy.usedByHelp}>{copy.usedByLabel}</InfoLabel>
442+ <Select
443+ value={String(targetScope)}
444+ onValueChange={(nextScope) =>
445+ onChange({
446+ name,
447+ secretId: null,
448+ secretScope: undefined,
449+ prefix,
450+ presetKey,
451+ targetScope: ScopeId.make(nextScope),
452+ })
453+ }
454+ >
455+ <SelectTrigger className="w-full">
456+ <SelectValue placeholder="Used by" />
457+ </SelectTrigger>
458+ <SelectContent>
459+ {bindingScopeOptions.map((option) => (
460+ <SelectItem key={option.scopeId} value={option.scopeId}>
461+ {option.label}
462+ </SelectItem>
463+ ))}
464+ </SelectContent>
465+ </Select>
466+ </div>
467+ )}
468+ </div>
252469
253470 {secretId && name.trim() && (
254− <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} />
471+ <PreviewComponent name={name.trim()} secretId={secretId} prefix={prefix} />
255472 )}
256473 </div>
257474 );
@@ -263,10 +480,12 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines
263480
264481 export function CreatableSecretPicker(props: {
265482 readonly value: string | null;
266− readonly onSelect: (secretId: string) => void;
483+ readonly onSelect: (secretId: string, scopeId?: ScopeId) => void;
267484 readonly secrets: readonly SecretPickerSecret[];
268485 readonly placeholder?: string;
269486 readonly targetScope: ScopeId;
487+ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
488+ readonly onCreatedScope?: (scopeId: ScopeId) => void;
270489 readonly suggestedId?: string;
271490 /**
272491 * Display name of the source the secret belongs to (e.g. "Stripe").
@@ -284,6 +503,8 @@ export function CreatableSecretPicker(props: { · 11 unmodified lines
284503 sourceName,
285504 secretLabel,
286505 targetScope,
506+ credentialScopeOptions,
507+ onCreatedScope,
287508 suggestedId: suggestedIdProp,
288509 } = props;
289510 const [creating, setCreating] = useState(false);
@@ -293,16 +514,19 @@ export function CreatableSecretPicker(props: { · 3 unmodified lines
293514
294515 if (creating) {
295516 return (
296− <InlineCreateSecret
517+ <CreateSecretDialog
518+ open={creating}
519+ onOpenChange={setCreating}
297520 suggestedName={suggestedName}
298521 existingSecretIds={scopedSecrets.map((secret) => secret.id)}
299522 fallbackId={suggestedIdProp?.trim() || "secret"}
300− onCreated={(id) => {
301− onSelect(id);
523+ onCreated={(id, scopeId) => {
524+ onCreatedScope?.(scopeId);
525+ onSelect(id, scopeId);
302526 setCreating(false);
303527 }}
304− onCancel={() => setCreating(false)}
305528 targetScope={targetScope}
529+ credentialScopeOptions={credentialScopeOptions}
306530 />
307531 );
308532 }
@@ -310,8 +534,9 @@ export function CreatableSecretPicker(props: { · 1 unmodified line
310534 return (
311535 <SecretPicker
312536 value={value}
313− onSelect={onSelect}
314− secrets={scopedSecrets}
537+ valueScopeId={String(targetScope)}
538+ onSelect={(id, scopeId) => onSelect(id, ScopeId.make(scopeId))}
539+ secrets={secrets}
315540 placeholder={placeholder}
316541 onCreateNew={() => setCreating(true)}
317542 />
packages/react/src/plugins/secret-picker.tsx+33−7
@@ -2,6 +2,7 @@ import { useState, type ChangeEvent, type FocusEvent } from "react"; · 1 unmodified line
22 import { PlusIcon } from "lucide-react";
33
44 import { Input } from "../components/input";
5+import { Badge } from "../components/badge";
56 import {
67 Command,
78 CommandEmpty,
@@ -11,6 +12,7 @@ import { · 3 unmodified lines
1112 CommandSeparator,
1213 } from "../components/command";
1314 import { Popover, PopoverAnchor, PopoverContent } from "../components/popover";
15+import { useScopeStack } from "../api/scope-context";
1416
1517 export interface SecretPickerSecret {
1618 readonly id: string;
@@ -33,17 +35,38 @@ const providerLabel = (key: string | undefined): string => { · 16 unmodified lines
3335
3436 export function SecretPicker(props: {
3537 readonly value: string | null;
36− readonly onSelect: (secretId: string) => void;
38+ readonly valueScopeId?: string;
39+ readonly onSelect: (secretId: string, scopeId: string) => void;
3740 readonly secrets: readonly SecretPickerSecret[];
3841 readonly placeholder?: string;
3942 /** When provided, renders a "+ New secret" row at the top of the dropdown. */
4043 readonly onCreateNew?: () => void;
4144 }) {
42− const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props;
45+ const {
46+ value,
47+ valueScopeId,
48+ onSelect,
49+ secrets,
50+ placeholder = "Search secrets…",
51+ onCreateNew,
52+ } = props;
4353 const [open, setOpen] = useState(false);
4454 const [query, setQuery] = useState("");
55+ const scopeStack = useScopeStack();
56+ const scopeLabel = (scopeId: string): string => {
57+ const index = scopeStack.findIndex((entry) => String(entry.id) === scopeId);
58+ if (index === 0) return "Personal";
59+ if (index > 0) return scopeStack[index]?.name || "Organization";
60+ return "Scoped";
61+ };
4562
46− const selected = secrets.find((secret) => secret.id === value) ?? null;
63+ const selected =
64+ secrets.find(
65+ (secret) =>
66+ secret.id === value && (valueScopeId === undefined || secret.scopeId === valueScopeId),
67+ ) ??
68+ secrets.find((secret) => secret.id === value) ??
69+ null;
4770
4871 const grouped = new Map<string, SecretPickerSecret[]>();
4972 for (const secret of secrets) {
@@ -132,15 +155,18 @@ export function SecretPicker(props: { · 82 unmodified lines
132155 <CommandGroup key={label} heading={showGroupHeadings ? label : undefined}>
133156 {filtered.map((secret) => (
134157 <CommandItem
135− key={secret.id}
136− value={`${secret.name} ${secret.id}`}
158+ key={`${secret.scopeId}:${secret.id}`}
159+ value={`${secret.name} ${secret.id} ${secret.scopeId}`}
137160 onSelect={() => {
138− onSelect(secret.id);
161+ onSelect(secret.id, secret.scopeId);
139162 setOpen(false);
140163 setQuery("");
141164 }}
142165 >
143− <span className="truncate">{secret.name}</span>
166+ <span className="min-w-0 flex-1 truncate">{secret.name}</span>
167+ <Badge variant="outline" className="ml-2 shrink-0 text-[10px]">
168+ {scopeLabel(secret.scopeId)}
169+ </Badge>
144170 </CommandItem>
145171 ))}
146172 </CommandGroup>
packages/react/src/plugins/source-identity.test.ts+24−0
@@ -0,0 +1,24 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ domainLabelFromUrl,
5+ pascalCaseDomainLabel,
6+ sourceDisplayNameFromUrl,
7+} from "./source-identity";
8+
9+describe("source identity URL display names", () => {
10+ it("uses the apex domain label without the public suffix", () => {
11+ expect(domainLabelFromUrl("https://api.example.co.uk/graphql")).toBe("example");
12+ });
13+
14+ it("normalizes domain labels to PascalCase", () => {
15+ expect(pascalCaseDomainLabel("my-api")).toBe("MyApi");
16+ });
17+
18+ it("appends the source kind to the PascalCase domain label", () => {
19+ expect(sourceDisplayNameFromUrl("https://mcp.linear.app/sse", "MCP")).toBe("Linear MCP");
20+ expect(sourceDisplayNameFromUrl("https://api.shopify.com/graphql", "GraphQL")).toBe(
21+ "Shopify GraphQL",
22+ );
23+ });
24+});
packages/react/src/plugins/source-identity.tsx+26−0
@@ -20,6 +20,32 @@ export function displayNameFromUrl(url: string): string | null { · 19 unmodified lines
2020 return label.charAt(0).toUpperCase() + label.slice(1);
2121 }
2222
23+export function domainLabelFromUrl(url: string): string | null {
24+ const trimmed = url.trim();
25+ if (!trimmed) return null;
26+ return parse(trimmed).domainWithoutSuffix ?? null;
27+}
28+
29+export function pascalCaseDomainLabel(label: string): string | null {
30+ const words = label
31+ .split(/[^a-z0-9]+/i)
32+ .map((word) => word.trim())
33+ .filter(Boolean);
34+ if (words.length === 0) return null;
35+ return words
36+ .map((word) => {
37+ const normalized = word.toLowerCase();
38+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);
39+ })
40+ .join("");
41+}
42+
43+export function sourceDisplayNameFromUrl(url: string, sourceKind: string): string | null {
44+ const label = domainLabelFromUrl(url);
45+ const displayLabel = label ? pascalCaseDomainLabel(label) : null;
46+ return displayLabel ? `${displayLabel} ${sourceKind}` : null;
47+}
48+
2349 // ---------------------------------------------------------------------------
2450 // Hook — owns the name + namespace state with namespace auto-derivation
2551 // ---------------------------------------------------------------------------