diff --git a/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql b/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql new file mode 100644 index 000000000..90339ea0f --- /dev/null +++ b/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql @@ -0,0 +1,2 @@ +ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint +CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id"); diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 53e4483a9..1653f238f 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1778192434062, "tag": "0014_repair_openapi_oauth_cutover_residue", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1778192434063, + "tag": "0015_add_credential_binding_secret_scope", + "breakpoints": true } ] } diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index 9a37ad694..4e8d73199 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -148,6 +148,7 @@ export const credential_binding = pgTable( kind: text("kind").notNull(), text_value: text("text_value"), secret_id: text("secret_id"), + secret_scope_id: text("secret_scope_id"), connection_id: text("connection_id"), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), @@ -161,6 +162,7 @@ export const credential_binding = pgTable( index("credential_binding_slot_key_idx").on(table.slot_key), index("credential_binding_kind_idx").on(table.kind), index("credential_binding_secret_id_idx").on(table.secret_id), + index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), index("credential_binding_connection_id_idx").on(table.connection_id), ], ); diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index e19660164..59443bbb8 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -830,6 +830,7 @@ describe("sources api (HTTP)", () => { value: { kind: "secret", secretId: SecretId.make("alice_pat"), + secretScopeId: ScopeId.make(aliceScope), }, }), ); @@ -858,6 +859,7 @@ describe("sources api (HTTP)", () => { value: { kind: "secret", secretId: SecretId.make("bob_pat"), + secretScopeId: ScopeId.make(bobScope), }, }), ); diff --git a/apps/local/drizzle/0010_add_credential_binding_secret_scope.sql b/apps/local/drizzle/0010_add_credential_binding_secret_scope.sql new file mode 100644 index 000000000..3bfb2bad4 --- /dev/null +++ b/apps/local/drizzle/0010_add_credential_binding_secret_scope.sql @@ -0,0 +1,2 @@ +ALTER TABLE `credential_binding` ADD COLUMN `secret_scope_id` text;--> statement-breakpoint +CREATE INDEX `credential_binding_secret_scope_id_idx` ON `credential_binding` (`secret_scope_id`); diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index ab41d8ba7..6654a84a3 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1778192434062, "tag": "0009_repair_openapi_oauth_cutover_residue", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1778192434063, + "tag": "0010_add_credential_binding_secret_scope", + "breakpoints": true } ] } diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/server/executor-schema.ts index c17922e69..b9a465130 100644 --- a/apps/local/src/server/executor-schema.ts +++ b/apps/local/src/server/executor-schema.ts @@ -137,6 +137,7 @@ export const credential_binding = sqliteTable( kind: text("kind").notNull(), text_value: text("text_value"), secret_id: text("secret_id"), + secret_scope_id: text("secret_scope_id"), connection_id: text("connection_id"), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), @@ -150,6 +151,7 @@ export const credential_binding = sqliteTable( index("credential_binding_slot_key_idx").on(table.slot_key), index("credential_binding_kind_idx").on(table.kind), index("credential_binding_secret_id_idx").on(table.secret_id), + index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), index("credential_binding_connection_id_idx").on(table.connection_id), ], ); diff --git a/apps/local/src/server/migrate-oauth-connections.test.ts b/apps/local/src/server/migrate-oauth-connections.test.ts index ef2dbd2be..7c35a9d7c 100644 --- a/apps/local/src/server/migrate-oauth-connections.test.ts +++ b/apps/local/src/server/migrate-oauth-connections.test.ts @@ -158,9 +158,7 @@ describe("0009_repair_openapi_oauth_cutover_residue", () => { ); `); - db.prepare( - "INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)", - ).run( + db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( "dealcloud_api", "org-1", JSON.stringify({ diff --git a/notes/product-scope-language.md b/notes/product-scope-language.md new file mode 100644 index 000000000..50f2946dc --- /dev/null +++ b/notes/product-scope-language.md @@ -0,0 +1,47 @@ +# Product Scope Language + +Executor has a real scope model, but the product should mostly avoid saying +"scope" to users. Use ownership and usage language instead. + +## Product Terms + +- **Personal**: only this user can use or update the credential/connection. +- **Organization**: everyone with access to the source can use the shared + credential/connection. +- **Source owner**: where the source definition and shared auth method live. + This is usually implicit from the current page/context and should not be + shown as debug information. +- **Used by**: who uses a specific credential value for a shared auth slot. +- **Saved to**: where a newly created secret or OAuth token/connection is + stored. + +## UI Rules + +- Communicate source auth as two separate choices: + - the shared authentication method for the source, such as bearer header, + query parameter, or OAuth; + - the credential/connection value used for that method. +- Do not imply users can change the auth method per person when the backend only + allows credential values to vary per scope. +- Put secret storage choices in the new-secret flow, because choosing Personal + or Organization there creates a reusable secret at that ownership level. +- Put credential usage choices next to the credential picker as **Used by**, + because attaching a secret to a source slot is separate from where the secret + itself is stored. +- Put OAuth token/connection storage next to **Connect via OAuth** as **Token + saved to**. This is independent from OAuth client ID/client secret storage. +- Secret lists should show secrets from all visible ownership levels with a + Personal/Organization badge. + +## Preferred Copy + +- Use "Personal" and "Organization" for selectors. +- Use "Used by" for source credential bindings. +- Use "Save secret to" in secret creation. +- Use "Token saved to" for OAuth sign-in results. +- Use "Add without credentials" whenever the source can be added with missing + initial credential values, not only for OAuth. + +Avoid copy like "scope", "target scope", "source scope", "binding scope", or +"credential target scope" in product UI unless it is explicitly a developer or +debug surface. diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index fc4fc37a4..aa0fe76b6 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -182,6 +182,7 @@ export const coreSchema = { kind: { type: credentialBindingKinds, required: true, index: true }, text_value: { type: "string", required: false }, secret_id: { type: "string", required: false, index: true }, + secret_scope_id: { type: "string", required: false, index: true }, connection_id: { type: "string", required: false, index: true }, created_at: { type: "date", required: true }, updated_at: { type: "date", required: true }, @@ -244,7 +245,7 @@ export type ConnectionRow = InferDBFieldsOutput; type CredentialBindingRowBase = Omit< CredentialBindingRowFields, - "kind" | "text_value" | "secret_id" | "connection_id" + "kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id" >; export type CredentialBindingRow = CredentialBindingRowBase & @@ -256,6 +257,7 @@ export type CredentialBindingRow = CredentialBindingRowBase & | { kind: "secret"; secret_id: string; + secret_scope_id?: string; } | { kind: "connection"; diff --git a/packages/core/sdk/src/credential-bindings.test.ts b/packages/core/sdk/src/credential-bindings.test.ts index b843d7cab..b635e609a 100644 --- a/packages/core/sdk/src/credential-bindings.test.ts +++ b/packages/core/sdk/src/credential-bindings.test.ts @@ -437,7 +437,10 @@ describe("credential bindings", () => { sourceId: TEST_SOURCE_ID, sourceScope: harness.scopes.org.id, slotKey: "oauth.connection", - value: { kind: "connection", connectionId: ConnectionId.make("oauth-connection") }, + value: { + kind: "connection", + connectionId: ConnectionId.make("oauth-connection"), + }, }); const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]); @@ -608,4 +611,127 @@ describe("credential bindings", () => { expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); }), ); + + it.effect("a personal binding can point at an organization-owned secret", () => + Effect.gen(function* () { + const harness = makeHarness(); + const orgExecutor = yield* harness.create([harness.scopes.org]); + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); + yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); + + const userExecutor = yield* harness.create([ + harness.scopes.userWorkspaceA, + harness.scopes.org, + ]); + + const binding = yield* userExecutor.credentialBindings.set({ + targetScope: harness.scopes.userWorkspaceA.id, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: harness.scopes.org.id, + slotKey: TEST_SLOT, + value: { + kind: "secret", + secretId: SecretId.make("shared-token-id"), + secretScopeId: harness.scopes.org.id, + }, + }); + + expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id); + expect(binding.value).toMatchObject({ + kind: "secret", + secretId: SecretId.make("shared-token-id"), + secretScopeId: harness.scopes.org.id, + }); + + const resolved = yield* userExecutor.credentialBindings.resolve({ + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: harness.scopes.org.id, + slotKey: TEST_SLOT, + }); + + expect(resolved.status).toBe("resolved"); + expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id); + }), + ); + + it.effect( + "removing an organization secret is blocked when a personal binding references it", + () => + Effect.gen(function* () { + const harness = makeHarness(); + const orgExecutor = yield* harness.create([harness.scopes.org]); + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); + yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); + + const userExecutor = yield* harness.create([ + harness.scopes.userWorkspaceA, + harness.scopes.org, + ]); + yield* userExecutor.credentialBindings.set({ + targetScope: harness.scopes.userWorkspaceA.id, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: harness.scopes.org.id, + slotKey: TEST_SLOT, + value: { + kind: "secret", + secretId: SecretId.make("shared-token-id"), + secretScopeId: harness.scopes.org.id, + }, + }); + + const result = yield* Effect.result( + userExecutor.secrets.remove( + new RemoveSecretInput({ + id: SecretId.make("shared-token-id"), + targetScope: harness.scopes.org.id, + }), + ), + ); + + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); + }), + ); + + it.effect("rejects an organization binding to a personal secret", () => + Effect.gen(function* () { + const harness = makeHarness(); + const orgExecutor = yield* harness.create([harness.scopes.org]); + yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); + + const userExecutor = yield* harness.create([ + harness.scopes.userWorkspaceA, + harness.scopes.org, + ]); + yield* setSecret( + userExecutor, + harness.scopes.userWorkspaceA.id, + "personal-token", + "sk-user-a", + ); + + const result = yield* Effect.result( + userExecutor.credentialBindings.set({ + targetScope: harness.scopes.org.id, + pluginId: TEST_PLUGIN_ID, + sourceId: TEST_SOURCE_ID, + sourceScope: harness.scopes.org.id, + slotKey: TEST_SLOT, + value: { + kind: "secret", + secretId: SecretId.make("personal-token"), + secretScopeId: harness.scopes.userWorkspaceA.id, + }, + }), + ); + + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("StorageError")(result.failure)).toBe(true); + }), + ); }); diff --git a/packages/core/sdk/src/credential-bindings.ts b/packages/core/sdk/src/credential-bindings.ts index c4851fbbd..7898d4305 100644 --- a/packages/core/sdk/src/credential-bindings.ts +++ b/packages/core/sdk/src/credential-bindings.ts @@ -17,6 +17,7 @@ export const CredentialBindingValue = Schema.Union([ Schema.Struct({ kind: Schema.Literal("secret"), secretId: SecretId, + secretScopeId: Schema.optional(ScopeId), }), Schema.Struct({ kind: Schema.Literal("connection"), @@ -36,6 +37,14 @@ export class ConfiguredCredentialBinding extends Schema.Class( "CredentialBindingRef", )({ @@ -172,9 +181,10 @@ export const credentialBindingValueFromRow = (row: CredentialBindingRow): Creden kind: "text" as const, text: text_value, })), - Match.when({ kind: "secret" }, ({ secret_id }) => ({ + Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({ kind: "secret" as const, secretId: SecretId.make(secret_id), + secretScopeId: ScopeId.make(secret_scope_id ?? scope_id), })), Match.when({ kind: "connection" }, ({ connection_id }) => ({ kind: "connection" as const, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 80493f441..ca5ed827b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -900,7 +900,10 @@ export const createExecutor = ): Effect.Effect => Effect.gen(function* () { yield* assertScopeInStack("secret get scope", scope); - const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope }); + const row = yield* findSecretRowAtScope({ + secretId: id, + scopeId: scope, + }); if (row?.owned_by_connection_id) { return yield* new SecretOwnedByConnectionError({ secretId: SecretId.make(id), @@ -922,7 +925,10 @@ export const createExecutor = ): Effect.Effect => Effect.gen(function* () { yield* assertScopeInStack("connection secret get scope", scope); - const row = yield* findSecretRowAtScope({ secretId: id, scopeId: scope }); + const row = yield* findSecretRowAtScope({ + secretId: id, + scopeId: scope, + }); return yield* resolveSecretValueAtScope(row, id); }); @@ -1423,7 +1429,10 @@ export const createExecutor = ): Effect.Effect => Effect.gen(function* () { yield* assertScopeInStack("connection get scope", scope); - const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope }); + const row = yield* findConnectionRowAtScope({ + connectionId: id, + scopeId: scope, + }); return row ? rowToConnection(row) : null; }); @@ -1962,7 +1971,10 @@ export const createExecutor = ): Effect.Effect => Effect.gen(function* () { yield* assertScopeInStack("connection accessToken scope", scope); - const row = yield* findConnectionRowAtScope({ connectionId: id, scopeId: scope }); + const row = yield* findConnectionRowAtScope({ + connectionId: id, + scopeId: scope, + }); if (!row) { return yield* new ConnectionNotFoundError({ connectionId: ConnectionId.make(id), @@ -2139,15 +2151,25 @@ export const createExecutor = } if (input.value.kind === "secret") { + const secretScope = input.value.secretScopeId ?? input.targetScope; + yield* assertScopeInStack("credential binding secretScope", secretScope); + if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) { + return yield* new StorageError({ + message: + `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}" ` + + `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`, + cause: undefined, + }); + } const secret = yield* findSecretRowAtScope({ secretId: input.value.secretId, - scopeId: input.targetScope, + scopeId: secretScope, }); if (!secret) { return yield* new StorageError({ message: - `Cannot bind secret "${input.value.secretId}" at scope "${input.targetScope}": ` + - `the secret must be owned by the same scope as the binding.`, + `Cannot bind secret "${input.value.secretId}" from scope "${secretScope}": ` + + `the secret must be visible and owned by that scope.`, cause: undefined, }); } @@ -2192,6 +2214,10 @@ export const createExecutor = kind: input.value.kind, text_value: input.value.kind === "text" ? input.value.text : undefined, secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, + secret_scope_id: + input.value.kind === "secret" + ? (input.value.secretScopeId ?? input.targetScope) + : undefined, connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, created_at: now, updated_at: now, @@ -2208,6 +2234,10 @@ export const createExecutor = kind: input.value.kind, text_value: input.value.kind === "text" ? input.value.text : undefined, secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, + secret_scope_id: + input.value.kind === "secret" + ? (input.value.secretScopeId ?? input.targetScope) + : undefined, connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, created_at: now, updated_at: now, @@ -2347,7 +2377,7 @@ export const createExecutor = if (!row.secret_id) return "missing"; const secret = yield* findSecretRowAtScope({ secretId: row.secret_id, - scopeId: row.scope_id, + scopeId: row.secret_scope_id ?? row.scope_id, }); if (!secret) return "missing"; return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing"; @@ -2416,7 +2446,9 @@ export const createExecutor = (row) => new Usage({ pluginId: row.plugin_id, - scopeId: ScopeId.make(row.scope_id), + scopeId: ScopeId.make( + row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id, + ), ownerKind: "credential-binding", ownerId: row.source_id, ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null, @@ -2882,7 +2914,11 @@ export const createExecutor = const defsMap = new Map(Object.entries(defs)); const preview = yield* Effect.sync(() => - buildToolTypeScriptPreview({ inputSchema, outputSchema, defs: defsMap }), + buildToolTypeScriptPreview({ + inputSchema, + outputSchema, + defs: defsMap, + }), ).pipe( Effect.withSpan("schema.compile.preview", { attributes: { diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 999facb89..00eb1dd94 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -129,6 +129,7 @@ export { CredentialBindingValue, ConfiguredCredentialBinding, ConfiguredCredentialValue, + ScopedSecretCredentialInput, CredentialBindingRef, SetCredentialBindingInput, CredentialBindingSourceInput, diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 92edf8093..dda75c677 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -16,7 +16,7 @@ // construction keeps the call sync and lets callers opt out of PAR // --------------------------------------------------------------------------- -import { Data, Effect } from "effect"; +import { Data, Effect, Predicate } from "effect"; import * as oauth from "oauth4webapi"; // --------------------------------------------------------------------------- @@ -121,11 +121,7 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string // to reauth-required) across wrappers. // --------------------------------------------------------------------------- -const isOAuth2Error = (cause: unknown): cause is OAuth2Error => - typeof cause === "object" && - cause !== null && - "_tag" in cause && - (cause as { readonly _tag?: unknown })._tag === "OAuth2Error"; +const isOAuth2Error = Predicate.isTagged("OAuth2Error") as (cause: unknown) => cause is OAuth2Error; const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => { if (cause instanceof Response) return cause; @@ -200,19 +196,26 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { }); }; -const toOAuth2ErrorWithHttpSummary = async (cause: unknown): Promise => { - if (isOAuth2Error(cause)) return cause; +const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect => { + if (isOAuth2Error(cause)) return Effect.succeed(cause); const base = toOAuth2Error(cause); const response = responseFromOAuthErrorCause(cause); - if (!response) return base; - const summary = await tokenEndpointHttpSummary(response); - return new OAuth2Error({ - message: `${base.message} (${summary})`, - error: base.error, - cause, - }); + if (!response) return Effect.succeed(base); + return Effect.promise(() => tokenEndpointHttpSummary(response)).pipe( + Effect.map( + (summary) => + new OAuth2Error({ + message: `${base.message} (${summary})`, + error: base.error, + cause, + }), + ), + ); }; +const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect => + toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error))); + // --------------------------------------------------------------------------- // oauth4webapi adapter helpers // --------------------------------------------------------------------------- @@ -348,40 +351,36 @@ export const exchangeAuthorizationCode = ( ): Effect.Effect => Effect.tryPromise({ try: async () => { - try { - const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { - idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, - }); - const client: oauth.Client = { client_id: input.clientId }; - const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); - // `authorizationCodeGrantRequest` requires its `callbackParameters` - // to have been returned from `validateAuthResponse`. Our public API - // takes the `code` directly (the UI already validated `state` by - // looking up the session), so skip the library's state-validation - // rail and go through the generic grant request instead. - const params = new URLSearchParams({ - code: input.code, - redirect_uri: input.redirectUrl, - code_verifier: input.codeVerifier, - }); - if (input.resource) { - params.set("resource", input.resource); - } - const response = await oauth.genericTokenEndpointRequest( - as, - client, - clientAuth, - "authorization_code", - params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), - ); - return await processTokenEndpointResponse(as, client, response); - } catch (cause) { - throw await toOAuth2ErrorWithHttpSummary(cause); + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { + idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, + }); + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); + // `authorizationCodeGrantRequest` requires its `callbackParameters` + // to have been returned from `validateAuthResponse`. Our public API + // takes the `code` directly (the UI already validated `state` by + // looking up the session), so skip the library's state-validation + // rail and go through the generic grant request instead. + const params = new URLSearchParams({ + code: input.code, + redirect_uri: input.redirectUrl, + code_verifier: input.codeVerifier, + }); + if (input.resource) { + params.set("resource", input.resource); } + const response = await oauth.genericTokenEndpointRequest( + as, + client, + clientAuth, + "authorization_code", + params, + oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), + ); + return await processTokenEndpointResponse(as, client, response); }, - catch: toOAuth2Error, - }); + catch: (cause) => cause, + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); // --------------------------------------------------------------------------- // Exchange client credentials → tokens (RFC 6749 §4.4) @@ -402,29 +401,25 @@ export const exchangeClientCredentials = ( ): Effect.Effect => Effect.tryPromise({ try: async () => { - try { - const as = asFromTokenUrl(input.tokenUrl); - const client: oauth.Client = { client_id: input.clientId }; - const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); - const params = new URLSearchParams(); - if (input.scopes && input.scopes.length > 0) { - params.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); - } - const response = await oauth.clientCredentialsGrantRequest( - as, - client, - clientAuth, - params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), - ); - const result = await oauth.processClientCredentialsResponse(as, client, response); - return tokenResponseFrom(result); - } catch (cause) { - throw await toOAuth2ErrorWithHttpSummary(cause); + const as = asFromTokenUrl(input.tokenUrl); + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); + const params = new URLSearchParams(); + if (input.scopes && input.scopes.length > 0) { + params.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); } + const response = await oauth.clientCredentialsGrantRequest( + as, + client, + clientAuth, + params, + oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), + ); + const result = await oauth.processClientCredentialsResponse(as, client, response); + return tokenResponseFrom(result); }, - catch: toOAuth2Error, - }); + catch: (cause) => cause, + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); // --------------------------------------------------------------------------- // Refresh access token @@ -452,43 +447,39 @@ export const refreshAccessToken = ( ): Effect.Effect => Effect.tryPromise({ try: async () => { - try { - const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { - idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, - }); - const client: oauth.Client = { client_id: input.clientId }; - const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); - const extraParams = new URLSearchParams(); - if (input.scopes && input.scopes.length > 0) { - extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); - } - if (input.resource) { - extraParams.set("resource", input.resource); - } - const additionalParameters = - Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; - const response = await oauth.refreshTokenGrantRequest( - as, - client, - clientAuth, - input.refreshToken, - { - ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), - additionalParameters, - }, - ); - const result = await oauth.processRefreshTokenResponse( - as, - client, - await stripIdToken(response), - ); - return tokenResponseFrom(result); - } catch (cause) { - throw await toOAuth2ErrorWithHttpSummary(cause); + const as = asFromTokenUrlAndIssuer(input.tokenUrl, input.issuerUrl, { + idTokenSigningAlgValuesSupported: input.idTokenSigningAlgValuesSupported, + }); + const client: oauth.Client = { client_id: input.clientId }; + const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body"); + const extraParams = new URLSearchParams(); + if (input.scopes && input.scopes.length > 0) { + extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " ")); } + if (input.resource) { + extraParams.set("resource", input.resource); + } + const additionalParameters = + Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; + const response = await oauth.refreshTokenGrantRequest( + as, + client, + clientAuth, + input.refreshToken, + { + ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs), + additionalParameters, + }, + ); + const result = await oauth.processRefreshTokenResponse( + as, + client, + await stripIdToken(response), + ); + return tokenResponseFrom(result); }, - catch: toOAuth2Error, - }); + catch: (cause) => cause, + }).pipe(Effect.catch(failOAuth2WithHttpSummary)); // --------------------------------------------------------------------------- // Refresh-needed predicate diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 2dea2201b..a300bc952 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -9,11 +9,12 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { HttpCredentialsEditor, httpCredentialsValid, + serializeScopedHttpCredentials, serializeHttpCredentials, type HttpCredentialsState, } from "@executor-js/react/plugins/http-credentials"; import { - displayNameFromUrl, + sourceDisplayNameFromUrl, slugifyNamespace, SourceIdentityFieldRows, useSourceIdentity, @@ -25,7 +26,8 @@ import { type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; import { - CredentialScopeSection, + CredentialControlField, + CredentialUsageRow, useCredentialTargetScope, } from "@executor-js/react/plugins/credential-target-scope"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; @@ -61,7 +63,7 @@ export default function AddGraphqlSource(props: { }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); const identity = useSourceIdentity({ - fallbackName: displayNameFromUrl(endpoint) ?? "", + fallbackName: sourceDisplayNameFromUrl(endpoint, "GraphQL") ?? "", }); const [credentials, setCredentials] = useState(initialGraphqlCredentials); const [adding, setAdding] = useState(false); @@ -70,8 +72,12 @@ export default function AddGraphqlSource(props: { const [tokens, setTokens] = useState(null); const scopeId = useScope(); - const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = - useCredentialTargetScope(); + const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); + const { + credentialTargetScope: oauthCredentialTargetScope, + setCredentialTargetScope: setOAuthCredentialTargetScope, + credentialScopeOptions, + } = useCredentialTargetScope(); const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit", }); @@ -91,9 +97,10 @@ export default function AddGraphqlSource(props: { const trimmedEndpoint = endpoint.trim(); const namespace = slugifyNamespace(identity.namespace) || - slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") || + slugifyNamespace(sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") ?? "") || "graphql"; - const displayName = identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace; + const displayName = + identity.name.trim() || sourceDisplayNameFromUrl(trimmedEndpoint, "GraphQL") || namespace; return { trimmedEndpoint, namespace, displayName }; }, [endpoint, identity.name, identity.namespace]); @@ -109,7 +116,7 @@ export default function AddGraphqlSource(props: { ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), redirectUrl: oauthCallbackUrl(), connectionId: oauthConnectionId({ pluginId: "graphql", namespace }), - tokenScope: credentialTargetScope, + tokenScope: oauthCredentialTargetScope, strategy: { kind: "dynamic-dcr" }, pluginId: "graphql", identityLabel: `${displayName} OAuth`, @@ -123,12 +130,15 @@ export default function AddGraphqlSource(props: { }, onError: setAddError, }); - }, [endpoint, credentials, oauth, sourceIdentity, credentialTargetScope]); + }, [endpoint, credentials, oauth, sourceIdentity, oauthCredentialTargetScope]); const handleAdd = async () => { setAdding(true); setAddError(null); - const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials); + const { headers: headerMap, queryParams } = serializeScopedHttpCredentials( + credentials, + requestCredentialTargetScope, + ); const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); const exit = await doAdd({ @@ -144,7 +154,10 @@ export default function AddGraphqlSource(props: { queryParams: queryParams as Record, } : {}), - credentialTargetScope, + credentialTargetScope: + authMode === "oauth2" && tokens + ? oauthCredentialTargetScope + : requestCredentialTargetScope, ...(authMode === "oauth2" && tokens ? { auth: { @@ -185,24 +198,18 @@ export default function AddGraphqlSource(props: { - { - setCredentialTargetScope(targetScope); - setTokens(null); - }} - > - - + -
+ {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} +
Authentication @@ -219,27 +226,38 @@ export default function AddGraphqlSource(props: {
{authMode === "oauth2" && ( -
- {tokens ? ( - - Authenticated - - ) : ( - - Sign in before adding so Executor can introspect the schema. - - )} - -
+ { + setOAuthCredentialTargetScope(targetScope); + setTokens(null); + }} + label="Connection saved to" + help="Choose who can use the OAuth connection." + > + +
+ {tokens ? ( + + Authenticated + + ) : ( + Not connected + )} + +
+
+
)}
diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index e3663b4dc..1ac16d08a 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -222,7 +222,8 @@ function EditForm(props: { targetScope={credentialTargetScope} /> -
+ {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} +
Authentication diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 0d16f9396..4e31a9888 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -604,6 +604,68 @@ describe("graphqlPlugin", () => { }), ); + it.effect("addSource stores direct GraphQL credential bindings at each row scope", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + scopes: stackedScopes, + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, + }), + ); + + yield* executor.secrets.set({ + id: SecretId.make("row-user-token"), + scope: ScopeId.make(USER_SCOPE), + name: "User token", + value: "user-secret", + provider: "memory", + }); + yield* executor.secrets.set({ + id: SecretId.make("row-org-query"), + scope: ScopeId.make(ORG_SCOPE), + name: "Org query", + value: "org-secret", + provider: "memory", + }); + + yield* executor.graphql.addSource({ + endpoint: "https://example.com/graphql", + scope: ORG_SCOPE, + namespace: "row_scoped_credentials", + introspectionJson, + headers: { + Authorization: { + secretId: "row-user-token", + prefix: "Bearer ", + targetScope: USER_SCOPE, + }, + }, + queryParams: { + token: { + secretId: "row-org-query", + targetScope: ORG_SCOPE, + }, + }, + }); + + const bindings = yield* executor.graphql.listSourceBindings( + "row_scoped_credentials", + ORG_SCOPE, + ); + + expect(bindings.map((binding) => binding.slot).sort()).toEqual([ + graphqlHeaderSlot("Authorization"), + graphqlQueryParamSlot("token"), + ]); + expect( + bindings.find((binding) => binding.slot === graphqlHeaderSlot("Authorization"))?.scopeId, + ).toBe(ScopeId.make(USER_SCOPE)); + expect( + bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId, + ).toBe(ScopeId.make(ORG_SCOPE)); + }), + ); + it.effect("org header binding resolves the org secret when a user has the same secret id", () => Effect.gen(function* () { const server = yield* serveGreetingServer; diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 510336e40..debac364a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -424,6 +424,19 @@ const bindingTargetScope = ( ); }; +const targetScopeForBinding = ( + fallbackTargetScope: string | undefined, + binding: { readonly targetScope?: string }, +): Effect.Effect => { + const targetScope = binding.targetScope ?? fallbackTargetScope; + if (targetScope) return Effect.succeed(targetScope); + return Effect.fail( + new GraphqlIntrospectionError({ + message: "credentialTargetScope is required when adding direct GraphQL credentials", + }), + ); +}; + const canonicalizeCredentialMap = ( values: Record | undefined, slotForName: (name: string) => string, @@ -432,10 +445,15 @@ const canonicalizeCredentialMap = ( readonly bindings: ReadonlyArray<{ readonly slot: string; readonly value: GraphqlSourceBindingValue; + readonly targetScope?: string; }>; } => { const nextValues: Record = {}; - const bindings: Array<{ slot: string; value: GraphqlSourceBindingValue }> = []; + const bindings: Array<{ + slot: string; + value: GraphqlSourceBindingValue; + targetScope?: string; + }> = []; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { nextValues[name] = value; @@ -453,9 +471,13 @@ const canonicalizeCredentialMap = ( }); bindings.push({ slot, + targetScope: "targetScope" in value ? value.targetScope : undefined, value: { kind: "secret", secretId: SecretId.make(value.secretId), + ...("secretScopeId" in value && value.secretScopeId + ? { secretScopeId: value.secretScopeId } + : {}), }, }); } @@ -469,6 +491,7 @@ const canonicalizeAuth = ( readonly bindings: ReadonlyArray<{ readonly slot: string; readonly value: GraphqlSourceBindingValue; + readonly targetScope?: string; }>; } => { if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; @@ -606,8 +629,12 @@ const makeGraphqlExtension = ( if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; continue; } + const secretScope = + "secretScopeId" in value + ? (value.secretScopeId ?? value.targetScope) + : (params.targetScope ?? params.sourceScope); const secret = yield* ctx.secrets - .getAtScope(SecretId.make(value.secretId), params.targetScope ?? params.sourceScope) + .getAtScope(SecretId.make(value.secretId), secretScope) .pipe( Effect.catchTag("SecretOwnedByConnectionError", () => Effect.fail( @@ -682,14 +709,21 @@ const makeGraphqlExtension = ( ...canonicalQueryParams.bindings, ...canonicalAuth.bindings, ]; - const targetScope = yield* bindingTargetScope(config.credentialTargetScope, directBindings); - if (targetScope) { + for (const binding of directBindings) { + const bindingTargetScope = yield* targetScopeForBinding( + config.credentialTargetScope, + binding, + ); yield* validateGraphqlBindingTarget(ctx, { sourceId: namespace, sourceScope: config.scope, - targetScope, + targetScope: bindingTargetScope, }); } + const targetScope = + directBindings[0] !== undefined + ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) + : undefined; let introspectionResult: IntrospectionResult; if (config.introspectionJson) { @@ -769,10 +803,14 @@ const makeGraphqlExtension = ( }); } - if (targetScope) { + if (directBindings.length > 0) { for (const binding of directBindings) { + const bindingTargetScope = yield* targetScopeForBinding( + config.credentialTargetScope, + binding, + ); yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(targetScope), + targetScope: ScopeId.make(bindingTargetScope), pluginId: GRAPHQL_PLUGIN_ID, sourceId: namespace, sourceScope: ScopeId.make(config.scope), diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index 9f8d07044..30a6a72bb 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -3,6 +3,7 @@ import { ConfiguredCredentialValue as ConfiguredCredentialValueSchema, CredentialBindingValue, credentialSlotKey, + ScopedSecretCredentialInput, SecretBackedValue, ScopeId, } from "@executor-js/sdk/core"; @@ -68,7 +69,11 @@ export type QueryParamValue = typeof QueryParamValue.Type; export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValueSchema; export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; -export const GraphqlCredentialInput = Schema.Union([HeaderValue, ConfiguredGraphqlCredentialValue]); +export const GraphqlCredentialInput = Schema.Union([ + ScopedSecretCredentialInput, + HeaderValue, + ConfiguredGraphqlCredentialValue, +]); export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index e03665590..12374be58 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -27,17 +27,15 @@ import { Skeleton } from "@executor-js/react/components/skeleton"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { Textarea } from "@executor-js/react/components/textarea"; -import { HeadersList } from "@executor-js/react/plugins/headers-list"; import { emptyHttpCredentials, httpCredentialsValid, HttpCredentialsEditor, + serializeScopedHttpCredentials, serializeHttpCredentials, - type SecretBackedValue, } from "@executor-js/react/plugins/http-credentials"; -import { type HeaderState } from "@executor-js/react/plugins/secret-header-auth"; import { - displayNameFromUrl, + sourceDisplayNameFromUrl, slugifyNamespace, SourceIdentityFieldRows, SourceIdentityFields, @@ -51,15 +49,16 @@ import { type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; import { - CredentialScopeSection, + CredentialControlField, + CredentialUsageRow, useCredentialTargetScope, } from "@executor-js/react/plugins/credential-target-scope"; -type RemoteAuthMode = "none" | "header" | "oauth2"; +type RemoteAuthMode = "none" | "oauth2"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; import { mcpPresets, type McpPreset } from "../sdk/presets"; -import { MCP_OAUTH_CONNECTION_SLOT } from "../sdk/types"; +import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -101,7 +100,7 @@ type PlainHeader = { type State = | { step: "url"; url: string } - | { step: "probing"; url: string } + | { step: "probing"; url: string; probe: ProbeResult | null } | { step: "probed"; url: string; probe: ProbeResult } | { step: "oauth-starting"; url: string; probe: ProbeResult } | { @@ -148,7 +147,7 @@ function reducer(state: State, action: Action): State { return { step: "url", url: action.url }; case "probe-start": - return { step: "probing", url: state.url }; + return { step: "probing", url: state.url, probe: "probe" in state ? state.probe : null }; case "probe-ok": return { step: "probed", url: state.url, probe: action.probe }; @@ -292,8 +291,12 @@ export default function AddMcpSource(props: { ); const scopeId = useScope(); - const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = - useCredentialTargetScope(); + const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope(); + const { + credentialTargetScope: oauthCredentialTargetScope, + setCredentialTargetScope: setOAuthCredentialTargetScope, + credentialScopeOptions, + } = useCredentialTargetScope(); const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit", @@ -306,14 +309,6 @@ export default function AddMcpSource(props: { }); const [remoteAuthMode, setRemoteAuthMode] = useState("none"); - const [remoteAuthHeaders, setRemoteAuthHeaders] = useState([ - { - name: "Authorization", - prefix: "Bearer ", - presetKey: "bearer", - secretId: null, - }, - ]); const [remoteHeaders, setRemoteHeaders] = useState([]); const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); @@ -321,25 +316,19 @@ export default function AddMcpSource(props: { const tokens = "tokens" in state ? state.tokens : null; const remoteIdentity = useSourceIdentity({ - fallbackName: probe?.serverName ?? probe?.name ?? displayNameFromUrl(state.url) ?? "", + fallbackName: + sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", }); const isProbing = state.step === "probing"; const isAdding = state.step === "adding"; const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; const canUseNone = probe?.requiresOAuth !== true; - const remoteAuthHeader = remoteAuthHeaders[0]; - const headerAuthComplete = Boolean(remoteAuthHeader?.name.trim() && remoteAuthHeader?.secretId); const remoteHeadersComplete = remoteHeaders.every( (header) => header.name.trim() && header.value.trim(), ); const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); - const authReady = - remoteAuthMode === "none" - ? canUseNone - : remoteAuthMode === "header" - ? headerAuthComplete - : tokens !== null; + const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; const canAdd = Boolean(probe) && authReady && @@ -392,17 +381,11 @@ export default function AddMcpSource(props: { handleProbeRef.current(); }, 400); return () => clearTimeout(handle); - }, [transport, state.step, state.url, remoteCredentials]); + }, [transport, state.step, state.url]); - const handleRemoteCredentialsChange = useCallback( - (next: typeof remoteCredentials) => { - setRemoteCredentials(next); - if (state.step === "error" || state.step === "probed" || state.step === "oauth-done") { - dispatch({ type: "set-url", url: state.url }); - } - }, - [state], - ); + const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => { + setRemoteCredentials(next); + }, []); const handleOAuth = useCallback(async () => { dispatch({ type: "oauth-start" }); @@ -421,7 +404,7 @@ export default function AddMcpSource(props: { pluginId: "mcp", namespace: namespaceSlug, }), - tokenScope: credentialTargetScope, + tokenScope: oauthCredentialTargetScope, strategy: { kind: "dynamic-dcr" }, pluginId: "mcp", identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`, @@ -440,7 +423,7 @@ export default function AddMcpSource(props: { dispatch({ type: "oauth-waiting", sessionId: result.sessionId }), onError: (error) => dispatch({ type: "oauth-fail", error }), }); - }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, credentialTargetScope]); + }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]); const handleCancelOAuth = useCallback(() => { oauth.cancel(); @@ -450,33 +433,28 @@ export default function AddMcpSource(props: { const handleAddRemote = useCallback(async () => { if (!probe) return; dispatch({ type: "add-start" }); - const headerAuth = remoteAuthHeaders[0]; const auth = - remoteAuthMode === "header" && headerAuth?.secretId - ? { - kind: "header" as const, - headerName: headerAuth.name.trim(), - secretId: headerAuth.secretId, - ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), - } - : remoteAuthMode === "oauth2" - ? tokens - ? { - kind: "oauth2" as const, - connectionId: tokens.connectionId, - } - : { - kind: "oauth2" as const, - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - } - : { kind: "none" as const }; + remoteAuthMode === "oauth2" + ? tokens + ? { + kind: "oauth2" as const, + connectionId: tokens.connectionId, + } + : { + kind: "oauth2" as const, + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, + } + : { kind: "none" as const }; const headers = Object.fromEntries( remoteHeaders .map((header) => [header.name.trim(), header.value.trim()] as const) .filter(([name, value]) => name && value), ); - const credentials = serializeHttpCredentials(remoteCredentials); - const remoteRequestHeaders: Record = { + const credentials = serializeScopedHttpCredentials( + remoteCredentials, + requestCredentialTargetScope, + ); + const remoteRequestHeaders: Record = { ...headers, ...credentials.headers, }; @@ -491,7 +469,10 @@ export default function AddMcpSource(props: { namespace: slugNamespace || undefined, endpoint: state.url.trim(), auth, - credentialTargetScope, + credentialTargetScope: + remoteAuthMode === "oauth2" && tokens + ? oauthCredentialTargetScope + : requestCredentialTargetScope, ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}), ...(Object.keys(credentials.queryParams).length > 0 ? { queryParams: credentials.queryParams } @@ -510,7 +491,6 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteAuthHeaders, remoteHeaders, remoteCredentials, remoteIdentity, @@ -519,7 +499,8 @@ export default function AddMcpSource(props: { doAdd, props, scopeId, - credentialTargetScope, + requestCredentialTargetScope, + oauthCredentialTargetScope, ]); // ---- Stdio actions ---- @@ -714,6 +695,7 @@ export default function AddMcpSource(props: {
{probeError}
- {remoteAuthMode === "header" && ( - { - setCredentialTargetScope(targetScope); - dispatch({ type: "oauth-reset" }); - }} - > - - - )} - {remoteAuthMode === "oauth2" && ( - { - setCredentialTargetScope(targetScope); + setOAuthCredentialTargetScope(targetScope); dispatch({ type: "oauth-reset" }); }} - description="Choose who can use the OAuth connection." + label="Connection saved to" + help="Choose who can use the OAuth connection." > - {!tokens && state.step === "probed" && ( -
- -

- Sign in before adding so Executor can discover and save the tool catalog. -

-
- )} + )} - {!tokens && state.step === "oauth-starting" && ( -
- - Starting authorization… -
- )} + {!tokens && state.step === "oauth-starting" && ( +
+ + + Starting authorization... + +
+ )} - {!tokens && state.step === "oauth-waiting" && ( -
- - - Waiting for authorization in popup… - - -
- )} + {!tokens && state.step === "oauth-waiting" && ( +
+ + + Waiting for authorization... + + +
+ )} - {tokens && ( -
- - - - - Authenticated - -
- )} -
+ {tokens && ( +
+ + + + + Authenticated + +
+ )} + + )}
)} @@ -854,8 +824,8 @@ export default function AddMcpSource(props: {

- Plaintext headers sent with every request. Use authentication for secret-backed - auth headers. + Plaintext headers sent with every request. Use request headers above for + secret-backed values.

@@ -957,6 +927,7 @@ export default function AddMcpSource(props: {

{otherError}

{(probe || isProbing) && ( - - -

- You can add the source without this OAuth connection. Secured operations - will ask for credentials on the source detail page. -

+
+
+
+ OAuth sign-in + + Start the provider OAuth flow. + +
+ +
+ { + setOAuthTokenTargetScope(targetScope); + setOauth2AuthState(null); + }} + label="Token saved to" + help="Choose who can use the signed-in OAuth token." + /> +
)} @@ -1117,7 +1350,7 @@ export default function AddOpenApiSource(props: {

{oauth2Error}

)} - + )}
@@ -1140,7 +1373,7 @@ export default function AddOpenApiSource(props: { {adding && } {adding ? "Adding…" - : selectedOAuth2Preset && !oauth2Auth + : willAddWithoutInitialCredentials ? "Add without credentials" : "Add source"} diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 473732eff..e2c382a0a 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -170,8 +170,12 @@ export default function EditOpenApiSource(props: { const secretList = useSecretPickerSecrets(); const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" }); - const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); - const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { mode: "promiseExit" }); + const doSetBinding = useAtomSet(setOpenApiSourceBinding, { + mode: "promiseExit", + }); + const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { + mode: "promiseExit", + }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, @@ -196,11 +200,15 @@ export default function EditOpenApiSource(props: { readonly connectionId: string; } | null>(null); const [loadedSourceKey, setLoadedSourceKey] = useState(null); - const [selectedCredentialScope, setSelectedCredentialScope] = useState( + const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState( userScope !== sourceScopeId ? userScope : sourceScopeId, ); const sourceSaveSeq = useRef(0); + useEffect(() => { + setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId); + }, [sourceScopeId, userScope]); + useEffect(() => { if (!source) return; const sourceKey = `${sourceScopeId}:${source.namespace}`; @@ -211,10 +219,6 @@ export default function EditOpenApiSource(props: { setLoadedSourceKey(sourceKey); }, [loadedSourceKey, source, sourceScopeId]); - useEffect(() => { - setSelectedCredentialScope(userScope !== sourceScopeId ? userScope : sourceScopeId); - }, [sourceScopeId, userScope]); - useEffect(() => { if (!source) return; const sourceKey = `${sourceScopeId}:${source.namespace}`; @@ -313,15 +317,39 @@ export default function EditOpenApiSource(props: { if (userScope !== sourceScopeId) { entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" }); } else { - entries[0] = { scopeId: ScopeId.make(sourceScopeId), label: "Credentials" }; + entries[0] = { + scopeId: ScopeId.make(sourceScopeId), + label: "Credentials", + }; } return entries; }, [sourceScopeId, userScope]); - const activeCredentialScope = - credentialScopes.find((entry) => entry.scopeId === selectedCredentialScope) ?? + const credentialScopeOptions = useMemo( + () => + credentialScopes.map((entry) => ({ + scopeId: entry.scopeId, + label: entry.label, + description: + entry.label === "Personal" + ? "Saved only for your account." + : "Shared with everyone who can use this source.", + })), + [credentialScopes], + ); + const organizationCredentialScope = + credentialScopes.find((entry) => entry.label === "Organization") ?? credentialScopes[0]!; + const personalCredentialScope = + credentialScopes.find((entry) => entry.label === "Personal") ?? null; + const secretBindingScopes = + personalCredentialScope && + personalCredentialScope.scopeId !== organizationCredentialScope.scopeId + ? [organizationCredentialScope, personalCredentialScope] + : [organizationCredentialScope]; + const activeOAuthTokenScope = + credentialScopes.find((entry) => entry.scopeId === selectedOAuthTokenScope) ?? credentialScopes[0]!; - const activeCredentialScopeId = activeCredentialScope.scopeId; - const activeCredentialScopeLabel = activeCredentialScope.label; + const activeOAuthTokenScopeId = activeOAuthTokenScope.scopeId; + const activeOAuthTokenScopeLabel = activeOAuthTokenScope.label; if (!source) { return ( @@ -332,7 +360,12 @@ export default function EditOpenApiSource(props: { ); } - const setSecretBinding = async (targetScope: ScopeId, slot: string, secretId: string) => { + const setSecretBinding = async ( + targetScope: ScopeId, + slot: string, + secretId: string, + secretScope: ScopeId, + ) => { const inputKey = `${targetScope}:${slot}`; const trimmed = secretId.trim(); if (!trimmed) return; @@ -345,7 +378,11 @@ export default function EditOpenApiSource(props: { sourceScope, scope: targetScope, slot, - value: { kind: "secret", secretId: SecretId.make(trimmed) }, + value: { + kind: "secret", + secretId: SecretId.make(trimmed), + secretScopeId: secretScope, + }, }, reactivityKeys: sourceWriteKeys, }); @@ -562,10 +599,6 @@ export default function EditOpenApiSource(props: {

OpenAPI Source

-

- Shared source settings stay on the source. Credentials can be saved personally or shared - with the organization. -

@@ -612,101 +645,110 @@ export default function EditOpenApiSource(props: { - {credentialScopes.length > 1 && ( - - - Credentials - - Choose whether each credential is personal or shared with the organization. - - - ({ - value: entry.scopeId, - label: entry.label, - }))} - value={activeCredentialScopeId} - onChange={setSelectedCredentialScope} - /> - - )} + + + Secrets + + {secretSlots .filter((slot) => slot.kind === "secret") .map((slot) => { - const exact = exactBindingForScope(bindingRows, slot.slot, activeCredentialScopeId); - const effective = effectiveBindingForScope( - bindingRows, - slot.slot, - activeCredentialScopeId, - scopeRanks, - ); - const inputKey = `${activeCredentialScopeId}:${slot.slot}`; - const savedHere = !!(exact && isSecretBindingValue(exact.value)); - const inherited = - !savedHere && - effective && - effective.scopeId !== activeCredentialScopeId && - isSecretBindingValue(effective.value); - const currentSecretId = - exact && isSecretBindingValue(exact.value) - ? exact.value.secretId - : inherited && effective && isSecretBindingValue(effective.value) - ? effective.value.secretId - : null; return ( - -
- - void setSecretBinding(activeCredentialScopeId, slot.slot, secretId) - } - secrets={secretList} - placeholder={ - savedHere - ? `Selected in ${activeCredentialScopeLabel.toLowerCase()}` - : inherited - ? "Using organization default" - : "Select or create a secret" - } - targetScope={activeCredentialScopeId} - suggestedId={bindingSecretId( - props.sourceId, + +
+ {secretBindingScopes.map((bindingScope) => { + const exact = exactBindingForScope( + bindingRows, slot.slot, - activeCredentialScopeId, - )} - sourceName={source.name} - secretLabel={slot.label} - /> -
- {savedHere && ( - - )} - {busyKey === inputKey && ( - Saving… - )} - {slot.hint && ( - {slot.hint} - )} -
-

- {savedHere - ? `Saved in ${activeCredentialScopeLabel.toLowerCase()}.` - : inherited - ? "No value saved here. Using the organization default." - : `No ${activeCredentialScopeLabel.toLowerCase()} value saved yet.`} -

+
+
+
{rowTitle}
+
+ {bindingScope.label === "Personal" && + !exactSecretId && + inheritedSecretId && ( + + Using organization default + + )} +
+ { + void setSecretBinding( + bindingScope.scopeId, + slot.slot, + secretId, + secretScopeId ?? bindingScope.scopeId, + ); + }} + secrets={secretList} + placeholder="Select or create a secret" + targetScope={bindingScope.scopeId} + credentialScopeOptions={credentialScopeOptions} + suggestedId={bindingSecretId( + props.sourceId, + slot.slot, + bindingScope.scopeId, + )} + sourceName={source.name} + secretLabel={slot.label} + /> +
+ {exactSecretId && ( + + )} + {busyKey === inputKey && ( + Saving… + )} + {slot.hint && ( + {slot.hint} + )} +
+
+ ); + })}
); @@ -725,19 +767,37 @@ export default function EditOpenApiSource(props: {

+ {credentialScopes.length > 1 && ( + + + OAuth token + + Choose where the signed-in OAuth token is saved. + + + ({ + value: entry.scopeId, + label: entry.label, + }))} + value={activeOAuthTokenScopeId} + onChange={setSelectedOAuthTokenScope} + /> + + )} {(() => { const exact = exactBindingForScope( bindingRows, source.config.oauth2!.connectionSlot, - activeCredentialScopeId, + activeOAuthTokenScopeId, ); const binding = exact ?? effectiveBindingForScope( bindingRows, source.config.oauth2!.connectionSlot, - activeCredentialScopeId, + activeOAuthTokenScopeId, scopeRanks, ); const connectionBinding = @@ -748,9 +808,10 @@ export default function EditOpenApiSource(props: { const bindingScopeId = connectionBinding && binding ? binding.scopeId : null; const isConnecting = busyKey === - `${activeCredentialScopeId}:${source.config.oauth2.connectionSlot}:connect`; + `${activeOAuthTokenScopeId}:${source.config.oauth2.connectionSlot}:connect`; const isPendingOAuthConnection = - pendingOAuthConnection?.scopeId === activeCredentialScopeId && + pendingOAuthConnection?.scopeId === activeOAuthTokenScopeId && + pendingOAuthConnection !== null && pendingOAuthConnection.slot === source.config.oauth2.connectionSlot; const isConnected = connection !== null && connection !== undefined; const statusText = @@ -758,24 +819,24 @@ export default function EditOpenApiSource(props: { ? "Saving OAuth connection..." : connectionBinding && bindingScopeId ? connection - ? bindingScopeId === activeCredentialScopeId - ? `Connected in ${activeCredentialScopeLabel.toLowerCase()} as ${ + ? bindingScopeId === activeOAuthTokenScopeId + ? `Connected in ${activeOAuthTokenScopeLabel.toLowerCase()} as ${ connection.identityLabel ?? connection.id }` : `Using organization connection ${ connection.identityLabel ?? connection.id }` - : bindingScopeId === activeCredentialScopeId - ? `Saved connection is missing in ${activeCredentialScopeLabel.toLowerCase()}` + : bindingScopeId === activeOAuthTokenScopeId + ? `Saved connection is missing in ${activeOAuthTokenScopeLabel.toLowerCase()}` : "Organization connection is missing" - : `No ${activeCredentialScopeLabel.toLowerCase()} connection`; + : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`; return (
{statusText}
+ + + {props.children} + + + + ); +} diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index 62655d97f..a78338943 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -11,6 +11,7 @@ import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; import { SecretForm } from "../plugins/secret-form"; import { useScope } from "../hooks/use-scope"; +import { useScopeStack } from "../api/scope-context"; import { Dialog, DialogContent, @@ -169,6 +170,7 @@ function SecretRow(props: { usageScopeId: ScopeId; showProvider: boolean; secret: { id: string; scopeId: ScopeId; name: string; provider?: string }; + scopeLabel: string; onRemove: () => void; }) { const { secret, showProvider } = props; @@ -192,6 +194,7 @@ function SecretRow(props: { + {props.scopeLabel} {showProvider && secret.provider && {secret.provider}} @@ -238,7 +241,14 @@ export function SecretsPage(props: { const secretProviderPlugins = useSecretProviderPlugins(); const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); + const scopeStack = useScopeStack(); const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); + const scopeLabel = (secretScopeId: ScopeId): string => { + const index = scopeStack.findIndex((entry) => entry.id === secretScopeId); + if (index === 0) return "Personal"; + if (index > 0) return scopeStack[index]?.name || "Organization"; + return "Scoped"; + }; const existingSecretIds = useMemo( () => AsyncResult.match(secrets, { @@ -367,6 +377,7 @@ export function SecretsPage(props: { name: s.name, provider: s.provider ? String(s.provider) : undefined, }} + scopeLabel={scopeLabel(s.scopeId)} onRemove={() => handleRemove(s)} /> ), diff --git a/packages/react/src/plugins/credential-target-scope.tsx b/packages/react/src/plugins/credential-target-scope.tsx index 5b9fa5810..34a334d3d 100644 --- a/packages/react/src/plugins/credential-target-scope.tsx +++ b/packages/react/src/plugins/credential-target-scope.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import type { ScopeId } from "@executor-js/sdk"; +import { ScopeId } from "@executor-js/sdk"; import { useScope, useUserScope } from "../api/scope-context"; import { @@ -13,6 +13,15 @@ import { CardStackEntryTitle, } from "../components/card-stack"; import { FilterTabs } from "../components/filter-tabs"; +import { FieldLabel } from "../components/field"; +import { HelpTooltip } from "../components/help-tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; export interface CredentialTargetScopeOption { readonly scopeId: ScopeId; @@ -147,10 +156,89 @@ export function CredentialScopeSection(props: { value={props.value} options={props.options} onChange={props.onChange} - title={props.title ?? "Save credentials to"} - description={props.description ?? "Choose who can use the credentials attached below."} + title={props.title ?? "Used by"} + description={props.description ?? "Choose who can use these credentials."} /> {props.children}
); } + +export function CredentialScopeDropdown(props: { + readonly value: ScopeId; + readonly options: readonly CredentialTargetScopeOption[]; + readonly onChange: (scope: ScopeId) => void; + readonly label?: string; + readonly help?: ReactNode; +}) { + const label = props.label ?? "Used by"; + if (props.options.length <= 1) return null; + + return ( +
+
+ {label} + + {props.help ?? "Choose who can use these credentials."} + +
+ +
+ ); +} + +export function CredentialControlField(props: { + readonly label: string; + readonly children: ReactNode; + readonly help?: ReactNode; +}) { + return ( +
+
+ {props.label} + {props.help ? {props.help} : null} +
+ {props.children} +
+ ); +} + +export function CredentialUsageRow(props: { + readonly value: ScopeId; + readonly options: readonly CredentialTargetScopeOption[]; + readonly onChange: (scope: ScopeId) => void; + readonly children: ReactNode; + readonly label?: string; + readonly help?: ReactNode; +}) { + if (props.options.length <= 1) { + return
{props.children}
; + } + + return ( +
+
{props.children}
+ +
+ ); +} diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx index e65519662..4ccf98df1 100644 --- a/packages/react/src/plugins/headers-list.tsx +++ b/packages/react/src/plugins/headers-list.tsx @@ -14,7 +14,10 @@ import { type HeaderAuthPreset, type HeaderState, SecretHeaderAuthRow, + type SecretCredentialPreviewComponent, + type SecretCredentialRowCopy, } from "./secret-header-auth"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; import type { SecretPickerSecret } from "./secret-picker"; export interface HeadersListProps { @@ -27,6 +30,10 @@ export interface HeadersListProps { readonly singleHeader?: boolean; /** Text shown in the empty state. */ readonly emptyLabel?: ReactNode; + readonly addLabel?: ReactNode; + readonly addAriaLabel?: string; + readonly rowCopy?: Partial; + readonly rowPreviewComponent?: SecretCredentialPreviewComponent; /** * Display name of the source that owns these headers (e.g. "Axiom"). Used * to derive unique default secret labels/IDs like `axiom-authorization`. @@ -34,6 +41,11 @@ export interface HeadersListProps { readonly sourceName?: string; /** Inline-created secrets are written to this explicit scope. */ readonly targetScope: ScopeId; + /** Scope choices shown only inside the inline "+ New secret" form. */ + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + /** Scope choices for where this source credential is used. */ + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly restrictSecretsToTargetScope?: boolean; } export function HeadersList({ @@ -43,11 +55,26 @@ export function HeadersList({ presets = defaultHeaderAuthPresets, singleHeader = false, emptyLabel = "No headers", + addLabel, + addAriaLabel = "Add header", + rowCopy, + rowPreviewComponent, sourceName, targetScope, + credentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope, }: HeadersListProps) { const [picking, setPicking] = useState(false); const canAddMore = !singleHeader || headers.length === 0; + const addFirstPreset = () => { + const preset = presets[0]; + if (presets.length === 1 && preset) { + addHeaderFromPreset(preset); + return; + } + setPicking(true); + }; const addHeaderFromPreset = (preset: HeaderAuthPreset) => { onHeadersChange([ @@ -57,6 +84,7 @@ export function HeadersList({ prefix: preset.prefix, presetKey: preset.key, secretId: null, + targetScope, }, ]); setPicking(false); @@ -69,6 +97,8 @@ export function HeadersList({ secretId: string | null; prefix?: string; presetKey?: string; + targetScope?: ScopeId; + secretScope?: ScopeId; }>, ) => { onHeadersChange(headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry))); @@ -89,7 +119,11 @@ export function HeadersList({ /> ) : headers.length === 0 ? ( canAddMore ? ( - {emptyLabel}} onClick={() => setPicking(true)} /> + {emptyLabel}} + onClick={addFirstPreset} + ariaLabel={addAriaLabel} + /> ) : ( {emptyLabel} @@ -104,15 +138,28 @@ export function HeadersList({ prefix={header.prefix} presetKey={header.presetKey} secretId={header.secretId} + secretScope={header.secretScope} onChange={(update) => updateHeader(index, update)} - onSelectSecret={(secretId) => updateHeader(index, { secretId })} + onSelectSecret={(secretId, scopeId) => + updateHeader(index, { + secretId, + ...(scopeId ? { secretScope: scopeId } : {}), + }) + } onRemove={singleHeader ? undefined : () => removeHeader(index)} existingSecrets={existingSecrets} sourceName={sourceName} - targetScope={targetScope} + targetScope={header.targetScope ?? targetScope} + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={bindingScopeOptions} + restrictSecretsToTargetScope={restrictSecretsToTargetScope} + copy={rowCopy} + previewComponent={rowPreviewComponent} /> ))} - {canAddMore && setPicking(true)} />} + {canAddMore && ( + + )} )} @@ -123,9 +170,10 @@ export function HeadersList({ interface AddHeaderRowProps { readonly onClick: () => void; readonly leading?: ReactNode; + readonly ariaLabel: string; } -function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { +function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) { return ( // oxlint-disable-next-line react/forbid-elements
)} @@ -127,170 +188,27 @@ export function HttpCredentialsEditor(props: { {showQueryParams && (
{props.labels?.queryParams ?? "Query parameters"} - - props.onChange({ ...props.credentials, queryParams }) - } + props.onChange({ ...props.credentials, queryParams })} existingSecrets={props.existingSecrets} sourceName={props.sourceName} targetScope={props.targetScope} + credentialScopeOptions={props.credentialScopeOptions} + bindingScopeOptions={props.bindingScopeOptions} + restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} + presets={queryParamPresets} + emptyLabel="No query parameters" + addLabel="Add query parameter" + addAriaLabel="Add query parameter" + rowCopy={{ + rowLabel: "Query parameter", + namePlaceholder: "token", + }} + rowPreviewComponent={QueryParamCredentialValuePreview} />
)} ); } - -function QueryParamsList(props: { - readonly queryParams: readonly QueryParamState[]; - readonly onQueryParamsChange: (queryParams: QueryParamState[]) => void; - readonly existingSecrets: readonly SecretPickerSecret[]; - readonly sourceName?: string; - readonly targetScope: ScopeId; -}) { - const addParam = () => { - props.onQueryParamsChange([...props.queryParams, { name: "", secretId: null }]); - }; - const updateParam = (index: number, update: Partial) => { - props.onQueryParamsChange( - props.queryParams.map((param, i) => (i === index ? { ...param, ...update } : param)), - ); - }; - const removeParam = (index: number) => { - props.onQueryParamsChange(props.queryParams.filter((_, i) => i !== index)); - }; - - return ( - - - {props.queryParams.length === 0 ? ( - No query parameters} onClick={addParam} /> - ) : ( - <> - {props.queryParams.map((param, index) => ( - updateParam(index, update)} - onRemove={() => removeParam(index)} - /> - ))} - - - )} - - - ); -} - -function QueryParamRow(props: { - readonly param: QueryParamState; - readonly existingSecrets: readonly SecretPickerSecret[]; - readonly sourceName?: string; - readonly targetScope: ScopeId; - readonly onChange: (update: Partial) => void; - readonly onRemove: () => void; -}) { - const nameInputId = useId(); - const prefixInputId = useId(); - const literalInputId = useId(); - const name = props.param.name.trim(); - const secretLabel = name ? `${name} query parameter` : "Query parameter"; - - return ( -
-
- - Query parameter - - -
- - - - Name - props.onChange({ name: event.currentTarget.value })} - placeholder="token" - className="font-mono" - /> - - - - Prefix (optional) - - props.onChange({ prefix: event.currentTarget.value || undefined })} - placeholder="Bearer " - className="font-mono" - /> - - - - props.onChange({ secretId, literalValue: undefined })} - secrets={props.existingSecrets} - placeholder="Select a secret" - sourceName={props.sourceName} - secretLabel={secretLabel} - targetScope={props.targetScope} - /> - - {!props.param.secretId && props.param.literalValue !== undefined && ( - - Literal value - props.onChange({ literalValue: event.currentTarget.value })} - placeholder="value" - className="font-mono" - /> - - )} -
- ); -} - -function AddQueryParamRow(props: { - readonly onClick: () => void; - readonly leading?: React.ReactNode; -}) { - return ( - - - - ); -} diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx index 82c4dc0d6..e601d025a 100644 --- a/packages/react/src/plugins/secret-form.tsx +++ b/packages/react/src/plugins/secret-form.tsx @@ -109,7 +109,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { const doSet = useAtomSet(setSecret, { mode: "promiseExit" }); const [state, setState] = useState(() => ({ - name: "", + name: suggestedName, value: "", idOverride: null, provider: initialProvider, @@ -215,7 +215,7 @@ function IdField(props: { placeholder?: string }) { ); } -function ValueField(props: { revealable?: boolean; placeholder?: string }) { +function ValueField(props: { revealable?: boolean; placeholder?: string; autoFocus?: boolean }) { const { state, actions } = useSecretForm(); const inputId = useId(); const revealable = props.revealable ?? false; @@ -231,6 +231,7 @@ function ValueField(props: { revealable?: boolean; placeholder?: string }) { value={state.value} onChange={(e) => actions.setValue((e.target as HTMLInputElement).value)} placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"} + autoFocus={props.autoFocus} className={revealable ? "pr-9 font-mono" : "font-mono"} style={ revealable && !state.revealed diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 184af1ef0..dd2d82863 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -1,11 +1,30 @@ -import { useId, useState } from "react"; +import { useId, useState, type ReactNode } from "react"; -import { type ScopeId } from "@executor-js/sdk"; +import { ScopeId } from "@executor-js/sdk"; import { Button } from "../components/button"; import { Field, FieldGroup, FieldLabel } from "../components/field"; +import { HelpTooltip } from "../components/help-tooltip"; import { Input } from "../components/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; import { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; +import { + CredentialTargetScopeSelector, + type CredentialTargetScopeOption, +} from "./credential-target-scope"; export const secretsForCredentialTarget = ( secrets: readonly SecretPickerSecret[], @@ -21,8 +40,18 @@ export interface HeaderAuthPreset { } export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ - { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " }, - { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " }, + { + key: "bearer", + label: "Bearer Token", + name: "Authorization", + prefix: "Bearer ", + }, + { + key: "basic", + label: "Basic Auth", + name: "Authorization", + prefix: "Basic ", + }, { key: "api-key", label: "API Key", name: "X-API-Key" }, { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" }, { key: "access-token", label: "Access Token", name: "X-Access-Token" }, @@ -30,51 +59,141 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ { key: "custom", label: "Custom", name: "" }, ]; -export function InlineCreateSecret(props: { +function CreateSecretContent(props: { suggestedName: string; existingSecretIds: readonly string[]; - onCreated: (secretId: string) => void; - onCancel: () => void; + onCreated: (secretId: string, scopeId: ScopeId) => void; + onCancel?: () => void; fallbackId?: string; targetScope: ScopeId; + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { + const [scopeId, setScopeId] = useState(props.targetScope); + const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId); + return ( props.onCreated(secretId, scopeId)} > -
-

New secret

+
+ {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( + + )}
- +
-
- - Create and use +
+ {props.onCancel && ( + + )} + Create and use
); } -function HeaderValuePreview(props: { headerName: string; secretId: string; prefix?: string }) { - const { headerName, prefix } = props; +export function InlineCreateSecret(props: { + suggestedName: string; + existingSecretIds: readonly string[]; + onCreated: (secretId: string, scopeId: ScopeId) => void; + onCancel: () => void; + fallbackId?: string; + targetScope: ScopeId; + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; +}) { + return ( +
+

+ New secret +

+ +
+ ); +} + +function CreateSecretDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly suggestedName: string; + readonly existingSecretIds: readonly string[]; + readonly onCreated: (secretId: string, scopeId: ScopeId) => void; + readonly fallbackId?: string; + readonly targetScope: ScopeId; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; +}) { + return ( + + + + New secret + + Create a reusable secret, then use it for this credential. + + + props.onOpenChange(false)} + targetScope={props.targetScope} + credentialScopeOptions={props.credentialScopeOptions} + /> + + + ); +} + +export type SecretCredentialPreviewProps = { + readonly name: string; + readonly secretId: string; + readonly prefix?: string; +}; + +export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode; + +export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) { + const { name, prefix } = props; + const maskedValue = "•".repeat(12); return (
- {headerName}: + {name}: {prefix && {prefix}} - {"•".repeat(12)} + {maskedValue} + +
+ ); +} + +export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewProps) { + const { name, prefix } = props; + const maskedValue = "•".repeat(12); + + return ( +
+ ?{name}= + + {prefix && {prefix}} + {maskedValue}
); @@ -90,6 +209,10 @@ export type HeaderState = { prefix?: string; presetKey?: string; fromPreset?: boolean; + /** Scope where this source credential value is used. */ + targetScope?: ScopeId; + /** Scope that owns the selected reusable secret. */ + secretScope?: ScopeId; }; export function matchPresetKey(name: string, prefix?: string): string { @@ -99,6 +222,39 @@ export function matchPresetKey(name: string, prefix?: string): string { return preset?.key ?? "custom"; } +function InfoLabel(props: { readonly children: string; readonly tooltip: string }) { + return ( +
+ {props.children} + {props.tooltip} +
+ ); +} + +export type SecretCredentialRowCopy = { + readonly rowLabel: string; + readonly nameLabel: string; + readonly namePlaceholder: string; + readonly prefixLabel: string; + readonly prefixPlaceholder: string; + readonly secretLabel: string; + readonly secretHelp: string; + readonly usedByLabel: string; + readonly usedByHelp: string; +}; + +const defaultSecretCredentialRowCopy: SecretCredentialRowCopy = { + rowLabel: "Header", + nameLabel: "Name", + namePlaceholder: "Authorization", + prefixLabel: "Prefix", + prefixPlaceholder: "Bearer ", + secretLabel: "Secret", + secretHelp: "Select or create a reusable secret.", + usedByLabel: "Used by", + usedByHelp: "Choose who uses this credential value.", +}; + export function headerValueToState( name: string, value: { secretId: string; prefix?: string } | string, @@ -138,12 +294,21 @@ export function SecretHeaderAuthRow(props: { prefix?: string; presetKey?: string; secretId: string | null; - onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; - onSelectSecret: (secretId: string) => void; + secretScope?: ScopeId; + onChange: (update: { + name: string; + secretId?: string | null; + prefix?: string; + presetKey?: string; + targetScope?: ScopeId; + secretScope?: ScopeId; + }) => void; + onSelectSecret: (secretId: string, scopeId?: ScopeId) => void; existingSecrets: readonly SecretPickerSecret[]; onRemove?: () => void; removeLabel?: string; - label?: string; + copy?: Partial; + previewComponent?: SecretCredentialPreviewComponent; /** * Display name of the source this header belongs to (e.g. "Axiom"). Used * to prefix the suggested secret label and ID so tokens from different @@ -151,6 +316,9 @@ export function SecretHeaderAuthRow(props: { */ sourceName?: string; targetScope: ScopeId; + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + restrictSecretsToTargetScope?: boolean; }) { const [creating, setCreating] = useState(false); const nameInputId = useId(); @@ -160,42 +328,49 @@ export function SecretHeaderAuthRow(props: { prefix, presetKey, secretId, + secretScope, onChange, onSelectSecret, existingSecrets, onRemove, removeLabel = "Remove", - label = "Header", + copy: copyOverride, + previewComponent: PreviewComponent = HeaderCredentialValuePreview, sourceName, targetScope, + credentialScopeOptions, + bindingScopeOptions, + restrictSecretsToTargetScope = false, } = props; const isCustom = presetKey === "custom" || presetKey === undefined; + const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride }; const headerLabel = name.trim() || "Custom Header"; const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); + const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets; - if (creating) { - return ( - + secret.id)} - onCreated={(id) => { - onSelectSecret(id); + onCreated={(id, scopeId) => { + onSelectSecret(id, scopeId); setCreating(false); }} - onCancel={() => setCreating(false)} targetScope={targetScope} + credentialScopeOptions={credentialScopeOptions} /> - ); - } - - return ( -
- {label} + + {copy.rowLabel} + {onRemove && (
); @@ -263,10 +480,12 @@ export function SecretHeaderAuthRow(props: { export function CreatableSecretPicker(props: { readonly value: string | null; - readonly onSelect: (secretId: string) => void; + readonly onSelect: (secretId: string, scopeId?: ScopeId) => void; readonly secrets: readonly SecretPickerSecret[]; readonly placeholder?: string; readonly targetScope: ScopeId; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly onCreatedScope?: (scopeId: ScopeId) => void; readonly suggestedId?: string; /** * Display name of the source the secret belongs to (e.g. "Stripe"). @@ -284,6 +503,8 @@ export function CreatableSecretPicker(props: { sourceName, secretLabel, targetScope, + credentialScopeOptions, + onCreatedScope, suggestedId: suggestedIdProp, } = props; const [creating, setCreating] = useState(false); @@ -293,16 +514,19 @@ export function CreatableSecretPicker(props: { if (creating) { return ( - secret.id)} fallbackId={suggestedIdProp?.trim() || "secret"} - onCreated={(id) => { - onSelect(id); + onCreated={(id, scopeId) => { + onCreatedScope?.(scopeId); + onSelect(id, scopeId); setCreating(false); }} - onCancel={() => setCreating(false)} targetScope={targetScope} + credentialScopeOptions={credentialScopeOptions} /> ); } @@ -310,8 +534,9 @@ export function CreatableSecretPicker(props: { return ( onSelect(id, ScopeId.make(scopeId))} + secrets={secrets} placeholder={placeholder} onCreateNew={() => setCreating(true)} /> diff --git a/packages/react/src/plugins/secret-picker.tsx b/packages/react/src/plugins/secret-picker.tsx index f59c1fc21..a63e97e4a 100644 --- a/packages/react/src/plugins/secret-picker.tsx +++ b/packages/react/src/plugins/secret-picker.tsx @@ -2,6 +2,7 @@ import { useState, type ChangeEvent, type FocusEvent } from "react"; import { PlusIcon } from "lucide-react"; import { Input } from "../components/input"; +import { Badge } from "../components/badge"; import { Command, CommandEmpty, @@ -11,6 +12,7 @@ import { CommandSeparator, } from "../components/command"; import { Popover, PopoverAnchor, PopoverContent } from "../components/popover"; +import { useScopeStack } from "../api/scope-context"; export interface SecretPickerSecret { readonly id: string; @@ -33,17 +35,38 @@ const providerLabel = (key: string | undefined): string => { export function SecretPicker(props: { readonly value: string | null; - readonly onSelect: (secretId: string) => void; + readonly valueScopeId?: string; + readonly onSelect: (secretId: string, scopeId: string) => void; readonly secrets: readonly SecretPickerSecret[]; readonly placeholder?: string; /** When provided, renders a "+ New secret" row at the top of the dropdown. */ readonly onCreateNew?: () => void; }) { - const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props; + const { + value, + valueScopeId, + onSelect, + secrets, + placeholder = "Search secrets…", + onCreateNew, + } = props; const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const scopeStack = useScopeStack(); + const scopeLabel = (scopeId: string): string => { + const index = scopeStack.findIndex((entry) => String(entry.id) === scopeId); + if (index === 0) return "Personal"; + if (index > 0) return scopeStack[index]?.name || "Organization"; + return "Scoped"; + }; - const selected = secrets.find((secret) => secret.id === value) ?? null; + const selected = + secrets.find( + (secret) => + secret.id === value && (valueScopeId === undefined || secret.scopeId === valueScopeId), + ) ?? + secrets.find((secret) => secret.id === value) ?? + null; const grouped = new Map(); for (const secret of secrets) { @@ -132,15 +155,18 @@ export function SecretPicker(props: { {filtered.map((secret) => ( { - onSelect(secret.id); + onSelect(secret.id, secret.scopeId); setOpen(false); setQuery(""); }} > - {secret.name} + {secret.name} + + {scopeLabel(secret.scopeId)} + ))} diff --git a/packages/react/src/plugins/source-identity.test.ts b/packages/react/src/plugins/source-identity.test.ts new file mode 100644 index 000000000..ac4ae9324 --- /dev/null +++ b/packages/react/src/plugins/source-identity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + domainLabelFromUrl, + pascalCaseDomainLabel, + sourceDisplayNameFromUrl, +} from "./source-identity"; + +describe("source identity URL display names", () => { + it("uses the apex domain label without the public suffix", () => { + expect(domainLabelFromUrl("https://api.example.co.uk/graphql")).toBe("example"); + }); + + it("normalizes domain labels to PascalCase", () => { + expect(pascalCaseDomainLabel("my-api")).toBe("MyApi"); + }); + + it("appends the source kind to the PascalCase domain label", () => { + expect(sourceDisplayNameFromUrl("https://mcp.linear.app/sse", "MCP")).toBe("Linear MCP"); + expect(sourceDisplayNameFromUrl("https://api.shopify.com/graphql", "GraphQL")).toBe( + "Shopify GraphQL", + ); + }); +}); diff --git a/packages/react/src/plugins/source-identity.tsx b/packages/react/src/plugins/source-identity.tsx index 765ffcca7..942eae194 100644 --- a/packages/react/src/plugins/source-identity.tsx +++ b/packages/react/src/plugins/source-identity.tsx @@ -20,6 +20,32 @@ export function displayNameFromUrl(url: string): string | null { return label.charAt(0).toUpperCase() + label.slice(1); } +export function domainLabelFromUrl(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) return null; + return parse(trimmed).domainWithoutSuffix ?? null; +} + +export function pascalCaseDomainLabel(label: string): string | null { + const words = label + .split(/[^a-z0-9]+/i) + .map((word) => word.trim()) + .filter(Boolean); + if (words.length === 0) return null; + return words + .map((word) => { + const normalized = word.toLowerCase(); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + }) + .join(""); +} + +export function sourceDisplayNameFromUrl(url: string, sourceKind: string): string | null { + const label = domainLabelFromUrl(url); + const displayLabel = label ? pascalCaseDomainLabel(label) : null; + return displayLabel ? `${displayLabel} ${sourceKind}` : null; +} + // --------------------------------------------------------------------------- // Hook — owns the name + namespace state with namespace auto-derivation // ---------------------------------------------------------------------------