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 @@
1ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint
2CREATE 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
106 "when": 1778192434062,106 "when": 1778192434062,
107 "tag": "0014_repair_openapi_oauth_cutover_residue",107 "tag": "0014_repair_openapi_oauth_cutover_residue",
108 "breakpoints": true108 "breakpoints": true
109 },
110 {
111 "idx": 15,
112 "version": "7",
113 "when": 1778192434063,
114 "tag": "0015_add_credential_binding_secret_scope",
115 "breakpoints": true
109 }116 }
110 ]117 ]
111}118}
apps/cloud/src/services/executor-schema.ts+2−0
@@ -148,6 +148,7 @@ export const credential_binding = pgTable( · 147 unmodified lines
148 kind: text("kind").notNull(),148 kind: text("kind").notNull(),
149 text_value: text("text_value"),149 text_value: text("text_value"),
150 secret_id: text("secret_id"),150 secret_id: text("secret_id"),
151 secret_scope_id: text("secret_scope_id"),
151 connection_id: text("connection_id"),152 connection_id: text("connection_id"),
152 created_at: timestamp("created_at").notNull(),153 created_at: timestamp("created_at").notNull(),
153 updated_at: timestamp("updated_at").notNull(),154 updated_at: timestamp("updated_at").notNull(),
@@ -161,6 +162,7 @@ export const credential_binding = pgTable( · 7 unmodified lines
161 index("credential_binding_slot_key_idx").on(table.slot_key),162 index("credential_binding_slot_key_idx").on(table.slot_key),
162 index("credential_binding_kind_idx").on(table.kind),163 index("credential_binding_kind_idx").on(table.kind),
163 index("credential_binding_secret_id_idx").on(table.secret_id),164 index("credential_binding_secret_id_idx").on(table.secret_id),
165 index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
164 index("credential_binding_connection_id_idx").on(table.connection_id),166 index("credential_binding_connection_id_idx").on(table.connection_id),
165 ],167 ],
166);168);
apps/cloud/src/services/sources-api.node.test.ts+2−0
@@ -830,6 +830,7 @@ describe("sources api (HTTP)", () => { · 829 unmodified lines
830 value: {830 value: {
831 kind: "secret",831 kind: "secret",
832 secretId: SecretId.make("alice_pat"),832 secretId: SecretId.make("alice_pat"),
833 secretScopeId: ScopeId.make(aliceScope),
833 },834 },
834 }),835 }),
835 );836 );
@@ -858,6 +859,7 @@ describe("sources api (HTTP)", () => { · 22 unmodified lines
858 value: {859 value: {
859 kind: "secret",860 kind: "secret",
860 secretId: SecretId.make("bob_pat"),861 secretId: SecretId.make("bob_pat"),
862 secretScopeId: ScopeId.make(bobScope),
861 },863 },
862 }),864 }),
863 );865 );
apps/local/drizzle/0010_add_credential_binding_secret_scope.sql+2−0
@@ -0,0 +1,2 @@
1ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint
2CREATE 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
71 "when": 1778192434062,71 "when": 1778192434062,
72 "tag": "0009_repair_openapi_oauth_cutover_residue",72 "tag": "0009_repair_openapi_oauth_cutover_residue",
73 "breakpoints": true73 "breakpoints": true
74 },
75 {
76 "idx": 10,
77 "version": "6",
78 "when": 1778192434063,
79 "tag": "0010_add_credential_binding_secret_scope",
80 "breakpoints": true
74 }81 }
75 ]82 ]
76}83}
apps/local/src/server/executor-schema.ts+2−0
@@ -137,6 +137,7 @@ export const credential_binding = sqliteTable( · 136 unmodified lines
137 kind: text("kind").notNull(),137 kind: text("kind").notNull(),
138 text_value: text("text_value"),138 text_value: text("text_value"),
139 secret_id: text("secret_id"),139 secret_id: text("secret_id"),
140 secret_scope_id: text("secret_scope_id"),
140 connection_id: text("connection_id"),141 connection_id: text("connection_id"),
141 created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),142 created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
142 updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),143 updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
@@ -150,6 +151,7 @@ export const credential_binding = sqliteTable( · 7 unmodified lines
150 index("credential_binding_slot_key_idx").on(table.slot_key),151 index("credential_binding_slot_key_idx").on(table.slot_key),
151 index("credential_binding_kind_idx").on(table.kind),152 index("credential_binding_kind_idx").on(table.kind),
152 index("credential_binding_secret_id_idx").on(table.secret_id),153 index("credential_binding_secret_id_idx").on(table.secret_id),
154 index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id),
153 index("credential_binding_connection_id_idx").on(table.connection_id),155 index("credential_binding_connection_id_idx").on(table.connection_id),
154 ],156 ],
155);157);
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
158 );158 );
159 `);159 `);
160 160
161 db.prepare(161 db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run(
162 "INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)",
163 ).run(
164 "dealcloud_api",162 "dealcloud_api",
165 "org-1",163 "org-1",
166 JSON.stringify({164 JSON.stringify({
notes/product-scope-language.md+47−0
@@ -0,0 +1,47 @@
1# Product Scope Language
2
3Executor 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
45Avoid copy like "scope", "target scope", "source scope", "binding scope", or
46"credential target scope" in product UI unless it is explicitly a developer or
47debug surface.
packages/core/sdk/src/core-schema.ts+3−1
@@ -182,6 +182,7 @@ export const coreSchema = { · 181 unmodified lines
182 kind: { type: credentialBindingKinds, required: true, index: true },182 kind: { type: credentialBindingKinds, required: true, index: true },
183 text_value: { type: "string", required: false },183 text_value: { type: "string", required: false },
184 secret_id: { type: "string", required: false, index: true },184 secret_id: { type: "string", required: false, index: true },
185 secret_scope_id: { type: "string", required: false, index: true },
185 connection_id: { type: "string", required: false, index: true },186 connection_id: { type: "string", required: false, index: true },
186 created_at: { type: "date", required: true },187 created_at: { type: "date", required: true },
187 updated_at: { type: "date", required: true },188 updated_at: { type: "date", required: true },
@@ -244,7 +245,7 @@ export type ConnectionRow = InferDBFieldsOutput<CoreSchema["connection"]["fields · 56 unmodified lines
244type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>;245type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>;
245type CredentialBindingRowBase = Omit<246type CredentialBindingRowBase = Omit<
246 CredentialBindingRowFields,247 CredentialBindingRowFields,
247 "kind" | "text_value" | "secret_id" | "connection_id"248 "kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id"
248>;249>;
249 250
250export type CredentialBindingRow = CredentialBindingRowBase &251export type CredentialBindingRow = CredentialBindingRowBase &
@@ -256,6 +257,7 @@ export type CredentialBindingRow = CredentialBindingRowBase & · 5 unmodified lines
256 | {257 | {
257 kind: "secret";258 kind: "secret";
258 secret_id: string;259 secret_id: string;
260 secret_scope_id?: string;
259 }261 }
260 | {262 | {
261 kind: "connection";263 kind: "connection";
packages/core/sdk/src/credential-bindings.test.ts+127−1
@@ -437,7 +437,10 @@ describe("credential bindings", () => { · 436 unmodified lines
437 sourceId: TEST_SOURCE_ID,437 sourceId: TEST_SOURCE_ID,
438 sourceScope: harness.scopes.org.id,438 sourceScope: harness.scopes.org.id,
439 slotKey: "oauth.connection",439 slotKey: "oauth.connection",
440 value: { kind: "connection", connectionId: ConnectionId.make("oauth-connection") },440 value: {
441 kind: "connection",
442 connectionId: ConnectionId.make("oauth-connection"),
443 },
441 });444 });
442 445
443 const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]);446 const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]);
@@ -608,4 +611,127 @@ describe("credential bindings", () => { · 164 unmodified lines
608 expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);611 expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true);
609 }),612 }),
610 );613 );
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 );
611});737});
packages/core/sdk/src/credential-bindings.ts+11−1
@@ -17,6 +17,7 @@ export const CredentialBindingValue = Schema.Union([ · 16 unmodified lines
17 Schema.Struct({17 Schema.Struct({
18 kind: Schema.Literal("secret"),18 kind: Schema.Literal("secret"),
19 secretId: SecretId,19 secretId: SecretId,
20 secretScopeId: Schema.optional(ScopeId),
20 }),21 }),
21 Schema.Struct({22 Schema.Struct({
22 kind: Schema.Literal("connection"),23 kind: Schema.Literal("connection"),
@@ -36,6 +37,14 @@ export class ConfiguredCredentialBinding extends Schema.Class<ConfiguredCredenti · 13 unmodified lines
36export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]);37export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]);
37export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type;38export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type;
38 39
40export const ScopedSecretCredentialInput = Schema.Struct({
41 secretId: Schema.String,
42 prefix: Schema.optional(Schema.String),
43 targetScope: ScopeId,
44 secretScopeId: Schema.optional(ScopeId),
45});
46export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type;
47
39export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>(48export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>(
40 "CredentialBindingRef",49 "CredentialBindingRef",
41)({50)({
@@ -172,9 +181,10 @@ export const credentialBindingValueFromRow = (row: CredentialBindingRow): Creden · 130 unmodified lines
172 kind: "text" as const,181 kind: "text" as const,
173 text: text_value,182 text: text_value,
174 })),183 })),
175 Match.when({ kind: "secret" }, ({ secret_id }) => ({184 Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({
176 kind: "secret" as const,185 kind: "secret" as const,
177 secretId: SecretId.make(secret_id),186 secretId: SecretId.make(secret_id),
187 secretScopeId: ScopeId.make(secret_scope_id ?? scope_id),
178 })),188 })),
179 Match.when({ kind: "connection" }, ({ connection_id }) => ({189 Match.when({ kind: "connection" }, ({ connection_id }) => ({
180 kind: "connection" as const,190 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
900 ): Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure> =>900 ): Effect.Effect<string | null, SecretOwnedByConnectionError | StorageFailure> =>
901 Effect.gen(function* () {901 Effect.gen(function* () {
902 yield* assertScopeInStack("secret get scope", scope);902 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 });
904 if (row?.owned_by_connection_id) {907 if (row?.owned_by_connection_id) {
905 return yield* new SecretOwnedByConnectionError({908 return yield* new SecretOwnedByConnectionError({
906 secretId: SecretId.make(id),909 secretId: SecretId.make(id),
@@ -922,7 +925,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 15 unmodified lines
922 ): Effect.Effect<string | null, StorageFailure> =>925 ): Effect.Effect<string | null, StorageFailure> =>
923 Effect.gen(function* () {926 Effect.gen(function* () {
924 yield* assertScopeInStack("connection secret get scope", scope);927 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 });
926 return yield* resolveSecretValueAtScope(row, id);932 return yield* resolveSecretValueAtScope(row, id);
927 });933 });
928 934
@@ -1423,7 +1429,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 494 unmodified lines
1423 ): Effect.Effect<ConnectionRef | null, StorageFailure> =>1429 ): Effect.Effect<ConnectionRef | null, StorageFailure> =>
1424 Effect.gen(function* () {1430 Effect.gen(function* () {
1425 yield* assertScopeInStack("connection get scope", scope);1431 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 });
1427 return row ? rowToConnection(row) : null;1436 return row ? rowToConnection(row) : null;
1428 });1437 });
1429 1438
@@ -1962,7 +1971,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 532 unmodified lines
1962 ): Effect.Effect<string, AccessTokenError> =>1971 ): Effect.Effect<string, AccessTokenError> =>
1963 Effect.gen(function* () {1972 Effect.gen(function* () {
1964 yield* assertScopeInStack("connection accessToken scope", scope);1973 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 });
1966 if (!row) {1978 if (!row) {
1967 return yield* new ConnectionNotFoundError({1979 return yield* new ConnectionNotFoundError({
1968 connectionId: ConnectionId.make(id),1980 connectionId: ConnectionId.make(id),
@@ -2139,15 +2151,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 170 unmodified lines
2139 }2151 }
2140 2152
2141 if (input.value.kind === "secret") {2153 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 }
2142 const secret = yield* findSecretRowAtScope({2164 const secret = yield* findSecretRowAtScope({
2143 secretId: input.value.secretId,2165 secretId: input.value.secretId,
2144 scopeId: input.targetScope,2166 scopeId: secretScope,
2145 });2167 });
2146 if (!secret) {2168 if (!secret) {
2147 return yield* new StorageError({2169 return yield* new StorageError({
2148 message:2170 message:
2149 `Cannot bind secret "${input.value.secretId}" at scope "${input.targetScope}": ` +2171 `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}": ` +
2150 `the secret must be owned by the same scope as the binding.`,2172 `the secret must be visible and owned by that scope.`,
2151 cause: undefined,2173 cause: undefined,
2152 });2174 });
2153 }2175 }
@@ -2192,6 +2214,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 38 unmodified lines
2192 kind: input.value.kind,2214 kind: input.value.kind,
2193 text_value: input.value.kind === "text" ? input.value.text : undefined,2215 text_value: input.value.kind === "text" ? input.value.text : undefined,
2194 secret_id: input.value.kind === "secret" ? input.value.secretId : undefined,2216 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,
2195 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,2221 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,
2196 created_at: now,2222 created_at: now,
2197 updated_at: now,2223 updated_at: now,
@@ -2208,6 +2234,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 10 unmodified lines
2208 kind: input.value.kind,2234 kind: input.value.kind,
2209 text_value: input.value.kind === "text" ? input.value.text : undefined,2235 text_value: input.value.kind === "text" ? input.value.text : undefined,
2210 secret_id: input.value.kind === "secret" ? input.value.secretId : undefined,2236 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,
2211 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,2241 connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined,
2212 created_at: now,2242 created_at: now,
2213 updated_at: now,2243 updated_at: now,
@@ -2347,7 +2377,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 133 unmodified lines
2347 if (!row.secret_id) return "missing";2377 if (!row.secret_id) return "missing";
2348 const secret = yield* findSecretRowAtScope({2378 const secret = yield* findSecretRowAtScope({
2349 secretId: row.secret_id,2379 secretId: row.secret_id,
2350 scopeId: row.scope_id,2380 scopeId: row.secret_scope_id ?? row.scope_id,
2351 });2381 });
2352 if (!secret) return "missing";2382 if (!secret) return "missing";
2353 return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing";2383 return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing";
@@ -2416,7 +2446,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 62 unmodified lines
2416 (row) =>2446 (row) =>
2417 new Usage({2447 new Usage({
2418 pluginId: row.plugin_id,2448 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 ),
2420 ownerKind: "credential-binding",2452 ownerKind: "credential-binding",
2421 ownerId: row.source_id,2453 ownerId: row.source_id,
2422 ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null,2454 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
2882 2914
2883 const defsMap = new Map<string, unknown>(Object.entries(defs));2915 const defsMap = new Map<string, unknown>(Object.entries(defs));
2884 const preview = yield* Effect.sync(() =>2916 const preview = yield* Effect.sync(() =>
2885 buildToolTypeScriptPreview({ inputSchema, outputSchema, defs: defsMap }),2917 buildToolTypeScriptPreview({
2918 inputSchema,
2919 outputSchema,
2920 defs: defsMap,
2921 }),
2886 ).pipe(2922 ).pipe(
2887 Effect.withSpan("schema.compile.preview", {2923 Effect.withSpan("schema.compile.preview", {
2888 attributes: {2924 attributes: {
packages/core/sdk/src/index.ts+1−0
@@ -129,6 +129,7 @@ export { · 128 unmodified lines
129 CredentialBindingValue,129 CredentialBindingValue,
130 ConfiguredCredentialBinding,130 ConfiguredCredentialBinding,
131 ConfiguredCredentialValue,131 ConfiguredCredentialValue,
132 ScopedSecretCredentialInput,
132 CredentialBindingRef,133 CredentialBindingRef,
133 SetCredentialBindingInput,134 SetCredentialBindingInput,
134 CredentialBindingSourceInput,135 CredentialBindingSourceInput,
packages/core/sdk/src/oauth-helpers.ts+94−103
@@ -16,7 +16,7 @@ · 15 unmodified lines
16// construction keeps the call sync and lets callers opt out of PAR16// construction keeps the call sync and lets callers opt out of PAR
17// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
18 18
19import { Data, Effect } from "effect";19import { Data, Effect, Predicate } from "effect";
20import * as oauth from "oauth4webapi";20import * as oauth from "oauth4webapi";
21 21
22// ---------------------------------------------------------------------------22// ---------------------------------------------------------------------------
@@ -121,11 +121,7 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 98 unmodified lines
121// to reauth-required) across wrappers.121// to reauth-required) across wrappers.
122// ---------------------------------------------------------------------------122// ---------------------------------------------------------------------------
123 123
124const isOAuth2Error = (cause: unknown): cause is OAuth2Error =>124const isOAuth2Error = Predicate.isTagged("OAuth2Error") as (cause: unknown) => cause is OAuth2Error;
125 typeof cause === "object" &&
126 cause !== null &&
127 "_tag" in cause &&
128 (cause as { readonly _tag?: unknown })._tag === "OAuth2Error";
129 125
130const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => {126const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => {
131 if (cause instanceof Response) return cause;127 if (cause instanceof Response) return cause;
@@ -200,19 +196,26 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 68 unmodified lines
200 });196 });
201};197};
202 198
203const toOAuth2ErrorWithHttpSummary = async (cause: unknown): Promise<OAuth2Error> => {199const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect<OAuth2Error> => {
204 if (isOAuth2Error(cause)) return cause;200 if (isOAuth2Error(cause)) return Effect.succeed(cause);
205 const base = toOAuth2Error(cause);201 const base = toOAuth2Error(cause);
206 const response = responseFromOAuthErrorCause(cause);202 const response = responseFromOAuthErrorCause(cause);
207 if (!response) return base;203 if (!response) return Effect.succeed(base);
208 const summary = await tokenEndpointHttpSummary(response);204 return Effect.promise(() => tokenEndpointHttpSummary(response)).pipe(
209 return new OAuth2Error({205 Effect.map(
210 message: `${base.message} (${summary})`,206 (summary) =>
211 error: base.error,207 new OAuth2Error({
212 cause,208 message: `${base.message} (${summary})`,
213 });209 error: base.error,
210 cause,
211 }),
212 ),
213 );
214};214};
215 215
216const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>
217 toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));
218
216// ---------------------------------------------------------------------------219// ---------------------------------------------------------------------------
217// oauth4webapi adapter helpers220// oauth4webapi adapter helpers
218// ---------------------------------------------------------------------------221// ---------------------------------------------------------------------------
@@ -348,40 +351,36 @@ export const exchangeAuthorizationCode = ( · 129 unmodified lines
348): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>351): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
349 Effect.tryPromise({352 Effect.tryPromise({
350 try: async () => {353 try: async () => {
351 try {354 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
352 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {355 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
353 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,356 });
354 });357 const client: oauth.Client = { client_id: input.clientId };
355 const client: oauth.Client = { client_id: input.clientId };358 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
356 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");359 // `authorizationCodeGrantRequest` requires its `callbackParameters`
357 // `authorizationCodeGrantRequest` requires its `callbackParameters`360 // to have been returned from `validateAuthResponse`. Our public API
358 // to have been returned from `validateAuthResponse`. Our public API361 // takes the `code` directly (the UI already validated `state` by
359 // takes the `code` directly (the UI already validated `state` by362 // looking up the session), so skip the library's state-validation
360 // looking up the session), so skip the library's state-validation363 // rail and go through the generic grant request instead.
361 // rail and go through the generic grant request instead.364 const params = new URLSearchParams({
362 const params = new URLSearchParams({365 code: input.code,
363 code: input.code,366 redirect_uri: input.redirectUrl,
364 redirect_uri: input.redirectUrl,367 code_verifier: input.codeVerifier,
365 code_verifier: input.codeVerifier,368 });
366 });369 if (input.resource) {
367 if (input.resource) {370 params.set("resource", 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);
381 }371 }
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);
382 },381 },
383 catch: toOAuth2Error,382 catch: (cause) => cause,
384 });383 }).pipe(Effect.catch(failOAuth2WithHttpSummary));
385 384
386// ---------------------------------------------------------------------------385// ---------------------------------------------------------------------------
387// Exchange client credentials → tokens (RFC 6749 §4.4)386// Exchange client credentials → tokens (RFC 6749 §4.4)
@@ -402,29 +401,25 @@ export const exchangeClientCredentials = ( · 14 unmodified lines
402): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>401): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
403 Effect.tryPromise({402 Effect.tryPromise({
404 try: async () => {403 try: async () => {
405 try {404 const as = asFromTokenUrl(input.tokenUrl);
406 const as = asFromTokenUrl(input.tokenUrl);405 const client: oauth.Client = { client_id: input.clientId };
407 const client: oauth.Client = { client_id: input.clientId };406 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
408 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");407 const params = new URLSearchParams();
409 const params = new URLSearchParams();408 if (input.scopes && input.scopes.length > 0) {
410 if (input.scopes && input.scopes.length > 0) {409 params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
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);
424 }410 }
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);
425 },420 },
426 catch: toOAuth2Error,421 catch: (cause) => cause,
427 });422 }).pipe(Effect.catch(failOAuth2WithHttpSummary));
428 423
429// ---------------------------------------------------------------------------424// ---------------------------------------------------------------------------
430// Refresh access token425// Refresh access token
@@ -452,43 +447,39 @@ export const refreshAccessToken = ( · 21 unmodified lines
452): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>447): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
453 Effect.tryPromise({448 Effect.tryPromise({
454 try: async () => {449 try: async () => {
455 try {450 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {
456 const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, {451 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,
457 idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported,452 });
458 });453 const client: oauth.Client = { client_id: input.clientId };
459 const client: oauth.Client = { client_id: input.clientId };454 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
460 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");455 const extraParams = new URLSearchParams();
461 const extraParams = new URLSearchParams();456 if (input.scopes && input.scopes.length > 0) {
462 if (input.scopes && input.scopes.length > 0) {457 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
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);
488 }458 }
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);
489 },480 },
490 catch: toOAuth2Error,481 catch: (cause) => cause,
491 });482 }).pipe(Effect.catch(failOAuth2WithHttpSummary));
492 483
493// ---------------------------------------------------------------------------484// ---------------------------------------------------------------------------
494// Refresh-needed predicate485// 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
9import {9import {
10 HttpCredentialsEditor,10 HttpCredentialsEditor,
11 httpCredentialsValid,11 httpCredentialsValid,
12 serializeScopedHttpCredentials,
12 serializeHttpCredentials,13 serializeHttpCredentials,
13 type HttpCredentialsState,14 type HttpCredentialsState,
14} from "@executor-js/react/plugins/http-credentials";15} from "@executor-js/react/plugins/http-credentials";
15import {16import {
16 displayNameFromUrl,17 sourceDisplayNameFromUrl,
17 slugifyNamespace,18 slugifyNamespace,
18 SourceIdentityFieldRows,19 SourceIdentityFieldRows,
19 useSourceIdentity,20 useSourceIdentity,
@@ -25,7 +26,8 @@ import { · 5 unmodified lines
25 type OAuthCompletionPayload,26 type OAuthCompletionPayload,
26} from "@executor-js/react/plugins/oauth-sign-in";27} from "@executor-js/react/plugins/oauth-sign-in";
27import {28import {
28 CredentialScopeSection,29 CredentialControlField,
30 CredentialUsageRow,
29 useCredentialTargetScope,31 useCredentialTargetScope,
30} from "@executor-js/react/plugins/credential-target-scope";32} from "@executor-js/react/plugins/credential-target-scope";
31import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";33import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";
@@ -61,7 +63,7 @@ export default function AddGraphqlSource(props: { · 29 unmodified lines
61}) {63}) {
62 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");64 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
63 const identity = useSourceIdentity({65 const identity = useSourceIdentity({
64 fallbackName: displayNameFromUrl(endpoint) ?? "",66 fallbackName: sourceDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
65 });67 });
66 const [credentials, setCredentials] = useState<HttpCredentialsState>(initialGraphqlCredentials);68 const [credentials, setCredentials] = useState<HttpCredentialsState>(initialGraphqlCredentials);
67 const [adding, setAdding] = useState(false);69 const [adding, setAdding] = useState(false);
@@ -70,8 +72,12 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
70 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);72 const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null);
71 73
72 const scopeId = useScope();74 const scopeId = useScope();
73 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =75 const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
74 useCredentialTargetScope();76 const {
77 credentialTargetScope: oauthCredentialTargetScope,
78 setCredentialTargetScope: setOAuthCredentialTargetScope,
79 credentialScopeOptions,
80 } = useCredentialTargetScope();
75 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), {81 const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), {
76 mode: "promiseExit",82 mode: "promiseExit",
77 });83 });
@@ -91,9 +97,10 @@ export default function AddGraphqlSource(props: { · 13 unmodified lines
91 const trimmedEndpoint = endpoint.trim();97 const trimmedEndpoint = endpoint.trim();
92 const namespace =98 const namespace =
93 slugifyNamespace(identity.namespace) ||99 slugifyNamespace(identity.namespace) ||
94 slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") ||100 slugifyNamespace(sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") ?? "") ||
95 "graphql";101 "graphql";
96 const displayName = identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace;102 const displayName =
103 identity.name.trim() || sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") || namespace;
97 return { trimmedEndpoint, namespace, displayName };104 return { trimmedEndpoint, namespace, displayName };
98 }, [endpoint, identity.name, identity.namespace]);105 }, [endpoint, identity.name, identity.namespace]);
99 106
@@ -109,7 +116,7 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
109 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),116 ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),
110 redirectUrl: oauthCallbackUrl(),117 redirectUrl: oauthCallbackUrl(),
111 connectionId: oauthConnectionId({ pluginId: "graphql", namespace }),118 connectionId: oauthConnectionId({ pluginId: "graphql", namespace }),
112 tokenScope: credentialTargetScope,119 tokenScope: oauthCredentialTargetScope,
113 strategy: { kind: "dynamic-dcr" },120 strategy: { kind: "dynamic-dcr" },
114 pluginId: "graphql",121 pluginId: "graphql",
115 identityLabel: `${displayName} OAuth`,122 identityLabel: `${displayName} OAuth`,
@@ -123,12 +130,15 @@ export default function AddGraphqlSource(props: { · 7 unmodified lines
123 },130 },
124 onError: setAddError,131 onError: setAddError,
125 });132 });
126 }, [endpoint, credentials, oauth, sourceIdentity, credentialTargetScope]);133 }, [endpoint, credentials, oauth, sourceIdentity, oauthCredentialTargetScope]);
127 134
128 const handleAdd = async () => {135 const handleAdd = async () => {
129 setAdding(true);136 setAdding(true);
130 setAddError(null);137 setAddError(null);
131 const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials);138 const { headers: headerMap, queryParams } = serializeScopedHttpCredentials(
139 credentials,
140 requestCredentialTargetScope,
141 );
132 142
133 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();143 const { trimmedEndpoint, namespace, displayName } = sourceIdentity();
134 const exit = await doAdd({144 const exit = await doAdd({
@@ -144,7 +154,10 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines
144 queryParams: queryParams as Record<string, GraphqlCredentialInput>,154 queryParams: queryParams as Record<string, GraphqlCredentialInput>,
145 }155 }
146 : {}),156 : {}),
147 credentialTargetScope,157 credentialTargetScope:
158 authMode === "oauth2" && tokens
159 ? oauthCredentialTargetScope
160 : requestCredentialTargetScope,
148 ...(authMode === "oauth2" && tokens161 ...(authMode === "oauth2" && tokens
149 ? {162 ? {
150 auth: {163 auth: {
@@ -185,24 +198,18 @@ export default function AddGraphqlSource(props: { · 34 unmodified lines
185 </CardStackContent>198 </CardStackContent>
186 </CardStack>199 </CardStack>
187 200
188 <CredentialScopeSection201 <HttpCredentialsEditor
189 value={credentialTargetScope}202 credentials={credentials}
190 options={credentialScopeOptions}203 onChange={setCredentials}
191 onChange={(targetScope) => {204 existingSecrets={secretList}
192 setCredentialTargetScope(targetScope);205 sourceName={identity.name}
193 setTokens(null);206 targetScope={requestCredentialTargetScope}
194 }}207 credentialScopeOptions={credentialScopeOptions}
195 >208 bindingScopeOptions={credentialScopeOptions}
196 <HttpCredentialsEditor209 />
197 credentials={credentials}
198 onChange={setCredentials}
199 existingSecrets={secretList}
200 sourceName={identity.name}
201 targetScope={credentialTargetScope}
202 />
203 </CredentialScopeSection>
204 210
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">
206 <div className="flex items-center justify-between gap-3">213 <div className="flex items-center justify-between gap-3">
207 <span className="text-sm font-medium text-foreground">Authentication</span>214 <span className="text-sm font-medium text-foreground">Authentication</span>
208 <FilterTabs<AuthMode>215 <FilterTabs<AuthMode>
@@ -219,27 +226,38 @@ export default function AddGraphqlSource(props: { · 10 unmodified lines
219 </div>226 </div>
220 227
221 {authMode === "oauth2" && (228 {authMode === "oauth2" && (
222 <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5">229 <CredentialUsageRow
223 {tokens ? (230 value={oauthCredentialTargetScope}
224 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">231 options={credentialScopeOptions}
225 Authenticated232 onChange={(targetScope) => {
226 </span>233 setOAuthCredentialTargetScope(targetScope);
227 ) : (234 setTokens(null);
228 <span className="text-xs text-muted-foreground">235 }}
229 Sign in before adding so Executor can introspect the schema.236 label="Connection saved to"
230 </span>237 help="Choose who can use the OAuth connection."
231 )}238 >
232 <Button239 <CredentialControlField label="Connect via OAuth" help="Start the provider OAuth flow.">
233 type="button"240 <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
234 variant="outline"241 {tokens ? (
235 size="sm"242 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
236 className="ml-auto h-7 px-2 text-xs"243 Authenticated
237 onClick={() => void handleOAuth()}244 </span>
238 disabled={!endpoint.trim() || !httpCredentialsValid(credentials) || oauth.busy}245 ) : (
239 >246 <span className="text-xs text-muted-foreground">Not connected</span>
240 {oauth.busy ? "Signing in..." : tokens ? "Reconnect" : "Sign in"}247 )}
241 </Button>248 <Button
242 </div>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>
243 )}261 )}
244 </section>262 </section>
245 263
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+2−1
@@ -222,7 +222,8 @@ function EditForm(props: { · 221 unmodified lines
222 targetScope={credentialTargetScope}222 targetScope={credentialTargetScope}
223 />223 />
224 224
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">
226 <div className="flex items-center justify-between gap-3">227 <div className="flex items-center justify-between gap-3">
227 <span className="text-sm font-medium text-foreground">Authentication</span>228 <span className="text-sm font-medium text-foreground">Authentication</span>
228 <FilterTabs<AuthMode>229 <FilterTabs<AuthMode>
packages/plugins/graphql/src/sdk/plugin.test.ts+62−0
@@ -604,6 +604,68 @@ describe("graphqlPlugin", () => { · 603 unmodified lines
604 }),604 }),
605 );605 );
606 606
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
607 it.effect("org header binding resolves the org secret when a user has the same secret id", () =>669 it.effect("org header binding resolves the org secret when a user has the same secret id", () =>
608 Effect.gen(function* () {670 Effect.gen(function* () {
609 const server = yield* serveGreetingServer;671 const server = yield* serveGreetingServer;
packages/plugins/graphql/src/sdk/plugin.ts+45−7
@@ -424,6 +424,19 @@ const bindingTargetScope = ( · 423 unmodified lines
424 );424 );
425};425};
426 426
427const 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
427const canonicalizeCredentialMap = (440const canonicalizeCredentialMap = (
428 values: Record<string, GraphqlCredentialInput> | undefined,441 values: Record<string, GraphqlCredentialInput> | undefined,
429 slotForName: (name: string) => string,442 slotForName: (name: string) => string,
@@ -432,10 +445,15 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines
432 readonly bindings: ReadonlyArray<{445 readonly bindings: ReadonlyArray<{
433 readonly slot: string;446 readonly slot: string;
434 readonly value: GraphqlSourceBindingValue;447 readonly value: GraphqlSourceBindingValue;
448 readonly targetScope?: string;
435 }>;449 }>;
436} => {450} => {
437 const nextValues: Record<string, ConfiguredGraphqlCredentialValue> = {};451 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 }> = [];
439 for (const [name, value] of Object.entries(values ?? {})) {457 for (const [name, value] of Object.entries(values ?? {})) {
440 if (typeof value === "string") {458 if (typeof value === "string") {
441 nextValues[name] = value;459 nextValues[name] = value;
@@ -453,9 +471,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
453 });471 });
454 bindings.push({472 bindings.push({
455 slot,473 slot,
474 targetScope: "targetScope" in value ? value.targetScope : undefined,
456 value: {475 value: {
457 kind: "secret",476 kind: "secret",
458 secretId: SecretId.make(value.secretId),477 secretId: SecretId.make(value.secretId),
478 ...("secretScopeId" in value && value.secretScopeId
479 ? { secretScopeId: value.secretScopeId }
480 : {}),
459 },481 },
460 });482 });
461 }483 }
@@ -469,6 +491,7 @@ const canonicalizeAuth = ( · 7 unmodified lines
469 readonly bindings: ReadonlyArray<{491 readonly bindings: ReadonlyArray<{
470 readonly slot: string;492 readonly slot: string;
471 readonly value: GraphqlSourceBindingValue;493 readonly value: GraphqlSourceBindingValue;
494 readonly targetScope?: string;
472 }>;495 }>;
473} => {496} => {
474 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };497 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };
@@ -606,8 +629,12 @@ const makeGraphqlExtension = ( · 131 unmodified lines
606 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];629 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];
607 continue;630 continue;
608 }631 }
632 const secretScope =
633 "secretScopeId" in value
634 ? (value.secretScopeId ?? value.targetScope)
635 : (params.targetScope ?? params.sourceScope);
609 const secret = yield* ctx.secrets636 const secret = yield* ctx.secrets
610 .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope)637 .getAtScope(SecretId.make(value.secretId), secretScope)
611 .pipe(638 .pipe(
612 Effect.catchTag("SecretOwnedByConnectionError", () =>639 Effect.catchTag("SecretOwnedByConnectionError", () =>
613 Effect.fail(640 Effect.fail(
@@ -682,14 +709,21 @@ const makeGraphqlExtension = ( · 68 unmodified lines
682 ...canonicalQueryParams.bindings,709 ...canonicalQueryParams.bindings,
683 ...canonicalAuth.bindings,710 ...canonicalAuth.bindings,
684 ];711 ];
685 const targetScope = yield* bindingTargetScope(config.credentialTargetScope, directBindings);712 for (const binding of directBindings) {
686 if (targetScope) {713 const bindingTargetScope = yield* targetScopeForBinding(
714 config.credentialTargetScope,
715 binding,
716 );
687 yield* validateGraphqlBindingTarget(ctx, {717 yield* validateGraphqlBindingTarget(ctx, {
688 sourceId: namespace,718 sourceId: namespace,
689 sourceScope: config.scope,719 sourceScope: config.scope,
690 targetScope,720 targetScope: bindingTargetScope,
691 });721 });
692 }722 }
723 const targetScope =
724 directBindings[0] !== undefined
725 ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0])
726 : undefined;
693 727
694 let introspectionResult: IntrospectionResult;728 let introspectionResult: IntrospectionResult;
695 if (config.introspectionJson) {729 if (config.introspectionJson) {
@@ -769,10 +803,14 @@ const makeGraphqlExtension = ( · 73 unmodified lines
769 });803 });
770 }804 }
771 805
772 if (targetScope) {806 if (directBindings.length > 0) {
773 for (const binding of directBindings) {807 for (const binding of directBindings) {
808 const bindingTargetScope = yield* targetScopeForBinding(
809 config.credentialTargetScope,
810 binding,
811 );
774 yield* ctx.credentialBindings.set({812 yield* ctx.credentialBindings.set({
775 targetScope: ScopeId.make(targetScope),813 targetScope: ScopeId.make(bindingTargetScope),
776 pluginId: GRAPHQL_PLUGIN_ID,814 pluginId: GRAPHQL_PLUGIN_ID,
777 sourceId: namespace,815 sourceId: namespace,
778 sourceScope: ScopeId.make(config.scope),816 sourceScope: ScopeId.make(config.scope),
packages/plugins/graphql/src/sdk/types.ts+6−1
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
3 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,3 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,
4 CredentialBindingValue,4 CredentialBindingValue,
5 credentialSlotKey,5 credentialSlotKey,
6 ScopedSecretCredentialInput,
6 SecretBackedValue,7 SecretBackedValue,
7 ScopeId,8 ScopeId,
8} from "@executor-js/sdk/core";9} from "@executor-js/sdk/core";
@@ -68,7 +69,11 @@ export type QueryParamValue = typeof QueryParamValue.Type; · 59 unmodified lines
68 69
69export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema;70export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema;
70export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type;71export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type;
71export const GraphqlCredentialInput = Schema.Union([HeaderValue, ConfiguredGraphqlCredentialValue]);72export const GraphqlCredentialInput = Schema.Union([
73 ScopedSecretCredentialInput,
74 HeaderValue,
75 ConfiguredGraphqlCredentialValue,
76]);
72export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type;77export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type;
73 78
74export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name);79export 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
27import { SourceFavicon } from "@executor-js/react/components/source-favicon";27import { SourceFavicon } from "@executor-js/react/components/source-favicon";
28import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";28import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
29import { Textarea } from "@executor-js/react/components/textarea";29import { Textarea } from "@executor-js/react/components/textarea";
30import { HeadersList } from "@executor-js/react/plugins/headers-list";
31import {30import {
32 emptyHttpCredentials,31 emptyHttpCredentials,
33 httpCredentialsValid,32 httpCredentialsValid,
34 HttpCredentialsEditor,33 HttpCredentialsEditor,
34 serializeScopedHttpCredentials,
35 serializeHttpCredentials,35 serializeHttpCredentials,
36 type SecretBackedValue,
37} from "@executor-js/react/plugins/http-credentials";36} from "@executor-js/react/plugins/http-credentials";
38import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth";
39import {37import {
40 displayNameFromUrl,38 sourceDisplayNameFromUrl,
41 slugifyNamespace,39 slugifyNamespace,
42 SourceIdentityFieldRows,40 SourceIdentityFieldRows,
43 SourceIdentityFields,41 SourceIdentityFields,
@@ -51,15 +49,16 @@ import { · 7 unmodified lines
51 type OAuthCompletionPayload,49 type OAuthCompletionPayload,
52} from "@executor-js/react/plugins/oauth-sign-in";50} from "@executor-js/react/plugins/oauth-sign-in";
53import {51import {
54 CredentialScopeSection,52 CredentialControlField,
53 CredentialUsageRow,
55 useCredentialTargetScope,54 useCredentialTargetScope,
56} from "@executor-js/react/plugins/credential-target-scope";55} from "@executor-js/react/plugins/credential-target-scope";
57 56
58type RemoteAuthMode = "none" | "header" | "oauth2";57type RemoteAuthMode = "none" | "oauth2";
59import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";58import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
60import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";59import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";
61import { mcpPresets, type McpPreset } from "../sdk/presets";60import { mcpPresets, type McpPreset } from "../sdk/presets";
62import { MCP_OAUTH_CONNECTION_SLOT } from "../sdk/types";61import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types";
63 62
64const ErrorMessage = Schema.Struct({ message: Schema.String });63const ErrorMessage = Schema.Struct({ message: Schema.String });
65const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);64const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
@@ -101,7 +100,7 @@ type PlainHeader = { · 35 unmodified lines
101 100
102type State =101type State =
103 | { step: "url"; url: string }102 | { step: "url"; url: string }
104 | { step: "probing"; url: string }103 | { step: "probing"; url: string; probe: ProbeResult | null }
105 | { step: "probed"; url: string; probe: ProbeResult }104 | { step: "probed"; url: string; probe: ProbeResult }
106 | { step: "oauth-starting"; url: string; probe: ProbeResult }105 | { step: "oauth-starting"; url: string; probe: ProbeResult }
107 | {106 | {
@@ -148,7 +147,7 @@ function reducer(state: State, action: Action): State { · 40 unmodified lines
148 return { step: "url", url: action.url };147 return { step: "url", url: action.url };
149 148
150 case "probe-start":149 case "probe-start":
151 return { step: "probing", url: state.url };150 return { step: "probing", url: state.url, probe: "probe" in state ? state.probe : null };
152 151
153 case "probe-ok":152 case "probe-ok":
154 return { step: "probed", url: state.url, probe: action.probe };153 return { step: "probed", url: state.url, probe: action.probe };
@@ -292,8 +291,12 @@ export default function AddMcpSource(props: { · 137 unmodified lines
292 );291 );
293 292
294 const scopeId = useScope();293 const scopeId = useScope();
295 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =294 const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();
296 useCredentialTargetScope();295 const {
296 credentialTargetScope: oauthCredentialTargetScope,
297 setCredentialTargetScope: setOAuthCredentialTargetScope,
298 credentialScopeOptions,
299 } = useCredentialTargetScope();
297 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });300 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
298 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {301 const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {
299 mode: "promiseExit",302 mode: "promiseExit",
@@ -306,14 +309,6 @@ export default function AddMcpSource(props: { · 6 unmodified lines
306 });309 });
307 310
308 const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");311 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 ]);
317 const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]);312 const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]);
318 const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials());313 const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials());
319 314
@@ -321,25 +316,19 @@ export default function AddMcpSource(props: { · 1 unmodified line
321 const tokens = "tokens" in state ? state.tokens : null;316 const tokens = "tokens" in state ? state.tokens : null;
322 317
323 const remoteIdentity = useSourceIdentity({318 const remoteIdentity = useSourceIdentity({
324 fallbackName: probe?.serverName ?? probe?.name ?? displayNameFromUrl(state.url) ?? "",319 fallbackName:
320 sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "",
325 });321 });
326 const isProbing = state.step === "probing";322 const isProbing = state.step === "probing";
327 const isAdding = state.step === "adding";323 const isAdding = state.step === "adding";
328 const isOAuthBusy =324 const isOAuthBusy =
329 state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy;325 state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy;
330 const canUseNone = probe?.requiresOAuth !== true;326 const canUseNone = probe?.requiresOAuth !== true;
331 const remoteAuthHeader = remoteAuthHeaders[0];
332 const headerAuthComplete = Boolean(remoteAuthHeader?.name.trim() && remoteAuthHeader?.secretId);
333 const remoteHeadersComplete = remoteHeaders.every(327 const remoteHeadersComplete = remoteHeaders.every(
334 (header) => header.name.trim() && header.value.trim(),328 (header) => header.name.trim() && header.value.trim(),
335 );329 );
336 const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials);330 const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials);
337 const authReady =331 const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null;
338 remoteAuthMode === "none"
339 ? canUseNone
340 : remoteAuthMode === "header"
341 ? headerAuthComplete
342 : tokens !== null;
343 const canAdd =332 const canAdd =
344 Boolean(probe) &&333 Boolean(probe) &&
345 authReady &&334 authReady &&
@@ -392,17 +381,11 @@ export default function AddMcpSource(props: { · 46 unmodified lines
392 handleProbeRef.current();381 handleProbeRef.current();
393 }, 400);382 }, 400);
394 return () => clearTimeout(handle);383 return () => clearTimeout(handle);
395 }, [transport, state.step, state.url, remoteCredentials]);384 }, [transport, state.step, state.url]);
396 385
397 const handleRemoteCredentialsChange = useCallback(386 const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => {
398 (next: typeof remoteCredentials) => {387 setRemoteCredentials(next);
399 setRemoteCredentials(next);388 }, []);
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 );
406 389
407 const handleOAuth = useCallback(async () => {390 const handleOAuth = useCallback(async () => {
408 dispatch({ type: "oauth-start" });391 dispatch({ type: "oauth-start" });
@@ -421,7 +404,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines
421 pluginId: "mcp",404 pluginId: "mcp",
422 namespace: namespaceSlug,405 namespace: namespaceSlug,
423 }),406 }),
424 tokenScope: credentialTargetScope,407 tokenScope: oauthCredentialTargetScope,
425 strategy: { kind: "dynamic-dcr" },408 strategy: { kind: "dynamic-dcr" },
426 pluginId: "mcp",409 pluginId: "mcp",
427 identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`,410 identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`,
@@ -440,7 +423,7 @@ export default function AddMcpSource(props: { · 12 unmodified lines
440 dispatch({ type: "oauth-waiting", sessionId: result.sessionId }),423 dispatch({ type: "oauth-waiting", sessionId: result.sessionId }),
441 onError: (error) => dispatch({ type: "oauth-fail", error }),424 onError: (error) => dispatch({ type: "oauth-fail", error }),
442 });425 });
443 }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, credentialTargetScope]);426 }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]);
444 427
445 const handleCancelOAuth = useCallback(() => {428 const handleCancelOAuth = useCallback(() => {
446 oauth.cancel();429 oauth.cancel();
@@ -450,33 +433,28 @@ export default function AddMcpSource(props: { · 3 unmodified lines
450 const handleAddRemote = useCallback(async () => {433 const handleAddRemote = useCallback(async () => {
451 if (!probe) return;434 if (!probe) return;
452 dispatch({ type: "add-start" });435 dispatch({ type: "add-start" });
453 const headerAuth = remoteAuthHeaders[0];
454 const auth =436 const auth =
455 remoteAuthMode === "header" && headerAuth?.secretId437 remoteAuthMode === "oauth2"
456 ? {438 ? tokens
457 kind: "header" as const,439 ? {
458 headerName: headerAuth.name.trim(),440 kind: "oauth2" as const,
459 secretId: headerAuth.secretId,441 connectionId: tokens.connectionId,
460 ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}),442 }
461 }443 : {
462 : remoteAuthMode === "oauth2"444 kind: "oauth2" as const,
463 ? tokens445 connectionSlot: MCP_OAUTH_CONNECTION_SLOT,
464 ? {446 }
465 kind: "oauth2" as const,447 : { kind: "none" 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 };
473 const headers = Object.fromEntries(448 const headers = Object.fromEntries(
474 remoteHeaders449 remoteHeaders
475 .map((header) => [header.name.trim(), header.value.trim()] as const)450 .map((header) => [header.name.trim(), header.value.trim()] as const)
476 .filter(([name, value]) => name && value),451 .filter(([name, value]) => name && value),
477 );452 );
478 const credentials = serializeHttpCredentials(remoteCredentials);453 const credentials = serializeScopedHttpCredentials(
479 const remoteRequestHeaders: Record<string, SecretBackedValue> = {454 remoteCredentials,
455 requestCredentialTargetScope,
456 );
457 const remoteRequestHeaders: Record<string, McpCredentialInput> = {
480 ...headers,458 ...headers,
481 ...credentials.headers,459 ...credentials.headers,
482 };460 };
@@ -491,7 +469,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines
491 namespace: slugNamespace || undefined,469 namespace: slugNamespace || undefined,
492 endpoint: state.url.trim(),470 endpoint: state.url.trim(),
493 auth,471 auth,
494 credentialTargetScope,472 credentialTargetScope:
473 remoteAuthMode === "oauth2" && tokens
474 ? oauthCredentialTargetScope
475 : requestCredentialTargetScope,
495 ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}),476 ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}),
496 ...(Object.keys(credentials.queryParams).length > 0477 ...(Object.keys(credentials.queryParams).length > 0
497 ? { queryParams: credentials.queryParams }478 ? { queryParams: credentials.queryParams }
@@ -510,7 +491,6 @@ export default function AddMcpSource(props: { · 12 unmodified lines
510 }, [491 }, [
511 probe,492 probe,
512 remoteAuthMode,493 remoteAuthMode,
513 remoteAuthHeaders,
514 remoteHeaders,494 remoteHeaders,
515 remoteCredentials,495 remoteCredentials,
516 remoteIdentity,496 remoteIdentity,
@@ -519,7 +499,8 @@ export default function AddMcpSource(props: { · 2 unmodified lines
519 doAdd,499 doAdd,
520 props,500 props,
521 scopeId,501 scopeId,
522 credentialTargetScope,502 requestCredentialTargetScope,
503 oauthCredentialTargetScope,
523 ]);504 ]);
524 505
525 // ---- Stdio actions ----506 // ---- Stdio actions ----
@@ -714,6 +695,7 @@ export default function AddMcpSource(props: { · 188 unmodified lines
714 <div className="mt-2 space-y-2">695 <div className="mt-2 space-y-2">
715 <FieldError>{probeError}</FieldError>696 <FieldError>{probeError}</FieldError>
716 <Button697 <Button
698 type="button"
717 variant="outline"699 variant="outline"
718 size="sm"700 size="sm"
719 onClick={handleProbe}701 onClick={handleProbe}
@@ -733,7 +715,9 @@ export default function AddMcpSource(props: { · 13 unmodified lines
733 onChange={handleRemoteCredentialsChange}715 onChange={handleRemoteCredentialsChange}
734 existingSecrets={secretList}716 existingSecrets={secretList}
735 sourceName={remoteIdentity.name}717 sourceName={remoteIdentity.name}
736 targetScope={credentialTargetScope}718 targetScope={requestCredentialTargetScope}
719 credentialScopeOptions={credentialScopeOptions}
720 bindingScopeOptions={credentialScopeOptions}
737 labels={{721 labels={{
738 headers: "Request headers",722 headers: "Request headers",
739 queryParams: "Query parameters",723 queryParams: "Query parameters",
@@ -748,13 +732,10 @@ export default function AddMcpSource(props: { · 8 unmodified lines
748 <FilterTabs<RemoteAuthMode>732 <FilterTabs<RemoteAuthMode>
749 tabs={733 tabs={
750 probe.requiresOAuth734 probe.requiresOAuth
751 ? [735 ? [{ value: "oauth2", label: "OAuth" }]
752 { value: "header", label: "Header" },
753 { value: "oauth2", label: "OAuth" },
754 ]
755 : [736 : [
756 { value: "none", label: "None" },737 { value: "none", label: "None" },
757 { value: "header", label: "Header" },738 { value: "oauth2", label: "OAuth" },
758 ]739 ]
759 }740 }
760 value={remoteAuthMode}741 value={remoteAuthMode}
@@ -762,88 +743,77 @@ export default function AddMcpSource(props: { · 1 unmodified line
762 />743 />
763 </div>744 </div>
764 745
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
785 {remoteAuthMode === "oauth2" && (746 {remoteAuthMode === "oauth2" && (
786 <CredentialScopeSection747 <CredentialUsageRow
787 value={credentialTargetScope}748 value={oauthCredentialTargetScope}
788 options={credentialScopeOptions}749 options={credentialScopeOptions}
789 onChange={(targetScope) => {750 onChange={(targetScope) => {
790 setCredentialTargetScope(targetScope);751 setOAuthCredentialTargetScope(targetScope);
791 dispatch({ type: "oauth-reset" });752 dispatch({ type: "oauth-reset" });
792 }}753 }}
793 description="Choose who can use the OAuth connection."754 label="Connection saved to"
755 help="Choose who can use the OAuth connection."
794 >756 >
795 {!tokens && state.step === "probed" && (757 <CredentialControlField
796 <div className="flex flex-col gap-2">758 label="Connect via OAuth"
797 <Button onClick={handleOAuth} variant="outline">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 >
798 Sign in768 Sign in
799 </Button>769 </Button>
800 <p className="text-[11px] text-muted-foreground">770 )}
801 Sign in before adding so Executor can discover and save the tool catalog.
802 </p>
803 </div>
804 )}
805 771
806 {!tokens && state.step === "oauth-starting" && (772 {!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">773 <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2">
808 <Spinner className="size-3.5" />774 <Spinner className="size-3.5" />
809 <span className="text-xs text-muted-foreground">Starting authorization…</span>775 <span className="text-xs text-muted-foreground">
810 </div>776 Starting authorization...
811 )}777 </span>
778 </div>
779 )}
812 780
813 {!tokens && state.step === "oauth-waiting" && (781 {!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">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">
815 <Spinner className="size-3.5 text-blue-500" />783 <Spinner className="size-3.5 text-blue-500" />
816 <span className="text-xs text-blue-600 dark:text-blue-400">784 <span className="text-xs text-blue-600 dark:text-blue-400">
817 Waiting for authorization in popup…785 Waiting for authorization...
818 </span>786 </span>
819 <Button787 <Button
820 variant="ghost"788 type="button"
821 size="sm"789 variant="ghost"
822 onClick={handleCancelOAuth}790 size="sm"
823 className="ml-auto h-7 px-2 text-xs"791 onClick={handleCancelOAuth}
824 >792 className="ml-auto h-7 px-2 text-xs"
825 Cancel793 >
826 </Button>794 Cancel
827 </div>795 </Button>
828 )}796 </div>
797 )}
829 798
830 {tokens && (799 {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">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">
832 <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">801 <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">
833 <path802 <path
834 d="M3 8.5l3 3 7-7"803 d="M3 8.5l3 3 7-7"
835 stroke="currentColor"804 stroke="currentColor"
836 strokeWidth="1.5"805 strokeWidth="1.5"
837 strokeLinecap="round"806 strokeLinecap="round"
838 strokeLinejoin="round"807 strokeLinejoin="round"
839 />808 />
840 </svg>809 </svg>
841 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">810 <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
842 Authenticated811 Authenticated
843 </span>812 </span>
844 </div>813 </div>
845 )}814 )}
846 </CredentialScopeSection>815 </CredentialControlField>
816 </CredentialUsageRow>
847 )}817 )}
848 </section>818 </section>
849 )}819 )}
@@ -854,8 +824,8 @@ export default function AddMcpSource(props: { · 4 unmodified lines
854 <div>824 <div>
855 <Label>Additional headers</Label>825 <Label>Additional headers</Label>
856 <p className="mt-1 text-[12px] text-muted-foreground">826 <p className="mt-1 text-[12px] text-muted-foreground">
857 Plaintext headers sent with every request. Use authentication for secret-backed827 Plaintext headers sent with every request. Use request headers above for
858 auth headers.828 secret-backed values.
859 </p>829 </p>
860 </div>830 </div>
861 831
@@ -957,6 +927,7 @@ export default function AddMcpSource(props: { · 95 unmodified lines
957 <p className="text-[12px] text-destructive">{otherError}</p>927 <p className="text-[12px] text-destructive">{otherError}</p>
958 </div>928 </div>
959 <Button929 <Button
930 type="button"
960 variant="outline"931 variant="outline"
961 size="sm"932 size="sm"
962 onClick={() => dispatch({ type: "retry" })}933 onClick={() => dispatch({ type: "retry" })}
@@ -969,6 +940,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
969 940
970 <FloatActions>941 <FloatActions>
971 <Button942 <Button
943 type="button"
972 variant="ghost"944 variant="ghost"
973 onClick={() => {945 onClick={() => {
974 oauth.cancel();946 oauth.cancel();
@@ -979,7 +951,7 @@ export default function AddMcpSource(props: { · 4 unmodified lines
979 Cancel951 Cancel
980 </Button>952 </Button>
981 {(probe || isProbing) && (953 {(probe || isProbing) && (
982 <Button onClick={handleAddRemote} disabled={!canAdd}>954 <Button type="button" onClick={handleAddRemote} disabled={!canAdd}>
983 {isAdding ? (955 {isAdding ? (
984 <>956 <>
985 <Spinner className="size-3.5" /> Adding…957 <Spinner className="size-3.5" /> Adding…
@@ -1046,10 +1018,14 @@ export default function AddMcpSource(props: { · 60 unmodified lines
1046 )}1018 )}
1047 1019
1048 <FloatActions>1020 <FloatActions>
1049 <Button variant="ghost" onClick={props.onCancel} disabled={stdioAdding}>1021 <Button type="button" variant="ghost" onClick={props.onCancel} disabled={stdioAdding}>
1050 Cancel1022 Cancel
1051 </Button>1023 </Button>
1052 <Button onClick={handleAddStdio} disabled={!stdioCommand.trim() || stdioAdding}>1024 <Button
1025 type="button"
1026 onClick={handleAddStdio}
1027 disabled={!stdioCommand.trim() || stdioAdding}
1028 >
1053 {stdioAdding ? (1029 {stdioAdding ? (
1054 <>1030 <>
1055 <Spinner className="size-3.5" /> Adding…1031 <Spinner className="size-3.5" /> Adding…
packages/plugins/mcp/src/sdk/plugin.ts+67−33
@@ -308,6 +308,20 @@ const bindingTargetScope = ( · 307 unmodified lines
308 );308 );
309};309};
310 310
311const 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
311const canonicalizeCredentialMap = (325const canonicalizeCredentialMap = (
312 values: Record<string, McpCredentialInput> | undefined,326 values: Record<string, McpCredentialInput> | undefined,
313 slotForName: (name: string) => string,327 slotForName: (name: string) => string,
@@ -316,10 +330,11 @@ const canonicalizeCredentialMap = ( · 2 unmodified lines
316 readonly bindings: ReadonlyArray<{330 readonly bindings: ReadonlyArray<{
317 readonly slot: string;331 readonly slot: string;
318 readonly value: McpSourceBindingValue;332 readonly value: McpSourceBindingValue;
333 readonly targetScope?: string;
319 }>;334 }>;
320} => {335} => {
321 const nextValues: Record<string, ConfiguredMcpCredentialValue> = {};336 const nextValues: Record<string, ConfiguredMcpCredentialValue> = {};
322 const bindings: Array<{ slot: string; value: McpSourceBindingValue }> = [];337 const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [];
323 for (const [name, value] of Object.entries(values ?? {})) {338 for (const [name, value] of Object.entries(values ?? {})) {
324 if (typeof value === "string") {339 if (typeof value === "string") {
325 nextValues[name] = value;340 nextValues[name] = value;
@@ -337,9 +352,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
337 });352 });
338 bindings.push({353 bindings.push({
339 slot,354 slot,
355 targetScope: "targetScope" in value ? value.targetScope : undefined,
340 value: {356 value: {
341 kind: "secret",357 kind: "secret",
342 secretId: SecretId.make(value.secretId),358 secretId: SecretId.make(value.secretId),
359 ...("secretScopeId" in value && value.secretScopeId
360 ? { secretScopeId: value.secretScopeId }
361 : {}),
343 },362 },
344 });363 });
345 }364 }
@@ -353,6 +372,7 @@ const canonicalizeAuth = ( · 7 unmodified lines
353 readonly bindings: ReadonlyArray<{372 readonly bindings: ReadonlyArray<{
354 readonly slot: string;373 readonly slot: string;
355 readonly value: McpSourceBindingValue;374 readonly value: McpSourceBindingValue;
375 readonly targetScope?: string;
356 }>;376 }>;
357} => {377} => {
358 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };378 if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] };
@@ -368,13 +388,18 @@ const canonicalizeAuth = ( · 9 unmodified lines
368 bindings: [388 bindings: [
369 {389 {
370 slot: MCP_HEADER_AUTH_SLOT,390 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 },
372 },397 },
373 ],398 ],
374 };399 };
375 }400 }
376 if ("connectionSlot" in auth) return { auth, bindings: [] };401 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 }> = [
378 {403 {
379 slot: MCP_OAUTH_CONNECTION_SLOT,404 slot: MCP_OAUTH_CONNECTION_SLOT,
380 value: {405 value: {
@@ -572,18 +597,20 @@ const resolveMcpCredentialInputMap = ( · 191 unmodified lines
572 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];597 if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name];
573 continue;598 continue;
574 }599 }
575 const secret = yield* ctx.secrets600 const secretScope =
576 .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope)601 "secretScopeId" in value
577 .pipe(602 ? (value.secretScopeId ?? value.targetScope)
578 Effect.catchTag("SecretOwnedByConnectionError", () =>603 : (params.targetScope ?? params.sourceScope);
579 Effect.fail(604 const secret = yield* ctx.secrets.getAtScope(SecretId.make(value.secretId), secretScope).pipe(
580 new McpConnectionError({605 Effect.catchTag("SecretOwnedByConnectionError", () =>
581 transport: "remote",606 Effect.fail(
582 message: `Failed to resolve secret for ${params.missingLabel} "${name}"`,607 new McpConnectionError({
583 }),608 transport: "remote",
584 ),609 message: `Failed to resolve secret for ${params.missingLabel} "${name}"`,
610 }),
585 ),611 ),
586 );612 ),
613 );
587 if (secret === null) {614 if (secret === null) {
588 return yield* new McpConnectionError({615 return yield* new McpConnectionError({
589 transport: "remote",616 transport: "remote",
@@ -681,18 +708,17 @@ const resolveMcpInputAuth = ( · 91 unmodified lines
681 const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth);708 const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth);
682 return { headers };709 return { headers };
683 }710 }
684 const secret = yield* ctx.secrets711 const secretScope = auth.secretScopeId ?? auth.targetScope ?? targetScope ?? sourceScope;
685 .getAtScope(SecretId.make(auth.secretId), targetScope ?? sourceScope)712 const secret = yield* ctx.secrets.getAtScope(SecretId.make(auth.secretId), secretScope).pipe(
686 .pipe(713 Effect.catchTag("SecretOwnedByConnectionError", () =>
687 Effect.catchTag("SecretOwnedByConnectionError", () =>714 Effect.fail(
688 Effect.fail(715 new McpConnectionError({
689 new McpConnectionError({716 transport: "remote",
690 transport: "remote",717 message: `Failed to resolve secret "${auth.secretId}"`,
691 message: `Failed to resolve secret "${auth.secretId}"`,718 }),
692 }),
693 ),
694 ),719 ),
695 );720 ),
721 );
696 if (secret === null) {722 if (secret === null) {
697 return yield* new McpConnectionError({723 return yield* new McpConnectionError({
698 transport: "remote",724 transport: "remote",
@@ -1074,17 +1100,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 375 unmodified lines
1074 ...canonicalRemote.auth.bindings,1100 ...canonicalRemote.auth.bindings,
1075 ]1101 ]
1076 : [];1102 : [];
1077 const targetScope =1103 for (const binding of directBindings) {
1078 config.transport === "remote"1104 const bindingTargetScope = yield* targetScopeForBinding(
1079 ? yield* bindingTargetScope(config.credentialTargetScope, directBindings)1105 config.transport === "remote" ? config.credentialTargetScope : undefined,
1080 : undefined;1106 binding,
1081 if (targetScope) {1107 );
1082 yield* validateMcpBindingTarget(ctx, {1108 yield* validateMcpBindingTarget(ctx, {
1083 sourceId: namespace,1109 sourceId: namespace,
1084 sourceScope: config.scope,1110 sourceScope: config.scope,
1085 targetScope,1111 targetScope: bindingTargetScope,
1086 });1112 });
1087 }1113 }
1114 const targetScope =
1115 config.transport === "remote" && directBindings[0]
1116 ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0])
1117 : undefined;
1088 const sd = toStoredSourceData(1118 const sd = toStoredSourceData(
1089 config,1119 config,
1090 canonicalRemote1120 canonicalRemote
@@ -1227,10 +1257,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 136 unmodified lines
1227 })),1257 })),
1228 });1258 });
1229 1259
1230 if (targetScope) {1260 if (directBindings.length > 0) {
1231 for (const binding of directBindings) {1261 for (const binding of directBindings) {
1262 const bindingTargetScope = yield* targetScopeForBinding(
1263 config.transport === "remote" ? config.credentialTargetScope : undefined,
1264 binding,
1265 );
1232 yield* ctx.credentialBindings.set({1266 yield* ctx.credentialBindings.set({
1233 targetScope: ScopeId.make(targetScope),1267 targetScope: ScopeId.make(bindingTargetScope),
1234 pluginId: MCP_PLUGIN_ID,1268 pluginId: MCP_PLUGIN_ID,
1235 sourceId: namespace,1269 sourceId: namespace,
1236 sourceScope: ScopeId.make(config.scope),1270 sourceScope: ScopeId.make(config.scope),
packages/plugins/mcp/src/sdk/types.ts+8−1
@@ -3,6 +3,7 @@ import { · 2 unmodified lines
3 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,3 ConfiguredCredentialValue as ConfiguredCredentialValueSchema,
4 CredentialBindingValue,4 CredentialBindingValue,
5 credentialSlotKey,5 credentialSlotKey,
6 ScopedSecretCredentialInput,
6 ScopeId,7 ScopeId,
7 SecretBackedMap,8 SecretBackedMap,
8 SecretBackedValue,9 SecretBackedValue,
@@ -24,7 +25,11 @@ export type McpTransport = typeof McpTransport.Type; · 15 unmodified lines
24export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema;25export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema;
25export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type;26export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type;
26 27
27export const McpCredentialInput = Schema.Union([SecretBackedValue, ConfiguredMcpCredentialValue]);28export const McpCredentialInput = Schema.Union([
29 ScopedSecretCredentialInput,
30 SecretBackedValue,
31 ConfiguredMcpCredentialValue,
32]);
28export type McpCredentialInput = typeof McpCredentialInput.Type;33export type McpCredentialInput = typeof McpCredentialInput.Type;
29 34
30export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name);35export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name);
@@ -69,6 +74,8 @@ export const McpConnectionAuthInput = Schema.Union([ · 38 unmodified lines
69 headerName: Schema.String,74 headerName: Schema.String,
70 secretId: Schema.String,75 secretId: Schema.String,
71 prefix: Schema.optional(Schema.String),76 prefix: Schema.optional(Schema.String),
77 targetScope: Schema.optional(ScopeId),
78 secretScopeId: Schema.optional(ScopeId),
72 }),79 }),
73 Schema.Struct({80 Schema.Struct({
74 kind: Schema.Literal("oauth2"),81 kind: Schema.Literal("oauth2"),
packages/plugins/openapi/src/api/group.ts+22−5
@@ -1,6 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core";3import { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core";
4import { InternalError } from "@executor-js/api";4import { InternalError } from "@executor-js/api";
5 5
6import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";6import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors";
@@ -43,6 +43,23 @@ const SpecFetchCredentialsPayload = Schema.Struct({ · 36 unmodified lines
43 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),43 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),
44});44});
45 45
46const ConfiguredCredentialBindingPayload = Schema.Struct({
47 kind: Schema.Literal("binding"),
48 slot: Schema.String,
49 prefix: Schema.optional(Schema.String),
50});
51
52const ConfiguredCredentialValuePayload = Schema.Union([
53 Schema.String,
54 ConfiguredCredentialBindingPayload,
55]);
56
57const OpenApiCredentialInputPayload = Schema.Union([
58 ScopedSecretCredentialInput,
59 SecretBackedValue,
60 ConfiguredCredentialValuePayload,
61]);
62
46// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
47// Payloads64// Payloads
48// ---------------------------------------------------------------------------65// ---------------------------------------------------------------------------
@@ -55,8 +72,8 @@ const AddSpecPayload = Schema.Struct({ · 6 unmodified lines
55 name: Schema.optional(Schema.String),72 name: Schema.optional(Schema.String),
56 baseUrl: Schema.optional(Schema.String),73 baseUrl: Schema.optional(Schema.String),
57 namespace: Schema.optional(Schema.String),74 namespace: Schema.optional(Schema.String),
58 headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),75 headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
59 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),76 queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
60 oauth2: Schema.optional(OAuth2SourceConfig),77 oauth2: Schema.optional(OAuth2SourceConfig),
61});78});
62 79
@@ -69,8 +86,8 @@ const UpdateSourcePayload = Schema.Struct({ · 6 unmodified lines
69 sourceScope: ScopeId,86 sourceScope: ScopeId,
70 name: Schema.optional(Schema.String),87 name: Schema.optional(Schema.String),
71 baseUrl: Schema.optional(Schema.String),88 baseUrl: Schema.optional(Schema.String),
72 headers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),89 headers: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
73 queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)),90 queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiCredentialInputPayload)),
74 credentialTargetScope: Schema.optional(ScopeId),91 credentialTargetScope: Schema.optional(ScopeId),
75 // Set after a successful re-authenticate to refresh the source's92 // Set after a successful re-authenticate to refresh the source's
76 // stored OAuth2 metadata.93 // stored OAuth2 metadata.
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+312−79
@@ -1,4 +1,4 @@
1import { useCallback, useEffect, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
@@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line
6 6
7import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";7import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core";
8import { startOAuth } from "@executor-js/react/api/atoms";8import { startOAuth } from "@executor-js/react/api/atoms";
9import { useScope } from "@executor-js/react/api/scope-context";9import { useScope, useScopeStack } from "@executor-js/react/api/scope-context";
10import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";10import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";
11 11
12// `addSpec` with an oauth2 payload persists a source row AND (for12// `addSpec` with an oauth2 payload persists a source row AND (for
@@ -28,10 +28,6 @@ import { · 15 unmodified lines
28 useOAuthPopupFlow,28 useOAuthPopupFlow,
29 type OAuthCompletionPayload,29 type OAuthCompletionPayload,
30} from "@executor-js/react/plugins/oauth-sign-in";30} from "@executor-js/react/plugins/oauth-sign-in";
31import {
32 CredentialScopeSection,
33 useCredentialTargetScope,
34} from "@executor-js/react/plugins/credential-target-scope";
35import {31import {
36 CreatableSecretPicker,32 CreatableSecretPicker,
37 matchPresetKey,33 matchPresetKey,
@@ -62,12 +58,20 @@ import { · 24 unmodified lines
62} from "@executor-js/react/components/card-stack";58} from "@executor-js/react/components/card-stack";
63import { FieldLabel } from "@executor-js/react/components/field";59import { FieldLabel } from "@executor-js/react/components/field";
64import { FloatActions } from "@executor-js/react/components/float-actions";60import { FloatActions } from "@executor-js/react/components/float-actions";
61import { HelpTooltip } from "@executor-js/react/components/help-tooltip";
65import { Input } from "@executor-js/react/components/input";62import { Input } from "@executor-js/react/components/input";
66import { Label } from "@executor-js/react/components/label";63import { Label } from "@executor-js/react/components/label";
67import { Textarea } from "@executor-js/react/components/textarea";64import { Textarea } from "@executor-js/react/components/textarea";
68import { Checkbox } from "@executor-js/react/components/checkbox";65import { Checkbox } from "@executor-js/react/components/checkbox";
69import { SourceFavicon } from "@executor-js/react/components/source-favicon";66import { SourceFavicon } from "@executor-js/react/components/source-favicon";
70import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";67import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";
68import {
69 Select,
70 SelectContent,
71 SelectItem,
72 SelectTrigger,
73 SelectValue,
74} from "@executor-js/react/components/select";
71import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";75import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";
72import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";76import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms";
73import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";77import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";
@@ -76,6 +80,7 @@ import { · 2 unmodified lines
76 oauth2ClientIdSlot,80 oauth2ClientIdSlot,
77 oauth2ClientSecretSlot,81 oauth2ClientSecretSlot,
78 oauth2ConnectionSlot,82 oauth2ConnectionSlot,
83 queryParamBindingSlot,
79} from "../sdk/store";84} from "../sdk/store";
80import {85import {
81 ConfiguredHeaderBinding,86 ConfiguredHeaderBinding,
@@ -200,6 +205,49 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 118 unmodified lines
200 });205 });
201}206}
202 207
208const secretStorageDescription = (label: string): string =>
209 label === "Personal"
210 ? "Only you can use this secret."
211 : "Everyone in the organization can use this secret.";
212
213function 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
203// ---------------------------------------------------------------------------251// ---------------------------------------------------------------------------
204// Main component — single progressive form252// Main component — single progressive form
205// ---------------------------------------------------------------------------253// ---------------------------------------------------------------------------
@@ -237,6 +285,8 @@ export default function AddOpenApiSource(props: { · 31 unmodified lines
237 // OAuth2 state (only populated while an oauth2 preset is selected)285 // OAuth2 state (only populated while an oauth2 preset is selected)
238 const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null);286 const [oauth2ClientIdSecretId, setOauth2ClientIdSecretId] = useState<string | null>(null);
239 const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null);287 const [oauth2ClientSecretSecretId, setOauth2ClientSecretSecretId] = useState<string | null>(null);
288 const [oauth2ClientIdScope, setOauth2ClientIdScope] = useState<ScopeId | null>(null);
289 const [oauth2ClientSecretScope, setOauth2ClientSecretScope] = useState<ScopeId | null>(null);
240 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());290 const [oauth2SelectedScopes, setOauth2SelectedScopes] = useState<Set<string>>(new Set());
241 const [oauth2AuthState, setOauth2AuthState] = useState<{291 const [oauth2AuthState, setOauth2AuthState] = useState<{
242 readonly fingerprint: string;292 readonly fingerprint: string;
@@ -250,8 +300,47 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines
250 const [addError, setAddError] = useState<string | null>(null);300 const [addError, setAddError] = useState<string | null>(null);
251 301
252 const scopeId = useScope();302 const scopeId = useScope();
253 const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } =303 const scopeStack = useScopeStack();
254 useCredentialTargetScope();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 : [];
255 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });344 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
256 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {345 const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), {
257 mode: "promiseExit",346 mode: "promiseExit",
@@ -261,6 +350,10 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
261 mode: "promiseExit",350 mode: "promiseExit",
262 });351 });
263 const secretList = useSecretPickerSecrets();352 const secretList = useSecretPickerSecrets();
353 const initialCredentialSecrets = useMemo(
354 () => secretList.filter((secret) => secret.scopeId === String(credentialTargetScope)),
355 [credentialTargetScope, secretList],
356 );
264 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({357 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
265 popupName: OPENAPI_OAUTH_POPUP_NAME,358 popupName: OPENAPI_OAUTH_POPUP_NAME,
266 popupBlockedMessage: "OAuth popup was blocked by the browser",359 popupBlockedMessage: "OAuth popup was blocked by the browser",
@@ -318,7 +411,19 @@ export default function AddOpenApiSource(props: { · 51 unmodified lines
318 const resolvedBaseUrl = baseUrl.trim();411 const resolvedBaseUrl = baseUrl.trim();
319 412
320 const configuredHeaders: Record<string, ConfiguredHeaderBinding> = {};413 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 }> = [];
322 for (const ch of customHeaders) {427 for (const ch of customHeaders) {
323 if (!ch.name.trim()) continue;428 if (!ch.name.trim()) continue;
324 const slot = headerBindingSlot(ch.name.trim());429 const slot = headerBindingSlot(ch.name.trim());
@@ -328,7 +433,34 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines
328 prefix: ch.prefix,433 prefix: ch.prefix,
329 });434 });
330 if (ch.secretId) {435 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();
332 }464 }
333 }465 }
334 466
@@ -380,6 +512,18 @@ export default function AddOpenApiSource(props: { · 45 unmodified lines
380 : null;512 : null;
381 const hasHeaders = Object.keys(configuredHeaders).length > 0;513 const hasHeaders = Object.keys(configuredHeaders).length > 0;
382 const oauth2Busy = startingOAuth || oauth.busy;514 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;
383 527
384 const canAdd = preview !== null && resolvedBaseUrl.length > 0;528 const canAdd = preview !== null && resolvedBaseUrl.length > 0;
385 529
@@ -470,6 +614,24 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines
470 }614 }
471 };615 };
472 616
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
473 const toggleOAuth2Scope = (scope: string) => {635 const toggleOAuth2Scope = (scope: string) => {
474 setOauth2SelectedScopes((prev) => {636 setOauth2SelectedScopes((prev) => {
475 const copy = new Set(prev);637 const copy = new Set(prev);
@@ -505,7 +667,7 @@ export default function AddOpenApiSource(props: { · 29 unmodified lines
505 endpoint: tokenUrl,667 endpoint: tokenUrl,
506 redirectUrl: tokenUrl,668 redirectUrl: tokenUrl,
507 connectionId,669 connectionId,
508 tokenScope: credentialTargetScope,670 tokenScope: oauthTokenTargetScope,
509 strategy: {671 strategy: {
510 kind: "client-credentials",672 kind: "client-credentials",
511 tokenEndpoint: tokenUrl,673 tokenEndpoint: tokenUrl,
@@ -542,14 +704,14 @@ export default function AddOpenApiSource(props: { · 30 unmodified lines
542 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);704 const issuerUrl = inferOAuthIssuerUrl(authorizationUrl);
543 705
544 await oauth.openAuthorization({706 await oauth.openAuthorization({
545 tokenScope: credentialTargetScope,707 tokenScope: oauthTokenTargetScope,
546 run: async () => {708 run: async () => {
547 const exit = await doStartOAuth({709 const exit = await doStartOAuth({
548 params: { scopeId },710 params: { scopeId },
549 payload: {711 payload: {
550 endpoint: authorizationUrl,712 endpoint: authorizationUrl,
551 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),713 connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow),
552 tokenScope: credentialTargetScope,714 tokenScope: oauthTokenTargetScope,
553 redirectUrl: oauth2RedirectUrl,715 redirectUrl: oauth2RedirectUrl,
554 strategy: {716 strategy: {
555 kind: "authorization-code",717 kind: "authorization-code",
@@ -604,7 +766,7 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines
604 resolvedSourceId,766 resolvedSourceId,
605 selectedOAuth2Fingerprint,767 selectedOAuth2Fingerprint,
606 oauth,768 oauth,
607 credentialTargetScope,769 oauthTokenTargetScope,
608 ]);770 ]);
609 771
610 const handleCancelOAuth2 = useCallback(() => {772 const handleCancelOAuth2 = useCallback(() => {
@@ -631,10 +793,8 @@ export default function AddOpenApiSource(props: { · 20 unmodified lines
631 namespace,793 namespace,
632 baseUrl: resolvedBaseUrl || undefined,794 baseUrl: resolvedBaseUrl || undefined,
633 ...(hasHeaders ? { headers: configuredHeaders } : {}),795 ...(hasHeaders ? { headers: configuredHeaders } : {}),
634 ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0796 ...(Object.keys(configuredQueryParams).length > 0
635 ? {797 ? { queryParams: configuredQueryParams }
636 queryParams: serializeHttpCredentials(runtimeCredentials).queryParams,
637 }
638 : {}),798 : {}),
639 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),799 ...(configuredOAuth2 ? { oauth2: configuredOAuth2 } : {}),
640 },800 },
@@ -649,6 +809,9 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
649 const sourceId = exit.value.namespace;809 const sourceId = exit.value.namespace;
650 const sourceScope = ScopeId.make(scopeId);810 const sourceScope = ScopeId.make(scopeId);
651 const bindingScope = ScopeId.make(credentialTargetScope);811 const bindingScope = ScopeId.make(credentialTargetScope);
812 const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope);
813 const clientIdSecretScope = oauth2ClientIdScope ?? bindingScope;
814 const clientSecretSecretScope = oauth2ClientSecretScope ?? bindingScope;
652 815
653 for (const binding of headerBindings) {816 for (const binding of headerBindings) {
654 const bindingExit = await doSetBinding({817 const bindingExit = await doSetBinding({
@@ -656,11 +819,35 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
656 payload: {819 payload: {
657 sourceId,820 sourceId,
658 sourceScope,821 sourceScope,
659 scope: bindingScope,822 scope: binding.scope,
660 slot: binding.slot,823 slot: binding.slot,
661 value: {824 value: {
662 kind: "secret",825 kind: "secret",
663 secretId: SecretId.make(binding.secretId),826 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,
664 },851 },
665 },852 },
666 reactivityKeys: bindingWriteKeys,853 reactivityKeys: bindingWriteKeys,
@@ -683,6 +870,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
683 value: {870 value: {
684 kind: "secret",871 kind: "secret",
685 secretId: SecretId.make(oauth2ClientIdSecretId),872 secretId: SecretId.make(oauth2ClientIdSecretId),
873 secretScopeId: clientIdSecretScope,
686 },874 },
687 },875 },
688 reactivityKeys: bindingWriteKeys,876 reactivityKeys: bindingWriteKeys,
@@ -705,6 +893,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
705 value: {893 value: {
706 kind: "secret",894 kind: "secret",
707 secretId: SecretId.make(oauth2ClientSecretSecretId),895 secretId: SecretId.make(oauth2ClientSecretSecretId),
896 secretScopeId: clientSecretSecretScope,
708 },897 },
709 },898 },
710 reactivityKeys: bindingWriteKeys,899 reactivityKeys: bindingWriteKeys,
@@ -722,7 +911,7 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
722 payload: {911 payload: {
723 sourceId,912 sourceId,
724 sourceScope,913 sourceScope,
725 scope: bindingScope,914 scope: oauthTokenBindingScope,
726 slot: configuredOAuth2.connectionSlot,915 slot: configuredOAuth2.connectionSlot,
727 value: {916 value: {
728 kind: "connection",917 kind: "connection",
@@ -747,10 +936,6 @@ export default function AddOpenApiSource(props: { · 18 unmodified lines
747 <div className="flex flex-1 flex-col gap-6">936 <div className="flex flex-1 flex-col gap-6">
748 <div>937 <div>
749 <h1 className="text-xl font-semibold text-foreground">Add OpenAPI Source</h1>938 <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>
754 </div>939 </div>
755 940
756 {!preview && (941 {!preview && (
@@ -880,7 +1065,7 @@ export default function AddOpenApiSource(props: { · 123 unmodified lines
880 {preview && (1065 {preview && (
881 <>1066 <>
882 <section className="space-y-2.5">1067 <section className="space-y-2.5">
883 <FieldLabel>Authentication</FieldLabel>1068 <FieldLabel>Authentication method</FieldLabel>
884 {/* RadioGroup always renders so the static Custom + None radios1069 {/* RadioGroup always renders so the static Custom + None radios
885 stay visible for specs with no security schemes (e.g. MS Graph).1070 stay visible for specs with no security schemes (e.g. MS Graph).
886 The preset .map() blocks below render nothing when their arrays1071 The preset .map() blocks below render nothing when their arrays
@@ -959,22 +1144,19 @@ export default function AddOpenApiSource(props: { · 72 unmodified lines
959 1144
960 {/* Header-based auth input */}1145 {/* Header-based auth input */}
961 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (1146 {strategy.kind !== "none" && strategy.kind !== "oauth2" && (
962 <CredentialScopeSection1147 <div className="space-y-3">
963 value={credentialTargetScope}
964 options={credentialScopeOptions}
965 onChange={(targetScope) => {
966 setCredentialTargetScope(targetScope);
967 setOauth2AuthState(null);
968 }}
969 >
970 <HeadersList1148 <HeadersList
971 headers={customHeaders}1149 headers={customHeaders}
972 onHeadersChange={handleHeadersChange}1150 onHeadersChange={handleHeadersChange}
973 existingSecrets={secretList}1151 existingSecrets={secretList}
974 sourceName={identity.name}1152 sourceName={identity.name}
975 targetScope={credentialTargetScope}1153 targetScope={credentialTargetScope}
1154 credentialScopeOptions={initialCredentialScopeOptions}
1155 bindingScopeOptions={bindingScopeOptions}
1156 restrictSecretsToTargetScope
1157 emptyLabel="No credentials yet. Add the header value this method should use."
976 />1158 />
977 </CredentialScopeSection>1159 </div>
978 )}1160 )}
979 1161
980 <HttpCredentialsEditor1162 <HttpCredentialsEditor
@@ -983,6 +1165,9 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
983 existingSecrets={secretList}1165 existingSecrets={secretList}
984 sourceName={identity.name}1166 sourceName={identity.name}
985 targetScope={credentialTargetScope}1167 targetScope={credentialTargetScope}
1168 credentialScopeOptions={initialCredentialScopeOptions}
1169 bindingScopeOptions={bindingScopeOptions}
1170 restrictSecretsToTargetScope
986 sections={{ headers: false, queryParams: true }}1171 sections={{ headers: false, queryParams: true }}
987 labels={{ queryParams: "Runtime query parameters" }}1172 labels={{ queryParams: "Runtime query parameters" }}
988 />1173 />
@@ -990,15 +1175,7 @@ export default function AddOpenApiSource(props: { · 1 unmodified line
990 {/* OAuth2 configuration */}1175 {/* OAuth2 configuration */}
991 {selectedOAuth2Preset && (1176 {selectedOAuth2Preset && (
992 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">1177 <div className="space-y-3 rounded-lg border border-border/60 bg-muted/10 p-3">
993 <CredentialScopeSection1178 <div className="space-y-3">
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 >
1002 <div className="space-y-1.5">1179 <div className="space-y-1.5">
1003 <FieldLabel className="text-[11px]">1180 <FieldLabel className="text-[11px]">
1004 Redirect URL{" "}1181 Redirect URL{" "}
@@ -1013,17 +1190,35 @@ export default function AddOpenApiSource(props: { · 8 unmodified lines
1013 </div>1190 </div>
1014 <div className="space-y-1.5">1191 <div className="space-y-1.5">
1015 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>1192 <FieldLabel className="text-[11px]">Client ID secret</FieldLabel>
1016 <CreatableSecretPicker1193 <div className="grid gap-2 md:grid-cols-2">
1017 value={oauth2ClientIdSecretId}1194 <div className="space-y-1.5">
1018 onSelect={(id: string) => {1195 <div className="flex items-center gap-1.5">
1019 setOauth2ClientIdSecretId(id);1196 <FieldLabel className="text-[11px]">Secret</FieldLabel>
1020 setOauth2AuthState(null);1197 <HelpTooltip label="Client ID secret">
1021 }}1198 Select or create the OAuth client ID secret.
1022 secrets={secretList}1199 </HelpTooltip>
1023 sourceName={identity.name}1200 </div>
1024 secretLabel="Client ID"1201 <CreatableSecretPicker
1025 targetScope={credentialTargetScope}1202 value={oauth2ClientIdSecretId}
1026 />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>
1027 </div>1222 </div>
1028 <div className="space-y-1.5">1223 <div className="space-y-1.5">
1029 <FieldLabel className="text-[11px]">1224 <FieldLabel className="text-[11px]">
@@ -1032,17 +1227,35 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
1032 · optional for public clients with PKCE1227 · optional for public clients with PKCE
1033 </span>1228 </span>
1034 </FieldLabel>1229 </FieldLabel>
1035 <CreatableSecretPicker1230 <div className="grid gap-2 md:grid-cols-2">
1036 value={oauth2ClientSecretSecretId}1231 <div className="space-y-1.5">
1037 onSelect={(id: string) => {1232 <div className="flex items-center gap-1.5">
1038 setOauth2ClientSecretSecretId(id);1233 <FieldLabel className="text-[11px]">Secret</FieldLabel>
1039 setOauth2AuthState(null);1234 <HelpTooltip label="Client secret">
1040 }}1235 Select or create the OAuth client secret.
1041 secrets={secretList}1236 </HelpTooltip>
1042 sourceName={identity.name}1237 </div>
1043 secretLabel="Client Secret"1238 <CreatableSecretPicker
1044 targetScope={credentialTargetScope}1239 value={oauth2ClientSecretSecretId}
1045 />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>
1046 </div>1259 </div>
1047 <div className="space-y-1.5">1260 <div className="space-y-1.5">
1048 <FieldLabel className="text-[11px]">Scopes</FieldLabel>1261 <FieldLabel className="text-[11px]">Scopes</FieldLabel>
@@ -1097,18 +1310,38 @@ export default function AddOpenApiSource(props: { · 48 unmodified lines
1097 </div>1310 </div>
1098 ) : (1311 ) : (
1099 <div className="flex flex-col gap-1.5">1312 <div className="flex flex-col gap-1.5">
1100 <Button1313 <div className="grid gap-2 md:grid-cols-2">
1101 variant="secondary"1314 <div className="space-y-1.5">
1102 onClick={handleConnectOAuth2}1315 <div className="flex items-center gap-1.5">
1103 disabled={!oauth2ClientIdSecretId || resolvedBaseUrl.length === 0}1316 <FieldLabel className="text-[11px]">OAuth sign-in</FieldLabel>
1104 className="w-full"1317 <HelpTooltip label="OAuth sign-in">
1105 >1318 Start the provider OAuth flow.
1106 Connect via OAuth1319 </HelpTooltip>
1107 </Button>1320 </div>
1108 <p className="text-[11px] text-muted-foreground">1321 <Button
1109 You can add the source without this OAuth connection. Secured operations1322 variant="secondary"
1110 will ask for credentials on the source detail page.1323 onClick={handleConnectOAuth2}
1111 </p>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>
1112 </div>1345 </div>
1113 )}1346 )}
1114 1347
@@ -1117,7 +1350,7 @@ export default function AddOpenApiSource(props: { · 2 unmodified lines
1117 <p className="text-[11px] text-destructive">{oauth2Error}</p>1350 <p className="text-[11px] text-destructive">{oauth2Error}</p>
1118 </div>1351 </div>
1119 )}1352 )}
1120 </CredentialScopeSection>1353 </div>
1121 </div>1354 </div>
1122 )}1355 )}
1123 </section>1356 </section>
@@ -1140,7 +1373,7 @@ export default function AddOpenApiSource(props: { · 16 unmodified lines
1140 {adding && <Spinner className="size-3.5" />}1373 {adding && <Spinner className="size-3.5" />}
1141 {adding1374 {adding
1142 ? "Adding…"1375 ? "Adding…"
1143 : selectedOAuth2Preset && !oauth2Auth1376 : willAddWithoutInitialCredentials
1144 ? "Add without credentials"1377 ? "Add without credentials"
1145 : "Add source"}1378 : "Add source"}
1146 </Button>1379 </Button>
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+177−116
@@ -170,8 +170,12 @@ export default function EditOpenApiSource(props: { · 169 unmodified lines
170 const secretList = useSecretPickerSecrets();170 const secretList = useSecretPickerSecrets();
171 171
172 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" });172 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" });
173 const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" });173 const doSetBinding = useAtomSet(setOpenApiSourceBinding, {
174 const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promiseExit" });174 mode: "promiseExit",
175 });
176 const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, {
177 mode: "promiseExit",
178 });
175 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });179 const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" });
176 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({180 const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({
177 popupName: OPENAPI_OAUTH_POPUP_NAME,181 popupName: OPENAPI_OAUTH_POPUP_NAME,
@@ -196,11 +200,15 @@ export default function EditOpenApiSource(props: { · 18 unmodified lines
196 readonly connectionId: string;200 readonly connectionId: string;
197 } | null>(null);201 } | null>(null);
198 const [loadedSourceKey, setLoadedSourceKey] = useState<string | null>(null);202 const [loadedSourceKey, setLoadedSourceKey] = useState<string | null>(null);
199 const [selectedCredentialScope, setSelectedCredentialScope] = useState<string>(203 const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState<string>(
200 userScope !== sourceScopeId ? userScope : sourceScopeId,204 userScope !== sourceScopeId ? userScope : sourceScopeId,
201 );205 );
202 const sourceSaveSeq = useRef(0);206 const sourceSaveSeq = useRef(0);
203 207
208 useEffect(() => {
209 setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId);
210 }, [sourceScopeId, userScope]);
211
204 useEffect(() => {212 useEffect(() => {
205 if (!source) return;213 if (!source) return;
206 const sourceKey = `${sourceScopeId}:${source.namespace}`;214 const sourceKey = `${sourceScopeId}:${source.namespace}`;
@@ -211,10 +219,6 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
211 setLoadedSourceKey(sourceKey);219 setLoadedSourceKey(sourceKey);
212 }, [loadedSourceKey, source, sourceScopeId]);220 }, [loadedSourceKey, source, sourceScopeId]);
213 221
214 useEffect(() => {
215 setSelectedCredentialScope(userScope !== sourceScopeId ? userScope : sourceScopeId);
216 }, [sourceScopeId, userScope]);
217
218 useEffect(() => {222 useEffect(() => {
219 if (!source) return;223 if (!source) return;
220 const sourceKey = `${sourceScopeId}:${source.namespace}`;224 const sourceKey = `${sourceScopeId}:${source.namespace}`;
@@ -313,15 +317,39 @@ export default function EditOpenApiSource(props: { · 92 unmodified lines
313 if (userScope !== sourceScopeId) {317 if (userScope !== sourceScopeId) {
314 entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" });318 entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" });
315 } else {319 } else {
316 entries[0] = { scopeId: ScopeId.make(sourceScopeId), label: "Credentials" };320 entries[0] = {
321 scopeId: ScopeId.make(sourceScopeId),
322 label: "Credentials",
323 };
317 }324 }
318 return entries;325 return entries;
319 }, [sourceScopeId, userScope]);326 }, [sourceScopeId, userScope]);
320 const activeCredentialScope =327 const credentialScopeOptions = useMemo(
321 credentialScopes.find((entry) => entry.scopeId === selectedCredentialScope) ??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) ??
322 credentialScopes[0]!;350 credentialScopes[0]!;
323 const activeCredentialScopeId = activeCredentialScope.scopeId;351 const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId;
324 const activeCredentialScopeLabel = activeCredentialScope.label;352 const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label;
325 353
326 if (!source) {354 if (!source) {
327 return (355 return (
@@ -332,7 +360,12 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
332 );360 );
333 }361 }
334 362
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 ) => {
336 const inputKey = `${targetScope}:${slot}`;369 const inputKey = `${targetScope}:${slot}`;
337 const trimmed = secretId.trim();370 const trimmed = secretId.trim();
338 if (!trimmed) return;371 if (!trimmed) return;
@@ -345,7 +378,11 @@ export default function EditOpenApiSource(props: { · 6 unmodified lines
345 sourceScope,378 sourceScope,
346 scope: targetScope,379 scope: targetScope,
347 slot,380 slot,
348 value: { kind: "secret", secretId: SecretId.make(trimmed) },381 value: {
382 kind: "secret",
383 secretId: SecretId.make(trimmed),
384 secretScopeId: secretScope,
385 },
349 },386 },
350 reactivityKeys: sourceWriteKeys,387 reactivityKeys: sourceWriteKeys,
351 });388 });
@@ -562,10 +599,6 @@ export default function EditOpenApiSource(props: { · 210 unmodified lines
562 <div className="space-y-6">599 <div className="space-y-6">
563 <div>600 <div>
564 <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1>601 <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>
569 </div>602 </div>
570 603
571 <CardStack>604 <CardStack>
@@ -612,101 +645,110 @@ export default function EditOpenApiSource(props: { · 40 unmodified lines
612 645
613 <CardStack>646 <CardStack>
614 <CardStackContent className="border-t-0">647 <CardStackContent className="border-t-0">
615 {credentialScopes.length > 1 && (648 <CardStackEntry>
616 <CardStackEntry>649 <CardStackEntryContent>
617 <CardStackEntryContent>650 <CardStackEntryTitle>Secrets</CardStackEntryTitle>
618 <CardStackEntryTitle>Credentials</CardStackEntryTitle>651 </CardStackEntryContent>
619 <CardStackEntryDescription>652 </CardStackEntry>
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 )}
633 653
634 {secretSlots654 {secretSlots
635 .filter((slot) => slot.kind === "secret")655 .filter((slot) => slot.kind === "secret")
636 .map((slot) => {656 .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;
657 return (657 return (
658 <CardStackEntryField658 <CardStackEntryField key={slot.slot} label={slot.label}>
659 key={`${slot.slot}:${activeCredentialScopeId}`}659 <div className="space-y-3">
660 label={slot.label}660 {secretBindingScopes.map((bindingScope) => {
661 >661 const exact = exactBindingForScope(
662 <div className="space-y-2">662 bindingRows,
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,
679 slot.slot,663 slot.slot,
680 activeCredentialScopeId,664 bindingScope.scopeId,
681 )}665 );
682 sourceName={source.name}666 const exactSecretId =
683 secretLabel={slot.label}667 exact && isSecretBindingValue(exact.value) ? exact.value.secretId : null;
684 />668 const inherited =
685 <div className="flex items-center gap-2">669 bindingScope.label === "Personal"
686 {savedHere && (670 ? effectiveBindingForScope(
687 <Button671 bindingRows,
688 variant="outline"672 slot.slot,
689 size="sm"673 bindingScope.scopeId,
690 onClick={() => void clearBinding(activeCredentialScopeId, slot.slot)}674 scopeRanks,
691 disabled={busyKey === `${activeCredentialScopeId}:${slot.slot}:clear`}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"
692 >696 >
693 Clear697 <div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
694 </Button>698 <div>
695 )}699 <div className="text-sm font-medium text-foreground">{rowTitle}</div>
696 {busyKey === inputKey && (700 </div>
697 <span className="text-xs text-muted-foreground">Saving…</span>701 {bindingScope.label === "Personal" &&
698 )}702 !exactSecretId &&
699 {slot.hint && (703 inheritedSecretId && (
700 <span className="text-xs text-muted-foreground">{slot.hint}</span>704 <span className="text-xs text-muted-foreground">
701 )}705 Using organization default
702 </div>706 </span>
703 <p className="text-xs text-muted-foreground">707 )}
704 {savedHere708 </div>
705 ? `Saved in ${activeCredentialScopeLabel.toLowerCase()}.`709 <CreatableSecretPicker
706 : inherited710 value={exactSecretId}
707 ? "No value saved here. Using the organization default."711 onSelect={(secretId, secretScopeId) => {
708 : `No ${activeCredentialScopeLabel.toLowerCase()} value saved yet.`}712 void setSecretBinding(
709 </p>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 })}
710 </div>752 </div>
711 </CardStackEntryField>753 </CardStackEntryField>
712 );754 );
@@ -725,19 +767,37 @@ export default function EditOpenApiSource(props: { · 12 unmodified lines
725 </p>767 </p>
726 </div>768 </div>
727 </CardStackEntryField>769 </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 )}
728 <CardStackEntryField label="OAuth Connection">788 <CardStackEntryField label="OAuth Connection">
729 {(() => {789 {(() => {
730 const exact = exactBindingForScope(790 const exact = exactBindingForScope(
731 bindingRows,791 bindingRows,
732 source.config.oauth2!.connectionSlot,792 source.config.oauth2!.connectionSlot,
733 activeCredentialScopeId,793 activeOAuthTokenScopeId,
734 );794 );
735 const binding =795 const binding =
736 exact ??796 exact ??
737 effectiveBindingForScope(797 effectiveBindingForScope(
738 bindingRows,798 bindingRows,
739 source.config.oauth2!.connectionSlot,799 source.config.oauth2!.connectionSlot,
740 activeCredentialScopeId,800 activeOAuthTokenScopeId,
741 scopeRanks,801 scopeRanks,
742 );802 );
743 const connectionBinding =803 const connectionBinding =
@@ -748,9 +808,10 @@ export default function EditOpenApiSource(props: { · 4 unmodified lines
748 const bindingScopeId = connectionBinding && binding ? binding.scopeId : null;808 const bindingScopeId = connectionBinding && binding ? binding.scopeId : null;
749 const isConnecting =809 const isConnecting =
750 busyKey ===810 busyKey ===
751 `${activeCredentialScopeId}:${source.config.oauth2.connectionSlot}:connect`;811 `${activeOAuthTokenScopeId}:${source.config.oauth2.connectionSlot}:connect`;
752 const isPendingOAuthConnection =812 const isPendingOAuthConnection =
753 pendingOAuthConnection?.scopeId === activeCredentialScopeId &&813 pendingOAuthConnection?.scopeId === activeOAuthTokenScopeId &&
814 pendingOAuthConnection !== null &&
754 pendingOAuthConnection.slot === source.config.oauth2.connectionSlot;815 pendingOAuthConnection.slot === source.config.oauth2.connectionSlot;
755 const isConnected = connection !== null && connection !== undefined;816 const isConnected = connection !== null && connection !== undefined;
756 const statusText =817 const statusText =
@@ -758,24 +819,24 @@ export default function EditOpenApiSource(props: { · 1 unmodified line
758 ? "Saving OAuth connection..."819 ? "Saving OAuth connection..."
759 : connectionBinding && bindingScopeId820 : connectionBinding && bindingScopeId
760 ? connection821 ? connection
761 ? bindingScopeId === activeCredentialScopeId822 ? bindingScopeId === activeOAuthTokenScopeId
762 ? `Connected in ${activeCredentialScopeLabel.toLowerCase()} as ${823 ? `Connected in ${activeOAuthTokenScopeLabel.toLowerCase()} as ${
763 connection.identityLabel ?? connection.id824 connection.identityLabel ?? connection.id
764 }`825 }`
765 : `Using organization connection ${826 : `Using organization connection ${
766 connection.identityLabel ?? connection.id827 connection.identityLabel ?? connection.id
767 }`828 }`
768 : bindingScopeId === activeCredentialScopeId829 : bindingScopeId === activeOAuthTokenScopeId
769 ? `Saved connection is missing in ${activeCredentialScopeLabel.toLowerCase()}`830 ? `Saved connection is missing in ${activeOAuthTokenScopeLabel.toLowerCase()}`
770 : "Organization connection is missing"831 : "Organization connection is missing"
771 : `No ${activeCredentialScopeLabel.toLowerCase()} connection`;832 : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`;
772 833
773 return (834 return (
774 <div className="space-y-2">835 <div className="space-y-2">
775 <div className="text-sm text-muted-foreground">{statusText}</div>836 <div className="text-sm text-muted-foreground">{statusText}</div>
776 <Button837 <Button
777 size="sm"838 size="sm"
778 onClick={() => void connectOAuth(activeCredentialScopeId)}839 onClick={() => void connectOAuth(activeOAuthTokenScopeId)}
779 disabled={isConnecting}840 disabled={isConnecting}
780 >841 >
781 {isConnecting ? "Connecting…" : isConnected ? "Reconnect" : "Connect"}842 {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
81 81
82const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive));82const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive));
83 83
84const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe(84const TestLayer = HttpRouter.serve(ApiLive, {
85 Layer.provideMerge(NodeHttpServer.layerTest),85 disableListenLog: true,
86);86 disableLogger: true,
87}).pipe(Layer.provideMerge(NodeHttpServer.layerTest));
87 88
88// ---------------------------------------------------------------------------89// ---------------------------------------------------------------------------
89// Tests90// Tests
@@ -296,7 +297,10 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 206 unmodified lines
296 "vercel.projects.list",297 "vercel.projects.list",
297 {},298 {},
298 autoApprove,299 autoApprove,
299 )) as { data: { authorization?: string; token?: string } | null; error: unknown };300 )) as {
301 data: { authorization?: string; token?: string } | null;
302 error: unknown;
303 };
300 expect(aliceResult.error).toBeNull();304 expect(aliceResult.error).toBeNull();
301 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");305 expect(aliceResult.data?.authorization).toBe("Bearer alice-vercel-token");
302 expect(aliceResult.data?.token).toBe("alice-team");306 expect(aliceResult.data?.token).toBe("alice-team");
@@ -882,8 +886,16 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 579 unmodified lines
882 const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) });886 const adapter = makeMemoryAdapter({ schema: collectSchemas(plugins) });
883 const blobs = makeInMemoryBlobStore();887 const blobs = makeInMemoryBlobStore();
884 const now = new Date();888 const now = new Date();
885 const orgScope = new Scope({ id: ScopeId.make("org"), name: "org", createdAt: now });889 const orgScope = new Scope({
886 const userScope = new Scope({ id: ScopeId.make("user"), name: "user", createdAt: now });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 });
887 899
888 const adminExec = yield* createExecutor({900 const adminExec = yield* createExecutor({
889 scopes: [orgScope],901 scopes: [orgScope],
@@ -949,4 +961,106 @@ layer(TestLayer)("OpenAPI multi-scope bearer (Vercel-style)", (it) => { · 59 unmodified lines
949 expect(result.data?.authorization).toBe("Bearer org-token");961 expect(result.data?.authorization).toBe("Bearer org-token");
950 }),962 }),
951 );963 );
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 );
952});1066});
packages/plugins/openapi/src/sdk/plugin.test.ts+1−0
@@ -1122,6 +1122,7 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 1121 unmodified lines
1122 expect(clientIdBinding?.value).toEqual({1122 expect(clientIdBinding?.value).toEqual({
1123 kind: "secret",1123 kind: "secret",
1124 secretId: SecretId.make("acme-client-id"),1124 secretId: SecretId.make("acme-client-id"),
1125 secretScopeId: ScopeId.make(TEST_SCOPE),
1125 });1126 });
1126 1127
1127 const connectionBinding = yield* executor.openapi1128 const connectionBinding = yield* executor.openapi
packages/plugins/openapi/src/sdk/plugin.ts+58−27
@@ -45,8 +45,10 @@ import { · 44 unmodified lines
45 ConfiguredHeaderValue as ConfiguredHeaderValueSchema,45 ConfiguredHeaderValue as ConfiguredHeaderValueSchema,
46 ConfiguredHeaderBinding,46 ConfiguredHeaderBinding,
47 OAuth2SourceConfig,47 OAuth2SourceConfig,
48 OpenApiCredentialInput as OpenApiCredentialInputSchema,
48 OpenApiSourceBindingInput,49 OpenApiSourceBindingInput,
49 OpenApiSourceBindingRef,50 OpenApiSourceBindingRef,
51 type OpenApiCredentialInput as OpenApiCredentialInputValue,
50 type OpenApiSourceBindingValue,52 type OpenApiSourceBindingValue,
51 OperationBinding,53 OperationBinding,
52 type ConfiguredHeaderValue as ConfiguredHeaderValueValue,54 type ConfiguredHeaderValue as ConfiguredHeaderValueValue,
@@ -60,7 +62,7 @@ import { · 7 unmodified lines
60export type HeaderValue = HeaderValueValue;62export type HeaderValue = HeaderValueValue;
61export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;63export type ConfiguredHeaderValue = ConfiguredHeaderValueValue;
62export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;64export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue;
63export type OpenApiCredentialInput = HeaderValue | ConfiguredHeaderValue;65export type OpenApiCredentialInput = OpenApiCredentialInputValue;
64export type OpenApiOAuthInput = OAuth2SourceConfig;66export type OpenApiOAuthInput = OAuth2SourceConfig;
65 67
66export interface OpenApiSpecFetchCredentialsInput {68export interface OpenApiSpecFetchCredentialsInput {
@@ -86,7 +88,7 @@ export interface OpenApiSpecConfig { · 19 unmodified lines
86 readonly name?: string;88 readonly name?: string;
87 readonly baseUrl?: string;89 readonly baseUrl?: string;
88 readonly namespace?: string;90 readonly namespace?: string;
89 readonly headers?: Record<string, OpenApiHeaderInput>;91 readonly headers?: Record<string, OpenApiCredentialInput>;
90 readonly queryParams?: Record<string, OpenApiCredentialInput>;92 readonly queryParams?: Record<string, OpenApiCredentialInput>;
91 readonly oauth2?: OpenApiOAuthInput;93 readonly oauth2?: OpenApiOAuthInput;
92 readonly credentialTargetScope?: string;94 readonly credentialTargetScope?: string;
@@ -95,7 +97,7 @@ export interface OpenApiSpecConfig { · 2 unmodified lines
95export interface OpenApiUpdateSourceInput {97export interface OpenApiUpdateSourceInput {
96 readonly name?: string;98 readonly name?: string;
97 readonly baseUrl?: string;99 readonly baseUrl?: string;
98 readonly headers?: Record<string, OpenApiHeaderInput>;100 readonly headers?: Record<string, OpenApiCredentialInput>;
99 readonly queryParams?: Record<string, OpenApiCredentialInput>;101 readonly queryParams?: Record<string, OpenApiCredentialInput>;
100 readonly credentialTargetScope?: string;102 readonly credentialTargetScope?: string;
101 /** Refresh the source's stored OAuth2 metadata after a successful103 /** Refresh the source's stored OAuth2 metadata after a successful
@@ -173,7 +175,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 71 unmodified lines
173type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;175type PreviewSpecInput = typeof PreviewSpecInputSchema.Type;
174 176
175const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);177const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
176const OpenApiCredentialInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]);
177const OpenApiOAuthInputSchema = OAuth2SourceConfig;178const OpenApiOAuthInputSchema = OAuth2SourceConfig;
178 179
179const AddSourceInputSchema = Schema.Struct({180const AddSourceInputSchema = Schema.Struct({
@@ -262,16 +263,21 @@ const specFetchQueryParamSlotFromName = (name: string): string => · 82 unmodified lines
262 `spec_fetch_query_param:${slotPart(name)}`;263 `spec_fetch_query_param:${slotPart(name)}`;
263 264
264const canonicalizeHeaders = (265const canonicalizeHeaders = (
265 headers: Record<string, OpenApiHeaderInput> | undefined,266 headers: Record<string, OpenApiCredentialInput> | undefined,
266): {267): {
267 readonly headers: Record<string, ConfiguredHeaderValue>;268 readonly headers: Record<string, ConfiguredHeaderValue>;
268 readonly bindings: ReadonlyArray<{269 readonly bindings: ReadonlyArray<{
269 readonly slot: string;270 readonly slot: string;
270 readonly value: OpenApiSourceBindingValue;271 readonly value: OpenApiSourceBindingValue;
272 readonly targetScope?: string;
271 }>;273 }>;
272} => {274} => {
273 const nextHeaders: Record<string, ConfiguredHeaderValue> = {};275 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 }> = [];
275 for (const [name, value] of Object.entries(headers ?? {})) {281 for (const [name, value] of Object.entries(headers ?? {})) {
276 if (typeof value === "string") {282 if (typeof value === "string") {
277 nextHeaders[name] = value;283 nextHeaders[name] = value;
@@ -289,9 +295,13 @@ const canonicalizeHeaders = ( · 11 unmodified lines
289 });295 });
290 bindings.push({296 bindings.push({
291 slot,297 slot,
298 targetScope: "targetScope" in value ? value.targetScope : undefined,
292 value: {299 value: {
293 kind: "secret",300 kind: "secret",
294 secretId: SecretId.make(value.secretId),301 secretId: SecretId.make(value.secretId),
302 ...("secretScopeId" in value && value.secretScopeId
303 ? { secretScopeId: value.secretScopeId }
304 : {}),
295 },305 },
296 });306 });
297 }307 }
@@ -306,10 +316,15 @@ const canonicalizeCredentialMap = ( · 8 unmodified lines
306 readonly bindings: ReadonlyArray<{316 readonly bindings: ReadonlyArray<{
307 readonly slot: string;317 readonly slot: string;
308 readonly value: OpenApiSourceBindingValue;318 readonly value: OpenApiSourceBindingValue;
319 readonly targetScope?: string;
309 }>;320 }>;
310} => {321} => {
311 const nextValues: Record<string, ConfiguredHeaderValue> = {};322 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 }> = [];
313 for (const [name, value] of Object.entries(values ?? {})) {328 for (const [name, value] of Object.entries(values ?? {})) {
314 if (typeof value === "string") {329 if (typeof value === "string") {
315 nextValues[name] = value;330 nextValues[name] = value;
@@ -327,9 +342,13 @@ const canonicalizeCredentialMap = ( · 11 unmodified lines
327 });342 });
328 bindings.push({343 bindings.push({
329 slot,344 slot,
345 targetScope: "targetScope" in value ? value.targetScope : undefined,
330 value: {346 value: {
331 kind: "secret",347 kind: "secret",
332 secretId: SecretId.make(value.secretId),348 secretId: SecretId.make(value.secretId),
349 ...("secretScopeId" in value && value.secretScopeId
350 ? { secretScopeId: value.secretScopeId }
351 : {}),
333 },352 },
334 });353 });
335 }354 }
@@ -348,6 +367,7 @@ const canonicalizeSpecFetchCredentials = ( · 12 unmodified lines
348 readonly bindings: ReadonlyArray<{367 readonly bindings: ReadonlyArray<{
349 readonly slot: string;368 readonly slot: string;
350 readonly value: OpenApiSourceBindingValue;369 readonly value: OpenApiSourceBindingValue;
370 readonly targetScope?: string;
351 }>;371 }>;
352} => {372} => {
353 const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName);373 const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName);
@@ -496,11 +516,11 @@ const validateOpenApiBindingTarget = ( · 142 unmodified lines
496 }516 }
497 });517 });
498 518
499const bindingTargetScope = (519const targetScopeForBinding = (
500 targetScope: string | undefined,520 fallbackTargetScope: string | undefined,
501 bindings: readonly unknown[],521 binding: { readonly slot: string; readonly targetScope?: string },
502): Effect.Effect<string | undefined, OpenApiOAuthError> => {522): Effect.Effect<string, OpenApiOAuthError> => {
503 if (bindings.length === 0) return Effect.succeed(undefined);523 const targetScope = binding.targetScope ?? fallbackTargetScope;
504 if (targetScope) return Effect.succeed(targetScope);524 if (targetScope) return Effect.succeed(targetScope);
505 return Effect.fail(525 return Effect.fail(
506 new OpenApiOAuthError({526 new OpenApiOAuthError({
@@ -579,15 +599,17 @@ const resolveConfiguredValueMap = ( · 72 unmodified lines
579 value.slot,599 value.slot,
580 );600 );
581 if (binding?.value.kind === "secret") {601 if (binding?.value.kind === "secret") {
582 const secret = yield* ctx.secrets.getAtScope(binding.value.secretId, binding.scopeId).pipe(602 const secret = yield* ctx.secrets
583 Effect.catchTag("SecretOwnedByConnectionError", () =>603 .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId)
584 Effect.fail(604 .pipe(
585 new OpenApiOAuthError({605 Effect.catchTag("SecretOwnedByConnectionError", () =>
586 message: `Secret not found for header "${name}"`,606 Effect.fail(
587 }),607 new OpenApiOAuthError({
608 message: `Secret not found for header "${name}"`,
609 }),
610 ),
588 ),611 ),
589 ),612 );
590 );
591 if (secret === null) {613 if (secret === null) {
592 return yield* new OpenApiOAuthError({614 return yield* new OpenApiOAuthError({
593 message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`,615 message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`,
@@ -808,15 +830,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 214 unmodified lines
808 ...canonicalSpecFetchCredentials.bindings,830 ...canonicalSpecFetchCredentials.bindings,
809 ...canonicalOAuth2.bindings,831 ...canonicalOAuth2.bindings,
810 ];832 ];
811 const targetScope = yield* bindingTargetScope(input.credentialTargetScope, directBindings);833 for (const binding of directBindings) {
812 if (targetScope) {834 const bindingTargetScope = yield* targetScopeForBinding(
835 input.credentialTargetScope,
836 binding,
837 );
813 yield* validateOpenApiBindingTarget(ctx, {838 yield* validateOpenApiBindingTarget(ctx, {
814 sourceId: namespace,839 sourceId: namespace,
815 sourceScope: input.scope,840 sourceScope: input.scope,
816 targetScope,841 targetScope: bindingTargetScope,
817 });842 });
818 }843 }
819
820 const definitions = compileToolDefinitions(result.operations);844 const definitions = compileToolDefinitions(result.operations);
821 const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace);845 const sourceName = input.name ?? Option.getOrElse(result.title, () => namespace);
822 846
@@ -867,10 +891,14 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 44 unmodified lines
867 })),891 })),
868 });892 });
869 893
870 if (targetScope) {894 if (directBindings.length > 0) {
871 for (const binding of directBindings) {895 for (const binding of directBindings) {
896 const bindingTargetScope = yield* targetScopeForBinding(
897 input.credentialTargetScope,
898 binding,
899 );
872 yield* ctx.credentialBindings.set({900 yield* ctx.credentialBindings.set({
873 targetScope: ScopeId.make(targetScope),901 targetScope: ScopeId.make(bindingTargetScope),
874 pluginId: OPENAPI_PLUGIN_ID,902 pluginId: OPENAPI_PLUGIN_ID,
875 sourceId: namespace,903 sourceId: namespace,
876 sourceScope: ScopeId.make(input.scope),904 sourceScope: ScopeId.make(input.scope),
@@ -1000,7 +1028,10 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 123 unmodified lines
1000 sourceScope: ScopeId.make(scope),1028 sourceScope: ScopeId.make(scope),
1001 });1029 });
1002 yield* ctx.storage.removeSource(namespace, scope);1030 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 });
1004 }),1035 }),
1005 );1036 );
1006 if (configFile) {1037 if (configFile) {
packages/plugins/openapi/src/sdk/store.ts+3−0
@@ -304,6 +304,9 @@ const slugifySlotPart = (value: string): string => · 303 unmodified lines
304export const headerBindingSlot = (headerName: string): string =>304export const headerBindingSlot = (headerName: string): string =>
305 `header:${slugifySlotPart(headerName)}`;305 `header:${slugifySlotPart(headerName)}`;
306 306
307export const queryParamBindingSlot = (name: string): string =>
308 `query_param:${slugifySlotPart(name)}`;
309
307export const oauth2ClientIdSlot = (securitySchemeName: string): string =>310export const oauth2ClientIdSlot = (securitySchemeName: string): string =>
308 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;311 `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`;
309 312
packages/plugins/openapi/src/sdk/types.ts+15−1
@@ -1,5 +1,11 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor-js/sdk/core";2import {
3 ConnectionId,
4 ScopeId,
5 ScopedSecretCredentialInput,
6 SecretBackedValue,
7 SecretId,
8} from "@executor-js/sdk/core";
3 9
4// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
5// Branded IDs11// Branded IDs
@@ -146,10 +152,18 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 140 unmodified lines
146export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]);152export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]);
147export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;153export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type;
148 154
155export const OpenApiCredentialInput = Schema.Union([
156 ScopedSecretCredentialInput,
157 HeaderValue,
158 ConfiguredHeaderValue,
159]);
160export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type;
161
149export const OpenApiSourceBindingValue = Schema.Union([162export const OpenApiSourceBindingValue = Schema.Union([
150 Schema.Struct({163 Schema.Struct({
151 kind: Schema.Literal("secret"),164 kind: Schema.Literal("secret"),
152 secretId: SecretId,165 secretId: SecretId,
166 secretScopeId: Schema.optional(ScopeId),
153 }),167 }),
154 Schema.Struct({168 Schema.Struct({
155 kind: Schema.Literal("connection"),169 kind: Schema.Literal("connection"),
packages/react/src/components/help-tooltip.tsx+32−0
@@ -0,0 +1,32 @@
1import { InfoIcon } from "lucide-react";
2
3import { cn } from "../lib/utils";
4import { Button } from "./button";
5import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
6
7export 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
11import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk";11import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk";
12import { SecretForm } from "../plugins/secret-form";12import { SecretForm } from "../plugins/secret-form";
13import { useScope } from "../hooks/use-scope";13import { useScope } from "../hooks/use-scope";
14import { useScopeStack } from "../api/scope-context";
14import {15import {
15 Dialog,16 Dialog,
16 DialogContent,17 DialogContent,
@@ -169,6 +170,7 @@ function SecretRow(props: { · 152 unmodified lines
169 usageScopeId: ScopeId;170 usageScopeId: ScopeId;
170 showProvider: boolean;171 showProvider: boolean;
171 secret: { id: string; scopeId: ScopeId; name: string; provider?: string };172 secret: { id: string; scopeId: ScopeId; name: string; provider?: string };
173 scopeLabel: string;
172 onRemove: () => void;174 onRemove: () => void;
173}) {175}) {
174 const { secret, showProvider } = props;176 const { secret, showProvider } = props;
@@ -192,6 +194,7 @@ function SecretRow(props: { · 17 unmodified lines
192 </Suspense>194 </Suspense>
193 </CardStackEntryContent>195 </CardStackEntryContent>
194 <CardStackEntryActions>196 <CardStackEntryActions>
197 <Badge variant="secondary">{props.scopeLabel}</Badge>
195 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}198 {showProvider && secret.provider && <Badge variant="outline">{secret.provider}</Badge>}
196 <DropdownMenu>199 <DropdownMenu>
197 <DropdownMenuTrigger asChild>200 <DropdownMenuTrigger asChild>
@@ -238,7 +241,14 @@ export function SecretsPage(props: { · 40 unmodified lines
238 const secretProviderPlugins = useSecretProviderPlugins();241 const secretProviderPlugins = useSecretProviderPlugins();
239 const [addOpen, setAddOpen] = useState(false);242 const [addOpen, setAddOpen] = useState(false);
240 const scopeId = useScope();243 const scopeId = useScope();
244 const scopeStack = useScopeStack();
241 const secrets = useAtomValue(secretsOptimisticAtom(scopeId));245 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 };
242 const existingSecretIds = useMemo(252 const existingSecretIds = useMemo(
243 () =>253 () =>
244 AsyncResult.match(secrets, {254 AsyncResult.match(secrets, {
@@ -367,6 +377,7 @@ export function SecretsPage(props: { · 122 unmodified lines
367 name: s.name,377 name: s.name,
368 provider: s.provider ? String(s.provider) : undefined,378 provider: s.provider ? String(s.provider) : undefined,
369 }}379 }}
380 scopeLabel={scopeLabel(s.scopeId)}
370 onRemove={() => handleRemove(s)}381 onRemove={() => handleRemove(s)}
371 />382 />
372 ),383 ),
packages/react/src/plugins/credential-target-scope.tsx+91−3
@@ -1,7 +1,7 @@
1import { useEffect, useMemo, useState } from "react";1import { useEffect, useMemo, useState } from "react";
2import type { ReactNode } from "react";2import type { ReactNode } from "react";
3 3
4import type { ScopeId } from "@executor-js/sdk";4import { ScopeId } from "@executor-js/sdk";
5 5
6import { useScope, useUserScope } from "../api/scope-context";6import { useScope, useUserScope } from "../api/scope-context";
7import {7import {
@@ -13,6 +13,15 @@ import { · 5 unmodified lines
13 CardStackEntryTitle,13 CardStackEntryTitle,
14} from "../components/card-stack";14} from "../components/card-stack";
15import { FilterTabs } from "../components/filter-tabs";15import { FilterTabs } from "../components/filter-tabs";
16import { FieldLabel } from "../components/field";
17import { HelpTooltip } from "../components/help-tooltip";
18import {
19 Select,
20 SelectContent,
21 SelectItem,
22 SelectTrigger,
23 SelectValue,
24} from "../components/select";
16 25
17export interface CredentialTargetScopeOption {26export interface CredentialTargetScopeOption {
18 readonly scopeId: ScopeId;27 readonly scopeId: ScopeId;
@@ -147,10 +156,89 @@ export function CredentialScopeSection(props: { · 128 unmodified lines
147 value={props.value}156 value={props.value}
148 options={props.options}157 options={props.options}
149 onChange={props.onChange}158 onChange={props.onChange}
150 title={props.title ?? "Save credentials to"}159 title={props.title ?? "Used by"}
151 description={props.description ?? "Choose who can use the credentials attached below."}160 description={props.description ?? "Choose who can use these credentials."}
152 />161 />
153 {props.children}162 {props.children}
154 </div>163 </div>
155 );164 );
156}165}
166
167export 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
204export 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
220export 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
14 type HeaderAuthPreset,14 type HeaderAuthPreset,
15 type HeaderState,15 type HeaderState,
16 SecretHeaderAuthRow,16 SecretHeaderAuthRow,
17 type SecretCredentialPreviewComponent,
18 type SecretCredentialRowCopy,
17} from "./secret-header-auth";19} from "./secret-header-auth";
20import type { CredentialTargetScopeOption } from "./credential-target-scope";
18import type { SecretPickerSecret } from "./secret-picker";21import type { SecretPickerSecret } from "./secret-picker";
19 22
20export interface HeadersListProps {23export interface HeadersListProps {
@@ -27,6 +30,10 @@ export interface HeadersListProps { · 6 unmodified lines
27 readonly singleHeader?: boolean;30 readonly singleHeader?: boolean;
28 /** Text shown in the empty state. */31 /** Text shown in the empty state. */
29 readonly emptyLabel?: ReactNode;32 readonly emptyLabel?: ReactNode;
33 readonly addLabel?: ReactNode;
34 readonly addAriaLabel?: string;
35 readonly rowCopy?: Partial<SecretCredentialRowCopy>;
36 readonly rowPreviewComponent?: SecretCredentialPreviewComponent;
30 /**37 /**
31 * Display name of the source that owns these headers (e.g. "Axiom"). Used38 * Display name of the source that owns these headers (e.g. "Axiom"). Used
32 * to derive unique default secret labels/IDs like `axiom-authorization`.39 * to derive unique default secret labels/IDs like `axiom-authorization`.
@@ -34,6 +41,11 @@ export interface HeadersListProps { · 1 unmodified line
34 readonly sourceName?: string;41 readonly sourceName?: string;
35 /** Inline-created secrets are written to this explicit scope. */42 /** Inline-created secrets are written to this explicit scope. */
36 readonly targetScope: ScopeId;43 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;
37}49}
38 50
39export function HeadersList({51export function HeadersList({
@@ -43,11 +55,26 @@ export function HeadersList({ · 3 unmodified lines
43 presets = defaultHeaderAuthPresets,55 presets = defaultHeaderAuthPresets,
44 singleHeader = false,56 singleHeader = false,
45 emptyLabel = "No headers",57 emptyLabel = "No headers",
58 addLabel,
59 addAriaLabel = "Add header",
60 rowCopy,
61 rowPreviewComponent,
46 sourceName,62 sourceName,
47 targetScope,63 targetScope,
64 credentialScopeOptions,
65 bindingScopeOptions,
66 restrictSecretsToTargetScope,
48}: HeadersListProps) {67}: HeadersListProps) {
49 const [picking, setPicking] = useState(false);68 const [picking, setPicking] = useState(false);
50 const canAddMore = !singleHeader || headers.length === 0;69 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 };
51 78
52 const addHeaderFromPreset = (preset: HeaderAuthPreset) => {79 const addHeaderFromPreset = (preset: HeaderAuthPreset) => {
53 onHeadersChange([80 onHeadersChange([
@@ -57,6 +84,7 @@ export function HeadersList({ · 3 unmodified lines
57 prefix: preset.prefix,84 prefix: preset.prefix,
58 presetKey: preset.key,85 presetKey: preset.key,
59 secretId: null,86 secretId: null,
87 targetScope,
60 },88 },
61 ]);89 ]);
62 setPicking(false);90 setPicking(false);
@@ -69,6 +97,8 @@ export function HeadersList({ · 6 unmodified lines
69 secretId: string | null;97 secretId: string | null;
70 prefix?: string;98 prefix?: string;
71 presetKey?: string;99 presetKey?: string;
100 targetScope?: ScopeId;
101 secretScope?: ScopeId;
72 }>,102 }>,
73 ) => {103 ) => {
74 onHeadersChange(headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)));104 onHeadersChange(headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)));
@@ -89,7 +119,11 @@ export function HeadersList({ · 14 unmodified lines
89 />119 />
90 ) : headers.length === 0 ? (120 ) : headers.length === 0 ? (
91 canAddMore ? (121 canAddMore ? (
92 <AddHeaderRow leading={<span>{emptyLabel}</span>} onClick={() => setPicking(true)} />122 <AddHeaderRow
123 leading={<span>{emptyLabel}</span>}
124 onClick={addFirstPreset}
125 ariaLabel={addAriaLabel}
126 />
93 ) : (127 ) : (
94 <CardStackEmpty>128 <CardStackEmpty>
95 <span>{emptyLabel}</span>129 <span>{emptyLabel}</span>
@@ -104,15 +138,28 @@ export function HeadersList({ · 8 unmodified lines
104 prefix={header.prefix}138 prefix={header.prefix}
105 presetKey={header.presetKey}139 presetKey={header.presetKey}
106 secretId={header.secretId}140 secretId={header.secretId}
141 secretScope={header.secretScope}
107 onChange={(update) => updateHeader(index, update)}142 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 }
109 onRemove={singleHeader ? undefined : () => removeHeader(index)}149 onRemove={singleHeader ? undefined : () => removeHeader(index)}
110 existingSecrets={existingSecrets}150 existingSecrets={existingSecrets}
111 sourceName={sourceName}151 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}
113 />158 />
114 ))}159 ))}
115 {canAddMore && <AddHeaderRow onClick={() => setPicking(true)} />}160 {canAddMore && (
161 <AddHeaderRow leading={addLabel} onClick={addFirstPreset} ariaLabel={addAriaLabel} />
162 )}
116 </>163 </>
117 )}164 )}
118 </CardStackContent>165 </CardStackContent>
@@ -123,9 +170,10 @@ export function HeadersList({ · 4 unmodified lines
123interface AddHeaderRowProps {170interface AddHeaderRowProps {
124 readonly onClick: () => void;171 readonly onClick: () => void;
125 readonly leading?: ReactNode;172 readonly leading?: ReactNode;
173 readonly ariaLabel: string;
126}174}
127 175
128function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) {176function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) {
129 return (177 return (
130 // oxlint-disable-next-line react/forbid-elements178 // oxlint-disable-next-line react/forbid-elements
131 <button179 <button
@@ -134,7 +182,7 @@ function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { · 2 unmodified lines
134 event.stopPropagation();182 event.stopPropagation();
135 onClick();183 onClick();
136 }}184 }}
137 aria-label="Add header"185 aria-label={ariaLabel}
138 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"186 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"
139 >187 >
140 <span className="min-w-0 flex-1 text-left">{leading}</span>188 <span className="min-w-0 flex-1 text-left">{leading}</span>
packages/react/src/plugins/http-credentials.tsx+84−166
@@ -1,18 +1,15 @@
1import { useId } from "react";1import type { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk";
2import { PlusIcon } from "lucide-react";
3import type { ScopeId, SecretBackedValue } from "@executor-js/sdk";
4 2
5import { Button } from "../components/button";3import { FieldLabel } from "../components/field";
6import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack";
7import { Field, FieldGroup, FieldLabel } from "../components/field";
8import { Input } from "../components/input";
9import { HeadersList } from "./headers-list";4import { HeadersList } from "./headers-list";
10import {5import {
11 CreatableSecretPicker,
12 headerValueToState,6 headerValueToState,
13 headersFromState,7 headersFromState,
8 QueryParamCredentialValuePreview,
9 type HeaderAuthPreset,
14 type HeaderState,10 type HeaderState,
15} from "./secret-header-auth";11} from "./secret-header-auth";
12import type { CredentialTargetScopeOption } from "./credential-target-scope";
16import type { SecretPickerSecret } from "./secret-picker";13import type { SecretPickerSecret } from "./secret-picker";
17 14
18export type { SecretBackedValue };15export type { SecretBackedValue };
@@ -22,8 +19,14 @@ export type QueryParamState = { · 3 unmodified lines
22 secretId: string | null;19 secretId: string | null;
23 prefix?: string;20 prefix?: string;
24 literalValue?: string;21 literalValue?: string;
22 targetScope?: ScopeId;
23 secretScope?: ScopeId;
25};24};
26 25
26const queryParamPresets: readonly HeaderAuthPreset[] = [
27 { key: "custom", label: "Query parameter", name: "" },
28];
29
27export type HttpCredentialsState = {30export type HttpCredentialsState = {
28 headers: HeaderState[];31 headers: HeaderState[];
29 queryParams: QueryParamState[];32 queryParams: QueryParamState[];
@@ -84,6 +87,58 @@ export const serializeHttpCredentials = ( · 54 unmodified lines
84 queryParams: serializeQueryCredentials(credentials.queryParams),87 queryParams: serializeQueryCredentials(credentials.queryParams),
85});88});
86 89
90export 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
109export 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
134export const serializeScopedHttpCredentials = (
135 credentials: HttpCredentialsState,
136 fallbackTargetScope: ScopeId,
137) => ({
138 headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope),
139 queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope),
140});
141
87export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean =>142export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean =>
88 credentials.headers.every((header) => header.name.trim() && header.secretId) &&143 credentials.headers.every((header) => header.name.trim() && header.secretId) &&
89 credentials.queryParams.every((param) => {144 credentials.queryParams.every((param) => {
@@ -97,6 +152,9 @@ export function HttpCredentialsEditor(props: { · 7 unmodified lines
97 readonly existingSecrets: readonly SecretPickerSecret[];152 readonly existingSecrets: readonly SecretPickerSecret[];
98 readonly sourceName?: string;153 readonly sourceName?: string;
99 readonly targetScope: ScopeId;154 readonly targetScope: ScopeId;
155 readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
156 readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[];
157 readonly restrictSecretsToTargetScope?: boolean;
100 readonly sections?: {158 readonly sections?: {
101 readonly headers?: boolean;159 readonly headers?: boolean;
102 readonly queryParams?: boolean;160 readonly queryParams?: boolean;
@@ -120,6 +178,9 @@ export function HttpCredentialsEditor(props: { · 17 unmodified lines
120 existingSecrets={props.existingSecrets}178 existingSecrets={props.existingSecrets}
121 sourceName={props.sourceName}179 sourceName={props.sourceName}
122 targetScope={props.targetScope}180 targetScope={props.targetScope}
181 credentialScopeOptions={props.credentialScopeOptions}
182 bindingScopeOptions={props.bindingScopeOptions}
183 restrictSecretsToTargetScope={props.restrictSecretsToTargetScope}
123 />184 />
124 </section>185 </section>
125 )}186 )}
@@ -127,170 +188,27 @@ export function HttpCredentialsEditor(props: { · 1 unmodified line
127 {showQueryParams && (188 {showQueryParams && (
128 <section className="space-y-2.5">189 <section className="space-y-2.5">
129 <FieldLabel>{props.labels?.queryParams ?? "Query parameters"}</FieldLabel>190 <FieldLabel>{props.labels?.queryParams ?? "Query parameters"}</FieldLabel>
130 <QueryParamsList191 <HeadersList
131 queryParams={props.credentials.queryParams}192 headers={props.credentials.queryParams}
132 onQueryParamsChange={(queryParams) =>193 onHeadersChange={(queryParams) => props.onChange({ ...props.credentials, queryParams })}
133 props.onChange({ ...props.credentials, queryParams })
134 }
135 existingSecrets={props.existingSecrets}194 existingSecrets={props.existingSecrets}
136 sourceName={props.sourceName}195 sourceName={props.sourceName}
137 targetScope={props.targetScope}196 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}
138 />209 />
139 </section>210 </section>
140 )}211 )}
141 </div>212 </div>
142 );213 );
143}214}
144
145function 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
190function 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
272function 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
109 const doSet = useAtomSet(setSecret, { mode: "promiseExit" });109 const doSet = useAtomSet(setSecret, { mode: "promiseExit" });
110 110
111 const [state, setState] = useState<SecretFormState>(() => ({111 const [state, setState] = useState<SecretFormState>(() => ({
112 name: "",112 name: suggestedName,
113 value: "",113 value: "",
114 idOverride: null,114 idOverride: null,
115 provider: initialProvider,115 provider: initialProvider,
@@ -215,7 +215,7 @@ function IdField(props: { placeholder?: string }) { · 99 unmodified lines
215 );215 );
216}216}
217 217
218function ValueField(props: { revealable?: boolean; placeholder?: string }) {218function ValueField(props: { revealable?: boolean; placeholder?: string; autoFocus?: boolean }) {
219 const { state, actions } = useSecretForm();219 const { state, actions } = useSecretForm();
220 const inputId = useId();220 const inputId = useId();
221 const revealable = props.revealable ?? false;221 const revealable = props.revealable ?? false;
@@ -231,6 +231,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string }) { · 9 unmodified lines
231 value={state.value}231 value={state.value}
232 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}232 onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)}
233 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}233 placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"}
234 autoFocus={props.autoFocus}
234 className={revealable ? "pr-9 font-mono" : "font-mono"}235 className={revealable ? "pr-9 font-mono" : "font-mono"}
235 style={236 style={
236 revealable && !state.revealed237 revealable && !state.revealed
packages/react/src/plugins/secret-header-auth.tsx+280−55
@@ -1,11 +1,30 @@
1import { useId, useState } from "react";1import { useId, useState, type ReactNode } from "react";
2 2
3import { type ScopeId } from "@executor-js/sdk";3import { ScopeId } from "@executor-js/sdk";
4import { Button } from "../components/button";4import { Button } from "../components/button";
5import { Field, FieldGroup, FieldLabel } from "../components/field";5import { Field, FieldGroup, FieldLabel } from "../components/field";
6import { HelpTooltip } from "../components/help-tooltip";
6import { Input } from "../components/input";7import { Input } from "../components/input";
8import {
9 Dialog,
10 DialogContent,
11 DialogDescription,
12 DialogHeader,
13 DialogTitle,
14} from "../components/dialog";
15import {
16 Select,
17 SelectContent,
18 SelectItem,
19 SelectTrigger,
20 SelectValue,
21} from "../components/select";
7import { SecretForm } from "./secret-form";22import { SecretForm } from "./secret-form";
8import { SecretPicker, type SecretPickerSecret } from "./secret-picker";23import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
24import {
25 CredentialTargetScopeSelector,
26 type CredentialTargetScopeOption,
27} from "./credential-target-scope";
9 28
10export const secretsForCredentialTarget = (29export const secretsForCredentialTarget = (
11 secrets: readonly SecretPickerSecret[],30 secrets: readonly SecretPickerSecret[],
@@ -21,8 +40,18 @@ export interface HeaderAuthPreset { · 9 unmodified lines
21}40}
22 41
23export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [42export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [
24 { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },43 {
25 { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },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 },
26 { key: "api-key", label: "API Key", name: "X-API-Key" },55 { key: "api-key", label: "API Key", name: "X-API-Key" },
27 { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },56 { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },
28 { key: "access-token", label: "Access Token", name: "X-Access-Token" },57 { key: "access-token", label: "Access Token", name: "X-Access-Token" },
@@ -30,51 +59,141 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ · 1 unmodified line
30 { key: "custom", label: "Custom", name: "" },59 { key: "custom", label: "Custom", name: "" },
31];60];
32 61
33export function InlineCreateSecret(props: {62function CreateSecretContent(props: {
34 suggestedName: string;63 suggestedName: string;
35 existingSecretIds: readonly string[];64 existingSecretIds: readonly string[];
36 onCreated: (secretId: string) => void;65 onCreated: (secretId: string, scopeId: ScopeId) => void;
37 onCancel: () => void;66 onCancel?: () => void;
38 fallbackId?: string;67 fallbackId?: string;
39 targetScope: ScopeId;68 targetScope: ScopeId;
69 credentialScopeOptions?: readonly CredentialTargetScopeOption[];
40}) {70}) {
71 const [scopeId, setScopeId] = useState(props.targetScope);
72 const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId);
73
41 return (74 return (
42 <SecretForm.Provider75 <SecretForm.Provider
43 existingSecretIds={props.existingSecretIds}76 existingSecretIds={props.existingSecretIds}
44 suggestedName={props.suggestedName}77 suggestedName={props.suggestedName}
45 fallbackId={props.fallbackId ?? "custom-header"}78 fallbackId={props.fallbackId ?? "custom-header"}
46 scopeId={props.targetScope}79 scopeId={scopeId}
47 onCreated={props.onCreated}80 onCreated={(secretId) => props.onCreated(secretId, scopeId)}
48 >81 >
49 <div className="bg-primary/[0.03] px-4 py-3 space-y-3">82 <div className="space-y-3">
50 <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p>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 )}
51 <FieldGroup className="gap-3">92 <FieldGroup className="gap-3">
52 <div className="grid grid-cols-2 gap-3">93 <div className="grid grid-cols-2 gap-3">
53 <SecretForm.NameField label="Label" placeholder="API Token" />94 <SecretForm.NameField label="Label" placeholder="API Token" />
54 <SecretForm.IdField placeholder="my-api-token" />95 <SecretForm.IdField placeholder="my-api-token" />
55 </div>96 </div>
56 <SecretForm.ValueField revealable placeholder="paste your token or key…" />97 <SecretForm.ValueField revealable autoFocus placeholder="paste your token or key…" />
57 </FieldGroup>98 </FieldGroup>
58 <div className="flex justify-end gap-1.5 pt-0.5">99 <div className="flex justify-end gap-2 pt-0.5">
59 <Button variant="outline" size="xs" onClick={props.onCancel}>100 {props.onCancel && (
60 Cancel101 <Button type="button" variant="outline" size="sm" onClick={props.onCancel}>
61 </Button>102 Cancel
62 <SecretForm.SubmitButton size="xs">Create and use</SecretForm.SubmitButton>103 </Button>
104 )}
105 <SecretForm.SubmitButton size="sm">Create and use</SecretForm.SubmitButton>
63 </div>106 </div>
64 </div>107 </div>
65 </SecretForm.Provider>108 </SecretForm.Provider>
66 );109 );
67}110}
68 111
69function HeaderValuePreview(props: { headerName: string; secretId: string; prefix?: string }) {112export function InlineCreateSecret(props: {
70 const { headerName, prefix } = 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
131function 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
164export type SecretCredentialPreviewProps = {
165 readonly name: string;
166 readonly secretId: string;
167 readonly prefix?: string;
168};
169
170export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode;
171
172export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) {
173 const { name, prefix } = props;
174 const maskedValue = "•".repeat(12);
71 175
72 return (176 return (
73 <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">177 <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>
75 <span className="text-foreground truncate">179 <span className="text-foreground truncate">
76 {prefix && <span className="text-muted-foreground">{prefix}</span>}180 {prefix && <span className="text-muted-foreground">{prefix}</span>}
77 {"•".repeat(12)}181 {maskedValue}
182 </span>
183 </div>
184 );
185}
186
187export 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}
78 </span>197 </span>
79 </div>198 </div>
80 );199 );
@@ -90,6 +209,10 @@ export type HeaderState = { · 9 unmodified lines
90 prefix?: string;209 prefix?: string;
91 presetKey?: string;210 presetKey?: string;
92 fromPreset?: boolean;211 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;
93};216};
94 217
95export function matchPresetKey(name: string, prefix?: string): string {218export function matchPresetKey(name: string, prefix?: string): string {
@@ -99,6 +222,39 @@ export function matchPresetKey(name: string, prefix?: string): string { · 3 unmodified lines
99 return preset?.key ?? "custom";222 return preset?.key ?? "custom";
100}223}
101 224
225function 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
234export 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
246const 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
102export function headerValueToState(258export function headerValueToState(
103 name: string,259 name: string,
104 value: { secretId: string; prefix?: string } | string,260 value: { secretId: string; prefix?: string } | string,
@@ -138,12 +294,21 @@ export function SecretHeaderAuthRow(props: { · 33 unmodified lines
138 prefix?: string;294 prefix?: string;
139 presetKey?: string;295 presetKey?: string;
140 secretId: string | null;296 secretId: string | null;
141 onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;297 secretScope?: ScopeId;
142 onSelectSecret: (secretId: string) => void;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;
143 existingSecrets: readonly SecretPickerSecret[];307 existingSecrets: readonly SecretPickerSecret[];
144 onRemove?: () => void;308 onRemove?: () => void;
145 removeLabel?: string;309 removeLabel?: string;
146 label?: string;310 copy?: Partial<SecretCredentialRowCopy>;
311 previewComponent?: SecretCredentialPreviewComponent;
147 /**312 /**
148 * Display name of the source this header belongs to (e.g. "Axiom"). Used313 * Display name of the source this header belongs to (e.g. "Axiom"). Used
149 * to prefix the suggested secret label and ID so tokens from different314 * to prefix the suggested secret label and ID so tokens from different
@@ -151,6 +316,9 @@ export function SecretHeaderAuthRow(props: { · 1 unmodified line
151 */316 */
152 sourceName?: string;317 sourceName?: string;
153 targetScope: ScopeId;318 targetScope: ScopeId;
319 credentialScopeOptions?: readonly CredentialTargetScopeOption[];
320 bindingScopeOptions?: readonly CredentialTargetScopeOption[];
321 restrictSecretsToTargetScope?: boolean;
154}) {322}) {
155 const [creating, setCreating] = useState(false);323 const [creating, setCreating] = useState(false);
156 const nameInputId = useId();324 const nameInputId = useId();
@@ -160,42 +328,49 @@ export function SecretHeaderAuthRow(props: { · 3 unmodified lines
160 prefix,328 prefix,
161 presetKey,329 presetKey,
162 secretId,330 secretId,
331 secretScope,
163 onChange,332 onChange,
164 onSelectSecret,333 onSelectSecret,
165 existingSecrets,334 existingSecrets,
166 onRemove,335 onRemove,
167 removeLabel = "Remove",336 removeLabel = "Remove",
168 label = "Header",337 copy: copyOverride,
338 previewComponent: PreviewComponent = HeaderCredentialValuePreview,
169 sourceName,339 sourceName,
170 targetScope,340 targetScope,
341 credentialScopeOptions,
342 bindingScopeOptions,
343 restrictSecretsToTargetScope = false,
171 } = props;344 } = props;
172 345
173 const isCustom = presetKey === "custom" || presetKey === undefined;346 const isCustom = presetKey === "custom" || presetKey === undefined;
347 const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride };
174 const headerLabel = name.trim() || "Custom Header";348 const headerLabel = name.trim() || "Custom Header";
175 const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" ");349 const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" ");
176 const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope);350 const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope);
351 const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets;
177 352
178 if (creating) {353 return (
179 return (354 <div className="space-y-2.5 px-4 py-3">
180 <InlineCreateSecret355 <CreateSecretDialog
356 open={creating}
357 onOpenChange={setCreating}
181 suggestedName={suggestedName}358 suggestedName={suggestedName}
182 existingSecretIds={scopedSecrets.map((secret) => secret.id)}359 existingSecretIds={scopedSecrets.map((secret) => secret.id)}
183 onCreated={(id) => {360 onCreated={(id, scopeId) => {
184 onSelectSecret(id);361 onSelectSecret(id, scopeId);
185 setCreating(false);362 setCreating(false);
186 }}363 }}
187 onCancel={() => setCreating(false)}
188 targetScope={targetScope}364 targetScope={targetScope}
365 credentialScopeOptions={credentialScopeOptions}
189 />366 />
190 );
191 }
192
193 return (
194 <div className="space-y-2.5 px-4 py-3">
195 <div className="flex w-full items-center justify-between gap-4">367 <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>
197 {onRemove && (371 {onRemove && (
198 <Button372 <Button
373 type="button"
199 variant="ghost"374 variant="ghost"
200 size="xs"375 size="xs"
201 className="text-muted-foreground hover:text-destructive"376 className="text-muted-foreground hover:text-destructive"
@@ -208,7 +383,7 @@ export function SecretHeaderAuthRow(props: { · 6 unmodified lines
208 383
209 <FieldGroup className="grid grid-cols-2 gap-3">384 <FieldGroup className="grid grid-cols-2 gap-3">
210 <Field>385 <Field>
211 <FieldLabel htmlFor={nameInputId}>Name</FieldLabel>386 <FieldLabel htmlFor={nameInputId}>{copy.nameLabel}</FieldLabel>
212 <Input387 <Input
213 id={nameInputId}388 id={nameInputId}
214 value={name}389 value={name}
@@ -219,13 +394,14 @@ export function SecretHeaderAuthRow(props: { · 4 unmodified lines
219 presetKey: isCustom ? "custom" : presetKey,394 presetKey: isCustom ? "custom" : presetKey,
220 })395 })
221 }396 }
222 placeholder="Authorization"397 placeholder={copy.namePlaceholder}
223 className="font-mono"398 className="font-mono"
224 />399 />
225 </Field>400 </Field>
226 <Field>401 <Field>
227 <FieldLabel htmlFor={prefixInputId}>402 <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>
229 </FieldLabel>405 </FieldLabel>
230 <Input406 <Input
231 id={prefixInputId}407 id={prefixInputId}
@@ -237,21 +413,62 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines
237 presetKey: isCustom ? "custom" : presetKey,413 presetKey: isCustom ? "custom" : presetKey,
238 })414 })
239 }415 }
240 placeholder="Bearer "416 placeholder={copy.prefixPlaceholder}
241 className="font-mono"417 className="font-mono"
242 />418 />
243 </Field>419 </Field>
244 </FieldGroup>420 </FieldGroup>
245 421
246 <SecretPicker422 <div
247 value={secretId}423 className={
248 onSelect={onSelectSecret}424 bindingScopeOptions && bindingScopeOptions.length > 1
249 secrets={scopedSecrets}425 ? "grid gap-2 md:grid-cols-2"
250 onCreateNew={() => setCreating(true)}426 : undefined
251 />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>
252 469
253 {secretId && name.trim() && (470 {secretId && name.trim() && (
254 <HeaderValuePreview headerName={name.trim()} secretId={secretId} prefix={prefix} />471 <PreviewComponent name={name.trim()} secretId={secretId} prefix={prefix} />
255 )}472 )}
256 </div>473 </div>
257 );474 );
@@ -263,10 +480,12 @@ export function SecretHeaderAuthRow(props: { · 5 unmodified lines
263 480
264export function CreatableSecretPicker(props: {481export function CreatableSecretPicker(props: {
265 readonly value: string | null;482 readonly value: string | null;
266 readonly onSelect: (secretId: string) => void;483 readonly onSelect: (secretId: string, scopeId?: ScopeId) => void;
267 readonly secrets: readonly SecretPickerSecret[];484 readonly secrets: readonly SecretPickerSecret[];
268 readonly placeholder?: string;485 readonly placeholder?: string;
269 readonly targetScope: ScopeId;486 readonly targetScope: ScopeId;
487 readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[];
488 readonly onCreatedScope?: (scopeId: ScopeId) => void;
270 readonly suggestedId?: string;489 readonly suggestedId?: string;
271 /**490 /**
272 * Display name of the source the secret belongs to (e.g. "Stripe").491 * Display name of the source the secret belongs to (e.g. "Stripe").
@@ -284,6 +503,8 @@ export function CreatableSecretPicker(props: { · 11 unmodified lines
284 sourceName,503 sourceName,
285 secretLabel,504 secretLabel,
286 targetScope,505 targetScope,
506 credentialScopeOptions,
507 onCreatedScope,
287 suggestedId: suggestedIdProp,508 suggestedId: suggestedIdProp,
288 } = props;509 } = props;
289 const [creating, setCreating] = useState(false);510 const [creating, setCreating] = useState(false);
@@ -293,16 +514,19 @@ export function CreatableSecretPicker(props: { · 3 unmodified lines
293 514
294 if (creating) {515 if (creating) {
295 return (516 return (
296 <InlineCreateSecret517 <CreateSecretDialog
518 open={creating}
519 onOpenChange={setCreating}
297 suggestedName={suggestedName}520 suggestedName={suggestedName}
298 existingSecretIds={scopedSecrets.map((secret) => secret.id)}521 existingSecretIds={scopedSecrets.map((secret) => secret.id)}
299 fallbackId={suggestedIdProp?.trim() || "secret"}522 fallbackId={suggestedIdProp?.trim() || "secret"}
300 onCreated={(id) => {523 onCreated={(id, scopeId) => {
301 onSelect(id);524 onCreatedScope?.(scopeId);
525 onSelect(id, scopeId);
302 setCreating(false);526 setCreating(false);
303 }}527 }}
304 onCancel={() => setCreating(false)}
305 targetScope={targetScope}528 targetScope={targetScope}
529 credentialScopeOptions={credentialScopeOptions}
306 />530 />
307 );531 );
308 }532 }
@@ -310,8 +534,9 @@ export function CreatableSecretPicker(props: { · 1 unmodified line
310 return (534 return (
311 <SecretPicker535 <SecretPicker
312 value={value}536 value={value}
313 onSelect={onSelect}537 valueScopeId={String(targetScope)}
314 secrets={scopedSecrets}538 onSelect={(id, scopeId) => onSelect(id, ScopeId.make(scopeId))}
539 secrets={secrets}
315 placeholder={placeholder}540 placeholder={placeholder}
316 onCreateNew={() => setCreating(true)}541 onCreateNew={() => setCreating(true)}
317 />542 />
packages/react/src/plugins/secret-picker.tsx+33−7
@@ -2,6 +2,7 @@ import { useState, type ChangeEvent, type FocusEvent } from "react"; · 1 unmodified line
2import { PlusIcon } from "lucide-react";2import { PlusIcon } from "lucide-react";
3 3
4import { Input } from "../components/input";4import { Input } from "../components/input";
5import { Badge } from "../components/badge";
5import {6import {
6 Command,7 Command,
7 CommandEmpty,8 CommandEmpty,
@@ -11,6 +12,7 @@ import { · 3 unmodified lines
11 CommandSeparator,12 CommandSeparator,
12} from "../components/command";13} from "../components/command";
13import { Popover, PopoverAnchor, PopoverContent } from "../components/popover";14import { Popover, PopoverAnchor, PopoverContent } from "../components/popover";
15import { useScopeStack } from "../api/scope-context";
14 16
15export interface SecretPickerSecret {17export interface SecretPickerSecret {
16 readonly id: string;18 readonly id: string;
@@ -33,17 +35,38 @@ const providerLabel = (key: string | undefined): string => { · 16 unmodified lines
33 35
34export function SecretPicker(props: {36export function SecretPicker(props: {
35 readonly value: string | null;37 readonly value: string | null;
36 readonly onSelect: (secretId: string) => void;38 readonly valueScopeId?: string;
39 readonly onSelect: (secretId: string, scopeId: string) => void;
37 readonly secrets: readonly SecretPickerSecret[];40 readonly secrets: readonly SecretPickerSecret[];
38 readonly placeholder?: string;41 readonly placeholder?: string;
39 /** When provided, renders a "+ New secret" row at the top of the dropdown. */42 /** When provided, renders a "+ New secret" row at the top of the dropdown. */
40 readonly onCreateNew?: () => void;43 readonly onCreateNew?: () => void;
41}) {44}) {
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;
43 const [open, setOpen] = useState(false);53 const [open, setOpen] = useState(false);
44 const [query, setQuery] = useState("");54 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 };
45 62
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;
47 70
48 const grouped = new Map<string, SecretPickerSecret[]>();71 const grouped = new Map<string, SecretPickerSecret[]>();
49 for (const secret of secrets) {72 for (const secret of secrets) {
@@ -132,15 +155,18 @@ export function SecretPicker(props: { · 82 unmodified lines
132 <CommandGroup key={label} heading={showGroupHeadings ? label : undefined}>155 <CommandGroup key={label} heading={showGroupHeadings ? label : undefined}>
133 {filtered.map((secret) => (156 {filtered.map((secret) => (
134 <CommandItem157 <CommandItem
135 key={secret.id}158 key={`${secret.scopeId}:${secret.id}`}
136 value={`${secret.name} ${secret.id}`}159 value={`${secret.name} ${secret.id} ${secret.scopeId}`}
137 onSelect={() => {160 onSelect={() => {
138 onSelect(secret.id);161 onSelect(secret.id, secret.scopeId);
139 setOpen(false);162 setOpen(false);
140 setQuery("");163 setQuery("");
141 }}164 }}
142 >165 >
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>
144 </CommandItem>170 </CommandItem>
145 ))}171 ))}
146 </CommandGroup>172 </CommandGroup>
packages/react/src/plugins/source-identity.test.ts+24−0
@@ -0,0 +1,24 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import {
4 domainLabelFromUrl,
5 pascalCaseDomainLabel,
6 sourceDisplayNameFromUrl,
7} from "./source-identity";
8
9describe("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
20 return label.charAt(0).toUpperCase() + label.slice(1);20 return label.charAt(0).toUpperCase() + label.slice(1);
21}21}
22 22
23export function domainLabelFromUrl(url: string): string | null {
24 const trimmed = url.trim();
25 if (!trimmed) return null;
26 return parse(trimmed).domainWithoutSuffix ?? null;
27}
28
29export 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
43export 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
23// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
24// Hook — owns the name + namespace state with namespace auto-derivation50// Hook — owns the name + namespace state with namespace auto-derivation
25// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------