Improve scope add flow
Unified · Split
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint | ||
| 2 | CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id"); | ||
| @@ -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": true | 108 | "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 | } |
| @@ -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 | ); |
| @@ -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 | ); |
| @@ -0,0 +1,2 @@ | |||
| 1 | ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint | ||
| 2 | CREATE INDEX `credential_binding_secret_scope_id_idx` ON `credential_binding` (`secret_scope_id`); | ||
| @@ -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": true | 73 | "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 | } |
| @@ -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 | ); |
| @@ -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({ |
| @@ -0,0 +1,47 @@ | |||
| 1 | # Product Scope Language | ||
| 2 | |||
| 3 | Executor has a real scope model, but the product should mostly avoid saying | ||
| 4 | "scope" to users. Use ownership and usage language instead. | ||
| 5 | |||
| 6 | ## Product Terms | ||
| 7 | |||
| 8 | - **Personal**: only this user can use or update the credential/connection. | ||
| 9 | - **Organization**: everyone with access to the source can use the shared | ||
| 10 | credential/connection. | ||
| 11 | - **Source owner**: where the source definition and shared auth method live. | ||
| 12 | This is usually implicit from the current page/context and should not be | ||
| 13 | shown as debug information. | ||
| 14 | - **Used by**: who uses a specific credential value for a shared auth slot. | ||
| 15 | - **Saved to**: where a newly created secret or OAuth token/connection is | ||
| 16 | stored. | ||
| 17 | |||
| 18 | ## UI Rules | ||
| 19 | |||
| 20 | - Communicate source auth as two separate choices: | ||
| 21 | - the shared authentication method for the source, such as bearer header, | ||
| 22 | query parameter, or OAuth; | ||
| 23 | - the credential/connection value used for that method. | ||
| 24 | - Do not imply users can change the auth method per person when the backend only | ||
| 25 | allows credential values to vary per scope. | ||
| 26 | - Put secret storage choices in the new-secret flow, because choosing Personal | ||
| 27 | or Organization there creates a reusable secret at that ownership level. | ||
| 28 | - Put credential usage choices next to the credential picker as **Used by**, | ||
| 29 | because attaching a secret to a source slot is separate from where the secret | ||
| 30 | itself is stored. | ||
| 31 | - Put OAuth token/connection storage next to **Connect via OAuth** as **Token | ||
| 32 | saved to**. This is independent from OAuth client ID/client secret storage. | ||
| 33 | - Secret lists should show secrets from all visible ownership levels with a | ||
| 34 | Personal/Organization badge. | ||
| 35 | |||
| 36 | ## Preferred Copy | ||
| 37 | |||
| 38 | - Use "Personal" and "Organization" for selectors. | ||
| 39 | - Use "Used by" for source credential bindings. | ||
| 40 | - Use "Save secret to" in secret creation. | ||
| 41 | - Use "Token saved to" for OAuth sign-in results. | ||
| 42 | - Use "Add without credentials" whenever the source can be added with missing | ||
| 43 | initial credential values, not only for OAuth. | ||
| 44 | |||
| 45 | Avoid copy like "scope", "target scope", "source scope", "binding scope", or | ||
| 46 | "credential target scope" in product UI unless it is explicitly a developer or | ||
| 47 | debug surface. | ||
| @@ -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 | |||
| 244 | type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>; | 245 | type CredentialBindingRowFields = InferDBFieldsOutput<CoreSchema["credential_binding"]["fields"]>; |
| 245 | type CredentialBindingRowBase = Omit< | 246 | type 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 | ||
| 250 | export type CredentialBindingRow = CredentialBindingRowBase & | 251 | export 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"; |
| @@ -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 | }); |
| @@ -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 | |||
| 36 | export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]); | 37 | export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]); |
| 37 | export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type; | 38 | export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type; |
| 38 | 39 | ||
| 40 | export const ScopedSecretCredentialInput = Schema.Struct({ | ||
| 41 | secretId: Schema.String, | ||
| 42 | prefix: Schema.optional(Schema.String), | ||
| 43 | targetScope: ScopeId, | ||
| 44 | secretScopeId: Schema.optional(ScopeId), | ||
| 45 | }); | ||
| 46 | export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type; | ||
| 47 | |||
| 39 | export class CredentialBindingRef extends Schema.Class<CredentialBindingRef>( | 48 | export 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, |
| @@ -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: { |
| @@ -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, |
| @@ -16,7 +16,7 @@ · 15 unmodified lines | |||
| 16 | // construction keeps the call sync and lets callers opt out of PAR | 16 | // construction keeps the call sync and lets callers opt out of PAR |
| 17 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | ||
| 19 | import { Data, Effect } from "effect"; | 19 | import { Data, Effect, Predicate } from "effect"; |
| 20 | import * as oauth from "oauth4webapi"; | 20 | import * 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 | ||
| 124 | const isOAuth2Error = (cause: unknown): cause is OAuth2Error => | 124 | const 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 | ||
| 130 | const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => { | 126 | const 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 | ||
| 203 | const toOAuth2ErrorWithHttpSummary = async (cause: unknown): Promise<OAuth2Error> => { | 199 | const 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 | ||
| 216 | const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> => | ||
| 217 | toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); | ||
| 218 | |||
| 216 | // --------------------------------------------------------------------------- | 219 | // --------------------------------------------------------------------------- |
| 217 | // oauth4webapi adapter helpers | 220 | // 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 API | 361 | // takes the `code` directly (the UI already validated `state` by |
| 359 | // takes the `code` directly (the UI already validated `state` by | 362 | // looking up the session), so skip the library's state-validation |
| 360 | // looking up the session), so skip the library's state-validation | 363 | // 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 token | 425 | // 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 predicate | 485 | // Refresh-needed predicate |
| @@ -9,11 +9,12 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 8 unmodified lines | |||
| 9 | import { | 9 | import { |
| 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"; |
| 15 | import { | 16 | import { |
| 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"; |
| 27 | import { | 28 | import { |
| 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"; |
| 31 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 33 | import { 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" && tokens | 161 | ...(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 | <CredentialScopeSection | 201 | <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 | <HttpCredentialsEditor | 209 | /> |
| 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 | Authenticated | 232 | 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 | <Button | 239 | <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 | ||
| @@ -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> |
| @@ -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; |
| @@ -424,6 +424,19 @@ const bindingTargetScope = ( · 423 unmodified lines | |||
| 424 | ); | 424 | ); |
| 425 | }; | 425 | }; |
| 426 | 426 | ||
| 427 | const targetScopeForBinding = ( | ||
| 428 | fallbackTargetScope: string | undefined, | ||
| 429 | binding: { readonly targetScope?: string }, | ||
| 430 | ): Effect.Effect<string, GraphqlIntrospectionError> => { | ||
| 431 | const targetScope = binding.targetScope ?? fallbackTargetScope; | ||
| 432 | if (targetScope) return Effect.succeed(targetScope); | ||
| 433 | return Effect.fail( | ||
| 434 | new GraphqlIntrospectionError({ | ||
| 435 | message: "credentialTargetScope is required when adding direct GraphQL credentials", | ||
| 436 | }), | ||
| 437 | ); | ||
| 438 | }; | ||
| 439 | |||
| 427 | const canonicalizeCredentialMap = ( | 440 | const 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.secrets | 636 | 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), |
| @@ -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 | ||
| 69 | export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema; | 70 | export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema; |
| 70 | export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; | 71 | export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; |
| 71 | export const GraphqlCredentialInput = Schema.Union([HeaderValue, ConfiguredGraphqlCredentialValue]); | 72 | export const GraphqlCredentialInput = Schema.Union([ |
| 73 | ScopedSecretCredentialInput, | ||
| 74 | HeaderValue, | ||
| 75 | ConfiguredGraphqlCredentialValue, | ||
| 76 | ]); | ||
| 72 | export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; | 77 | export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; |
| 73 | 78 | ||
| 74 | export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); | 79 | export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); |
| @@ -27,17 +27,15 @@ import { Skeleton } from "@executor-js/react/components/skeleton"; · 26 unmodified lines | |||
| 27 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | 27 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 28 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 28 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 29 | import { Textarea } from "@executor-js/react/components/textarea"; | 29 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 30 | import { HeadersList } from "@executor-js/react/plugins/headers-list"; | ||
| 31 | import { | 30 | import { |
| 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"; |
| 38 | import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth"; | ||
| 39 | import { | 37 | import { |
| 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"; |
| 53 | import { | 51 | import { |
| 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 | ||
| 58 | type RemoteAuthMode = "none" | "header" | "oauth2"; | 57 | type RemoteAuthMode = "none" | "oauth2"; |
| 59 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 58 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 60 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; | 59 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; |
| 61 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 60 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 62 | import { MCP_OAUTH_CONNECTION_SLOT } from "../sdk/types"; | 61 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; |
| 63 | 62 | ||
| 64 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 63 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 65 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); | 64 | const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); |
| @@ -101,7 +100,7 @@ type PlainHeader = { · 35 unmodified lines | |||
| 101 | 100 | ||
| 102 | type State = | 101 | type 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?.secretId | 437 | 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 | ? tokens | 445 | 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 | remoteHeaders | 449 | 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 > 0 | 477 | ...(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 | <Button | 697 | <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.requiresOAuth | 734 | 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 | <CredentialScopeSection | 747 | <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 in | 768 | 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 | <Button | 787 | <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 | Cancel | 793 | > |
| 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 | <path | 802 | <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 | Authenticated | 811 | 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-backed | 827 | 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 | <Button | 929 | <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 | <Button | 942 | <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 | Cancel | 951 | 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 | Cancel | 1022 | 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… |
| @@ -308,6 +308,20 @@ const bindingTargetScope = ( · 307 unmodified lines | |||
| 308 | ); | 308 | ); |
| 309 | }; | 309 | }; |
| 310 | 310 | ||
| 311 | const targetScopeForBinding = ( | ||
| 312 | fallbackTargetScope: string | undefined, | ||
| 313 | binding: { readonly targetScope?: string }, | ||
| 314 | ): Effect.Effect<string, McpConnectionError> => { | ||
| 315 | const targetScope = binding.targetScope ?? fallbackTargetScope; | ||
| 316 | if (targetScope) return Effect.succeed(targetScope); | ||
| 317 | return Effect.fail( | ||
| 318 | new McpConnectionError({ | ||
| 319 | transport: "remote", | ||
| 320 | message: "credentialTargetScope is required when adding direct MCP credentials", | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | }; | ||
| 324 | |||
| 311 | const canonicalizeCredentialMap = ( | 325 | const 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.secrets | 600 | 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.secrets | 711 | 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 | canonicalRemote | 1120 | 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), |
| @@ -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 | |||
| 24 | export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema; | 25 | export const ConfiguredMcpCredentialValue = ConfiguredCredentialValueSchema; |
| 25 | export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type; | 26 | export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type; |
| 26 | 27 | ||
| 27 | export const McpCredentialInput = Schema.Union([SecretBackedValue, ConfiguredMcpCredentialValue]); | 28 | export const McpCredentialInput = Schema.Union([ |
| 29 | ScopedSecretCredentialInput, | ||
| 30 | SecretBackedValue, | ||
| 31 | ConfiguredMcpCredentialValue, | ||
| 32 | ]); | ||
| 28 | export type McpCredentialInput = typeof McpCredentialInput.Type; | 33 | export type McpCredentialInput = typeof McpCredentialInput.Type; |
| 29 | 34 | ||
| 30 | export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name); | 35 | export 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"), |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ScopeId, SecretBackedValue } from "@executor-js/sdk/core"; | 3 | import { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/core"; |
| 4 | import { InternalError } from "@executor-js/api"; | 4 | import { InternalError } from "@executor-js/api"; |
| 5 | 5 | ||
| 6 | import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; | 6 | import { 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 | ||
| 46 | const ConfiguredCredentialBindingPayload = Schema.Struct({ | ||
| 47 | kind: Schema.Literal("binding"), | ||
| 48 | slot: Schema.String, | ||
| 49 | prefix: Schema.optional(Schema.String), | ||
| 50 | }); | ||
| 51 | |||
| 52 | const ConfiguredCredentialValuePayload = Schema.Union([ | ||
| 53 | Schema.String, | ||
| 54 | ConfiguredCredentialBindingPayload, | ||
| 55 | ]); | ||
| 56 | |||
| 57 | const OpenApiCredentialInputPayload = Schema.Union([ | ||
| 58 | ScopedSecretCredentialInput, | ||
| 59 | SecretBackedValue, | ||
| 60 | ConfiguredCredentialValuePayload, | ||
| 61 | ]); | ||
| 62 | |||
| 46 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| 47 | // Payloads | 64 | // 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's | 92 | // Set after a successful re-authenticate to refresh the source's |
| 76 | // stored OAuth2 metadata. | 93 | // stored OAuth2 metadata. |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useCallback, useEffect, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| @@ -6,7 +6,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line | |||
| 6 | 6 | ||
| 7 | import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; | 7 | import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/core"; |
| 8 | import { startOAuth } from "@executor-js/react/api/atoms"; | 8 | import { startOAuth } from "@executor-js/react/api/atoms"; |
| 9 | import { useScope } from "@executor-js/react/api/scope-context"; | 9 | import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; |
| 10 | import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 10 | import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 11 | 11 | ||
| 12 | // `addSpec` with an oauth2 payload persists a source row AND (for | 12 | // `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"; |
| 31 | import { | ||
| 32 | CredentialScopeSection, | ||
| 33 | useCredentialTargetScope, | ||
| 34 | } from "@executor-js/react/plugins/credential-target-scope"; | ||
| 35 | import { | 31 | import { |
| 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"; |
| 63 | import { FieldLabel } from "@executor-js/react/components/field"; | 59 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 64 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 60 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 61 | import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; | ||
| 65 | import { Input } from "@executor-js/react/components/input"; | 62 | import { Input } from "@executor-js/react/components/input"; |
| 66 | import { Label } from "@executor-js/react/components/label"; | 63 | import { Label } from "@executor-js/react/components/label"; |
| 67 | import { Textarea } from "@executor-js/react/components/textarea"; | 64 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 68 | import { Checkbox } from "@executor-js/react/components/checkbox"; | 65 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 69 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | 66 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| 70 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; | 67 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 68 | import { | ||
| 69 | Select, | ||
| 70 | SelectContent, | ||
| 71 | SelectItem, | ||
| 72 | SelectTrigger, | ||
| 73 | SelectValue, | ||
| 74 | } from "@executor-js/react/components/select"; | ||
| 71 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 75 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 72 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | 76 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 73 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 77 | import 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"; |
| 80 | import { | 85 | import { |
| 81 | ConfiguredHeaderBinding, | 86 | ConfiguredHeaderBinding, |
| @@ -200,6 +205,49 @@ function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { · 118 unmodified lines | |||
| 200 | }); | 205 | }); |
| 201 | } | 206 | } |
| 202 | 207 | ||
| 208 | const secretStorageDescription = (label: string): string => | ||
| 209 | label === "Personal" | ||
| 210 | ? "Only you can use this secret." | ||
| 211 | : "Everyone in the organization can use this secret."; | ||
| 212 | |||
| 213 | function CredentialScopeDropdown(props: { | ||
| 214 | readonly value: ScopeId; | ||
| 215 | readonly options: readonly { | ||
| 216 | readonly scopeId: ScopeId; | ||
| 217 | readonly label: string; | ||
| 218 | }[]; | ||
| 219 | readonly onChange: (scopeId: ScopeId) => void; | ||
| 220 | readonly label?: string; | ||
| 221 | readonly help?: ReactNode; | ||
| 222 | }) { | ||
| 223 | const label = props.label ?? "Used by"; | ||
| 224 | return ( | ||
| 225 | <div className="space-y-1.5"> | ||
| 226 | <div className="flex items-center gap-1.5"> | ||
| 227 | <FieldLabel className="text-[11px]">{label}</FieldLabel> | ||
| 228 | <HelpTooltip label={label}> | ||
| 229 | {props.help ?? "Choose who uses this credential value."} | ||
| 230 | </HelpTooltip> | ||
| 231 | </div> | ||
| 232 | <Select | ||
| 233 | value={String(props.value)} | ||
| 234 | onValueChange={(value) => props.onChange(ScopeId.make(value))} | ||
| 235 | > | ||
| 236 | <SelectTrigger className="w-full"> | ||
| 237 | <SelectValue placeholder={label} /> | ||
| 238 | </SelectTrigger> | ||
| 239 | <SelectContent> | ||
| 240 | {props.options.map((option) => ( | ||
| 241 | <SelectItem key={option.scopeId} value={option.scopeId}> | ||
| 242 | {option.label} | ||
| 243 | </SelectItem> | ||
| 244 | ))} | ||
| 245 | </SelectContent> | ||
| 246 | </Select> | ||
| 247 | </div> | ||
| 248 | ); | ||
| 249 | } | ||
| 250 | |||
| 203 | // --------------------------------------------------------------------------- | 251 | // --------------------------------------------------------------------------- |
| 204 | // Main component — single progressive form | 252 | // 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 > 0 | 796 | ...(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 radios | 1069 | {/* 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 arrays | 1071 | 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 | <CredentialScopeSection | 1147 | <div className="space-y-3"> |
| 963 | value={credentialTargetScope} | ||
| 964 | options={credentialScopeOptions} | ||
| 965 | onChange={(targetScope) => { | ||
| 966 | setCredentialTargetScope(targetScope); | ||
| 967 | setOauth2AuthState(null); | ||
| 968 | }} | ||
| 969 | > | ||
| 970 | <HeadersList | 1148 | <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 | <HttpCredentialsEditor | 1162 | <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 | <CredentialScopeSection | 1178 | <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 | <CreatableSecretPicker | 1193 | <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 PKCE | 1227 | · optional for public clients with PKCE |
| 1033 | </span> | 1228 | </span> |
| 1034 | </FieldLabel> | 1229 | </FieldLabel> |
| 1035 | <CreatableSecretPicker | 1230 | <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 | <Button | 1313 | <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 OAuth | 1319 | </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 operations | 1322 | 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 | {adding | 1374 | {adding |
| 1142 | ? "Adding…" | 1375 | ? "Adding…" |
| 1143 | : selectedOAuth2Preset && !oauth2Auth | 1376 | : willAddWithoutInitialCredentials |
| 1144 | ? "Add without credentials" | 1377 | ? "Add without credentials" |
| 1145 | : "Add source"} | 1378 | : "Add source"} |
| 1146 | </Button> | 1379 | </Button> |
| @@ -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 | {secretSlots | 654 | {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 | <CardStackEntryField | 658 | <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 | <Button | 671 | 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 | Clear | 697 | <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 | {savedHere | 708 | </div> |
| 705 | ? `Saved in ${activeCredentialScopeLabel.toLowerCase()}.` | 709 | <CreatableSecretPicker |
| 706 | : inherited | 710 | 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 && bindingScopeId | 820 | : connectionBinding && bindingScopeId |
| 760 | ? connection | 821 | ? connection |
| 761 | ? bindingScopeId === activeCredentialScopeId | 822 | ? bindingScopeId === activeOAuthTokenScopeId |
| 762 | ? `Connected in ${activeCredentialScopeLabel.toLowerCase()} as ${ | 823 | ? `Connected in ${activeOAuthTokenScopeLabel.toLowerCase()} as ${ |
| 763 | connection.identityLabel ?? connection.id | 824 | connection.identityLabel ?? connection.id |
| 764 | }` | 825 | }` |
| 765 | : `Using organization connection ${ | 826 | : `Using organization connection ${ |
| 766 | connection.identityLabel ?? connection.id | 827 | connection.identityLabel ?? connection.id |
| 767 | }` | 828 | }` |
| 768 | : bindingScopeId === activeCredentialScopeId | 829 | : 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 | <Button | 837 | <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"} |
| @@ -81,9 +81,10 @@ const ProjectsGroupLive = HttpApiBuilder.group(VercelApi, "projects", (handlers) · 80 unmodified lines | |||
| 81 | 81 | ||
| 82 | const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive)); | 82 | const ApiLive = HttpApiBuilder.layer(VercelApi).pipe(Layer.provide(ProjectsGroupLive)); |
| 83 | 83 | ||
| 84 | const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe( | 84 | const 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 | // Tests | 90 | // 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 | }); |
| @@ -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.openapi | 1128 | const connectionBinding = yield* executor.openapi |
| @@ -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 | |||
| 60 | export type HeaderValue = HeaderValueValue; | 62 | export type HeaderValue = HeaderValueValue; |
| 61 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; | 63 | export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; |
| 62 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; | 64 | export type OpenApiHeaderInput = HeaderValue | ConfiguredHeaderValue; |
| 63 | export type OpenApiCredentialInput = HeaderValue | ConfiguredHeaderValue; | 65 | export type OpenApiCredentialInput = OpenApiCredentialInputValue; |
| 64 | export type OpenApiOAuthInput = OAuth2SourceConfig; | 66 | export type OpenApiOAuthInput = OAuth2SourceConfig; |
| 65 | 67 | ||
| 66 | export interface OpenApiSpecFetchCredentialsInput { | 68 | export 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 | |||
| 95 | export interface OpenApiUpdateSourceInput { | 97 | export 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 successful | 103 | /** Refresh the source's stored OAuth2 metadata after a successful |
| @@ -173,7 +175,6 @@ const PreviewSpecInputSchema = Schema.Struct({ · 71 unmodified lines | |||
| 173 | type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; | 175 | type PreviewSpecInput = typeof PreviewSpecInputSchema.Type; |
| 174 | 176 | ||
| 175 | const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); | 177 | const OpenApiHeaderInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); |
| 176 | const OpenApiCredentialInputSchema = Schema.Union([HeaderValueSchema, ConfiguredHeaderValueSchema]); | ||
| 177 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; | 178 | const OpenApiOAuthInputSchema = OAuth2SourceConfig; |
| 178 | 179 | ||
| 179 | const AddSourceInputSchema = Schema.Struct({ | 180 | const 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 | ||
| 264 | const canonicalizeHeaders = ( | 265 | const 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 | ||
| 499 | const bindingTargetScope = ( | 519 | const 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) { |
| @@ -304,6 +304,9 @@ const slugifySlotPart = (value: string): string => · 303 unmodified lines | |||
| 304 | export const headerBindingSlot = (headerName: string): string => | 304 | export const headerBindingSlot = (headerName: string): string => |
| 305 | `header:${slugifySlotPart(headerName)}`; | 305 | `header:${slugifySlotPart(headerName)}`; |
| 306 | 306 | ||
| 307 | export const queryParamBindingSlot = (name: string): string => | ||
| 308 | `query_param:${slugifySlotPart(name)}`; | ||
| 309 | |||
| 307 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => | 310 | export const oauth2ClientIdSlot = (securitySchemeName: string): string => |
| 308 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; | 311 | `oauth2:${slugifySlotPart(securitySchemeName)}:client-id`; |
| 309 | 312 | ||
| @@ -1,5 +1,11 @@ | |||
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { ConnectionId, ScopeId, SecretBackedValue, SecretId } from "@executor-js/sdk/core"; | 2 | import { |
| 3 | ConnectionId, | ||
| 4 | ScopeId, | ||
| 5 | ScopedSecretCredentialInput, | ||
| 6 | SecretBackedValue, | ||
| 7 | SecretId, | ||
| 8 | } from "@executor-js/sdk/core"; | ||
| 3 | 9 | ||
| 4 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 5 | // Branded IDs | 11 | // Branded IDs |
| @@ -146,10 +152,18 @@ export class ConfiguredHeaderBinding extends Schema.Class<ConfiguredHeaderBindin · 140 unmodified lines | |||
| 146 | export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]); | 152 | export const ConfiguredHeaderValue = Schema.Union([Schema.String, ConfiguredHeaderBinding]); |
| 147 | export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type; | 153 | export type ConfiguredHeaderValue = typeof ConfiguredHeaderValue.Type; |
| 148 | 154 | ||
| 155 | export const OpenApiCredentialInput = Schema.Union([ | ||
| 156 | ScopedSecretCredentialInput, | ||
| 157 | HeaderValue, | ||
| 158 | ConfiguredHeaderValue, | ||
| 159 | ]); | ||
| 160 | export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type; | ||
| 161 | |||
| 149 | export const OpenApiSourceBindingValue = Schema.Union([ | 162 | export 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"), |
| @@ -0,0 +1,32 @@ | |||
| 1 | import { InfoIcon } from "lucide-react"; | ||
| 2 | |||
| 3 | import { cn } from "../lib/utils"; | ||
| 4 | import { Button } from "./button"; | ||
| 5 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"; | ||
| 6 | |||
| 7 | export function HelpTooltip(props: { | ||
| 8 | readonly label: string; | ||
| 9 | readonly children: React.ReactNode; | ||
| 10 | readonly className?: string; | ||
| 11 | }) { | ||
| 12 | return ( | ||
| 13 | <TooltipProvider> | ||
| 14 | <Tooltip> | ||
| 15 | <TooltipTrigger asChild> | ||
| 16 | <Button | ||
| 17 | type="button" | ||
| 18 | variant="ghost" | ||
| 19 | size="icon-xs" | ||
| 20 | className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" | ||
| 21 | aria-label={`${props.label} help`} | ||
| 22 | > | ||
| 23 | <InfoIcon className="size-3.5" aria-hidden /> | ||
| 24 | </Button> | ||
| 25 | </TooltipTrigger> | ||
| 26 | <TooltipContent className={cn("w-max max-w-72 text-left text-wrap", props.className)}> | ||
| 27 | {props.children} | ||
| 28 | </TooltipContent> | ||
| 29 | </Tooltip> | ||
| 30 | </TooltipProvider> | ||
| 31 | ); | ||
| 32 | } | ||
| @@ -11,6 +11,7 @@ import { useSecretProviderPlugins } from "@executor-js/sdk/client"; · 10 unmodified lines | |||
| 11 | import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; | 11 | import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; |
| 12 | import { SecretForm } from "../plugins/secret-form"; | 12 | import { SecretForm } from "../plugins/secret-form"; |
| 13 | import { useScope } from "../hooks/use-scope"; | 13 | import { useScope } from "../hooks/use-scope"; |
| 14 | import { useScopeStack } from "../api/scope-context"; | ||
| 14 | import { | 15 | import { |
| 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 | ), |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { useEffect, useMemo, useState } from "react"; | 1 | import { useEffect, useMemo, useState } from "react"; |
| 2 | import type { ReactNode } from "react"; | 2 | import type { ReactNode } from "react"; |
| 3 | 3 | ||
| 4 | import type { ScopeId } from "@executor-js/sdk"; | 4 | import { ScopeId } from "@executor-js/sdk"; |
| 5 | 5 | ||
| 6 | import { useScope, useUserScope } from "../api/scope-context"; | 6 | import { useScope, useUserScope } from "../api/scope-context"; |
| 7 | import { | 7 | import { |
| @@ -13,6 +13,15 @@ import { · 5 unmodified lines | |||
| 13 | CardStackEntryTitle, | 13 | CardStackEntryTitle, |
| 14 | } from "../components/card-stack"; | 14 | } from "../components/card-stack"; |
| 15 | import { FilterTabs } from "../components/filter-tabs"; | 15 | import { FilterTabs } from "../components/filter-tabs"; |
| 16 | import { FieldLabel } from "../components/field"; | ||
| 17 | import { HelpTooltip } from "../components/help-tooltip"; | ||
| 18 | import { | ||
| 19 | Select, | ||
| 20 | SelectContent, | ||
| 21 | SelectItem, | ||
| 22 | SelectTrigger, | ||
| 23 | SelectValue, | ||
| 24 | } from "../components/select"; | ||
| 16 | 25 | ||
| 17 | export interface CredentialTargetScopeOption { | 26 | export 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 | |||
| 167 | export function CredentialScopeDropdown(props: { | ||
| 168 | readonly value: ScopeId; | ||
| 169 | readonly options: readonly CredentialTargetScopeOption[]; | ||
| 170 | readonly onChange: (scope: ScopeId) => void; | ||
| 171 | readonly label?: string; | ||
| 172 | readonly help?: ReactNode; | ||
| 173 | }) { | ||
| 174 | const label = props.label ?? "Used by"; | ||
| 175 | if (props.options.length <= 1) return null; | ||
| 176 | |||
| 177 | return ( | ||
| 178 | <div className="space-y-1.5"> | ||
| 179 | <div className="flex items-center gap-1.5"> | ||
| 180 | <FieldLabel className="text-[11px]">{label}</FieldLabel> | ||
| 181 | <HelpTooltip label={label}> | ||
| 182 | {props.help ?? "Choose who can use these credentials."} | ||
| 183 | </HelpTooltip> | ||
| 184 | </div> | ||
| 185 | <Select | ||
| 186 | value={String(props.value)} | ||
| 187 | onValueChange={(value) => props.onChange(ScopeId.make(value))} | ||
| 188 | > | ||
| 189 | <SelectTrigger className="w-full"> | ||
| 190 | <SelectValue placeholder={label} /> | ||
| 191 | </SelectTrigger> | ||
| 192 | <SelectContent> | ||
| 193 | {props.options.map((option) => ( | ||
| 194 | <SelectItem key={option.scopeId} value={option.scopeId}> | ||
| 195 | {option.label} | ||
| 196 | </SelectItem> | ||
| 197 | ))} | ||
| 198 | </SelectContent> | ||
| 199 | </Select> | ||
| 200 | </div> | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | |||
| 204 | export function CredentialControlField(props: { | ||
| 205 | readonly label: string; | ||
| 206 | readonly children: ReactNode; | ||
| 207 | readonly help?: ReactNode; | ||
| 208 | }) { | ||
| 209 | return ( | ||
| 210 | <div className="space-y-1.5"> | ||
| 211 | <div className="flex items-center gap-1.5"> | ||
| 212 | <FieldLabel className="text-[11px]">{props.label}</FieldLabel> | ||
| 213 | {props.help ? <HelpTooltip label={props.label}>{props.help}</HelpTooltip> : null} | ||
| 214 | </div> | ||
| 215 | {props.children} | ||
| 216 | </div> | ||
| 217 | ); | ||
| 218 | } | ||
| 219 | |||
| 220 | export function CredentialUsageRow(props: { | ||
| 221 | readonly value: ScopeId; | ||
| 222 | readonly options: readonly CredentialTargetScopeOption[]; | ||
| 223 | readonly onChange: (scope: ScopeId) => void; | ||
| 224 | readonly children: ReactNode; | ||
| 225 | readonly label?: string; | ||
| 226 | readonly help?: ReactNode; | ||
| 227 | }) { | ||
| 228 | if (props.options.length <= 1) { | ||
| 229 | return <div className="space-y-2.5">{props.children}</div>; | ||
| 230 | } | ||
| 231 | |||
| 232 | return ( | ||
| 233 | <div className="grid gap-2 md:grid-cols-2"> | ||
| 234 | <div className="min-w-0 space-y-2.5">{props.children}</div> | ||
| 235 | <CredentialScopeDropdown | ||
| 236 | value={props.value} | ||
| 237 | options={props.options} | ||
| 238 | onChange={props.onChange} | ||
| 239 | label={props.label} | ||
| 240 | help={props.help} | ||
| 241 | /> | ||
| 242 | </div> | ||
| 243 | ); | ||
| 244 | } | ||
| @@ -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"; |
| 20 | import type { CredentialTargetScopeOption } from "./credential-target-scope"; | ||
| 18 | import type { SecretPickerSecret } from "./secret-picker"; | 21 | import type { SecretPickerSecret } from "./secret-picker"; |
| 19 | 22 | ||
| 20 | export interface HeadersListProps { | 23 | export 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"). Used | 38 | * 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 | ||
| 39 | export function HeadersList({ | 51 | export 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 | |||
| 123 | interface AddHeaderRowProps { | 170 | interface 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 | ||
| 128 | function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { | 176 | function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) { |
| 129 | return ( | 177 | return ( |
| 130 | // oxlint-disable-next-line react/forbid-elements | 178 | // oxlint-disable-next-line react/forbid-elements |
| 131 | <button | 179 | <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> |
| @@ -1,18 +1,15 @@ | |||
| 1 | import { useId } from "react"; | 1 | import type { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk"; |
| 2 | import { PlusIcon } from "lucide-react"; | ||
| 3 | import type { ScopeId, SecretBackedValue } from "@executor-js/sdk"; | ||
| 4 | 2 | ||
| 5 | import { Button } from "../components/button"; | 3 | import { FieldLabel } from "../components/field"; |
| 6 | import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack"; | ||
| 7 | import { Field, FieldGroup, FieldLabel } from "../components/field"; | ||
| 8 | import { Input } from "../components/input"; | ||
| 9 | import { HeadersList } from "./headers-list"; | 4 | import { HeadersList } from "./headers-list"; |
| 10 | import { | 5 | import { |
| 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"; |
| 12 | import type { CredentialTargetScopeOption } from "./credential-target-scope"; | ||
| 16 | import type { SecretPickerSecret } from "./secret-picker"; | 13 | import type { SecretPickerSecret } from "./secret-picker"; |
| 17 | 14 | ||
| 18 | export type { SecretBackedValue }; | 15 | export 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 | ||
| 26 | const queryParamPresets: readonly HeaderAuthPreset[] = [ | ||
| 27 | { key: "custom", label: "Query parameter", name: "" }, | ||
| 28 | ]; | ||
| 29 | |||
| 27 | export type HttpCredentialsState = { | 30 | export 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 | ||
| 90 | export const serializeScopedHeaderCredentials = ( | ||
| 91 | headers: readonly HeaderState[], | ||
| 92 | fallbackTargetScope: ScopeId, | ||
| 93 | ): Record<string, ScopedSecretCredentialInput> => { | ||
| 94 | const result: Record<string, ScopedSecretCredentialInput> = {}; | ||
| 95 | for (const header of headers) { | ||
| 96 | const name = header.name.trim(); | ||
| 97 | if (!name || !header.secretId) continue; | ||
| 98 | const targetScope = header.targetScope ?? fallbackTargetScope; | ||
| 99 | result[name] = { | ||
| 100 | secretId: header.secretId, | ||
| 101 | targetScope, | ||
| 102 | ...(header.secretScope ? { secretScopeId: header.secretScope } : {}), | ||
| 103 | ...(header.prefix ? { prefix: header.prefix } : {}), | ||
| 104 | }; | ||
| 105 | } | ||
| 106 | return result; | ||
| 107 | }; | ||
| 108 | |||
| 109 | export const serializeScopedQueryCredentials = ( | ||
| 110 | queryParams: readonly QueryParamState[], | ||
| 111 | fallbackTargetScope: ScopeId, | ||
| 112 | ): Record<string, string | ScopedSecretCredentialInput> => { | ||
| 113 | const result: Record<string, string | ScopedSecretCredentialInput> = {}; | ||
| 114 | for (const param of queryParams) { | ||
| 115 | const name = param.name.trim(); | ||
| 116 | if (!name) continue; | ||
| 117 | if (param.secretId) { | ||
| 118 | const targetScope = param.targetScope ?? fallbackTargetScope; | ||
| 119 | result[name] = { | ||
| 120 | secretId: param.secretId, | ||
| 121 | targetScope, | ||
| 122 | ...(param.secretScope ? { secretScopeId: param.secretScope } : {}), | ||
| 123 | ...(param.prefix ? { prefix: param.prefix } : {}), | ||
| 124 | }; | ||
| 125 | continue; | ||
| 126 | } | ||
| 127 | if (param.literalValue?.trim()) { | ||
| 128 | result[name] = param.literalValue.trim(); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | return result; | ||
| 132 | }; | ||
| 133 | |||
| 134 | export const serializeScopedHttpCredentials = ( | ||
| 135 | credentials: HttpCredentialsState, | ||
| 136 | fallbackTargetScope: ScopeId, | ||
| 137 | ) => ({ | ||
| 138 | headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope), | ||
| 139 | queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), | ||
| 140 | }); | ||
| 141 | |||
| 87 | export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => | 142 | export 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 | <QueryParamsList | 191 | <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 | |||
| 145 | function QueryParamsList(props: { | ||
| 146 | readonly queryParams: readonly QueryParamState[]; | ||
| 147 | readonly onQueryParamsChange: (queryParams: QueryParamState[]) => void; | ||
| 148 | readonly existingSecrets: readonly SecretPickerSecret[]; | ||
| 149 | readonly sourceName?: string; | ||
| 150 | readonly targetScope: ScopeId; | ||
| 151 | }) { | ||
| 152 | const addParam = () => { | ||
| 153 | props.onQueryParamsChange([...props.queryParams, { name: "", secretId: null }]); | ||
| 154 | }; | ||
| 155 | const updateParam = (index: number, update: Partial<QueryParamState>) => { | ||
| 156 | props.onQueryParamsChange( | ||
| 157 | props.queryParams.map((param, i) => (i === index ? { ...param, ...update } : param)), | ||
| 158 | ); | ||
| 159 | }; | ||
| 160 | const removeParam = (index: number) => { | ||
| 161 | props.onQueryParamsChange(props.queryParams.filter((_, i) => i !== index)); | ||
| 162 | }; | ||
| 163 | |||
| 164 | return ( | ||
| 165 | <CardStack> | ||
| 166 | <CardStackContent className="[&>*+*]:before:inset-x-0"> | ||
| 167 | {props.queryParams.length === 0 ? ( | ||
| 168 | <AddQueryParamRow leading={<span>No query parameters</span>} onClick={addParam} /> | ||
| 169 | ) : ( | ||
| 170 | <> | ||
| 171 | {props.queryParams.map((param, index) => ( | ||
| 172 | <QueryParamRow | ||
| 173 | key={index} | ||
| 174 | param={param} | ||
| 175 | existingSecrets={props.existingSecrets} | ||
| 176 | sourceName={props.sourceName} | ||
| 177 | targetScope={props.targetScope} | ||
| 178 | onChange={(update) => updateParam(index, update)} | ||
| 179 | onRemove={() => removeParam(index)} | ||
| 180 | /> | ||
| 181 | ))} | ||
| 182 | <AddQueryParamRow onClick={addParam} /> | ||
| 183 | </> | ||
| 184 | )} | ||
| 185 | </CardStackContent> | ||
| 186 | </CardStack> | ||
| 187 | ); | ||
| 188 | } | ||
| 189 | |||
| 190 | function QueryParamRow(props: { | ||
| 191 | readonly param: QueryParamState; | ||
| 192 | readonly existingSecrets: readonly SecretPickerSecret[]; | ||
| 193 | readonly sourceName?: string; | ||
| 194 | readonly targetScope: ScopeId; | ||
| 195 | readonly onChange: (update: Partial<QueryParamState>) => void; | ||
| 196 | readonly onRemove: () => void; | ||
| 197 | }) { | ||
| 198 | const nameInputId = useId(); | ||
| 199 | const prefixInputId = useId(); | ||
| 200 | const literalInputId = useId(); | ||
| 201 | const name = props.param.name.trim(); | ||
| 202 | const secretLabel = name ? `${name} query parameter` : "Query parameter"; | ||
| 203 | |||
| 204 | return ( | ||
| 205 | <div className="space-y-2.5 px-4 py-3"> | ||
| 206 | <div className="flex w-full items-center justify-between gap-4"> | ||
| 207 | <span className="text-[10px] uppercase tracking-wider text-muted-foreground"> | ||
| 208 | Query parameter | ||
| 209 | </span> | ||
| 210 | <Button | ||
| 211 | type="button" | ||
| 212 | variant="ghost" | ||
| 213 | size="xs" | ||
| 214 | className="text-muted-foreground hover:text-destructive" | ||
| 215 | onClick={props.onRemove} | ||
| 216 | > | ||
| 217 | Remove | ||
| 218 | </Button> | ||
| 219 | </div> | ||
| 220 | |||
| 221 | <FieldGroup className="grid grid-cols-2 gap-3"> | ||
| 222 | <Field> | ||
| 223 | <FieldLabel htmlFor={nameInputId}>Name</FieldLabel> | ||
| 224 | <Input | ||
| 225 | id={nameInputId} | ||
| 226 | value={props.param.name} | ||
| 227 | onChange={(event) => props.onChange({ name: event.currentTarget.value })} | ||
| 228 | placeholder="token" | ||
| 229 | className="font-mono" | ||
| 230 | /> | ||
| 231 | </Field> | ||
| 232 | <Field> | ||
| 233 | <FieldLabel htmlFor={prefixInputId}> | ||
| 234 | Prefix <span className="font-normal text-muted-foreground/60">(optional)</span> | ||
| 235 | </FieldLabel> | ||
| 236 | <Input | ||
| 237 | id={prefixInputId} | ||
| 238 | value={props.param.prefix ?? ""} | ||
| 239 | onChange={(event) => props.onChange({ prefix: event.currentTarget.value || undefined })} | ||
| 240 | placeholder="Bearer " | ||
| 241 | className="font-mono" | ||
| 242 | /> | ||
| 243 | </Field> | ||
| 244 | </FieldGroup> | ||
| 245 | |||
| 246 | <CreatableSecretPicker | ||
| 247 | value={props.param.secretId} | ||
| 248 | onSelect={(secretId) => props.onChange({ secretId, literalValue: undefined })} | ||
| 249 | secrets={props.existingSecrets} | ||
| 250 | placeholder="Select a secret" | ||
| 251 | sourceName={props.sourceName} | ||
| 252 | secretLabel={secretLabel} | ||
| 253 | targetScope={props.targetScope} | ||
| 254 | /> | ||
| 255 | |||
| 256 | {!props.param.secretId && props.param.literalValue !== undefined && ( | ||
| 257 | <Field> | ||
| 258 | <FieldLabel htmlFor={literalInputId}>Literal value</FieldLabel> | ||
| 259 | <Input | ||
| 260 | id={literalInputId} | ||
| 261 | value={props.param.literalValue} | ||
| 262 | onChange={(event) => props.onChange({ literalValue: event.currentTarget.value })} | ||
| 263 | placeholder="value" | ||
| 264 | className="font-mono" | ||
| 265 | /> | ||
| 266 | </Field> | ||
| 267 | )} | ||
| 268 | </div> | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 272 | function AddQueryParamRow(props: { | ||
| 273 | readonly onClick: () => void; | ||
| 274 | readonly leading?: React.ReactNode; | ||
| 275 | }) { | ||
| 276 | return ( | ||
| 277 | <CardStackEntry | ||
| 278 | asChild | ||
| 279 | className="justify-between gap-4 px-0 py-0 text-sm text-muted-foreground" | ||
| 280 | > | ||
| 281 | <Button | ||
| 282 | type="button" | ||
| 283 | variant="ghost" | ||
| 284 | onClick={(event) => { | ||
| 285 | event.stopPropagation(); | ||
| 286 | props.onClick(); | ||
| 287 | }} | ||
| 288 | aria-label="Add query parameter" | ||
| 289 | className="h-auto w-full justify-between rounded-none px-4 py-3 text-left text-muted-foreground hover:bg-accent/40 focus-visible:bg-accent/40" | ||
| 290 | > | ||
| 291 | <span className="min-w-0 flex-1">{props.leading}</span> | ||
| 292 | <PlusIcon aria-hidden className="size-4 shrink-0" /> | ||
| 293 | </Button> | ||
| 294 | </CardStackEntry> | ||
| 295 | ); | ||
| 296 | } | ||
| @@ -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 | ||
| 218 | function ValueField(props: { revealable?: boolean; placeholder?: string }) { | 218 | function 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.revealed | 237 | revealable && !state.revealed |
| @@ -1,11 +1,30 @@ | |||
| 1 | import { useId, useState } from "react"; | 1 | import { useId, useState, type ReactNode } from "react"; |
| 2 | 2 | ||
| 3 | import { type ScopeId } from "@executor-js/sdk"; | 3 | import { ScopeId } from "@executor-js/sdk"; |
| 4 | import { Button } from "../components/button"; | 4 | import { Button } from "../components/button"; |
| 5 | import { Field, FieldGroup, FieldLabel } from "../components/field"; | 5 | import { Field, FieldGroup, FieldLabel } from "../components/field"; |
| 6 | import { HelpTooltip } from "../components/help-tooltip"; | ||
| 6 | import { Input } from "../components/input"; | 7 | import { Input } from "../components/input"; |
| 8 | import { | ||
| 9 | Dialog, | ||
| 10 | DialogContent, | ||
| 11 | DialogDescription, | ||
| 12 | DialogHeader, | ||
| 13 | DialogTitle, | ||
| 14 | } from "../components/dialog"; | ||
| 15 | import { | ||
| 16 | Select, | ||
| 17 | SelectContent, | ||
| 18 | SelectItem, | ||
| 19 | SelectTrigger, | ||
| 20 | SelectValue, | ||
| 21 | } from "../components/select"; | ||
| 7 | import { SecretForm } from "./secret-form"; | 22 | import { SecretForm } from "./secret-form"; |
| 8 | import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; | 23 | import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; |
| 24 | import { | ||
| 25 | CredentialTargetScopeSelector, | ||
| 26 | type CredentialTargetScopeOption, | ||
| 27 | } from "./credential-target-scope"; | ||
| 9 | 28 | ||
| 10 | export const secretsForCredentialTarget = ( | 29 | export const secretsForCredentialTarget = ( |
| 11 | secrets: readonly SecretPickerSecret[], | 30 | secrets: readonly SecretPickerSecret[], |
| @@ -21,8 +40,18 @@ export interface HeaderAuthPreset { · 9 unmodified lines | |||
| 21 | } | 40 | } |
| 22 | 41 | ||
| 23 | export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ | 42 | export 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 | ||
| 33 | export function InlineCreateSecret(props: { | 62 | function 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.Provider | 75 | <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 | Cancel | 101 | <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 | ||
| 69 | function HeaderValuePreview(props: { headerName: string; secretId: string; prefix?: string }) { | 112 | export 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 | |||
| 131 | function CreateSecretDialog(props: { | ||
| 132 | readonly open: boolean; | ||
| 133 | readonly onOpenChange: (open: boolean) => void; | ||
| 134 | readonly suggestedName: string; | ||
| 135 | readonly existingSecretIds: readonly string[]; | ||
| 136 | readonly onCreated: (secretId: string, scopeId: ScopeId) => void; | ||
| 137 | readonly fallbackId?: string; | ||
| 138 | readonly targetScope: ScopeId; | ||
| 139 | readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; | ||
| 140 | }) { | ||
| 141 | return ( | ||
| 142 | <Dialog open={props.open} onOpenChange={props.onOpenChange}> | ||
| 143 | <DialogContent> | ||
| 144 | <DialogHeader> | ||
| 145 | <DialogTitle>New secret</DialogTitle> | ||
| 146 | <DialogDescription> | ||
| 147 | Create a reusable secret, then use it for this credential. | ||
| 148 | </DialogDescription> | ||
| 149 | </DialogHeader> | ||
| 150 | <CreateSecretContent | ||
| 151 | suggestedName={props.suggestedName} | ||
| 152 | existingSecretIds={props.existingSecretIds} | ||
| 153 | fallbackId={props.fallbackId} | ||
| 154 | onCreated={props.onCreated} | ||
| 155 | onCancel={() => props.onOpenChange(false)} | ||
| 156 | targetScope={props.targetScope} | ||
| 157 | credentialScopeOptions={props.credentialScopeOptions} | ||
| 158 | /> | ||
| 159 | </DialogContent> | ||
| 160 | </Dialog> | ||
| 161 | ); | ||
| 162 | } | ||
| 163 | |||
| 164 | export type SecretCredentialPreviewProps = { | ||
| 165 | readonly name: string; | ||
| 166 | readonly secretId: string; | ||
| 167 | readonly prefix?: string; | ||
| 168 | }; | ||
| 169 | |||
| 170 | export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode; | ||
| 171 | |||
| 172 | export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) { | ||
| 173 | const { name, prefix } = props; | ||
| 174 | const maskedValue = "•".repeat(12); | ||
| 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 | |||
| 187 | export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewProps) { | ||
| 188 | const { name, prefix } = props; | ||
| 189 | const maskedValue = "•".repeat(12); | ||
| 190 | |||
| 191 | return ( | ||
| 192 | <div className="rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs"> | ||
| 193 | <span className="text-muted-foreground">?{name}=</span> | ||
| 194 | <span className="text-foreground"> | ||
| 195 | {prefix && <span className="text-muted-foreground">{prefix}</span>} | ||
| 196 | {maskedValue} | ||
| 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 | ||
| 95 | export function matchPresetKey(name: string, prefix?: string): string { | 218 | export 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 | ||
| 225 | function InfoLabel(props: { readonly children: string; readonly tooltip: string }) { | ||
| 226 | return ( | ||
| 227 | <div className="flex items-center gap-1.5"> | ||
| 228 | <FieldLabel>{props.children}</FieldLabel> | ||
| 229 | <HelpTooltip label={props.children}>{props.tooltip}</HelpTooltip> | ||
| 230 | </div> | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | export type SecretCredentialRowCopy = { | ||
| 235 | readonly rowLabel: string; | ||
| 236 | readonly nameLabel: string; | ||
| 237 | readonly namePlaceholder: string; | ||
| 238 | readonly prefixLabel: string; | ||
| 239 | readonly prefixPlaceholder: string; | ||
| 240 | readonly secretLabel: string; | ||
| 241 | readonly secretHelp: string; | ||
| 242 | readonly usedByLabel: string; | ||
| 243 | readonly usedByHelp: string; | ||
| 244 | }; | ||
| 245 | |||
| 246 | const defaultSecretCredentialRowCopy: SecretCredentialRowCopy = { | ||
| 247 | rowLabel: "Header", | ||
| 248 | nameLabel: "Name", | ||
| 249 | namePlaceholder: "Authorization", | ||
| 250 | prefixLabel: "Prefix", | ||
| 251 | prefixPlaceholder: "Bearer ", | ||
| 252 | secretLabel: "Secret", | ||
| 253 | secretHelp: "Select or create a reusable secret.", | ||
| 254 | usedByLabel: "Used by", | ||
| 255 | usedByHelp: "Choose who uses this credential value.", | ||
| 256 | }; | ||
| 257 | |||
| 102 | export function headerValueToState( | 258 | export 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"). Used | 313 | * 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 different | 314 | * 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 | <InlineCreateSecret | 355 | <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 | <Button | 372 | <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 | <Input | 387 | <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 | <Input | 406 | <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 | <SecretPicker | 422 | <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 | ||
| 264 | export function CreatableSecretPicker(props: { | 481 | export 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 | <InlineCreateSecret | 517 | <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 | <SecretPicker | 535 | <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 | /> |
| @@ -2,6 +2,7 @@ import { useState, type ChangeEvent, type FocusEvent } from "react"; · 1 unmodified line | |||
| 2 | import { PlusIcon } from "lucide-react"; | 2 | import { PlusIcon } from "lucide-react"; |
| 3 | 3 | ||
| 4 | import { Input } from "../components/input"; | 4 | import { Input } from "../components/input"; |
| 5 | import { Badge } from "../components/badge"; | ||
| 5 | import { | 6 | import { |
| 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"; |
| 13 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; | 14 | import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; |
| 15 | import { useScopeStack } from "../api/scope-context"; | ||
| 14 | 16 | ||
| 15 | export interface SecretPickerSecret { | 17 | export 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 | ||
| 34 | export function SecretPicker(props: { | 36 | export 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 | <CommandItem | 157 | <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> |
| @@ -0,0 +1,24 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | domainLabelFromUrl, | ||
| 5 | pascalCaseDomainLabel, | ||
| 6 | sourceDisplayNameFromUrl, | ||
| 7 | } from "./source-identity"; | ||
| 8 | |||
| 9 | describe("source identity URL display names", () => { | ||
| 10 | it("uses the apex domain label without the public suffix", () => { | ||
| 11 | expect(domainLabelFromUrl("https://api.example.co.uk/graphql")).toBe("example"); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("normalizes domain labels to PascalCase", () => { | ||
| 15 | expect(pascalCaseDomainLabel("my-api")).toBe("MyApi"); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("appends the source kind to the PascalCase domain label", () => { | ||
| 19 | expect(sourceDisplayNameFromUrl("https://mcp.linear.app/sse", "MCP")).toBe("Linear MCP"); | ||
| 20 | expect(sourceDisplayNameFromUrl("https://api.shopify.com/graphql", "GraphQL")).toBe( | ||
| 21 | "Shopify GraphQL", | ||
| 22 | ); | ||
| 23 | }); | ||
| 24 | }); | ||
| @@ -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 | ||
| 23 | export function domainLabelFromUrl(url: string): string | null { | ||
| 24 | const trimmed = url.trim(); | ||
| 25 | if (!trimmed) return null; | ||
| 26 | return parse(trimmed).domainWithoutSuffix ?? null; | ||
| 27 | } | ||
| 28 | |||
| 29 | export function pascalCaseDomainLabel(label: string): string | null { | ||
| 30 | const words = label | ||
| 31 | .split(/[^a-z0-9]+/i) | ||
| 32 | .map((word) => word.trim()) | ||
| 33 | .filter(Boolean); | ||
| 34 | if (words.length === 0) return null; | ||
| 35 | return words | ||
| 36 | .map((word) => { | ||
| 37 | const normalized = word.toLowerCase(); | ||
| 38 | return normalized.charAt(0).toUpperCase() + normalized.slice(1); | ||
| 39 | }) | ||
| 40 | .join(""); | ||
| 41 | } | ||
| 42 | |||
| 43 | export function sourceDisplayNameFromUrl(url: string, sourceKind: string): string | null { | ||
| 44 | const label = domainLabelFromUrl(url); | ||
| 45 | const displayLabel = label ? pascalCaseDomainLabel(label) : null; | ||
| 46 | return displayLabel ? `${displayLabel} ${sourceKind}` : null; | ||
| 47 | } | ||
| 48 | |||
| 23 | // --------------------------------------------------------------------------- | 49 | // --------------------------------------------------------------------------- |
| 24 | // Hook — owns the name + namespace state with namespace auto-derivation | 50 | // Hook — owns the name + namespace state with namespace auto-derivation |
| 25 | // --------------------------------------------------------------------------- | 51 | // --------------------------------------------------------------------------- |