diff --git a/apps/cloud/drizzle/0017_plugin_storage_sources.sql b/apps/cloud/drizzle/0017_plugin_storage_sources.sql new file mode 100644 index 000000000..a0ef902b1 --- /dev/null +++ b/apps/cloud/drizzle/0017_plugin_storage_sources.sql @@ -0,0 +1,172 @@ +CREATE TABLE IF NOT EXISTS "plugin_storage" ( + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "id" varchar(255) NOT NULL, + "scope_id" varchar(255) NOT NULL, + "plugin_id" text NOT NULL, + "collection" text NOT NULL, + "key" text NOT NULL, + "data" json NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_storage_scope_id_id_uidx" ON "plugin_storage" USING btree ("scope_id","id"); +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('openapi:source:' || s."scope_id" || ':' || s."id"), + '["openapi","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'openapi', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'config', json_strip_nulls(json_build_object( + 'spec', s."spec", + 'sourceUrl', s."source_url", + 'baseUrl', s."base_url", + 'headers', h."headers", + 'queryParams', q."queryParams", + 'specFetchCredentials', CASE WHEN sfh."headers" IS NULL AND sfq."queryParams" IS NULL THEN NULL ELSE json_strip_nulls(json_build_object('headers', sfh."headers", 'queryParams', sfq."queryParams")) END, + 'oauth2', s."oauth2" + )) + ), + now(), + now() +FROM "openapi_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "openapi_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "openapi_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "openapi_source_spec_fetch_header" + GROUP BY "scope_id", "source_id" +) sfh ON sfh."scope_id" = s."scope_id" AND sfh."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "openapi_source_spec_fetch_query_param" + GROUP BY "scope_id", "source_id" +) sfq ON sfq."scope_id" = s."scope_id" AND sfq."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('openapi:operation:' || o."scope_id" || ':' || o."id"), '["openapi","operation",' || to_json(o."id")::text || ']', o."scope_id", 'openapi', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() +FROM "openapi_operation" o +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('graphql:source:' || s."scope_id" || ':' || s."id"), + '["graphql","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'graphql', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'endpoint', s."endpoint", + 'headers', COALESCE(h."headers", '{}'::json), + 'queryParams', COALESCE(q."queryParams", '{}'::json), + 'auth', CASE WHEN s."auth_kind" = 'oauth2' AND s."auth_connection_slot" IS NOT NULL THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot") ELSE json_build_object('kind', 'none') END + ), + now(), + now() +FROM "graphql_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "graphql_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "graphql_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('graphql:operation:' || o."scope_id" || ':' || o."id"), '["graphql","operation",' || to_json(o."id")::text || ']', o."scope_id", 'graphql', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() +FROM "graphql_operation" o +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT + 'plugin_storage_' || md5('mcp:source:' || s."scope_id" || ':' || s."id"), + '["mcp","source",' || to_json(s."id")::text || ']', + s."scope_id", + 'mcp', + 'source', + s."id", + json_build_object( + 'namespace', s."id", + 'scope', s."scope_id", + 'name', s."name", + 'config', CASE WHEN s."config"->>'transport' = 'remote' THEN jsonb_strip_nulls(s."config"::jsonb || jsonb_build_object( + 'headers', h."headers", + 'queryParams', q."queryParams", + 'auth', CASE + WHEN s."auth_kind" = 'header' THEN json_build_object('kind', 'header', 'headerName', COALESCE(s."auth_header_name", ''), 'secretSlot', s."auth_header_slot", 'prefix', s."auth_header_prefix") + WHEN s."auth_kind" = 'oauth2' THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot", 'clientIdSlot', s."auth_client_id_slot", 'clientSecretSlot', s."auth_client_secret_slot") + ELSE json_build_object('kind', 'none') + END + )) ELSE s."config"::jsonb END + ), + now(), + now() +FROM "mcp_source" s +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" + FROM "mcp_source_header" + GROUP BY "scope_id", "source_id" +) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" +LEFT JOIN ( + SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" + FROM "mcp_source_query_param" + GROUP BY "scope_id", "source_id" +) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" +ON CONFLICT DO NOTHING; +--> statement-breakpoint +INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") +SELECT 'plugin_storage_' || md5('mcp:binding:' || b."scope_id" || ':' || b."id"), '["mcp","binding",' || to_json(b."id")::text || ']', b."scope_id", 'mcp', 'binding', b."id", json_build_object('namespace', b."source_id", 'toolId', b."id", 'binding', b."binding"), b."created_at", now() +FROM "mcp_binding" b +ON CONFLICT DO NOTHING; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_operation"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_spec_fetch_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "openapi_source_spec_fetch_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "graphql_operation"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source_header"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_source_query_param"; +--> statement-breakpoint +DROP TABLE IF EXISTS "mcp_binding"; diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 2a55c3b59..5988a6188 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1778781460169, "tag": "0016_fumadb_cutover", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1779087600000, + "tag": "0017_plugin_storage_sources", + "breakpoints": true } ] } diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index 5e52d9b86..429a4a79c 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -61,9 +61,10 @@ const ApproveHandlers = HttpApiBuilder.group(UpstreamApi, "approve", (h) => // Services // --------------------------------------------------------------------------- -class Upstream extends Context.Service()( - "MiniflareE2E/Upstream", -) {} +class Upstream extends Context.Service< + Upstream, + { readonly baseUrl: string; readonly specJson: string } +>()("MiniflareE2E/Upstream") {} class Worker extends Context.Service< Worker, @@ -110,7 +111,7 @@ const UpstreamLive = Layer.effect( return { server, scope }; }), ({ scope }) => Scope.close(scope, Exit.void), - ).pipe(Effect.map(({ server }) => ({ specJson: server.specJson }))), + ).pipe(Effect.map(({ server }) => ({ baseUrl: server.baseUrl, specJson: server.specJson }))), ); // --------------------------------------------------------------------------- @@ -755,7 +756,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) () => Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; - const { specJson } = yield* Upstream; + const { baseUrl: upstreamBaseUrl, specJson } = yield* Upstream; const orgId = nextOrgId(); yield* Effect.promise(() => seedOrg(orgId, "Elicit Org")); @@ -781,7 +782,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) // `HttpApiGroup` name ("approve") becomes part of the sandbox path, // so the invocation reads `tools.approveapi.approve.approveThing`. const code = [ - `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`, + `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`, `return await tools.approveapi.approve.approveThing({});`, ].join("\n"); const result = yield* Effect.promise(() => diff --git a/apps/cloud/src/services/executor-schema.ts b/apps/cloud/src/services/executor-schema.ts index d6f1f381b..e4c9eb815 100644 --- a/apps/cloud/src/services/executor-schema.ts +++ b/apps/cloud/src/services/executor-schema.ts @@ -111,6 +111,20 @@ export const credential_binding = pgTable("credential_binding", { uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id) ]) +export const plugin_storage = pgTable("plugin_storage", { + row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), + id: varchar("id", { length: 255 }).notNull(), + scope_id: varchar("scope_id", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: json("data").notNull(), + created_at: timestamp("created_at").notNull(), + updated_at: timestamp("updated_at").notNull() +}, (table) => [ + uniqueIndex("plugin_storage_scope_id_id_uidx").on(table.scope_id, table.id) +]) + export const tool_policy = pgTable("tool_policy", { row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), id: varchar("id", { length: 255 }).notNull(), @@ -134,192 +148,6 @@ export const blob = pgTable("blob", { uniqueIndex("blob_id_uidx").on(table.id) ]) -export const openapi_source = pgTable("openapi_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - spec: text("spec").notNull(), - source_url: text("source_url"), - base_url: text("base_url"), - oauth2: json("oauth2") -}, (table) => [ - uniqueIndex("openapi_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_operation = pgTable("openapi_operation", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull() -}, (table) => [ - uniqueIndex("openapi_operation_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_header = pgTable("openapi_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_query_param = pgTable("openapi_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_spec_fetch_header = pgTable("openapi_source_spec_fetch_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_spec_fetch_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const openapi_source_spec_fetch_query_param = pgTable("openapi_source_spec_fetch_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("openapi_source_spec_fetch_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source = pgTable("mcp_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - config: json("config").notNull(), - auth_kind: text("auth_kind").notNull().default("none"), - auth_header_name: text("auth_header_name"), - auth_header_slot: text("auth_header_slot"), - auth_header_prefix: text("auth_header_prefix"), - auth_connection_slot: text("auth_connection_slot"), - auth_client_id_slot: text("auth_client_id_slot"), - auth_client_secret_slot: text("auth_client_secret_slot"), - created_at: timestamp("created_at").notNull() -}, (table) => [ - uniqueIndex("mcp_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source_header = pgTable("mcp_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("mcp_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_source_query_param = pgTable("mcp_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("mcp_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const mcp_binding = pgTable("mcp_binding", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull(), - created_at: timestamp("created_at").notNull() -}, (table) => [ - uniqueIndex("mcp_binding_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source = pgTable("graphql_source", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - name: text("name").notNull(), - endpoint: text("endpoint").notNull(), - auth_kind: text("auth_kind").notNull().default("none"), - auth_connection_slot: text("auth_connection_slot") -}, (table) => [ - uniqueIndex("graphql_source_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source_header = pgTable("graphql_source_header", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("graphql_source_header_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_source_query_param = pgTable("graphql_source_query_param", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix") -}, (table) => [ - uniqueIndex("graphql_source_query_param_scope_id_id_uidx").on(table.scope_id, table.id) -]) - -export const graphql_operation = pgTable("graphql_operation", { - row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - source_id: text("source_id").notNull(), - binding: json("binding").notNull() -}, (table) => [ - uniqueIndex("graphql_operation_scope_id_id_uidx").on(table.scope_id, table.id) -]) - export const workos_vault_metadata = pgTable("workos_vault_metadata", { row_id: varchar("row_id", { length: 255 }).primaryKey().notNull().$defaultFn(() => createId()), id: varchar("id", { length: 255 }).notNull(), @@ -334,4 +162,4 @@ export const workos_vault_metadata = pgTable("workos_vault_metadata", { export const private_executor_cloud_settings = pgTable("private_executor_cloud_settings", { id: varchar("id", { length: 255 }).primaryKey().notNull(), version: varchar("version", { length: 255 }).notNull().default("1.0.0") -}) \ No newline at end of file +}) diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index c471a71a8..a8cfa8ee5 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -230,7 +230,6 @@ describe("sources api (HTTP)", () => { .addSource({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote", name: "Broken MCP", endpoint: "http://127.0.0.1:1/mcp", @@ -275,7 +274,6 @@ describe("sources api (HTTP)", () => { client.graphql.addSource({ params: { scopeId }, payload: { - targetScope: scopeId, endpoint: server.endpoint, namespace, name: "Cloud GraphQL", @@ -327,6 +325,66 @@ describe("sources api (HTTP)", () => { }), ); + it.effect( + "GraphQL add accepts a user-scoped bearer credential for org source introspection", + () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema({ includeMutation: false }), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer github-token"), + }, + }); + const orgId = `org_${crypto.randomUUID()}`; + const userId = `user_${crypto.randomUUID()}`; + const userScope = testUserOrgScopeId(userId, orgId); + const namespace = `github_graphql_${crypto.randomUUID().replace(/-/g, "_")}`; + + yield* asUser(userId, orgId, (client) => + client.secrets.set({ + params: { scopeId: ScopeId.make(userScope) }, + payload: { + id: SecretId.make("github-graphql-authorization"), + name: "Github GraphQL Authorization", + value: "github-token", + }, + }), + ); + + const added = yield* asUser(userId, orgId, (client) => + client.graphql.addSource({ + params: { scopeId: ScopeId.make(orgId) }, + payload: { + endpoint: server.endpoint, + namespace, + name: "Github GraphQL", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: ScopeId.make(userScope), + headers: { + Authorization: { + kind: "secret", + secretId: "github-graphql-authorization", + secretScope: userScope, + prefix: "Bearer ", + }, + }, + }, + }, + }), + ); + + expect(added).toEqual({ namespace, toolCount: 1 }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer github-token"), + ).toBe(true); + }), + ); + it.effect("added MCP source can be inspected and invoked through execution", () => Effect.gen(function* () { const server = yield* serveMcpServer(() => @@ -344,7 +402,6 @@ describe("sources api (HTTP)", () => { client.mcp.addSource({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote", name: "Cloud MCP", endpoint: server.endpoint, @@ -453,7 +510,7 @@ describe("sources api (HTTP)", () => { }), ); - it.effect("openapi.updateSource round-trips baseUrl + name changes", () => + it.effect("sources.configure round-trips OpenAPI baseUrl + name changes", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; @@ -464,12 +521,17 @@ describe("sources api (HTTP)", () => { params: { scopeId: ScopeId.make(org) }, payload: makeMinimalOpenApiSourcePayload(namespace), }); - yield* client.openapi.updateSource({ - params: { scopeId: ScopeId.make(org), namespace }, + yield* client.sources.configure({ + params: { scopeId: ScopeId.make(org) }, payload: { - sourceScope: ScopeId.make(org), - name: "Renamed API", - baseUrl: "https://override.example.com", + source: { id: namespace, scope: ScopeId.make(org) }, + scope: ScopeId.make(org), + type: "openapi", + config: { + scope: org, + name: "Renamed API", + baseUrl: "https://override.example.com", + }, }, }); }), @@ -517,13 +579,12 @@ describe("sources api (HTTP)", () => { value: "alice-secret", }, }); - const binding = yield* client.openapi.setSourceBinding({ + const binding = yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(aliceScope) }, payload: { - sourceId: namespace, - sourceScope: ScopeId.make(orgId), scope: ScopeId.make(aliceScope), - slot: "header:authorization", + source: { id: namespace, scope: ScopeId.make(orgId) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -534,7 +595,7 @@ describe("sources api (HTTP)", () => { sourceId: namespace, sourceScopeId: ScopeId.make(orgId), scopeId: ScopeId.make(aliceScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -555,13 +616,12 @@ describe("sources api (HTTP)", () => { value: "bob-secret", }, }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(bobScope) }, payload: { - sourceId: namespace, - sourceScope: ScopeId.make(orgId), scope: ScopeId.make(bobScope), - slot: "header:authorization", + source: { id: namespace, scope: ScopeId.make(orgId) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -572,10 +632,10 @@ describe("sources api (HTTP)", () => { ); const aliceBindings = yield* asUser(aliceId, orgId, (client) => - client.openapi.listSourceBindings({ + client.sources.listBindings({ params: { scopeId: ScopeId.make(aliceScope), - namespace, + sourceId: namespace, sourceScopeId: ScopeId.make(orgId), }, }), @@ -583,7 +643,7 @@ describe("sources api (HTTP)", () => { expect(aliceBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(aliceScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("alice_pat"), @@ -594,17 +654,17 @@ describe("sources api (HTTP)", () => { expect( aliceBindings.some( (binding) => - binding.slot === "header:authorization" && + binding.slotKey === "header:authorization" && binding.value.kind === "secret" && binding.value.secretId === SecretId.make("bob_pat"), ), ).toBe(false); const bobBindings = yield* asUser(bobId, orgId, (client) => - client.openapi.listSourceBindings({ + client.sources.listBindings({ params: { scopeId: ScopeId.make(bobScope), - namespace, + sourceId: namespace, sourceScopeId: ScopeId.make(orgId), }, }), @@ -612,7 +672,7 @@ describe("sources api (HTTP)", () => { expect(bobBindings).toContainEqual( expect.objectContaining({ scopeId: ScopeId.make(bobScope), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("bob_pat"), @@ -623,7 +683,7 @@ describe("sources api (HTTP)", () => { expect( bobBindings.some( (binding) => - binding.slot === "header:authorization" && + binding.slotKey === "header:authorization" && binding.value.kind === "secret" && binding.value.secretId === SecretId.make("alice_pat"), ), diff --git a/apps/cloud/src/services/tenant-isolation.node.test.ts b/apps/cloud/src/services/tenant-isolation.node.test.ts index 81288fd2e..b1c850db6 100644 --- a/apps/cloud/src/services/tenant-isolation.node.test.ts +++ b/apps/cloud/src/services/tenant-isolation.node.test.ts @@ -263,13 +263,12 @@ describe("tenant isolation (HTTP)", () => { }, }, }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceId: namespaceA, - sourceScope: ScopeId.make(orgA), scope: ScopeId.make(orgA), - slot: "header:authorization", + source: { id: namespaceA, scope: ScopeId.make(orgA) }, + slotKey: "header:authorization", value: { kind: "secret", secretId: secretIdA }, }, }); @@ -299,13 +298,12 @@ describe("tenant isolation (HTTP)", () => { params: { scopeId: ScopeId.make(orgA) }, payload: makeTenantOpenApiSourcePayload(namespaceA), }); - yield* client.openapi.setSourceBinding({ + yield* client.sources.setBinding({ params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceId: namespaceA, - sourceScope: ScopeId.make(orgA), scope: ScopeId.make(orgA), - slot: "auth:conn", + source: { id: namespaceA, scope: ScopeId.make(orgA) }, + slotKey: "auth:conn", value: { kind: "connection", connectionId: connectionIdA }, }, }); @@ -348,12 +346,17 @@ describe("tenant isolation (HTTP)", () => { ); yield* asOrg(orgA, (client) => - client.openapi.updateSource({ - params: { scopeId: ScopeId.make(orgA), namespace }, + client.sources.configure({ + params: { scopeId: ScopeId.make(orgA) }, payload: { - sourceScope: ScopeId.make(orgA), - name: "Org A Updated API", - baseUrl: "https://org-a-updated.example.com", + source: { id: namespace, scope: ScopeId.make(orgA) }, + scope: ScopeId.make(orgA), + type: "openapi", + config: { + scope: orgA, + name: "Org A Updated API", + baseUrl: "https://org-a-updated.example.com", + }, }, }), ); diff --git a/apps/local/drizzle/0011_plugin_storage_sources.sql b/apps/local/drizzle/0011_plugin_storage_sources.sql new file mode 100644 index 000000000..c9cfeeb68 --- /dev/null +++ b/apps/local/drizzle/0011_plugin_storage_sources.sql @@ -0,0 +1,215 @@ +CREATE TABLE IF NOT EXISTS `openapi_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `spec` text NOT NULL, `source_url` text, `base_url` text, `oauth2` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_operation` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_spec_fetch_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `openapi_source_spec_fetch_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `endpoint` text NOT NULL, `auth_kind` text NOT NULL DEFAULT 'none', `auth_connection_slot` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `graphql_operation` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source` (`id` text NOT NULL, `scope_id` text NOT NULL, `name` text NOT NULL, `config` text NOT NULL, `auth_kind` text NOT NULL DEFAULT 'none', `auth_header_name` text, `auth_header_slot` text, `auth_header_prefix` text, `auth_connection_slot` text, `auth_client_id_slot` text, `auth_client_secret_slot` text, `created_at` integer); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source_header` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_source_query_param` (`id` text, `scope_id` text, `source_id` text, `name` text, `kind` text, `text_value` text, `slot_key` text, `prefix` text); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `mcp_binding` (`id` text NOT NULL, `scope_id` text NOT NULL, `source_id` text NOT NULL, `binding` text NOT NULL, `created_at` integer); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `plugin_storage` ( + `id` text NOT NULL, + `scope_id` text NOT NULL, + `plugin_id` text NOT NULL, + `collection` text NOT NULL, + `key` text NOT NULL, + `data` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + PRIMARY KEY(`scope_id`, `id`) +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_scope_id_idx` ON `plugin_storage` (`scope_id`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_plugin_id_collection_idx` ON `plugin_storage` (`plugin_id`, `collection`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `plugin_storage_key_idx` ON `plugin_storage` (`key`); +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('openapi', 'source', s.`id`), + s.`scope_id`, + 'openapi', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'config', json_patch( + json_patch( + json_patch( + json_patch( + json_patch( + json_patch( + json_object('spec', s.`spec`), + CASE WHEN s.`source_url` IS NULL THEN json_object() ELSE json_object('sourceUrl', s.`source_url`) END + ), + CASE WHEN s.`base_url` IS NULL THEN json_object() ELSE json_object('baseUrl', s.`base_url`) END + ), + CASE WHEN h.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(h.`headers`)) END + ), + CASE WHEN q.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(q.`queryParams`)) END + ), + CASE WHEN sfh.`headers` IS NULL AND sfq.`queryParams` IS NULL THEN json_object() ELSE json_object('specFetchCredentials', json_patch( + CASE WHEN sfh.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(sfh.`headers`)) END, + CASE WHEN sfq.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(sfq.`queryParams`)) END + )) END + ), + CASE WHEN s.`oauth2` IS NULL THEN json_object() ELSE json_object('oauth2', json(s.`oauth2`)) END + ) + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `openapi_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `openapi_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `openapi_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `openapi_source_spec_fetch_header` + GROUP BY `scope_id`, `source_id` +) sfh ON sfh.`scope_id` = s.`scope_id` AND sfh.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `openapi_source_spec_fetch_query_param` + GROUP BY `scope_id`, `source_id` +) sfq ON sfq.`scope_id` = s.`scope_id` AND sfq.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('openapi', 'operation', o.`id`), o.`scope_id`, 'openapi', 'operation', o.`id`, json_object('toolId', o.`id`, 'sourceId', o.`source_id`, 'binding', json(o.`binding`)), unixepoch('now') * 1000, unixepoch('now') * 1000 +FROM `openapi_operation` o; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('graphql', 'source', s.`id`), + s.`scope_id`, + 'graphql', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'endpoint', s.`endpoint`, + 'headers', COALESCE(json(h.`headers`), json_object()), + 'queryParams', COALESCE(json(q.`queryParams`), json_object()), + 'auth', CASE WHEN s.`auth_kind` = 'oauth2' AND s.`auth_connection_slot` IS NOT NULL THEN json_object('kind', 'oauth2', 'connectionSlot', s.`auth_connection_slot`) ELSE json_object('kind', 'none') END + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `graphql_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `graphql_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `graphql_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('graphql', 'operation', o.`id`), o.`scope_id`, 'graphql', 'operation', o.`id`, json_object('toolId', o.`id`, 'sourceId', o.`source_id`, 'binding', json(o.`binding`)), unixepoch('now') * 1000, unixepoch('now') * 1000 +FROM `graphql_operation` o; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT + json_array('mcp', 'source', s.`id`), + s.`scope_id`, + 'mcp', + 'source', + s.`id`, + json_object( + 'namespace', s.`id`, + 'scope', s.`scope_id`, + 'name', s.`name`, + 'config', CASE WHEN json_extract(s.`config`, '$.transport') = 'remote' THEN json_patch( + json_patch( + json_patch( + json(s.`config`), + CASE WHEN h.`headers` IS NULL THEN json_object() ELSE json_object('headers', json(h.`headers`)) END + ), + CASE WHEN q.`queryParams` IS NULL THEN json_object() ELSE json_object('queryParams', json(q.`queryParams`)) END + ), + json_object('auth', + CASE + WHEN s.`auth_kind` = 'header' THEN json_object('kind', 'header', 'headerName', COALESCE(s.`auth_header_name`, ''), 'secretSlot', s.`auth_header_slot`, 'prefix', s.`auth_header_prefix`) + WHEN s.`auth_kind` = 'oauth2' THEN json_object('kind', 'oauth2', 'connectionSlot', s.`auth_connection_slot`, 'clientIdSlot', s.`auth_client_id_slot`, 'clientSecretSlot', s.`auth_client_secret_slot`) + ELSE json_object('kind', 'none') + END + ) + ) ELSE json(s.`config`) END + ), + unixepoch('now') * 1000, + unixepoch('now') * 1000 +FROM `mcp_source` s +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `headers` + FROM `mcp_source_header` + GROUP BY `scope_id`, `source_id` +) h ON h.`scope_id` = s.`scope_id` AND h.`source_id` = s.`id` +LEFT JOIN ( + SELECT `scope_id`, `source_id`, json_group_object(`name`, CASE WHEN `kind` = 'text' THEN json_quote(`text_value`) ELSE json_object('kind', 'binding', 'slot', `slot_key`, 'prefix', `prefix`) END) AS `queryParams` + FROM `mcp_source_query_param` + GROUP BY `scope_id`, `source_id` +) q ON q.`scope_id` = s.`scope_id` AND q.`source_id` = s.`id`; +--> statement-breakpoint +INSERT OR REPLACE INTO `plugin_storage` (`id`, `scope_id`, `plugin_id`, `collection`, `key`, `data`, `created_at`, `updated_at`) +SELECT json_array('mcp', 'binding', b.`id`), b.`scope_id`, 'mcp', 'binding', b.`id`, json_object('namespace', b.`source_id`, 'toolId', b.`id`, 'binding', json(b.`binding`)), COALESCE(b.`created_at`, unixepoch('now') * 1000), unixepoch('now') * 1000 +FROM `mcp_binding` b; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_operation`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_spec_fetch_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `openapi_source_spec_fetch_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `graphql_operation`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source_header`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_source_query_param`; +--> statement-breakpoint +DROP TABLE IF EXISTS `mcp_binding`; diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 6654a84a3..96892e22b 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1778192434063, "tag": "0010_add_credential_binding_secret_scope", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1779087600000, + "tag": "0011_plugin_storage_sources", + "breakpoints": true } ] } diff --git a/apps/local/src/server/executor-schema.ts b/apps/local/src/server/executor-schema.ts index b9a465130..7f36764ba 100644 --- a/apps/local/src/server/executor-schema.ts +++ b/apps/local/src/server/executor-schema.ts @@ -156,206 +156,40 @@ export const credential_binding = sqliteTable( ], ); -export const tool_policy = sqliteTable( - "tool_policy", +export const plugin_storage = sqliteTable( + "plugin_storage", { id: text("id").notNull(), scope_id: text("scope_id").notNull(), - pattern: text("pattern").notNull(), - action: text("action").notNull(), - position: text("position").notNull(), + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: text("data", { mode: "json" }).notNull(), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), - ], -); - -export const openapi_source = sqliteTable( - "openapi_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - spec: text("spec").notNull(), - source_url: text("source_url"), - base_url: text("base_url"), - oauth2: text("oauth2", { mode: "json" }), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_scope_id_idx").on(table.scope_id), - ], -); - -export const openapi_operation = sqliteTable( - "openapi_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_operation_scope_id_idx").on(table.scope_id), - index("openapi_operation_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_header = sqliteTable( - "openapi_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_header_scope_id_idx").on(table.scope_id), - index("openapi_source_header_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_query_param = sqliteTable( - "openapi_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const openapi_source_spec_fetch_header = sqliteTable( - "openapi_source_spec_fetch_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id), + index("plugin_storage_scope_id_idx").on(table.scope_id), + index("plugin_storage_plugin_id_collection_idx").on(table.plugin_id, table.collection), + index("plugin_storage_key_idx").on(table.key), ], ); -export const openapi_source_spec_fetch_query_param = sqliteTable( - "openapi_source_spec_fetch_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id), - index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id), - ], -); - -export const mcp_source = sqliteTable( - "mcp_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - config: text("config", { mode: "json" }).notNull(), - auth_kind: text({ enum: ["none", "header", "oauth2"] }) - .default("none") - .notNull(), - auth_header_name: text("auth_header_name"), - auth_header_slot: text("auth_header_slot"), - auth_header_prefix: text("auth_header_prefix"), - auth_connection_slot: text("auth_connection_slot"), - auth_client_id_slot: text("auth_client_id_slot"), - auth_client_secret_slot: text("auth_client_secret_slot"), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_scope_id_idx").on(table.scope_id), - ], -); - -export const mcp_source_header = sqliteTable( - "mcp_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_header_scope_id_idx").on(table.scope_id), - index("mcp_source_header_source_id_idx").on(table.source_id), - ], -); - -export const mcp_source_query_param = sqliteTable( - "mcp_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_source_query_param_scope_id_idx").on(table.scope_id), - index("mcp_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const mcp_binding = sqliteTable( - "mcp_binding", +export const tool_policy = sqliteTable( + "tool_policy", { id: text("id").notNull(), scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), + pattern: text("pattern").notNull(), + action: text("action").notNull(), + position: text("position").notNull(), created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), }, (table) => [ primaryKey({ columns: [table.scope_id, table.id] }), - index("mcp_binding_scope_id_idx").on(table.scope_id), - index("mcp_binding_source_id_idx").on(table.source_id), + index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), ], ); @@ -444,74 +278,3 @@ export const google_discovery_binding = sqliteTable( index("google_discovery_binding_source_id_idx").on(table.source_id), ], ); - -export const graphql_source = sqliteTable( - "graphql_source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - endpoint: text("endpoint").notNull(), - auth_kind: text({ enum: ["none", "oauth2"] }) - .default("none") - .notNull(), - auth_connection_slot: text("auth_connection_slot"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_scope_id_idx").on(table.scope_id), - ], -); - -export const graphql_source_header = sqliteTable( - "graphql_source_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_header_scope_id_idx").on(table.scope_id), - index("graphql_source_header_source_id_idx").on(table.source_id), - ], -); - -export const graphql_source_query_param = sqliteTable( - "graphql_source_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "binding"] }).notNull(), - text_value: text("text_value"), - slot_key: text("slot_key"), - prefix: text("prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_source_query_param_scope_id_idx").on(table.scope_id), - index("graphql_source_query_param_source_id_idx").on(table.source_id), - ], -); - -export const graphql_operation = sqliteTable( - "graphql_operation", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("graphql_operation_scope_id_idx").on(table.scope_id), - index("graphql_operation_source_id_idx").on(table.source_id), - ], -); diff --git a/apps/local/src/server/migrate-graphql-bindings.test.ts b/apps/local/src/server/migrate-graphql-bindings.test.ts index 6615dcb87..699d71f97 100644 --- a/apps/local/src/server/migrate-graphql-bindings.test.ts +++ b/apps/local/src/server/migrate-graphql-bindings.test.ts @@ -18,28 +18,10 @@ const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); const NullableString = Schema.NullOr(Schema.String); -const GraphqlAuthRow = Schema.Struct({ - auth_kind: Schema.String, - auth_connection_slot: NullableString, -}); - const TableInfoRow = Schema.Struct({ name: Schema.String, }); -const GraphqlHeaderRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - text_value: NullableString, - slot_key: NullableString, - prefix: NullableString, -}); - -const GraphqlQueryParamRow = Schema.Struct({ - kind: Schema.String, - slot_key: Schema.String, -}); - const BindingRow = Schema.Struct({ scope_id: Schema.String, plugin_id: Schema.String, @@ -51,24 +33,12 @@ const BindingRow = Schema.Struct({ connection_id: NullableString, }); -const CountRow = Schema.Struct({ - n: Schema.Number, -}); +const PluginStorageRow = Schema.Struct({ data: Schema.String }); -const GraphqlHeaderIdRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, - name: Schema.String, - text_value: Schema.String, -}); - -const decodeAuthRow = Schema.decodeUnknownSync(GraphqlAuthRow); const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); -const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderRow)); -const decodeQueryParamRow = Schema.decodeUnknownSync(GraphqlQueryParamRow); const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodeCountRow = Schema.decodeUnknownSync(CountRow); -const decodeHeaderIdRows = Schema.decodeUnknownSync(Schema.Array(GraphqlHeaderIdRow)); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); let dir: string; @@ -103,13 +73,17 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = decodeAuthRow( - after - .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?") - .get("github"), - ); - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_slot).toBe("auth:oauth2:connection"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "github"), + ).data, + ) as { readonly auth: { readonly kind: string; readonly connectionSlot?: string } }; + expect(source.auth.kind).toBe("oauth2"); + expect(source.auth.connectionSlot).toBe("auth:oauth2:connection"); const bindings = decodeBindingRows( after .prepare( @@ -174,40 +148,28 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const headerRows = decodeHeaderRows( - after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM graphql_source_header WHERE source_id = ? ORDER BY name", - ) - .all("example"), - ); - expect(headerRows).toHaveLength(3); - - const byName = new Map(headerRows.map((r) => [r.name, r])); - expect(byName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal-value", - slot_key: null, - }); - expect(byName.get("Authorization")).toMatchObject({ - kind: "binding", - text_value: null, - slot_key: "header:authorization", - prefix: null, + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "example"), + ).data, + ) as { + readonly headers: Record; + readonly queryParams: Record; + }; + expect(source.headers).toMatchObject({ + "X-Static": "literal-value", + Authorization: { kind: "binding", slot: "header:authorization" }, + "X-Bearer": { kind: "binding", slot: "header:x-bearer", prefix: "Bearer " }, }); - expect(byName.get("X-Bearer")).toMatchObject({ + expect(source.queryParams.api_key).toMatchObject({ kind: "binding", - slot_key: "header:x-bearer", - prefix: "Bearer ", + slot: "query_param:api-key", }); - const paramRow = decodeQueryParamRow( - after - .prepare("SELECT kind, slot_key FROM graphql_source_query_param WHERE source_id = ?") - .get("example"), - ); - expect(paramRow).toMatchObject({ kind: "binding", slot_key: "query_param:api-key" }); - const bindings = decodeBindingRows( after .prepare( @@ -298,20 +260,17 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = decodeAuthRow( - after - .prepare("SELECT auth_kind, auth_connection_slot FROM graphql_source WHERE id = ?") - .get("bare"), - ); - expect(row.auth_kind).toBe("none"); - expect(row.auth_connection_slot).toBeNull(); - - const headerCount = decodeCountRow( - after - .prepare("SELECT count(*) as n FROM graphql_source_header WHERE source_id = ?") - .get("bare"), - ).n; - expect(headerCount).toBe(0); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("graphql", "source", "bare"), + ).data, + ) as { readonly auth: { readonly kind: string }; readonly headers: Record }; + expect(source.auth.kind).toBe("none"); + expect(source.headers).toEqual({}); after.close(); }); @@ -344,28 +303,21 @@ describe("graphql credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const rows = decodeHeaderIdRows( - after - .prepare( - "SELECT id, source_id, name, text_value FROM graphql_source_header ORDER BY source_id, name", - ) - .all(), - ); - expect(rows).toHaveLength(2); - expect(rows).toEqual([ - { - id: '["a","b:c"]', - source_id: "a", - name: "b:c", - text_value: "second", - }, - { - id: '["a:b","c"]', - source_id: "a:b", - name: "c", - text_value: "first", - }, - ]); + const rows = after + .prepare( + "SELECT key, data FROM plugin_storage WHERE plugin_id = ? AND collection = ? ORDER BY key", + ) + .all("graphql", "source") + .map((row) => { + const decoded = decodePluginStorageRow(row); + return { key: (row as { key: string }).key, data: decodePluginStorageData(decoded.data) }; + }) as ReadonlyArray<{ + readonly key: string; + readonly data: { readonly headers: Record }; + }>; + expect(rows.map((row) => row.key)).toEqual(["a", "a:b"]); + expect(rows[0]?.data.headers).toEqual({ "b:c": "second" }); + expect(rows[1]?.data.headers).toEqual({ c: "first" }); after.close(); }); }); diff --git a/apps/local/src/server/migrate-mcp-bindings.test.ts b/apps/local/src/server/migrate-mcp-bindings.test.ts index dc1eff6ac..6b00033a9 100644 --- a/apps/local/src/server/migrate-mcp-bindings.test.ts +++ b/apps/local/src/server/migrate-mcp-bindings.test.ts @@ -16,16 +16,9 @@ import { PRE_0007_SQL, stampPriorMigrationsApplied } from "./__test-helpers__/pr const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); -const ConfigJson = Schema.fromJsonString( - Schema.Struct({ - auth: Schema.optional(Schema.Unknown), - command: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - transport: Schema.String, - }), -); - -const decodeConfigJson = Schema.decodeUnknownSync(ConfigJson); +const PluginStorageRow = Schema.Struct({ data: Schema.String }); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const tempDirs: Array = []; @@ -72,21 +65,32 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_header_name, auth_header_slot, auth_header_prefix, config FROM mcp_source WHERE id = ?", - ) - .get("remote-headers") as { - auth_kind: string; - auth_header_name: string; - auth_header_slot: string; - auth_header_prefix: string; - config: string; + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "remote-headers"), + ).data, + ) as { + readonly config: { + readonly auth?: { + readonly kind: string; + readonly headerName?: string; + readonly secretSlot?: string; + readonly prefix?: string; + }; + readonly endpoint?: string; + readonly transport: string; + }; }; - expect(row.auth_kind).toBe("header"); - expect(row.auth_header_name).toBe("X-API-Key"); - expect(row.auth_header_slot).toBe("auth:header"); - expect(row.auth_header_prefix).toBe("Bearer "); + expect(source.config.auth).toMatchObject({ + kind: "header", + headerName: "X-API-Key", + secretSlot: "auth:header", + prefix: "Bearer ", + }); const binding = after .prepare( "SELECT slot_key, kind, secret_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? AND slot_key = ?", @@ -97,11 +101,8 @@ describe("mcp credential migrations", () => { kind: "secret", secret_id: "tok-secret", }); - // The auth key should be stripped from config json after migration. - const config = decodeConfigJson(row.config); - expect(config.auth).toBeUndefined(); - expect(config.transport).toBe("remote"); - expect(config.endpoint).toBe("https://example.com/mcp"); + expect(source.config.transport).toBe("remote"); + expect(source.config.endpoint).toBe("https://example.com/mcp"); after.close(); }); @@ -142,15 +143,27 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare( - "SELECT auth_kind, auth_connection_slot, auth_client_id_slot, auth_client_secret_slot FROM mcp_source WHERE id = ?", - ) - .get("remote-oauth") as Record; - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_slot).toBe("auth:oauth2:connection"); - expect(row.auth_client_id_slot).toBe("auth:oauth2:client-id"); - expect(row.auth_client_secret_slot).toBe("auth:oauth2:client-secret"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "remote-oauth"), + ).data, + ) as { + readonly config: { + readonly auth?: Record; + readonly headers?: Record; + readonly queryParams?: Record; + }; + }; + expect(source.config.auth).toMatchObject({ + kind: "oauth2", + connectionSlot: "auth:oauth2:connection", + clientIdSlot: "auth:oauth2:client-id", + clientSecretSlot: "auth:oauth2:client-secret", + }); const authBindings = after .prepare( @@ -171,34 +184,18 @@ describe("mcp credential migrations", () => { secret_id: "client-secret-sec", }); - const headers = after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM mcp_source_header WHERE source_id = ? ORDER BY name", - ) - .all("remote-oauth") as ReadonlyArray>; - expect(headers).toHaveLength(2); - const byName = new Map(headers.map((h) => [h.name, h])); - expect(byName.get("X-Trace")).toMatchObject({ - kind: "text", - text_value: "static", - }); - expect(byName.get("X-Token")).toMatchObject({ - kind: "binding", - slot_key: "header:x-token", + expect(source.config.headers).toMatchObject({ + "X-Trace": "static", + "X-Token": { kind: "binding", slot: "header:x-token" }, }); expect(bySlot.get("header:x-token")).toMatchObject({ kind: "secret", secret_id: "extra-tok", }); - const params = after - .prepare("SELECT name, kind, slot_key FROM mcp_source_query_param WHERE source_id = ?") - .all("remote-oauth") as ReadonlyArray>; - expect(params).toHaveLength(1); - expect(params[0]).toMatchObject({ - name: "org", + expect(source.config.queryParams?.org).toMatchObject({ kind: "binding", - slot_key: "query_param:org", + slot: "query_param:org", }); expect(bySlot.get("query_param:org")).toMatchObject({ kind: "secret", @@ -263,18 +260,17 @@ describe("mcp credential migrations", () => { migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER }); const after = new Database(dbPath, { readonly: true }); - const row = after - .prepare("SELECT auth_kind, auth_header_slot, config FROM mcp_source WHERE id = ?") - .get("stdio-only") as { - auth_kind: string; - auth_header_slot: string | null; - config: string; - }; - expect(row.auth_kind).toBe("none"); - expect(row.auth_header_slot).toBeNull(); - const config = decodeConfigJson(row.config); - expect(config.transport).toBe("stdio"); - expect(config.command).toBe("/usr/bin/server"); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("mcp", "source", "stdio-only"), + ).data, + ) as { readonly config: { readonly transport: string; readonly command?: string } }; + expect(source.config.transport).toBe("stdio"); + expect(source.config.command).toBe("/usr/bin/server"); after.close(); }); }); diff --git a/apps/local/src/server/migrate-openapi-bindings.test.ts b/apps/local/src/server/migrate-openapi-bindings.test.ts index 493ec2bf5..cc4503100 100644 --- a/apps/local/src/server/migrate-openapi-bindings.test.ts +++ b/apps/local/src/server/migrate-openapi-bindings.test.ts @@ -31,36 +31,8 @@ const BindingRow = Schema.Struct({ text_value: Schema.NullOr(Schema.String), }); -const QueryParamRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - text_value: Schema.NullOr(Schema.String), - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const HeaderRow = QueryParamRow; - -const FetchHeaderRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const FetchQueryParamRow = Schema.Struct({ - name: Schema.String, - kind: Schema.String, - slot_key: Schema.NullOr(Schema.String), - prefix: Schema.NullOr(Schema.String), -}); - -const SourceJsonRow = Schema.Struct({ - oauth2: Schema.NullOr(Schema.String), -}); - -const TableInfoRow = Schema.Struct({ - name: Schema.String, +const PluginStorageRow = Schema.Struct({ + data: Schema.String, }); const CountRow = Schema.Struct({ @@ -68,16 +40,9 @@ const CountRow = Schema.Struct({ }); const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodeQueryParamRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow)); -const decodeHeaderRows = Schema.decodeUnknownSync(Schema.Array(HeaderRow)); -const decodeFetchHeaderRows = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow)); -const decodeFetchQueryParamRows = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow)); -const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); const decodeCountRow = Schema.decodeUnknownSync(CountRow); -const decodeSourceJsonRow = Schema.decodeUnknownSync(SourceJsonRow); -const decodeJsonRecord = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); +const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); +const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("0007_normalize_plugin_secret_refs (openapi)", () => { let dir: string; @@ -257,55 +222,44 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { const after = openDatabase(dbPath, { readonly: true }); - const qpRows = decodeQueryParamRows( - after - .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM openapi_source_query_param WHERE source_id = ? ORDER BY name", - ) - .all("src"), - ); - expect(qpRows).toHaveLength(2); - const byName = new Map(qpRows.map((r) => [r.name, r])); - expect(byName.get("api_key")).toMatchObject({ - kind: "binding", - slot_key: "query_param:api-key", - prefix: null, - }); - expect(byName.get("flag")).toMatchObject({ - kind: "text", - text_value: "true", - slot_key: null, + const sourceData = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "src"), + ).data, + ) as { + readonly config: { + readonly queryParams?: Record; + readonly specFetchCredentials?: { + readonly headers?: Record; + readonly queryParams?: Record; + }; + }; + }; + expect(sourceData.config.queryParams).toMatchObject({ + api_key: { kind: "binding", slot: "query_param:api-key" }, + flag: "true", }); - - const fetchHeaders = decodeFetchHeaderRows( - after - .prepare( - "SELECT name, kind, slot_key, prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?", - ) - .all("src"), - ); - expect(fetchHeaders).toHaveLength(1); - expect(fetchHeaders[0]).toMatchObject({ - name: "Authorization", + expect(sourceData.config.specFetchCredentials?.headers?.Authorization).toMatchObject({ kind: "binding", - slot_key: "spec_fetch_header:authorization", + slot: "spec_fetch_header:authorization", prefix: "Bearer ", }); - - const fetchQp = decodeFetchQueryParamRows( + expect(sourceData.config.specFetchCredentials?.queryParams?.token).toMatchObject({ + kind: "binding", + slot: "spec_fetch_query_param:token", + }); + const oldQueryParamTableCount = decodeCountRow( after .prepare( - "SELECT name, kind, slot_key, prefix FROM openapi_source_spec_fetch_query_param WHERE source_id = ?", + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'", ) - .all("src"), + .get(), ); - expect(fetchQp).toHaveLength(1); - expect(fetchQp[0]).toMatchObject({ - name: "token", - kind: "binding", - slot_key: "spec_fetch_query_param:token", - prefix: null, - }); + expect(oldQueryParamTableCount.n).toBe(0); const bindings = decodeBindingRows( after @@ -320,10 +274,14 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ["spec_fetch_query_param:token", "secret", "fetch-qp"], ]); - // Old json columns dropped. - const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('openapi_source')").all()); - expect(cols.some((c) => c.name === "query_params")).toBe(false); - expect(cols.some((c) => c.name === "invocation_config")).toBe(false); + const oldSourceTableCount = decodeCountRow( + after + .prepare( + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", + ) + .get(), + ); + expect(oldSourceTableCount.n).toBe(0); }); it("fails instead of silently collapsing colliding legacy query parameter slots", () => { @@ -423,37 +381,35 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); - const source = decodeSourceJsonRow( - after.prepare("SELECT oauth2 FROM openapi_source WHERE id = ?").get("src"), - ); - const headerRows = decodeHeaderRows( + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "src"), + ).data, + ) as { + readonly config: { + readonly headers?: Record; + readonly oauth2?: Record; + }; + }; + expect(source.config.headers).toMatchObject({ + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + "X-Static": "literal", + "X-Already": { kind: "binding", slot: "header:x-already" }, + }); + const oldHeaderTableCount = decodeCountRow( after .prepare( - "SELECT name, kind, text_value, slot_key, prefix FROM openapi_source_header WHERE source_id = ? ORDER BY name", + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'", ) - .all("src"), + .get(), ); - expect(headerRows).toHaveLength(3); - const headersByName = new Map(headerRows.map((row) => [row.name, row])); - expect(headersByName.get("Authorization")).toMatchObject({ - kind: "binding", - text_value: null, - slot_key: "header:authorization", - prefix: "Bearer ", - }); - expect(headersByName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal", - slot_key: null, - prefix: null, - }); - expect(headersByName.get("X-Already")).toMatchObject({ - kind: "binding", - slot_key: "header:x-already", - prefix: null, - }); + expect(oldHeaderTableCount.n).toBe(0); - const migratedOAuth2 = decodeJsonRecord(source.oauth2 ?? "{}"); + const migratedOAuth2 = source.config.oauth2 ?? {}; expect(migratedOAuth2).toMatchObject({ kind: "oauth2", securitySchemeName: "oauth2", @@ -480,8 +436,14 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { ["oauth2:oauth2:connection", "connection", null, "conn-1"], ]); - const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('openapi_source')").all()); - expect(cols.some((c) => c.name === "headers")).toBe(false); + const oldSourceTableCount = decodeCountRow( + after + .prepare( + "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", + ) + .get(), + ); + expect(oldSourceTableCount.n).toBe(0); }); it("survives empty / missing json on bindings and sources", () => { @@ -501,11 +463,15 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { closeDatabase(drizzleSqlite); const after = openDatabase(dbPath, { readonly: true }); - const qpCount = decodeCountRow( - after - .prepare("SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?") - .get("bare"), - ).n; - expect(qpCount).toBe(0); + const source = decodePluginStorageData( + decodePluginStorageRow( + after + .prepare( + "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", + ) + .get("openapi", "source", "bare"), + ).data, + ) as { readonly config: { readonly queryParams?: unknown } }; + expect(source.config.queryParams).toBeUndefined(); }); }); diff --git a/apps/marketing/src/pages/api/detect.ts b/apps/marketing/src/pages/api/detect.ts index f7e66c76a..7bc0b4054 100644 --- a/apps/marketing/src/pages/api/detect.ts +++ b/apps/marketing/src/pages/api/detect.ts @@ -94,6 +94,7 @@ export const POST: APIRoute = async ({ request }) => { } else if (match.kind === "graphql") { yield* executor.graphql.addSource({ endpoint: match.endpoint, + name: match.name, namespace: match.namespace, scope: "test-scope", }); diff --git a/bun.lock b/bun.lock index ead45222a..2780ff876 100644 --- a/bun.lock +++ b/bun.lock @@ -684,6 +684,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "graphql": "^16.12.0", @@ -716,6 +717,32 @@ "react", ], }, + "packages/plugins/http-source": { + "name": "@executor-js/plugin-http-source", + "version": "1.4.29", + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/react": "workspace:*", + "react": "catalog:", + }, + "optionalPeers": [ + "@executor-js/react", + "react", + ], + }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", "version": "1.4.29", @@ -739,6 +766,7 @@ "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", @@ -810,6 +838,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", @@ -1374,6 +1403,8 @@ "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], + "@executor-js/plugin-http-source": ["@executor-js/plugin-http-source@workspace:packages/plugins/http-source"], + "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"], diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index b61e2f1fc..c8c4d2a1e 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -356,6 +356,7 @@ const program = Effect.gen(function* () { const gqlResult = yield* executor.graphql.addSource({ endpoint: "https://example.com/graphql", + name: "Example GraphQL", introspectionJson, namespace: "example-graphql", scope: "example-scope", diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index 55cffd6f8..d14463ed2 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -72,6 +72,7 @@ await executor.openapi.addSpec({ await executor.graphql.addSource({ endpoint: "https://graphql.anilist.co", + name: "AniList", namespace: "anilist", scope: "my-app", }); diff --git a/notes/plugin-derived-source-configure.md b/notes/plugin-derived-source-configure.md new file mode 100644 index 000000000..a5afd8a4a --- /dev/null +++ b/notes/plugin-derived-source-configure.md @@ -0,0 +1,356 @@ +# Plugin-Derived Source Configure Notes + +Date: 2026-05-17 +Status: planning + +## Summary + +`executor.sources.configure(...)` can exist without making headers, query +params, OAuth, database passwords, CLI env vars, or any other protocol-specific +concept part of core. + +The key distinction: + +- Core owns source identity, scoped binding storage, resolution, validation, and + dispatch. +- Plugins own their configure schemas and the translation from configure input + to core bindings. +- Shared UI lives in reusable plugin-family components, not in core source + semantics. + +This means `executor.openapi.configure(...)` and +`executor.graphql.configure(...)` can remain the typed plugin-native APIs, while +`executor.sources.configure(...)` becomes a generic dispatcher derived from the +installed plugin implementations. + +## Why Not Put Headers In Core + +Core needs to support many source types: + +- OpenAPI and GraphQL over HTTP. +- MCP over HTTP or stdio. +- Databases. +- CLIs. +- Future source types we have not named yet. + +Only some of these have request headers, query params, or OAuth. A database +source may have a connection string, username, password, TLS certs, and schema +selection. A CLI source may have env vars, argv templates, working directory, +and stdin. If core's public configure model says `headers` and `query`, it is +quietly HTTP-specific. + +Core's portable primitive is still: + +```ts +{ + source: { id, scope }, + scope, + slot, + value, +} +``` + +where the owning plugin defines what `slot` means. + +## Desired API Shape + +Plugin APIs remain first-class: + +```ts +await executor.openapi.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +```ts +await executor.graphql.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("github_token"), + }, + }, +}); +``` + +The generic source API is derived from those plugin implementations: + +```ts +await executor.sources.configure(source, { + type: "openapi", + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +The `type` discriminant is for dispatch and type narrowing. The actual source +record should still be checked so callers cannot configure an OpenAPI source +with a GraphQL payload. + +```ts +const storedSource = yield * sources.get(source); + +if (storedSource.type !== input.type) { + return ( + yield * + Effect.fail( + new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }), + ) + ); +} +``` + +## Registration Model + +Each plugin registers its configure implementation with core: + +```ts +openapiPlugin.registerSourceConfigure({ + type: "openapi", + schema: OpenApiConfigureInput, + configure: openApiConfigure, +}); +``` + +```ts +graphqlPlugin.registerSourceConfigure({ + type: "graphql", + schema: GraphqlConfigureInput, + configure: graphqlConfigure, +}); +``` + +Core dispatch stays small: + +```ts +const configure = (source, input) => + Effect.gen(function* () { + const storedSource = yield* Sources.get(source); + const implementation = yield* SourceConfigureRegistry.get(input.type); + + if (storedSource.type !== input.type) { + return yield* Effect.fail( + new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }), + ); + } + + const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); + + return yield* implementation.configure(source, parsed); + }); +``` + +The plugin implementation compiles its domain-specific configure input into +core binding operations: + +```ts +const openApiConfigure = (source, input) => + Effect.gen(function* () { + const bindings = yield* compileOpenApiConfigureBindings(source, input); + + yield* Sources.replaceBindings({ + source, + scope: input.scope, + bindings, + }); + }); +``` + +## Shared HTTP Credential Pieces + +OpenAPI, GraphQL, and HTTP MCP should reuse HTTP credential vocabulary, but +that vocabulary should live in a shared protocol helper, not core. + +```ts +type HttpRequestCredentialConfig = { + headers?: Record; + query?: Record; + oauth?: OAuthCredentialConfig; +}; +``` + +Then plugins embed that helper where it makes sense: + +```ts +type OpenApiConfigureInput = { + type: "openapi"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; + specFetch?: HttpRequestCredentialConfig; +}; +``` + +```ts +type GraphqlConfigureInput = { + type: "graphql"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; + introspection?: HttpRequestCredentialConfig; +}; +``` + +```ts +type HttpMcpConfigureInput = { + type: "mcp"; + scope: ScopeId; + request?: HttpRequestCredentialConfig; +}; +``` + +Database and CLI plugins should not see this shape unless they opt into it. + +## Shared UI Direction + +The generic UI should call one mutation: + +```ts +configureSource(source, input); +``` + +but forms should be plugin-specific or plugin-family-specific. + +HTTP-ish plugins can share components: + +```tsx + setConfig({ ...config, request })} +/> +``` + +OpenAPI can use it twice: + +```tsx + + +``` + +GraphQL can use it for request and introspection credentials: + +```tsx + + +``` + +MCP HTTP can use it for its request transport credentials: + +```tsx + +``` + +Other source families get their own shared components: + +```tsx + + +``` + +The reuse boundary is therefore explicit: + +- Core mutation and atoms are shared. +- Plugin configure schemas are plugin-owned. +- HTTP credential UI is shared only by plugins that use HTTP credential shapes. +- Database and CLI UI are not forced through HTTP concepts. + +## OAuth Notes + +OAuth should be part of the HTTP credential helper, but it should not be modeled +as just another header value. OAuth configuration needs room for: + +- Authorization URL. +- Token URL. +- Client ID. +- Client secret. +- Scopes. +- PKCE and auth-code state. +- Refresh behavior. +- Token placement after exchange. + +The resulting access token may be placed into a header or query param, but the +configuration and lifecycle are richer than a raw `Authorization` binding. + +Avoid double nesting like: + +```ts +oauth2: { + oauth2: { + ... + }, +} +``` + +Prefer a single OAuth object embedded at the credential boundary: + +```ts +request: { + oauth: { + clientId: SecretId.make("client_id"), + clientSecret: SecretId.make("client_secret"), + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token", + scopes: ["read", "write"], + placement: { + header: "Authorization", + scheme: "Bearer", + }, + }, +} +``` + +## MCP And Auth-Dependent Metadata + +MCP can expose different tool descriptions depending on auth state. GraphQL can +also theoretically expose different introspection results by credential scope. + +This does not invalidate the shared configure dispatch model, but it means the +plugin must decide whether metadata is global source shape or scoped resolved +shape. + +For MCP especially, avoid assuming a single globally cached tool manifest per +source forever. A future MCP implementation may need: + +- shared source transport config; +- scoped credential bindings; +- scoped or credential-derived tool metadata cache. + +That is plugin behavior, not core binding behavior. + +## Design Guardrails + +- Do not add `headers`, `query`, or `oauth` as universal core source fields. +- Do keep `executor.openapi.configure` and other plugin-native configure APIs. +- Do derive `executor.sources.configure` from registered plugin implementations. +- Do validate that `input.type` matches the stored source type. +- Do compile plugin configure input into core binding writes internally. +- Do build shared React components around explicit plugin-family shapes such as + HTTP request credentials. +- Do not require database, CLI, or future non-HTTP sources to fit the HTTP + credential model. + +## Likely Implementation Order + +1. Keep core binding APIs protocol-agnostic. +2. Make OpenAPI's configure implementation compile to those core bindings. +3. Introduce source-level configure dispatch over registered plugin configure + implementations. +4. Move OpenAPI UI to the generic configure mutation while keeping OpenAPI's + form domain-specific. +5. Extract shared HTTP credential config types and UI components when GraphQL or + MCP is ported, rather than abstracting from OpenAPI alone. diff --git a/notes/plugin-source-configuration-overhaul.md b/notes/plugin-source-configuration-overhaul.md new file mode 100644 index 000000000..2c8312270 --- /dev/null +++ b/notes/plugin-source-configuration-overhaul.md @@ -0,0 +1,729 @@ +# Plugin Source Configuration Overhaul + +Date: 2026-05-18 +Status: implemented in progress + +## Goal + +Turn the source credential/configuration work into a full plugin overhaul instead +of an incremental OpenAPI cleanup. + +Implementation goal for this PR: + +```txt +PR #844 should become the complete source-configuration overhaul for first-party +source plugins. OpenAPI, GraphQL, and MCP should all move onto plugin-derived +configure APIs, shared core credential bindings, shared HTTP credential helpers +where applicable, JSON-backed plugin source config, and composed shared React +credential UI. The old plugin-specific binding wrappers, endpoints, stores, +credential child tables, and duplicated UI atoms should be removed rather than +kept as compatibility shims. +``` + +By the end, OpenAPI, GraphQL, and MCP should share the same underlying source +configuration architecture: + +- Core owns generic source identity, scoped credential bindings, source + configure dispatch, and validation. +- Plugins own their source-specific configure schemas and source config + decoding. +- HTTP-ish plugins share HTTP credential config, runtime helpers, and React + components through a shared HTTP package. +- Plugin-private storage remains an overridable plugin facility, backed by one + shared plugin storage table rather than plugin-specific SQL tables. +- Duplicated OpenAPI/GraphQL/MCP binding endpoints, stores, React atoms, and + child tables should be deleted aggressively. + +The desired result is that a future improvement to HTTP credential handling +such as adding a plaintext header path, changing the secret input UI, or +improving OAuth flows benefits OpenAPI, GraphQL, and MCP HTTP rather than being +reimplemented per plugin. + +## Settled Decisions + +- This can be one large PR/overhaul, not a series of small incremental PRs. +- `executor.sources.configure(...)` should be implemented in this overhaul. +- Plugin-native configure APIs should remain: + - `executor.openapi.configure(...)` + - `executor.graphql.configure(...)` + - `executor.mcp.configure(...)` +- Low-level binding APIs should be hidden from normal consumers where possible. + They may remain exported for internal/advanced use, but product and SDK + happy paths should lead with configure. +- Source config may move to JSON. Use Effect Schema at boundaries. +- Migrations are one-shot migrations. Preserve existing source configuration and + credential values. +- Persist source config; derive slot manifests from source config. +- Add a shared HTTP package, likely `@executor-js/plugin-http-source`. +- MCP configure should be transport-discriminated. +- Shared UI components can omit MCP stdio for this pass. +- Plaintext credential values are not needed unless a current flow already has + them. +- OAuth should be designed for the long-term model, not as a short-term header + hack. +- UI should be hand-composed from reusable components, not generated entirely + from manifests. +- GraphQL can use the same request URL/credential config for introspection for + now. +- MCP may require auth at add time if auth is needed to list tools. +- Naming like `request`, `specFetch`, `introspection`, `transport`, `env`, and + `query` is acceptable. + +## Storage Model + +Do not kill plugin storage. + +The overhaul should separate three storage concerns that are currently blurred: + +1. First-class Executor source records. +2. Scoped credential values. +3. Plugin-private state/cache/documents. + +### First-Class Source Records + +Sources are product entities. Core needs to list them, scope them, remove them, +refresh them, attach tools/policies to them, and expose them through generic +source APIs. + +Source records should remain first-class, but plugin-specific source details can +move into plugin-owned JSON config decoded by Effect Schema. + +For example: + +```txt +openapi_source + id + scope_id + name + config_json +``` + +```txt +graphql_source + id + scope_id + name + config_json +``` + +```txt +mcp_source + id + scope_id + name + config_json +``` + +The exact physical table shape can follow the repo's current storage layout, but +the duplicated credential child tables should go away where they only represent +source config and binding placeholders. + +Plugin packages own their config schemas: + +```ts +export const OpenApiStoredSourceConfig = Schema.Struct({ + request: Schema.optional(HttpRequestSourceConfig), + specFetch: Schema.optional(HttpRequestSourceConfig), + // OpenAPI-specific spec/base URL/operation details... +}); +``` + +```ts +export const GraphqlStoredSourceConfig = Schema.Struct({ + request: Schema.optional(HttpRequestSourceConfig), + // GraphQL-specific endpoint/schema details... +}); +``` + +```ts +export const McpStoredSourceConfig = Schema.Union( + Schema.Struct({ + transport: Schema.Literal("http"), + request: Schema.optional(HttpRequestSourceConfig), + // MCP HTTP details... + }), + Schema.Struct({ + transport: Schema.Literal("stdio"), + command: Schema.String, + args: Schema.Array(Schema.String), + env: Schema.Record({ key: Schema.String, value: ProcessEnvSourceConfig }), + }), +); +``` + +### Credential Bindings + +Core `credential_binding` remains the only place scoped credential values live. + +```txt +credential_binding + plugin_id + source_id + source_scope_id + scope_id + slot_key + kind + secret_id + secret_scope_id + connection_id + text_value + created_at + updated_at +``` + +Source config declares slots and their wire-format meaning. Credential bindings +store scoped values for those slots. + +Example OpenAPI source config: + +```ts +{ + request: { + headers: { + Authorization: { + slotKey: "request.headers.authorization", + prefix: "Bearer ", + }, + }, + query: {}, + }, + specFetch: { + headers: {}, + query: {}, + }, +} +``` + +Example value: + +```txt +credential_binding + plugin_id = "openapi" + source_id = "stripe" + source_scope_id = "org_acme" + scope_id = "user_rhys" + slot_key = "request.headers.authorization" + kind = "secret" + secret_id = "stripe_api_key" +``` + +### Plugin-Private Storage + +Plugin storage should remain as an overridable facility. + +The target is one shared table for all plugin-private storage, partitioned by +plugin and collection: + +```txt +plugin_storage + plugin_id + collection + scope_id + key + data_json + created_at + updated_at +``` + +Primary key: + +```txt +(plugin_id, collection, scope_id, key) +``` + +This is for plugin-owned private state/cache/documents: + +- OAuth state caches. +- Remote metadata caches. +- Probe results. +- Background job state. +- Plugin-specific documents that are not first-class Executor sources. + +The plugin storage API should remain overridable when constructing the executor +or installing/creating a plugin. This is separate from source storage and +credential binding storage. + +Conceptually: + +```ts +createExecutor({ + pluginStorage: myPluginStorageProvider, +}); +``` + +or, if plugin-level override is already the local pattern: + +```ts +openApiPlugin({ + storage: myPluginStorageProvider, +}); +``` + +The exact wiring should preserve the repo's existing ability for users to +override plugin storage. The overhaul should consolidate plugin-private storage +tables, not remove plugin storage. + +### What We Are Deleting + +Delete plugin-specific storage that only duplicates source config or +credential-binding behavior. + +Likely delete/replace: + +- OpenAPI source binding wrappers and backing adapters. +- OpenAPI header/query/spec-fetch child tables that only store slot config. +- GraphQL header/query child tables. +- MCP header/query child tables. +- Plugin-specific binding resolvers/listers/validators. +- Plugin-specific HTTP binding endpoints. +- Plugin-specific React binding atoms. + +Do not delete: + +- First-class source records. +- Core `credential_binding`. +- Generic plugin-private storage. +- The ability to provide/override plugin storage. + +## Configure API Model + +Core gets a plugin-derived configure dispatch: + +```ts +await executor.sources.configure(source, { + type: "openapi", + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +Plugin-native APIs remain: + +```ts +await executor.openapi.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("stripe_api_key"), + }, + }, +}); +``` + +```ts +await executor.graphql.configure(source, { + scope: user, + request: { + headers: { + Authorization: SecretId.make("github_token"), + }, + }, +}); +``` + +```ts +await executor.mcp.configure(source, { + scope: user, + transport: "http", + request: { + headers: { + Authorization: SecretId.make("mcp_token"), + }, + }, +}); +``` + +MCP stdio is transport-specific and should not be forced through HTTP helpers: + +```ts +await executor.mcp.configure(source, { + scope: user, + transport: "stdio", + env: { + GITHUB_TOKEN: SecretId.make("github_token"), + }, +}); +``` + +Core dispatch: + +```ts +const configure = (source, input) => + Effect.gen(function* () { + const storedSource = yield* Sources.get(source); + const implementation = yield* SourceConfigureRegistry.get(input.type); + + if (storedSource.type !== input.type) { + return yield* new SourceTypeMismatch({ + source, + expected: storedSource.type, + received: input.type, + }); + } + + const parsed = yield* Schema.decodeUnknown(implementation.schema)(input); + + return yield* implementation.configure(source, parsed); + }); +``` + +Plugin registration: + +```ts +openapiPlugin.registerSourceConfigure({ + type: "openapi", + schema: OpenApiConfigureInput, + configure: openApiConfigure, + manifest: deriveOpenApiCredentialManifest, +}); +``` + +The plugin configure implementation compiles plugin input into core binding +operations: + +```ts +const openApiConfigure = (source, input) => + Effect.gen(function* () { + const bindings = yield* compileOpenApiConfigureBindings(source, input); + + yield* Sources.replaceBindings({ + source, + scope: input.scope, + slotPrefixes: ["request.", "specFetch."], + bindings, + }); + }); +``` + +## Shared HTTP Package + +Create a shared package for HTTP source helpers, likely: + +```txt +packages/plugins/http-source +``` + +Package name: + +```txt +@executor-js/plugin-http-source +``` + +This package is not core. It exists because OpenAPI, GraphQL, and MCP HTTP share +HTTP credential concepts while databases, CLIs, and MCP stdio do not. + +It should own: + +- HTTP credential config types. +- Header/query slot key helpers. +- OAuth config types. +- Binding compiler helpers. +- Runtime resolution helpers. +- Helpers that apply resolved credentials to HTTP requests. +- Slot manifest helpers. +- React credential components, either directly or via a `/react` subpath export. + +Possible layout: + +```txt +packages/plugins/http-source + src/ + sdk/ + types.ts + slots.ts + configure.ts + resolve.ts + oauth.ts + react/ + HttpCredentialsProvider.tsx + HttpHeaderCredentials.tsx + HttpQueryCredentials.tsx + OAuthCredentials.tsx + index.ts +``` + +Example shared config: + +```ts +type HttpRequestSourceConfig = { + headers?: Record; + query?: Record; + oauth?: HttpOAuthSourceConfig; +}; +``` + +Example configure input: + +```ts +type HttpRequestConfigureInput = { + headers?: Record; + query?: Record; + oauth?: HttpOAuthConfigureInput; +}; +``` + +OpenAPI can embed the shared shape twice: + +```ts +type OpenApiConfigureInput = { + type: "openapi"; + scope: ScopeId; + request?: HttpRequestConfigureInput; + specFetch?: HttpRequestConfigureInput; +}; +``` + +GraphQL can embed it once for now: + +```ts +type GraphqlConfigureInput = { + type: "graphql"; + scope: ScopeId; + request?: HttpRequestConfigureInput; +}; +``` + +MCP HTTP can embed it behind a transport discriminant: + +```ts +type McpConfigureInput = + | { + type: "mcp"; + transport: "http"; + scope: ScopeId; + request?: HttpRequestConfigureInput; + } + | { + type: "mcp"; + transport: "stdio"; + scope: ScopeId; + env?: Record; + }; +``` + +## OAuth Direction + +OAuth belongs with HTTP helpers, but it should not be modeled as only a header +value. OAuth configuration needs to support long-term flow requirements: + +- Authorization URL. +- Token URL. +- Client ID. +- Client secret. +- Scopes. +- PKCE/auth-code state. +- Refresh behavior. +- Resulting token placement. + +## Implementation Notes + +This branch implements the core shape described above: + +- `executor.sources.configure(...)` dispatches through the owning plugin's + registered `sourceConfigure` implementation. +- `executor.openapi.configure(...)`, `executor.graphql.configure(...)`, and + `executor.mcp.configure(...)` remain plugin-native entry points. +- OpenAPI, GraphQL, and MCP source/operation/plugin rows now use the shared + `plugin_storage` table instead of plugin-specific SQL source/operation tables. +- Core `credential_binding` remains the shared source credential value store. +- GraphQL and MCP no longer expose plugin-specific source binding HTTP + endpoints or SDK wrapper methods; React callers use core source credential + binding atoms. +- Local and cloud one-shot migrations copy old plugin source rows into + `plugin_storage` and drop the old plugin-specific source/config tables. +- The shared HTTP source package exists as `@executor-js/plugin-http-source`. + +The React layer still intentionally composes the existing shared credential +components instead of replacing whole source forms with generated UIs. That +keeps MCP stdio out of HTTP-specific components while making header/query/OAuth +credential changes land in shared components used by OpenAPI, GraphQL, and MCP +HTTP. + +- User-scoped connections. + +The resulting access token may be placed in a header or query parameter, but the +OAuth lifecycle is richer than raw header configuration. + +Avoid double nesting: + +```ts +oauth2: { + oauth2: { + // ... + }, +} +``` + +Prefer one OAuth object at the HTTP credential boundary: + +```ts +request: { + oauth: { + clientId: SecretId.make("client_id"), + clientSecret: SecretId.make("client_secret"), + authorizationUrl: "https://example.com/oauth/authorize", + tokenUrl: "https://example.com/oauth/token", + scopes: ["read", "write"], + placement: { + header: "Authorization", + scheme: "Bearer", + }, + }, +} +``` + +## Slot Manifest + +Persist source config, derive manifests from it. + +The manifest is for UI/status/validation. It should not be the source of truth +if it can be derived from plugin-owned config. + +Example derived manifest entry: + +```ts +{ + slotKey: "request.headers.authorization", + label: "Authorization", + family: "http.header", + required: true, + valueKind: "secret", + placement: { + header: "Authorization", + prefix: "Bearer ", + }, +} +``` + +Core may expose generic manifest/query APIs, but it should not interpret +`family: "http.header"` beyond using it as metadata. The HTTP package and UI +components interpret HTTP metadata. + +## React/UI Direction + +Use composition, not a mega-form with many boolean props. + +Avoid: + +```tsx + +``` + +Prefer plugin forms composed from shared sections: + +```tsx + + + + + +``` + +```tsx + + + +``` + +```tsx + + + +``` + +MCP stdio can be omitted from shared HTTP components for now. It should later +compose process/env components instead of forcing itself through the HTTP +credential UI. + +Shared UI pieces likely include: + +- `CredentialValueInput` +- `SecretPicker` +- `ConnectionPicker` +- `HttpHeaderCredentials` +- `HttpQueryCredentials` +- `OAuthCredentials` +- `CredentialStatusList` +- `SourceConfigureProvider` + +The generic UI mutation should call: + +```ts +configureSource(source, input); +``` + +Plugin forms produce typed configure payloads. + +## MCP Notes + +MCP stdio causes code-sharing problems if MCP is treated as one credential +family. The sharing boundary should be transport-level: + +- OpenAPI, GraphQL, and MCP HTTP share HTTP credential helpers. +- MCP stdio and future CLI sources should share process/env helpers later. +- Databases should have their own connection helper family later. + +It is acceptable for MCP add/import to require auth if the server needs auth to +list tools. Longer term, MCP may need scoped/auth-derived tool metadata because +some servers expose different tools/descriptions based on auth state. + +This overhaul does not need to solve scoped MCP metadata fully, but it should +avoid baking in an assumption that one global tool manifest is always correct. + +## Migration Plan + +This is a one-shot migration that preserves existing config and values. + +Migration responsibilities: + +1. Move concrete credential values into `credential_binding` if any remain in + plugin-specific tables/JSON. +2. Move source declaration/config into plugin-owned JSON config. +3. Preserve source IDs, names, scopes, base URLs, specs, endpoints, transports, + and tool relationships. +4. Preserve OAuth connections and client credentials. +5. Drop or ignore old plugin-specific child tables after data is migrated. + +Tests should cover: + +- OpenAPI header/query/spec-fetch migration. +- OpenAPI OAuth/client credential migration. +- GraphQL header/query/auth migration. +- MCP HTTP header/query/auth migration. +- MCP stdio source survival. +- Collision detection where legacy slot canonicalization would collapse names. +- Existing source listing and tool invocation after migration. +- Secret/connection usage isolation after migration. + +## Implementation Order + +Even in one large PR, sequence the work internally: + +1. Document the architecture and update the existing notes. +2. Add/finish core source binding and configure registry. +3. Add generic plugin storage table/provider if not already present. +4. Create `@executor-js/plugin-http-source`. +5. Port OpenAPI to configure + HTTP helpers + JSON source config. +6. Port GraphQL to configure + HTTP helpers + JSON source config. +7. Port MCP with transport-discriminated configure. +8. Replace React credential flows with composed shared components. +9. Add one-shot migrations and migration tests. +10. Delete old plugin-specific binding APIs, stores, tables, atoms, and helpers. +11. Run full verification and fix fallout. + +## Success Criteria + +- Normal SDK users configure credentials through plugin-native configure APIs. +- Generic UI can call `executor.sources.configure(...)`. +- Core does not expose headers/query/OAuth as universal source concepts. +- OpenAPI/GraphQL/MCP do not each implement their own binding resolver/lister. +- HTTP credential UI changes apply to OpenAPI, GraphQL, and MCP HTTP. +- Plugin storage remains overridable and is backed by one shared plugin storage + table for plugin-private data. +- Source config remains first-class enough for source listing, tools, policies, + refresh, and scopes. +- Old plugin-specific credential child tables and endpoints are gone. diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts index 775ffdb8e..a72b2eb9c 100644 --- a/packages/core/api/src/handlers/sources.ts +++ b/packages/core/api/src/handlers/sources.ts @@ -86,5 +86,56 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han })); }), ), + ) + .handle("configure", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.configure({ + source: payload.source, + scope: payload.scope ?? path.scopeId, + type: payload.type, + config: payload.config, + }); + }), + ), + ) + .handle("listBindings", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.listBindings({ + source: { + id: path.sourceId, + scope: path.sourceScopeId, + }, + }); + }), + ), + ) + .handle("setBinding", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.setBinding(payload); + }), + ), + ) + .handle("removeBinding", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.sources.removeBinding(payload); + return { removed: true }; + }), + ), + ) + .handle("replaceBindings", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.sources.replaceBindings(payload); + }), + ), ), ); diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts index 5e6b9a4eb..e61889279 100644 --- a/packages/core/api/src/sources/api.ts +++ b/packages/core/api/src/sources/api.ts @@ -2,8 +2,12 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { InternalError, + CredentialBindingRef, + RemoveSourceCredentialBindingInput, ScopeId, + SetSourceCredentialBindingInput, SourceRemovalNotAllowedError, + ReplaceSourceCredentialBindingsInput, ToolId, } from "@executor-js/sdk/shared"; @@ -13,6 +17,11 @@ import { const ScopeParams = { scopeId: ScopeId }; const SourceParams = { scopeId: ScopeId, sourceId: Schema.String }; +const SourceBindingParams = { + scopeId: ScopeId, + sourceId: Schema.String, + sourceScopeId: ScopeId, +}; // --------------------------------------------------------------------------- // Response schemas @@ -55,6 +64,16 @@ const DetectRequest = Schema.Struct({ url: Schema.String.check(Schema.isMaxLength(2_048)), }); +const ConfigureSourceRequest = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: ScopeId, + }), + scope: ScopeId, + type: Schema.optional(Schema.String), + config: Schema.Unknown, +}); + const DetectResultResponse = Schema.Struct({ kind: Schema.String, confidence: Schema.Literals(["high", "medium", "low"]), @@ -109,4 +128,47 @@ export const SourcesApi = HttpApiGroup.make("sources") success: Schema.Array(DetectResultResponse), error: InternalError, }), + ) + .add( + HttpApiEndpoint.post("configure", "/scopes/:scopeId/sources/configure", { + params: ScopeParams, + payload: ConfigureSourceRequest, + success: Schema.Unknown, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get( + "listBindings", + "/scopes/:scopeId/sources/:sourceId/base/:sourceScopeId/bindings", + { + params: SourceBindingParams, + success: Schema.Array(CredentialBindingRef), + error: InternalError, + }, + ), + ) + .add( + HttpApiEndpoint.post("setBinding", "/scopes/:scopeId/source-bindings", { + params: ScopeParams, + payload: SetSourceCredentialBindingInput, + success: CredentialBindingRef, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("removeBinding", "/scopes/:scopeId/source-bindings/remove", { + params: ScopeParams, + payload: RemoveSourceCredentialBindingInput, + success: Schema.Struct({ removed: Schema.Boolean }), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("replaceBindings", "/scopes/:scopeId/source-bindings/replace", { + params: ScopeParams, + payload: ReplaceSourceCredentialBindingsInput, + success: Schema.Array(CredentialBindingRef), + error: InternalError, + }), ); diff --git a/packages/core/config/src/schema.ts b/packages/core/config/src/schema.ts index 0343d8f46..15f06a839 100644 --- a/packages/core/config/src/schema.ts +++ b/packages/core/config/src/schema.ts @@ -71,8 +71,8 @@ export const McpRemoteSourceConfig = Schema.Struct({ endpoint: Schema.String, remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), namespace: Schema.optional(Schema.String), - queryParams: Schema.optional(StringMap), - headers: Schema.optional(StringMap), + queryParams: Schema.optional(ConfigHeaders), + headers: Schema.optional(ConfigHeaders), auth: Schema.optional(McpAuthConfig), }); export type McpRemoteSourceConfig = typeof McpRemoteSourceConfig.Type; diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index d3dd385d2..6333746b8 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -92,6 +92,12 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ refresh: (input) => fromPromise(() => pe.sources.refresh(input)), detect: (url) => fromPromise(() => pe.sources.detect(url)), definitions: (id) => fromPromise(() => pe.sources.definitions(id)), + configure: (input) => fromPromise(() => pe.sources.configure(input)), + listBindings: (input) => fromPromise(() => pe.sources.listBindings(input)), + resolveBinding: (input) => fromPromise(() => pe.sources.resolveBinding(input)), + setBinding: (input) => fromPromise(() => pe.sources.setBinding(input)), + removeBinding: (input) => fromPromise(() => pe.sources.removeBinding(input)), + replaceBindings: (input) => fromPromise(() => pe.sources.replaceBindings(input)), }, secrets: { get: (id) => fromPromise(() => pe.secrets.get(id)), @@ -120,6 +126,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ }, credentialBindings: { listForSource: (input) => fromPromise(() => pe.credentialBindings.listForSource(input)), + resolveBinding: (input) => fromPromise(() => pe.credentialBindings.resolveBinding(input)), resolve: (input) => fromPromise(() => pe.credentialBindings.resolve(input)), set: (input) => fromPromise(() => pe.credentialBindings.set(input)), remove: (input) => fromPromise(() => pe.credentialBindings.remove(input)), diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 588cc3eac..491cb190e 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -194,6 +194,14 @@ export const coreTables = defineTables({ created_at: dateColumn("created_at"), }), credential_binding: credentialBindingTable, + plugin_storage: scopedExecutorTable("plugin_storage", { + plugin_id: textColumn("plugin_id"), + collection: textColumn("collection"), + key: textColumn("key"), + data: jsonColumn("data"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }), tool_policy: scopedExecutorTable("tool_policy", { pattern: textColumn("pattern"), action: textColumn("action"), @@ -216,6 +224,7 @@ export type ToolRow = FumaRow; export type DefinitionRow = FumaRow; export type SecretRow = FumaRow; export type ConnectionRow = FumaRow; +export type PluginStorageRow = FumaRow; type CredentialBindingRowBase = Omit< FumaRow, diff --git a/packages/core/sdk/src/credential-bindings.test.ts b/packages/core/sdk/src/credential-bindings.test.ts index e1c824c7b..c3526ef00 100644 --- a/packages/core/sdk/src/credential-bindings.test.ts +++ b/packages/core/sdk/src/credential-bindings.test.ts @@ -172,6 +172,51 @@ describe("credential bindings", () => { }), ); + it.effect("exposes source binding helpers over the source facade", () => + 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, "api-token", "sk-user-a"); + const binding = yield* userExecutor.sources.setBinding({ + scope: harness.scopes.userWorkspaceA.id, + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + slotKey: TEST_SLOT, + value: { kind: "secret", secretId: SecretId.make("api-token") }, + }); + + const listed = yield* userExecutor.sources.listBindings({ + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + }); + const resolved = yield* userExecutor.sources.resolveBinding({ + source: { + id: TEST_SOURCE_ID, + scope: harness.scopes.org.id, + }, + slotKey: TEST_SLOT, + }); + + expect(listed.map((row) => row.id)).toEqual([binding.id]); + expect(resolved?.id).toBe(binding.id); + expect(resolved?.value).toEqual({ + kind: "secret", + secretId: SecretId.make("api-token"), + secretScopeId: harness.scopes.userWorkspaceA.id, + }); + }), + ); + it.effect("workspace credential bindings shadow org bindings without copying the source", () => Effect.gen(function* () { const harness = makeHarness(); diff --git a/packages/core/sdk/src/credential-bindings.ts b/packages/core/sdk/src/credential-bindings.ts index 7890860ce..2f50f0bd3 100644 --- a/packages/core/sdk/src/credential-bindings.ts +++ b/packages/core/sdk/src/credential-bindings.ts @@ -57,15 +57,14 @@ export const CredentialBindingRef = Schema.Struct({ }); export type CredentialBindingRef = typeof CredentialBindingRef.Type; -export const SetCredentialBindingInput = Schema.Struct({ - targetScope: ScopeId, - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, - slotKey: Schema.String, - value: CredentialBindingValue, -}); -export type SetCredentialBindingInput = typeof SetCredentialBindingInput.Type; +export type SetPluginCredentialBindingInput = { + readonly targetScope: ScopeId; + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: ScopeId; + readonly slotKey: string; + readonly value: CredentialBindingValue; +}; export const CredentialBindingSourceInput = Schema.Struct({ pluginId: Schema.String, @@ -107,6 +106,46 @@ export const ReplaceCredentialBindingsInput = Schema.Struct({ }); export type ReplaceCredentialBindingsInput = typeof ReplaceCredentialBindingsInput.Type; +export const SourceCredentialBindingSource = Schema.Struct({ + id: Schema.String, + scope: ScopeId, +}); +export type SourceCredentialBindingSource = typeof SourceCredentialBindingSource.Type; + +export const SourceCredentialBindingSourceInput = Schema.Struct({ + source: SourceCredentialBindingSource, +}); +export type SourceCredentialBindingSourceInput = typeof SourceCredentialBindingSourceInput.Type; + +export const SourceCredentialBindingSlotInput = Schema.Struct({ + source: SourceCredentialBindingSource, + slotKey: Schema.String, +}); +export type SourceCredentialBindingSlotInput = typeof SourceCredentialBindingSlotInput.Type; + +export const SetSourceCredentialBindingInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotKey: Schema.String, + value: CredentialBindingValue, +}); +export type SetSourceCredentialBindingInput = typeof SetSourceCredentialBindingInput.Type; + +export const RemoveSourceCredentialBindingInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotKey: Schema.String, +}); +export type RemoveSourceCredentialBindingInput = typeof RemoveSourceCredentialBindingInput.Type; + +export const ReplaceSourceCredentialBindingsInput = Schema.Struct({ + scope: ScopeId, + source: SourceCredentialBindingSource, + slotPrefixes: Schema.Array(Schema.String), + bindings: Schema.Array(ReplaceCredentialBindingValue), +}); +export type ReplaceSourceCredentialBindingsInput = typeof ReplaceSourceCredentialBindingsInput.Type; + export const CredentialBindingResolutionStatus = Schema.Literals([ "resolved", "missing", @@ -129,11 +168,14 @@ export interface CredentialBindingsFacade { readonly listForSource: ( input: CredentialBindingSourceInput, ) => Effect.Effect; + readonly resolveBinding: ( + input: CredentialBindingSlotInput, + ) => Effect.Effect; readonly resolve: ( input: CredentialBindingSlotInput, ) => Effect.Effect; readonly set: ( - input: SetCredentialBindingInput, + input: SetPluginCredentialBindingInput, ) => Effect.Effect; readonly remove: (input: RemoveCredentialBindingInput) => Effect.Effect; readonly replaceForSource: ( diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 76dbefddf..f36055a37 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Predicate } from "effect"; +import { Data, Effect, Predicate, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { scopedExecutorTable, textColumn } from "./core-schema"; @@ -189,6 +189,50 @@ const caseSensitiveDynamicPlugin = definePlugin(() => ({ invokeTool: ({ toolRow }) => Effect.succeed({ invokedToolId: toolRow.id }), }))(); +const configurableSourcePlugin = definePlugin(() => ({ + id: "configurable" as const, + storage: ({ pluginStorage }) => ({ + get: (scope: string, sourceId = "configured-source") => + pluginStorage.getAtScope<{ readonly header: string; readonly sourceScope: string }>({ + scope, + collection: "source-config", + key: sourceId, + }), + visible: (sourceId = "configured-source") => + pluginStorage.get<{ readonly header: string; readonly sourceScope: string }>({ + collection: "source-config", + key: sourceId, + }), + }), + extension: (ctx) => ({ + registerSource: (scope: string) => + ctx.core.sources.register({ + id: "configured-source", + scope, + kind: "configurable", + name: "Configurable Source", + canRemove: true, + tools: [{ name: "run", description: "run configurable source" }], + }), + getConfigAtScope: (scope: string) => ctx.storage.get(scope), + getVisibleConfig: () => ctx.storage.visible(), + }), + sourceConfigure: { + type: "configurable", + schema: Schema.Struct({ header: Schema.String }), + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => + ctx.pluginStorage.put({ + scope: targetScope, + collection: "source-config", + key: sourceId, + data: { + ...(config as { readonly header: string }), + sourceScope, + }, + }), + }, +}))(); + describe("createExecutor", () => { it.effect("rolls back plugin and core writes from ctx.transaction failures", () => Effect.gen(function* () { @@ -389,4 +433,44 @@ describe("createExecutor", () => { expect(error.suggestions).toEqual(["case_source.listdashboards"]); }), ); + + it.effect("dispatches source.configure through the owning plugin with explicit scopes", () => + Effect.gen(function* () { + const orgScope = Scope.make({ + id: ScopeId.make("org"), + name: "Org", + createdAt: new Date(), + }); + const userScope = Scope.make({ + id: ScopeId.make("user"), + name: "User", + createdAt: new Date(), + }); + const executor = yield* createExecutor({ + scopes: [userScope, orgScope], + plugins: [configurableSourcePlugin] as const, + onElicitation: "accept-all", + }); + + yield* executor.configurable.registerSource("org"); + yield* executor.sources.configure({ + source: { id: "configured-source", scope: "org" }, + scope: "org", + type: "configurable", + config: { header: "org-token" }, + }); + yield* executor.sources.configure({ + source: { id: "configured-source", scope: "org" }, + scope: "user", + type: "configurable", + config: { header: "user-token" }, + }); + + const orgConfig = yield* executor.configurable.getConfigAtScope("org"); + const visibleConfig = yield* executor.configurable.getVisibleConfig(); + + expect(orgConfig?.data).toEqual({ header: "org-token", sourceScope: "org" }); + expect(visibleConfig?.data).toEqual({ header: "user-token", sourceScope: "org" }); + }), + ); }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index bad0fadc1..5674d7f23 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -46,9 +46,14 @@ import { type CredentialBindingSlotInput, type CredentialBindingSourceInput, type RemoveCredentialBindingInput, + type RemoveSourceCredentialBindingInput, type ReplaceCredentialBindingsInput, + type ReplaceSourceCredentialBindingsInput, ResolvedCredentialSlot, - type SetCredentialBindingInput, + type SetPluginCredentialBindingInput, + type SetSourceCredentialBindingInput, + type SourceCredentialBindingSlotInput, + type SourceCredentialBindingSourceInput, } from "./credential-bindings"; import { coreSchema, @@ -110,6 +115,11 @@ import type { StaticToolSchema, StorageDeps, } from "./plugin"; +import { + pluginStorageId, + type PluginStorageEntry, + type PluginStorageFacade, +} from "./plugin-storage"; import type { Scope } from "./scope"; import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; import { Usage } from "./usages"; @@ -222,6 +232,30 @@ export type Executor = { readonly definitions: ( sourceId: string, ) => Effect.Effect, StorageFailure>; + readonly configure: (input: { + readonly source: { + readonly id: string; + readonly scope: ScopeId | string; + }; + readonly scope: ScopeId | string; + readonly type?: string; + readonly config: unknown; + }) => Effect.Effect; + readonly listBindings: ( + input: SourceCredentialBindingSourceInput, + ) => Effect.Effect; + readonly resolveBinding: ( + input: SourceCredentialBindingSlotInput, + ) => Effect.Effect; + readonly setBinding: ( + input: SetSourceCredentialBindingInput, + ) => Effect.Effect; + readonly removeBinding: ( + input: RemoveSourceCredentialBindingInput, + ) => Effect.Effect; + readonly replaceBindings: ( + input: ReplaceSourceCredentialBindingsInput, + ) => Effect.Effect; }; readonly secrets: { @@ -528,6 +562,21 @@ const toToolJsonSchema = ( }); }; +const decodeConfigureInput = ( + schema: StaticToolSchema | Schema.Decoder | undefined, + input: unknown, +): Effect.Effect => { + if (schema == null) return Effect.succeed(input); + if (!("~standard" in schema)) { + return Schema.decodeUnknownEffect(schema)(input); + } + return Effect.promise(() => Promise.resolve(schema["~standard"].validate(input))).pipe( + Effect.flatMap((result) => + "value" in result ? Effect.succeed(result.value) : Effect.fail(result), + ), + ); +}; + const EXECUTOR_SOURCE_ID = "executor"; const EXECUTOR_SOURCE: StaticSourceDecl = { id: EXECUTOR_SOURCE_ID, @@ -703,6 +752,128 @@ const makeCoreDb = (fuma: ReturnType) => ({ ), }); +const pluginStorageEntryFromRow = (row: CoreRow<"plugin_storage">): PluginStorageEntry => ({ + id: row.id, + scopeId: ScopeId.make(row.scope_id), + pluginId: row.plugin_id, + collection: row.collection, + key: row.key, + data: row.data as T, + createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), +}); + +const makePluginStorageFacade = (input: { + readonly core: ReturnType; + readonly pluginId: string; + readonly scopeIds: readonly string[]; +}): PluginStorageFacade => { + const whereFor = (collection: string, key?: string) => + scopedWhere(input.scopeIds, (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("collection", "=", collection), + key === undefined ? true : b("key", "=", key), + ), + ); + + const sortByScopePrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => + [...rows].sort((left, right) => { + const leftIndex = input.scopeIds.indexOf(left.scope_id); + const rightIndex = input.scopeIds.indexOf(right.scope_id); + return leftIndex - rightIndex || left.key.localeCompare(right.key); + }); + + const getVisible = (collection: string, key: string) => + input.core + .findMany("plugin_storage", { where: whereFor(collection, key) }) + .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null)) + .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))); + + return { + get: (storageInput) => getVisible(storageInput.collection, storageInput.key), + getAtScope: (storageInput) => + input.core + .findFirst("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }), + ), + }) + .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))), + list: (storageInput) => + input.core.findMany("plugin_storage", { where: whereFor(storageInput.collection) }).pipe( + Effect.map((rows) => + sortByScopePrecedence(rows) + .filter((row) => + storageInput.keyPrefix === undefined + ? true + : row.key.startsWith(storageInput.keyPrefix), + ) + .map((row) => pluginStorageEntryFromRow(row)), + ), + ), + put: (storageInput) => + Effect.gen(function* () { + if (!input.scopeIds.includes(storageInput.scope)) { + return yield* new StorageError({ + message: `Unknown plugin storage target scope: ${storageInput.scope}`, + cause: undefined, + }); + } + const id = pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }); + const existing = yield* input.core.findFirst("plugin_storage", { + where: byScopedId(storageInput.scope, id), + }); + const now = new Date(); + if (existing) { + yield* input.core.updateMany("plugin_storage", { + where: byScopedId(storageInput.scope, id), + set: { + data: storageInput.data, + updated_at: now, + }, + }); + return pluginStorageEntryFromRow({ + ...existing, + data: storageInput.data, + updated_at: now, + }); + } + const row = yield* input.core.create("plugin_storage", { + id, + scope_id: storageInput.scope, + plugin_id: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + data: storageInput.data, + created_at: now, + updated_at: now, + }); + return pluginStorageEntryFromRow(row); + }), + remove: (storageInput) => + input.core.deleteMany("plugin_storage", { + where: byScopedId( + storageInput.scope, + pluginStorageId({ + pluginId: input.pluginId, + collection: storageInput.collection, + key: storageInput.key, + }), + ), + }), + }; +}; + // --------------------------------------------------------------------------- // Dynamic-row writers — used by ctx.core.sources.register. Static sources // never touch these functions. @@ -2097,6 +2268,17 @@ export const createExecutor = => + Effect.gen(function* () { + if (!scopeIds.includes(input.sourceScope)) return null; + return yield* core.findFirst("source", { + where: byScopedId(input.sourceScope, input.sourceId), + }); + }); + const findSecretRowAtScope = (input: { readonly secretId: string; readonly scopeId: string; @@ -2199,7 +2381,7 @@ export const createExecutor = + const credentialBindingSet = (input: SetPluginCredentialBindingInput) => Effect.gen(function* () { yield* assertScopeInStack("credential binding targetScope", input.targetScope); yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); @@ -2505,6 +2687,13 @@ export const createExecutor = + Effect.gen(function* () { + const rows = yield* credentialBindingRowsForSlot(input); + const row = findInnermost(rows); + return row ? credentialBindingRowToRef(row) : null; + }); + const credentialBindingResolve = (input: CredentialBindingSlotInput) => Effect.gen(function* () { const rows = yield* credentialBindingRowsForSlot(input); @@ -2589,6 +2778,7 @@ export const createExecutor = + Effect.gen(function* () { + const source = yield* findSourceOwnerRowAtScope({ + sourceId: input.source.id, + sourceScope: input.source.scope, + }); + return source + ? ({ + pluginId: source.plugin_id, + sourceId: input.source.id, + sourceScope: input.source.scope, + } satisfies CredentialBindingSourceInput) + : null; + }); + + const sourceBindingList = (input: SourceCredentialBindingSourceInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + return bindingInput ? yield* credentialBindingListForSource(bindingInput) : []; + }); + + const sourceBindingResolve = (input: SourceCredentialBindingSlotInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + return bindingInput + ? yield* credentialBindingResolveBinding({ + ...bindingInput, + slotKey: input.slotKey, + }) + : null; + }); + + const sourceBindingSet = (input: SetSourceCredentialBindingInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot set credential binding for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + return yield* credentialBindingSet({ + ...bindingInput, + targetScope: input.scope, + slotKey: input.slotKey, + value: input.value, + }); + }); + + const sourceBindingRemove = (input: RemoveSourceCredentialBindingInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot remove credential binding for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + yield* credentialBindingRemove({ + ...bindingInput, + targetScope: input.scope, + slotKey: input.slotKey, + }); + }); + + const sourceBindingReplace = (input: ReplaceSourceCredentialBindingsInput) => + Effect.gen(function* () { + const bindingInput = yield* credentialBindingInputForSource(input); + if (!bindingInput) { + return yield* new StorageError({ + message: + `Cannot replace credential bindings for source "${input.source.id}" ` + + `at scope "${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + return yield* credentialBindingReplaceForSource({ + ...bindingInput, + targetScope: input.scope, + slotPrefixes: input.slotPrefixes, + bindings: input.bindings, + }); + }); + + const sourceConfigure = (input: { + readonly source: { + readonly id: string; + readonly scope: ScopeId | string; + }; + readonly scope: ScopeId | string; + readonly type?: string; + readonly config: unknown; + }) => + Effect.gen(function* () { + yield* assertScopeInStack("source configure source scope", input.source.scope); + yield* assertScopeInStack("source configure target scope", input.scope); + + const source = yield* core.findFirst("source", { + where: byScopedId(input.source.scope, input.source.id), + }); + if (!source) { + return yield* new StorageError({ + message: + `Cannot configure source "${input.source.id}" at scope ` + + `"${input.source.scope}": source is not visible.`, + cause: undefined, + }); + } + + const runtime = runtimes.get(source.plugin_id); + const configure = runtime?.plugin.sourceConfigure; + if (!runtime || !configure) { + return yield* new StorageError({ + message: `Plugin "${source.plugin_id}" does not support source.configure.`, + cause: undefined, + }); + } + if (input.type !== undefined && input.type !== configure.type) { + return yield* new StorageError({ + message: + `Source configure type mismatch for plugin "${source.plugin_id}": ` + + `expected "${configure.type}", received "${input.type}".`, + cause: undefined, + }); + } + + const decoded = yield* decodeConfigureInput(configure.schema, input.config).pipe( + Effect.mapError((cause) => + storageFailureFromUnknown( + `Invalid source.configure payload for ${configure.type}`, + cause, + ), + ), + ); + + return yield* configure + .configure({ + ctx: runtime.ctx, + sourceId: input.source.id, + sourceScope: input.source.scope, + targetScope: input.scope, + config: decoded, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(source.plugin_id, "sourceConfigure", cause), + ), + ); + }); + const oauthBundle = makeOAuth2Service({ fuma, secretsGet: (id) => @@ -2632,6 +2976,11 @@ export const createExecutor = = { scopes, storage, + pluginStorage, httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer, core: { sources: { @@ -3713,6 +4064,12 @@ export const createExecutor = + params.scopeId && deps.secretsGetAtScope + ? deps + .secretsGetAtScope(params.secretId, params.scopeId) + .pipe( + Effect.map((value) => + value === null ? null : { value, scopeId: params.scopeId ?? null }, + ), + ) + : secretsGetResolved(params.secretId); // ------------------------------------------------------------------- // probe @@ -450,7 +463,10 @@ export const makeOAuth2Service = ( strategy: OAuthAuthorizationCodeStrategy, ): Effect.Effect => Effect.gen(function* () { - const clientIdRef = yield* secretsGetResolved(strategy.clientIdSecretId).pipe( + const clientIdRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientIdSecretId, + scopeId: strategy.clientIdSecretScopeId, + }).pipe( Effect.mapError( (err) => // Storage failure propagates; null returns aren't errors — the @@ -491,7 +507,10 @@ export const makeOAuth2Service = ( clientIdSecretScopeId: clientIdRef.scopeId, clientSecretSecretId: strategy.clientSecretSecretId ?? null, clientSecretSecretScopeId: strategy.clientSecretSecretId - ? ((yield* secretsGetResolved(strategy.clientSecretSecretId))?.scopeId ?? null) + ? ((yield* secretsGetResolvedAtScope({ + secretId: strategy.clientSecretSecretId, + scopeId: strategy.clientSecretSecretScopeId, + }))?.scopeId ?? null) : null, scopes: [...strategy.scopes], scopeSeparator: strategy.scopeSeparator, @@ -517,8 +536,14 @@ export const makeOAuth2Service = ( strategy: OAuthClientCredentialsStrategy, ): Effect.Effect => Effect.gen(function* () { - const clientIdRef = yield* secretsGetResolved(strategy.clientIdSecretId); - const clientSecretRef = yield* secretsGetResolved(strategy.clientSecretSecretId); + const clientIdRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientIdSecretId, + scopeId: strategy.clientIdSecretScopeId, + }); + const clientSecretRef = yield* secretsGetResolvedAtScope({ + secretId: strategy.clientSecretSecretId, + scopeId: strategy.clientSecretSecretScopeId, + }); if (clientIdRef === null || clientSecretRef === null) { return yield* new OAuthStartError({ message: "client_id / client_secret secret not found", diff --git a/packages/core/sdk/src/oauth.ts b/packages/core/sdk/src/oauth.ts index 6f24dccdc..8e1f52e79 100644 --- a/packages/core/sdk/src/oauth.ts +++ b/packages/core/sdk/src/oauth.ts @@ -60,9 +60,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({ * an inline string so the value lives at the scope where the caller * configured it and shadowing behaves consistently. */ clientIdSecretId: Schema.String, + clientIdSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), /** Secret id for `client_secret`. Null for public clients using * PKCE without a confidential secret. */ clientSecretSecretId: Schema.NullOr(Schema.String), + clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.Array(Schema.String), /** Separator between scopes. RFC 6749 says space; some providers * (GitHub classic) use comma. */ @@ -84,7 +86,9 @@ export const OAuthClientCredentialsStrategy = Schema.Struct({ kind: Schema.Literal("client-credentials"), tokenEndpoint: Schema.String, clientIdSecretId: Schema.String, + clientIdSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), clientSecretSecretId: Schema.String, + clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.optional(Schema.Array(Schema.String)), scopeSeparator: Schema.optional(Schema.String), clientAuth: Schema.optional(Schema.Literals(["body", "basic"])), diff --git a/packages/core/sdk/src/plugin-storage.ts b/packages/core/sdk/src/plugin-storage.ts new file mode 100644 index 000000000..d9c4a3534 --- /dev/null +++ b/packages/core/sdk/src/plugin-storage.ts @@ -0,0 +1,55 @@ +import { Effect } from "effect"; + +import type { StorageFailure } from "./fuma-runtime"; +import type { ScopeId } from "./ids"; + +export interface PluginStorageKeyInput { + readonly collection: string; + readonly key: string; +} + +export interface PluginStorageScopedKeyInput extends PluginStorageKeyInput { + readonly scope: ScopeId | string; +} + +export interface PluginStorageListInput { + readonly collection: string; + readonly keyPrefix?: string; +} + +export interface PluginStoragePutInput extends PluginStorageScopedKeyInput { + readonly data: unknown; +} + +export interface PluginStorageEntry { + readonly id: string; + readonly scopeId: ScopeId | string; + readonly pluginId: string; + readonly collection: string; + readonly key: string; + readonly data: T; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +export interface PluginStorageFacade { + readonly get: ( + input: PluginStorageKeyInput, + ) => Effect.Effect | null, StorageFailure>; + readonly getAtScope: ( + input: PluginStorageScopedKeyInput, + ) => Effect.Effect | null, StorageFailure>; + readonly list: ( + input: PluginStorageListInput, + ) => Effect.Effect[], StorageFailure>; + readonly put: ( + input: PluginStoragePutInput, + ) => Effect.Effect, StorageFailure>; + readonly remove: (input: PluginStorageScopedKeyInput) => Effect.Effect; +} + +export const pluginStorageId = (input: { + readonly pluginId: string; + readonly collection: string; + readonly key: string; +}): string => JSON.stringify([input.pluginId, input.collection, input.key]); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 5a241c751..f333c7192 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, type Schema as EffectSchema } from "effect"; import type { Context, Layer } from "effect"; import type { HttpClient } from "effect/unstable/http"; import type { HttpApiGroup } from "effect/unstable/httpapi"; @@ -33,6 +33,7 @@ import type { SecretOwnedByConnectionError, } from "./errors"; import type { OAuthService } from "./oauth"; +import type { PluginStorageFacade } from "./plugin-storage"; import type { Scope } from "./scope"; import type { RemoveSecretInput, SecretProvider, SecretRef, SetSecretInput } from "./secrets"; import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./usages"; @@ -55,6 +56,7 @@ export interface StorageDeps>; readonly blobs: PluginBlobStore; + readonly pluginStorage: PluginStorageFacade; } // --------------------------------------------------------------------------- @@ -84,6 +86,7 @@ export interface PluginCtx { */ readonly scopes: readonly Scope[]; readonly storage: TStore; + readonly pluginStorage: PluginStorageFacade; readonly httpClientLayer: Layer.Layer; readonly core: { @@ -352,6 +355,22 @@ export interface SourceLifecycleInput { readonly scope: string; } +export interface ConfigureSourceHandlerInput { + readonly ctx: PluginCtx; + readonly sourceId: string; + readonly sourceScope: string; + readonly targetScope: string; + readonly config: unknown; +} + +export interface SourceConfigureDecl { + readonly type: string; + readonly schema?: StaticToolSchema | EffectSchema.Decoder; + readonly configure: ( + input: ConfigureSourceHandlerInput, + ) => Effect.Effect; +} + // --------------------------------------------------------------------------- // PluginSpec — what a `definePlugin(factory)` call returns. // --------------------------------------------------------------------------- @@ -534,6 +553,14 @@ export interface PluginSpec< readonly refreshSource?: (input: SourceLifecycleInput) => Effect.Effect; + /** Core-dispatched source configuration. The executor resolves the + * source row, finds the owning plugin, and calls this handler with + * an explicit source scope plus explicit target scope for credential + * values. Plugin-native `openapi.configure` style methods can call + * the same implementation, but callers do not need to know about + * low-level credential bindings. */ + readonly sourceConfigure?: SourceConfigureDecl; + /** URL autodetection hook. When the user pastes a URL in the * onboarding UI, `executor.sources.detect(url)` fans out to every * plugin's `detect`. Return a `SourceDetectionResult` if you diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 4532377d5..a57f64b31 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -39,11 +39,33 @@ export { export { ConfiguredCredentialBinding, ConfiguredCredentialValue, + CredentialBindingRef, CredentialBindingValue, + CredentialBindingSlotInput, + RemoveCredentialBindingInput, + RemoveSourceCredentialBindingInput, ScopedSecretCredentialInput, + SetSourceCredentialBindingInput, + ReplaceCredentialBindingValue, + ReplaceCredentialBindingsInput, + ReplaceSourceCredentialBindingsInput, + SourceCredentialBindingSource, + SourceCredentialBindingSourceInput, + SourceCredentialBindingSlotInput, credentialSlotKey, + credentialSlotPart, } from "./credential-bindings"; +export { + pluginStorageId, + type PluginStorageEntry, + type PluginStorageFacade, + type PluginStorageKeyInput, + type PluginStorageListInput, + type PluginStoragePutInput, + type PluginStorageScopedKeyInput, +} from "./plugin-storage"; + export { SourceDetectionResult, type Source } from "./types"; export { Usage } from "./usages"; diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index bb0b2a479..89388786f 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -63,6 +63,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "graphql": "^16.12.0", diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index f190e6e3e..b6c97062a 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -4,13 +4,13 @@ import { InternalError, ScopeId } from "@executor-js/sdk/shared"; import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; import { + GraphqlConfiguredValueInput, ConfiguredGraphqlCredentialValue, GraphqlCredentialInput, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, } from "../sdk/types"; +import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; // StoredGraphqlSource shape as an HTTP response schema. Kept local to the // api layer because the sdk-side `StoredGraphqlSource` is a plain interface. @@ -37,47 +37,26 @@ const SourceParams = { namespace: Schema.String, }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - // --------------------------------------------------------------------------- // Payloads // --------------------------------------------------------------------------- const AddSourcePayload = Schema.Struct({ - targetScope: ScopeId, endpoint: Schema.String, - name: Schema.optional(Schema.String), + name: Schema.String, introspectionJson: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(GraphqlSourceAuthInput), -}); - -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(GraphqlSourceAuthInput), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); - -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, + namespace: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInput)), + oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional( + Schema.Struct({ + scope: ScopeId, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInput)), + auth: Schema.optional(GraphqlSourceAuthInput), + }), + ), }); // --------------------------------------------------------------------------- @@ -128,41 +107,6 @@ export const GraphqlGroup = HttpApiGroup.make("graphql") success: Schema.NullOr(StoredSourceSchema), error: GraphqlErrors, }), - ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/graphql/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - success: UpdateSourceResponse, - error: GraphqlErrors, - }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/graphql/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(GraphqlSourceBindingRef), - error: GraphqlErrors, - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/graphql/source-bindings", { - params: ScopeParams, - payload: GraphqlSourceBindingInput, - success: GraphqlSourceBindingRef, - error: GraphqlErrors, - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/graphql/source-bindings/remove", { - params: ScopeParams, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), - error: GraphqlErrors, - }), ); // Plugin domain errors carry their own HTTP status (4xx); // `InternalError` is the shared opaque 500 translated at the HTTP edge. diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index 579e5bf33..38c1a4cfb 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -3,8 +3,7 @@ import { Context, Effect } from "effect"; import { addGroup, capture } from "@executor-js/api"; import { ScopeId } from "@executor-js/sdk/core"; -import type { GraphqlPluginExtension, GraphqlUpdateSourceInput } from "../sdk/plugin"; -import { GraphqlSourceBindingInput } from "../sdk/types"; +import type { GraphqlPluginExtension } from "../sdk/plugin"; import { GraphqlGroup } from "./group"; // --------------------------------------------------------------------------- @@ -41,20 +40,20 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => handlers - .handle("addSource", ({ payload }) => + .handle("addSource", ({ params: path, payload }) => capture( Effect.gen(function* () { const ext = yield* GraphqlExtensionService; const result = yield* ext.addSource({ endpoint: payload.endpoint, - scope: payload.targetScope, + scope: path.scopeId, name: payload.name, introspectionJson: payload.introspectionJson, namespace: payload.namespace, headers: payload.headers, queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth, + oauth2: payload.oauth2, + credentials: payload.credentials, }); return { toolCount: result.toolCount, @@ -71,51 +70,5 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra return source ? { ...source, scope: ScopeId.make(source.scope) } : null; }), ), - ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - endpoint: payload.endpoint, - headers: payload.headers, - queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth, - } as GraphqlUpdateSourceInput); - return { updated: true }; - }), - ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - return yield* ext.setSourceBinding(GraphqlSourceBindingInput.make(payload)); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GraphqlExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; - }), - ), ), ); diff --git a/packages/plugins/graphql/src/promise.ts b/packages/plugins/graphql/src/promise.ts index 6f4c6bebc..4685c9488 100644 --- a/packages/plugins/graphql/src/promise.ts +++ b/packages/plugins/graphql/src/promise.ts @@ -3,6 +3,6 @@ export type { GraphqlPluginOptions, GraphqlPluginExtension, GraphqlSourceConfig, - GraphqlUpdateSourceInput, + GraphqlConfigureSourceInput, HeaderValue, } from "./sdk/plugin"; diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 8d2ea5e97..e504d3491 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -9,10 +9,11 @@ import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { HttpCredentialsEditor, httpCredentialsValid, - serializeScopedHttpCredentials, + serializeConfigureHttpCredentials, serializeHttpCredentials, + serializeTemplateHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -37,7 +38,7 @@ import { Spinner } from "@executor-js/react/components/spinner"; import { addGraphqlSourceOptimistic } from "./atoms"; import { initialGraphqlCredentials } from "./defaults"; import { GraphqlSourceFields } from "./GraphqlSourceFields"; -import type { GraphqlCredentialInput } from "../sdk/types"; +import type { GraphqlConfiguredValueInput, GraphqlCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -129,34 +130,54 @@ export default function AddGraphqlSource(props: { const handleAdd = async () => { setAdding(true); setAddError(null); - const { headers: headerMap, queryParams } = serializeScopedHttpCredentials( - credentials, - requestCredentialTargetScope, - ); + const { headers: templateHeaders, queryParams: templateQueryParams } = + serializeTemplateHttpCredentials(credentials); + const { headers: configureHeaders, queryParams: configureQueryParams } = + serializeConfigureHttpCredentials(credentials, requestCredentialTargetScope); + const hasInitialCredentials = + Object.keys(configureHeaders).length > 0 || + Object.keys(configureQueryParams).length > 0 || + (authMode === "oauth2" && tokens); const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, endpoint: trimmedEndpoint, name: displayName, namespace, - ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), - ...(Object.keys(queryParams).length > 0 + ...(Object.keys(templateHeaders).length > 0 + ? { headers: templateHeaders as Record } + : {}), + ...(Object.keys(templateQueryParams).length > 0 ? { - queryParams: queryParams as Record, + queryParams: templateQueryParams as Record, } : {}), - credentialTargetScope: - authMode === "oauth2" && tokens - ? oauthCredentialTargetScope - : requestCredentialTargetScope, - ...(authMode === "oauth2" && tokens + ...(hasInitialCredentials ? { - auth: { - kind: "oauth2" as const, - connectionId: tokens.connectionId, + credentials: { + scope: requestCredentialTargetScope, + ...(Object.keys(configureHeaders).length > 0 + ? { headers: configureHeaders as Record } + : {}), + ...(Object.keys(configureQueryParams).length > 0 + ? { + queryParams: configureQueryParams as Record, + } + : {}), + ...(authMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), }, } : {}), diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 89b496f9c..a96a7818c 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -1,42 +1,49 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; import { - graphqlSourceAtom, - graphqlSourceBindingsAtom, - setGraphqlSourceBinding, - updateGraphqlSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; + configureSource, + connectionsAtom, + setSourceCredentialBinding, +} from "@executor-js/react/api/atoms"; +import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentialsEditor, + httpCredentialsFromConfiguredCredentialBindings, serializeHttpCredentials, - serializeScopedHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; -import { - effectiveCredentialBindingForScope, - httpCredentialsFromConfiguredCredentialBindings, - initialCredentialTargetScope, -} from "@executor-js/react/plugins/credential-bindings"; +} from "@executor-js/plugin-http-source/react"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; +import { + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; import { Button } from "@executor-js/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; -import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; +import { + SourceOAuthConnectionControl, + sourceOAuthConnectionUiState, +} from "@executor-js/react/plugins/source-oauth-connection"; import { Badge } from "@executor-js/react/components/badge"; -import { ScopeId } from "@executor-js/sdk/shared"; -import { GraphqlSourceFields } from "./GraphqlSourceFields"; +import { type CredentialBindingRef, ScopeId } from "@executor-js/sdk/shared"; import { - GRAPHQL_OAUTH_CONNECTION_SLOT, - type GraphqlCredentialInput, - GraphqlSourceBindingInput, - type GraphqlSourceBindingRef, -} from "../sdk/types"; + SecretCredentialSlotBindings, + secretCredentialSlotsFromHttpConfig, +} from "@executor-js/react/plugins/credential-slot-bindings"; +import { GraphqlSourceFields } from "./GraphqlSourceFields"; +import type { GraphqlSourceAuthInput } from "../sdk/types"; import type { StoredGraphqlSource } from "../sdk/store"; type EditableSource = StoredGraphqlSource; @@ -49,113 +56,93 @@ type AuthMode = "none" | "oauth2"; function EditForm(props: { sourceId: string; initial: EditableSource; - bindings: readonly GraphqlSourceBindingRef[]; + bindings: readonly CredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); + const userScope = useUserScope(); const sourceScope = ScopeId.make(props.initial.scope); - const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ - sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), - }); const { credentialTargetScope: oauthCredentialTargetScope, setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + initialTargetScope: userScope, }); - const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); + const setConnectionBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, fallbackNamespace: props.initial.namespace, }); const [endpoint, setEndpoint] = useState(props.initial.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ - headers: props.initial.headers, - queryParams: props.initial.queryParams, - bindings: props.bindings, - }), + const credentials = useMemo( + () => + httpCredentialsFromConfiguredCredentialBindings({ + headers: props.initial.headers, + queryParams: props.initial.queryParams, + bindings: props.bindings, + }), + [props.bindings, props.initial.headers, props.initial.queryParams], ); const [authMode, setAuthMode] = useState(props.initial.auth.kind); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); const [authDirty, setAuthDirty] = useState(false); + const { busyKey, setSecretBinding, clearBinding } = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + }); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty || authDirty; + const dirty = metadataDirty || authDirty; const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, + const { credentialScopeOptions, secretBindingScopes, scopeRanks } = + useSourceCredentialBindingScopes({ sourceScope }); + const secretSlots = secretCredentialSlotsFromHttpConfig({ + headers: props.initial.headers, + queryParams: props.initial.queryParams, + }); + const oauthConnectionState = oauth2 + ? sourceOAuthConnectionUiState({ + bindings: props.bindings, + connectionSlot: oauth2.connectionSlot, + tokenScope: oauthCredentialTargetScope, scopeRanks, - ) + credentialScopeOptions, + connections, + }) : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); const oauthRequestCredentials = serializeHttpCredentials(credentials); - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; - const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( - credentials, - credentialTargetScope, - ); - const payload: { - sourceScope: ScopeId; + const config: { name?: string; endpoint?: string; - headers?: Record; - queryParams?: Record; - credentialTargetScope?: ScopeId; - auth?: { kind: "none" } | { kind: "oauth2"; connectionSlot: string }; + auth?: GraphqlSourceAuthInput; } = { - sourceScope, name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { - payload.headers = headers; - payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; - } if (authDirty) { - payload.auth = - authMode === "oauth2" - ? { - kind: "oauth2", - connectionSlot: - props.initial.auth.kind === "oauth2" - ? props.initial.auth.connectionSlot - : GRAPHQL_OAUTH_CONNECTION_SLOT, - } - : { kind: "none" }; - payload.credentialTargetScope = credentialTargetScope; + config.auth = authMode === "oauth2" ? { oauth2: {} } : { kind: "none" }; } - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, - payload, + const exit = await doConfigure({ + params: { scopeId: displayScope }, + payload: { + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "graphql", + config, + }, reactivityKeys: sourceWriteKeys, }); @@ -165,7 +152,6 @@ function EditForm(props: { return; } - setCredentialsDirty(false); setAuthDirty(false); props.onSave(); setSaving(false); @@ -196,15 +182,33 @@ function EditForm(props: { namespaceReadOnly /> - + {secretSlots.length > 0 && ( + + + + + Request credentials + + Headers and query parameters sent with every GraphQL request. + + + + + + + )} {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */}
@@ -229,7 +233,7 @@ function EditForm(props: { )}
- {oauth2 && ( + {oauth2 && oauthConnectionState && ( { - await setBinding({ + await setConnectionBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: GraphqlSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} + signInLabel={oauthConnectionState.signInLabel} /> )} @@ -284,12 +290,13 @@ function EditForm(props: { export default function EditGraphqlSource(props: { sourceId: string; onSave: () => void }) { const scopeId = useScope(); + const userScope = useUserScope(); const sourceResult = useAtomValue(graphqlSourceAtom(scopeId, props.sourceId)); const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; const bindingsResult = useAtomValue( - graphqlSourceBindingsAtom(scopeId, props.sourceId, sourceScope), + graphqlSourceBindingsAtom(userScope, props.sourceId, sourceScope), ); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { diff --git a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx index d6d9d2978..4288c2b89 100644 --- a/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSignInButton.tsx @@ -1,7 +1,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { connectionsAtom, setSourceCredentialBinding } from "@executor-js/react/api/atoms"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { SourceOAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; @@ -9,8 +9,7 @@ import { slugifyNamespace } from "@executor-js/react/plugins/source-identity"; import { secretBackedValuesFromConfiguredCredentialBindings } from "@executor-js/react/plugins/credential-bindings"; import { ScopeId } from "@executor-js/sdk/shared"; -import { graphqlSourceAtom, graphqlSourceBindingsAtom, setGraphqlSourceBinding } from "./atoms"; -import { GraphqlSourceBindingInput } from "../sdk/types"; +import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; export default function GraphqlSignInButton(props: { sourceId: string }) { const scopeId = useScope(); @@ -23,12 +22,12 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { graphqlSourceBindingsAtom(userScopeId, props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(userScopeId)); - const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const oauth2 = source?.auth.kind === "oauth2" ? source.auth : null; const bindings = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : null; const connectionBinding = bindings?.find( - (binding) => oauth2 !== null && binding.slot === oauth2.connectionSlot, + (binding) => oauth2 !== null && binding.slotKey === oauth2.connectionSlot, ); const boundConnectionId = connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; @@ -60,13 +59,12 @@ export default function GraphqlSignInButton(props: { sourceId: string }) { onConnected={async (connectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: GraphqlSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: userScopeId, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/graphql/src/react/atoms.ts b/packages/plugins/graphql/src/react/atoms.ts index c120ddd93..c5e9bf2d8 100644 --- a/packages/plugins/graphql/src/react/atoms.ts +++ b/packages/plugins/graphql/src/react/atoms.ts @@ -1,7 +1,7 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { GraphqlClient } from "./client"; @@ -20,12 +20,7 @@ export const graphqlSourceBindingsAtom = ( scopeId: ScopeId, namespace: string, sourceScopeId: ScopeId, -) => - GraphqlClient.query("graphql", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); +) => sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId); // --------------------------------------------------------------------------- // Mutation atoms @@ -41,7 +36,7 @@ export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; const source = { id, - scopeId: arg.payload.targetScope, + scopeId, kind: "graphql", pluginId: "graphql", name: arg.payload.name ?? id, @@ -59,9 +54,3 @@ export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => }), ), ); - -export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); - -export const setGraphqlSourceBinding = GraphqlClient.mutation("graphql", "setSourceBinding"); - -export const removeGraphqlSourceBinding = GraphqlClient.mutation("graphql", "removeSourceBinding"); diff --git a/packages/plugins/graphql/src/react/defaults.test.ts b/packages/plugins/graphql/src/react/defaults.test.ts index 1bbfba512..a9ed885c3 100644 --- a/packages/plugins/graphql/src/react/defaults.test.ts +++ b/packages/plugins/graphql/src/react/defaults.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { httpCredentialsValid } from "@executor-js/react/plugins/http-credentials"; +import { httpCredentialsValid } from "@executor-js/plugin-http-source/react"; import { initialGraphqlCredentials } from "./defaults"; describe("initialGraphqlCredentials", () => { diff --git a/packages/plugins/graphql/src/react/defaults.ts b/packages/plugins/graphql/src/react/defaults.ts index fbf38618c..24f25d790 100644 --- a/packages/plugins/graphql/src/react/defaults.ts +++ b/packages/plugins/graphql/src/react/defaults.ts @@ -1,6 +1,6 @@ import { emptyHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; export const initialGraphqlCredentials = (): HttpCredentialsState => emptyHttpCredentials(); diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 52d1ab8d4..16ba47c7c 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -6,7 +6,8 @@ export { type GraphqlSourceConfig, type GraphqlPluginExtension, type GraphqlPluginOptions, - type GraphqlUpdateSourceInput, + type GraphqlConfigureSourceInput, + type GraphqlSourceRef, } from "./plugin"; export { graphqlSchema, @@ -31,9 +32,6 @@ export { GraphqlOperationKind, GraphqlSourceAuth, GraphqlSourceAuthInput, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, - GraphqlSourceBindingValue, InvocationConfig, InvocationResult, OperationBinding, diff --git a/packages/plugins/graphql/src/sdk/introspect.ts b/packages/plugins/graphql/src/sdk/introspect.ts index ec0c936a3..bd9f7741f 100644 --- a/packages/plugins/graphql/src/sdk/introspect.ts +++ b/packages/plugins/graphql/src/sdk/introspect.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { GraphqlIntrospectionError } from "./errors"; @@ -84,37 +84,37 @@ const INTROSPECTION_QUERY = ` const IntrospectionTypeRefLeaf = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.Null, + ofType: Schema.optional(Schema.Null), }); const IntrospectionTypeRef5 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRefLeaf), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRefLeaf)), }); const IntrospectionTypeRef4 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef5), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef5)), }); const IntrospectionTypeRef3 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef4), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef4)), }); const IntrospectionTypeRef2 = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef3), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef3)), }); const IntrospectionTypeRefSchema = Schema.Struct({ kind: Schema.String, name: Schema.NullOr(Schema.String), - ofType: Schema.NullOr(IntrospectionTypeRef2), + ofType: Schema.optional(Schema.NullOr(IntrospectionTypeRef2)), }); const IntrospectionInputValueSchema = Schema.Struct({ @@ -160,10 +160,24 @@ const IntrospectionResponseSchema = Schema.Struct({ errors: Schema.optional(Schema.Array(Schema.Unknown)), }); +const UpstreamErrorResponseSchema = Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Struct({ + message: Schema.optional(Schema.String), + }), + ), + ), +}); + const IntrospectionJsonSchema = Schema.Union([ Schema.Struct({ data: IntrospectionResultSchema }), IntrospectionResultSchema, ]); +const JsonTextSchema = Schema.fromJsonString(Schema.Unknown); + +const decodeUpstreamErrorResponse = Schema.decodeUnknownOption(UpstreamErrorResponseSchema); export type IntrospectionTypeRef = typeof IntrospectionTypeRefSchema.Type; export type IntrospectionInputValue = typeof IntrospectionInputValueSchema.Type; @@ -175,6 +189,42 @@ export type IntrospectionType = typeof IntrospectionTypeSchema.Type; export type IntrospectionSchema = (typeof IntrospectionResultSchema.Type)["__schema"]; export type IntrospectionResult = typeof IntrospectionResultSchema.Type; +const firstUpstreamErrorMessage = (value: unknown): string | null => { + const decoded = decodeUpstreamErrorResponse(value); + return Option.match(decoded, { + onNone: () => null, + onSome: (response) => { + if (response.message) return response.message; + for (const entry of response.errors ?? []) { + const message = entry.message; + if (message) return message; + } + return null; + }, + }); +}; + +const redactUpstreamBody = (body: string): string => + body + .replaceAll( + /("(?:access_token|refresh_token|id_token|client_secret|token|authorization)"\s*:\s*")[^"]*(")/gi, + "$1[redacted]$2", + ) + .replaceAll( + /((?:access_token|refresh_token|id_token|client_secret|token|authorization)=)[^&\s]*/gi, + "$1[redacted]", + ) + .replaceAll( + /((?:authorization|access-token|refresh-token|id-token|client-secret|token)\s*:\s*)(?:bearer\s+)?[^\s,;]+/gi, + "$1[redacted]", + ); + +const upstreamTextMessage = (body: string): string | null => { + const text = redactUpstreamBody(body.replaceAll(/\s+/g, " ").trim()); + if (text.length === 0) return null; + return text.length > 500 ? `${text.slice(0, 500)}...` : text; +}; + // --------------------------------------------------------------------------- // Introspect a GraphQL endpoint // --------------------------------------------------------------------------- @@ -198,6 +248,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( let request = HttpClientRequest.post(requestEndpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), + HttpClientRequest.setHeader("Accept", "application/json"), + HttpClientRequest.setHeader("User-Agent", "executor-graphql"), HttpClientRequest.bodyJsonUnsafe({ query: INTROSPECTION_QUERY, }), @@ -220,8 +272,19 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ); if (response.status !== 200) { + const responseText = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))); + const raw = responseText + ? yield* Schema.decodeUnknownEffect(JsonTextSchema)(responseText).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null; + const upstreamMessage = upstreamTextMessage( + (raw === null ? null : firstUpstreamErrorMessage(raw)) ?? responseText, + ); return yield* new GraphqlIntrospectionError({ - message: `Introspection failed with status ${response.status}`, + message: upstreamMessage + ? `Introspection failed with status ${response.status}: ${upstreamMessage}` + : `Introspection failed with status ${response.status}`, }); } @@ -245,8 +308,11 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( ); if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) { + const upstreamMessage = firstUpstreamErrorMessage(json); return yield* new GraphqlIntrospectionError({ - message: `Introspection returned ${json.errors.length} error(s)`, + message: upstreamMessage + ? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}` + : `Introspection returned ${json.errors.length} error(s)`, }); } diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index c97cd9c8e..b84198b9f 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -20,7 +20,7 @@ import { memorySecretsPlugin } from "@executor-js/sdk/testing"; import { graphqlPlugin } from "./plugin"; import { endpointForTelemetry } from "./invoke"; import { introspect } from "./introspect"; -import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; +import { GRAPHQL_OAUTH_CONNECTION_SLOT, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; import type { IntrospectionResult } from "./introspect"; import { makeGreetingGraphqlSchema, @@ -29,6 +29,17 @@ import { } from "../testing"; const TEST_SCOPE = "test-scope"; +const graphqlOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: "auth:oauth2:client-id", + clientSecretSlot: null, + connectionSlot: GRAPHQL_OAUTH_CONNECTION_SLOT, + scopes: [], +}; // --------------------------------------------------------------------------- // Mock introspection response @@ -134,11 +145,11 @@ describe("graphqlPlugin real protocol server", () => { ); }); - it.effect("does not include upstream response bodies in introspection status errors", () => + it.effect("includes redacted upstream text in introspection status errors", () => Effect.gen(function* () { const server = yield* serveGraphqlFailureTestServer({ status: 500, - body: "internal token value", + body: 'upstream failed {"access_token":"secret-value"} token=another-secret', }); const error = yield* introspect(server.endpoint).pipe( @@ -146,8 +157,131 @@ describe("graphqlPlugin real protocol server", () => { Effect.flip, ); - expect(error).toHaveProperty("message", "Introspection failed with status 500"); - expect(error).not.toHaveProperty("message", expect.stringContaining("internal token value")); + expect(error).toHaveProperty( + "message", + 'Introspection failed with status 500: upstream failed {"access_token":"[redacted]"} token=[redacted]', + ); + expect(error).not.toHaveProperty("message", expect.stringContaining("secret-value")); + expect(error).not.toHaveProperty("message", expect.stringContaining("another-secret")); + }), + ); + + it.effect("includes safe upstream JSON messages in introspection status errors", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { message: "Resource protected by organization SSO" }, + { status: 403 }, + ), + ), + ); + + const error = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + Effect.flip, + ); + + expect(error).toHaveProperty( + "message", + "Introspection failed with status 403: Resource protected by organization SSO", + ); + }), + ); + + it.effect("redacts secrets from upstream JSON messages in introspection status errors", () => + Effect.gen(function* () { + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + { message: "Authorization: Bearer github-secret-token" }, + { status: 403 }, + ), + ), + ); + + const error = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + Effect.flip, + ); + + expect(error).toHaveProperty( + "message", + "Introspection failed with status 403: Authorization: [redacted]", + ); + expect(error).not.toHaveProperty("message", expect.stringContaining("github-secret-token")); + }), + ); + + it.effect("accepts standard introspection responses with omitted deepest ofType", () => + Effect.gen(function* () { + const deepType = { + kind: "NON_NULL", + name: null, + ofType: { + kind: "LIST", + name: null, + ofType: { + kind: "NON_NULL", + name: null, + ofType: { + kind: "LIST", + name: null, + ofType: { + kind: "NON_NULL", + name: null, + ofType: { + kind: "SCALAR", + name: "String", + }, + }, + }, + }, + }, + }; + const server = yield* serveTestHttpApp(() => + Effect.succeed( + HttpServerResponse.jsonUnsafe({ + data: { + __schema: { + queryType: { name: "Query" }, + mutationType: null, + types: [ + { + kind: "OBJECT", + name: "Query", + description: null, + fields: [ + { + name: "deep", + description: null, + args: [], + type: deepType, + }, + ], + inputFields: null, + enumValues: null, + }, + { + kind: "SCALAR", + name: "String", + description: null, + fields: null, + inputFields: null, + enumValues: null, + }, + ], + }, + }, + }), + ), + ); + + const result = yield* introspect(server.url("/graphql")).pipe( + Effect.provide(server.httpClientLayer), + ); + + expect(result.__schema.queryType?.name).toBe("Query"); }), ); @@ -176,6 +310,169 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("uses initial credential bindings for add-time introspection", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const }), + ); + yield* executor.secrets.set({ + id: SecretId.make("github-token"), + scope: ScopeId.make(TEST_SCOPE), + name: "GitHub token", + value: "secret-token", + provider: "memory", + }); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: TEST_SCOPE, + namespace: "initial_credentials", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: TEST_SCOPE, + headers: { + Authorization: { kind: "secret", secretId: "github-token" }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "initial_credentials" }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer secret-token"), + ).toBe(true); + }), + ); + + it.effect( + "uses user-scoped initial credential bindings for org-scope add-time introspection", + () => + Effect.gen(function* () { + const orgScope = "org-scope"; + const userScope = "user-scope"; + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer user-secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ + scopes: [ + Scope.make({ + id: ScopeId.make(userScope), + name: "user", + createdAt: new Date(), + }), + Scope.make({ + id: ScopeId.make(orgScope), + name: "org", + createdAt: new Date(), + }), + ], + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, + }), + ); + yield* executor.secrets.set({ + id: SecretId.make("github-graphql-authorization"), + scope: ScopeId.make(userScope), + name: "GitHub GraphQL Authorization", + value: "user-secret-token", + provider: "memory", + }); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: orgScope, + name: "Github GraphQL", + namespace: "github_graphql", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: userScope, + headers: { + Authorization: { + kind: "secret", + secretId: "github-graphql-authorization", + secretScope: userScope, + prefix: "Bearer ", + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "github_graphql" }); + const requests = yield* server.requests; + expect( + requests.some((request) => request.headers.authorization === "Bearer user-secret-token"), + ).toBe(true); + }), + ); + + it.effect("marks source oauth-backed when add-time credentials include oauth", () => + Effect.gen(function* () { + const server = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer oauth-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const }), + ); + const connectionId = ConnectionId.make("graphql-oauth2-initial"); + yield* executor.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: ScopeId.make(TEST_SCOPE), + provider: "oauth2", + identityLabel: "Initial GraphQL OAuth", + accessToken: TokenMaterial.make({ + secretId: SecretId.make(`${connectionId}.access_token`), + name: "Initial GraphQL OAuth Access Token", + value: "oauth-token", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, + }), + ); + + const result = yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: TEST_SCOPE, + namespace: "initial_oauth_graphql", + credentials: { + scope: TEST_SCOPE, + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 2, namespace: "initial_oauth_graphql" }); + const source = yield* executor.graphql.getSource("initial_oauth_graphql", TEST_SCOPE); + expect(source?.auth.kind).toBe("oauth2"); + if (source?.auth.kind !== "oauth2") return; + expect(source.auth.connectionSlot).toBe(GRAPHQL_OAUTH_CONNECTION_SLOT); + }), + ); + it.effect("invokes a live query with headers and query params", () => Effect.gen(function* () { const server = yield* serveGreetingServer; @@ -281,8 +578,19 @@ describe("graphqlPlugin real protocol server", () => { endpoint: server.endpoint, scope: TEST_SCOPE, namespace: "oauth_graph", - credentialTargetScope: TEST_SCOPE, - auth: { kind: "oauth2", connectionId }, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "oauth_graph", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); yield* server.clearRequests; @@ -411,7 +719,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource patches endpoint/headers without re-registering", () => + it.effect("sources.configure patches endpoint/headers without re-registering", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ plugins: [graphqlPlugin()] as const }), @@ -424,9 +732,14 @@ describe("graphqlPlugin", () => { namespace: "patched", }); - yield* executor.graphql.updateSource("patched", TEST_SCOPE, { - endpoint: "http://localhost:5000/graphql", - headers: { "x-custom": "abc" }, + yield* executor.sources.configure({ + source: { id: "patched", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + endpoint: "http://localhost:5000/graphql", + headers: { "x-custom": "abc" }, + }, }); const source = yield* executor.graphql.getSource("patched", TEST_SCOPE); @@ -459,6 +772,7 @@ describe("graphqlPlugin", () => { { scope: String(orgScope), endpoint: "http://localhost:4000/graphql", + name: "Via Static", introspectionJson, namespace: "via_static", }, @@ -486,7 +800,7 @@ describe("graphqlPlugin", () => { expect(schema!.inputTypeScript).toContain("endpoint: string"); expect( (schema!.inputSchema as { properties?: Record }).properties, - ).toHaveProperty("credentialTargetScope"); + ).not.toHaveProperty("targetScope"); expect(schema!.inputTypeScript).not.toBe("Record"); }), ); @@ -537,6 +851,7 @@ describe("graphqlPlugin", () => { { endpoint: server.endpoint, scope: TEST_SCOPE, + name: "Header Materialization", namespace: "header_materialization", headers: { authorization: "Bearer sample-token", @@ -651,7 +966,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource on user shadow does not mutate the org row", () => + it.effect("sources.configure on user shadow does not mutate the org row", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -675,9 +990,14 @@ describe("graphqlPlugin", () => { name: "User Source", }); - yield* executor.graphql.updateSource("shared", USER_SCOPE, { - name: "User Renamed", - endpoint: "http://user-new.example.com/graphql", + yield* executor.sources.configure({ + source: { id: "shared", scope: ScopeId.make(USER_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + type: "graphql", + config: { + name: "User Renamed", + endpoint: "http://user-new.example.com/graphql", + }, }); const userView = yield* executor.graphql.getSource("shared", USER_SCOPE); @@ -735,32 +1055,38 @@ describe("graphqlPlugin", () => { namespace: "shared_credentials", introspectionJson, headers: { - Authorization: { secretId: "org-token", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, }, queryParams: { - token: { secretId: "org-query" }, + token: { kind: "secret" }, + }, + }); + yield* executor.sources.configure({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "org-token", prefix: "Bearer " }, + }, + queryParams: { + token: { kind: "secret", secretId: "org-query" }, + }, }, - credentialTargetScope: ORG_SCOPE, }); - yield* executor.graphql.setSourceBinding( - GraphqlSourceBindingInput.make({ - sourceId: "shared_credentials", - sourceScope: ScopeId.make(ORG_SCOPE), - scope: ScopeId.make(USER_SCOPE), - slot: graphqlHeaderSlot("Authorization"), - value: { kind: "secret", secretId: SecretId.make("user-token") }, - }), - ); - yield* executor.graphql.setSourceBinding( - GraphqlSourceBindingInput.make({ - sourceId: "shared_credentials", - sourceScope: ScopeId.make(ORG_SCOPE), - scope: ScopeId.make(USER_SCOPE), - slot: graphqlQueryParamSlot("token"), - value: { kind: "secret", secretId: SecretId.make("user-query") }, - }), - ); + yield* executor.sources.setBinding({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + slotKey: graphqlHeaderSlot("Authorization"), + value: { kind: "secret", secretId: SecretId.make("user-token") }, + }); + yield* executor.sources.setBinding({ + source: { id: "shared_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + slotKey: graphqlQueryParamSlot("token"), + value: { kind: "secret", secretId: SecretId.make("user-query") }, + }); yield* server.clearRequests; const result = yield* executor.tools.invoke("shared_credentials.query.hello", { @@ -777,7 +1103,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("addSource stores direct GraphQL credential bindings at each row scope", () => + it.effect("sources.configure stores GraphQL credential bindings at the target scope", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -807,34 +1133,46 @@ describe("graphqlPlugin", () => { namespace: "row_scoped_credentials", introspectionJson, headers: { - Authorization: { - secretId: "row-user-token", - prefix: "Bearer ", - targetScope: USER_SCOPE, - }, + Authorization: { kind: "secret", prefix: "Bearer " }, }, queryParams: { - token: { - secretId: "row-org-query", - targetScope: ORG_SCOPE, + token: { kind: "secret" }, + }, + }); + yield* executor.sources.configure({ + source: { id: "row_scoped_credentials", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { + kind: "secret", + secretId: "row-user-token", + prefix: "Bearer ", + }, + }, + queryParams: { + token: { + kind: "secret", + secretId: "row-org-query", + }, }, }, }); - const bindings = yield* executor.graphql.listSourceBindings( - "row_scoped_credentials", - ORG_SCOPE, - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "row_scoped_credentials", scope: ScopeId.make(ORG_SCOPE) }, + }); - expect(bindings.map((binding) => binding.slot).sort()).toEqual([ + expect(bindings.map((binding) => binding.slotKey).sort()).toEqual([ graphqlHeaderSlot("Authorization"), graphqlQueryParamSlot("token"), ]); expect( - bindings.find((binding) => binding.slot === graphqlHeaderSlot("Authorization"))?.scopeId, - ).toBe(ScopeId.make(USER_SCOPE)); + bindings.find((binding) => binding.slotKey === graphqlHeaderSlot("Authorization"))?.scopeId, + ).toBe(ScopeId.make(ORG_SCOPE)); expect( - bindings.find((binding) => binding.slot === graphqlQueryParamSlot("token"))?.scopeId, + bindings.find((binding) => binding.slotKey === graphqlQueryParamSlot("token"))?.scopeId, ).toBe(ScopeId.make(ORG_SCOPE)); }), ); @@ -863,9 +1201,18 @@ describe("graphqlPlugin", () => { namespace: "org_bound_secret", introspectionJson, headers: { - Authorization: { secretId: "shared-token", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* executor.sources.configure({ + source: { id: "org_bound_secret", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "shared-token", prefix: "Bearer " }, + }, }, - credentialTargetScope: ORG_SCOPE, }); yield* executor.secrets.set({ @@ -926,8 +1273,19 @@ describe("graphqlPlugin", () => { scope: ORG_SCOPE, namespace: "org_bound_connection", introspectionJson, - auth: { kind: "oauth2", connectionId }, - credentialTargetScope: ORG_SCOPE, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "org_bound_connection", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); yield* executor.connections.create( @@ -962,7 +1320,7 @@ describe("graphqlPlugin", () => { }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -984,15 +1342,25 @@ describe("graphqlPlugin", () => { scope: ORG_SCOPE, namespace: "stale_binding", introspectionJson, - headers: { "X-Old": { secretId: "old-token" } }, - credentialTargetScope: ORG_SCOPE, + headers: { "X-Old": { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { headers: { "X-Old": { kind: "secret", secretId: "old-token" } } }, }); - yield* executor.graphql.updateSource("stale_binding", ORG_SCOPE, { - headers: {}, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + scope: ScopeId.make(ORG_SCOPE), + type: "graphql", + config: { headers: {} }, }); - const bindings = yield* executor.graphql.listSourceBindings("stale_binding", ORG_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make(ORG_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -1025,11 +1393,21 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "with_secret", - credentialTargetScope: TEST_SCOPE, headers: { - Authorization: { secretId: "api-key", prefix: "Bearer " }, + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + queryParams: { token: { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + headers: { + Authorization: { kind: "secret", secretId: "api-key", prefix: "Bearer " }, + }, + queryParams: { token: { kind: "secret", secretId: "api-key" } }, }, - queryParams: { token: { secretId: "api-key" } }, }); const usages = yield* executor.secrets.usages(SecretId.make("api-key")); @@ -1064,8 +1442,13 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "ref", - credentialTargetScope: TEST_SCOPE, - headers: { "X-Token": { secretId: "locked" } }, + headers: { "X-Token": { kind: "secret" } }, + }); + yield* executor.sources.configure({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { headers: { "X-Token": { kind: "secret", secretId: "locked" } } }, }); const result = yield* executor.secrets @@ -1124,8 +1507,19 @@ describe("graphqlPlugin", () => { scope: TEST_SCOPE, introspectionJson, namespace: "oauth_ref", - credentialTargetScope: TEST_SCOPE, - auth: { kind: "oauth2", connectionId }, + oauth2: graphqlOAuth2Config, + }); + yield* executor.sources.configure({ + source: { id: "oauth_ref", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "graphql", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, }); const usages = yield* executor.connections.usages(connectionId); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 76fb6cf35..1eb840e09 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -3,13 +3,11 @@ import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; import { - ConnectionId, - ConfiguredCredentialBinding, type CredentialBindingRef, + type CredentialBindingValue, definePlugin, tool, ScopeId, - SecretId, SourceDetectionResult, StorageError, ToolResult, @@ -18,6 +16,12 @@ import { type ToolAnnotations, type ToolRow, } from "@executor-js/sdk/core"; +import { + compileHttpNamedCredentialMap, + OAuth2SourceConfig, + httpCredentialInputToBindingValue, + type HttpConfiguredValueInput, +} from "@executor-js/plugin-http-source/sdk"; import { headersToConfigValues, @@ -45,20 +49,19 @@ import { } from "./store"; import { ExtractedField, + GraphqlConfiguredValueInput as GraphqlConfiguredValueInputSchema, GRAPHQL_OAUTH_CONNECTION_SLOT, GraphqlCredentialInput as GraphqlCredentialInputSchema, GraphqlSourceAuthInput as GraphqlSourceAuthInputSchema, - GraphqlSourceBindingInput, - GraphqlSourceBindingRef, graphqlHeaderSlot, graphqlQueryParamSlot, OperationBinding, type ConfiguredGraphqlCredentialValue, + type GraphqlConfiguredValueInput, type GraphqlCredentialInput, type GraphqlSourceAuth, type HeaderValue as HeaderValueValue, type GraphqlSourceAuthInput, - type GraphqlSourceBindingValue, type GraphqlOperationKind, } from "./types"; @@ -92,34 +95,46 @@ export interface GraphqlSourceConfig { * every inner (per-user) scope via fall-through reads. */ readonly scope: string; - /** Display name for the source. Falls back to namespace if not provided. */ - readonly name?: string; + /** Display name for the source. */ + readonly name: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; - /** Namespace for the tools (derived from endpoint if not provided) */ - readonly namespace?: string; - /** Headers applied to every request. Direct secrets are rewritten to slots. */ - readonly headers?: Record; - /** Query parameters applied to every request. Direct secrets are rewritten to slots. */ - readonly queryParams?: Record; - /** - * Scope that owns any direct credentials supplied on this call. Required - * whenever headers/queryParams/auth carry direct secret or connection ids. - */ - readonly credentialTargetScope?: string; + /** Namespace for the tools. */ + readonly namespace: string; + /** Headers applied to every request. Secret entries declare source-owned slots. */ + readonly headers?: Record; + /** Query parameters applied to every request. Secret entries declare source-owned slots. */ + readonly queryParams?: Record; /** Optional OAuth2 credential used as a Bearer token for every request. */ - readonly auth?: GraphqlSourceAuthInput; + readonly oauth2?: OAuth2SourceConfig; + /** Initial credential bindings used while adding and introspecting this source. */ + readonly credentials?: GraphqlInitialCredentialsInput; } +const GraphqlInitialCredentialsInputSchema = Schema.Struct({ + scope: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), + auth: Schema.optional(GraphqlSourceAuthInputSchema), +}); +type GraphqlInitialCredentialsInput = typeof GraphqlInitialCredentialsInputSchema.Type; + const StaticAddSourceInputSchema = Schema.Struct({ scope: Schema.String, endpoint: Schema.String, - name: Schema.optional(Schema.String), + name: Schema.String, introspectionJson: Schema.optional(Schema.String), - namespace: Schema.optional(Schema.String), + namespace: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)), + oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional(GraphqlInitialCredentialsInputSchema), +}); +const SourceConfigureInputSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), - credentialTargetScope: Schema.optional(Schema.String), auth: Schema.optional(GraphqlSourceAuthInputSchema), }); @@ -134,12 +149,17 @@ const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1( // Plugin extension // --------------------------------------------------------------------------- -export interface GraphqlUpdateSourceInput { +export interface GraphqlSourceRef { + readonly id: string; + readonly scope: string; +} + +export interface GraphqlConfigureSourceInput { + readonly scope: string; readonly name?: string; readonly endpoint?: string; readonly headers?: Record; readonly queryParams?: Record; - readonly credentialTargetScope?: string; readonly auth?: GraphqlSourceAuthInput; } @@ -343,42 +363,12 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToGraphqlBinding = (binding: CredentialBindingRef): GraphqlSourceBindingRef => - GraphqlSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listGraphqlSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: GRAPHQL_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToGraphqlBinding); - }); - const resolveGraphqlSourceBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -394,7 +384,7 @@ const resolveGraphqlSourceBinding = ( candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToGraphqlBinding(binding) : null; + return binding ?? null; }); const validateGraphqlBindingTarget = ( @@ -437,105 +427,133 @@ const validateGraphqlBindingTarget = ( } }); -const bindingTargetScope = ( - targetScope: string | undefined, - bindings: readonly unknown[], -): Effect.Effect => { - if (bindings.length === 0) return Effect.succeed(undefined); - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new GraphqlIntrospectionError({ - message: "credentialTargetScope is required when adding direct GraphQL credentials", - }), - ); -}; +const canonicalizeCredentialMap = compileHttpNamedCredentialMap; -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, +const canonicalizeConfiguredValueMap = ( + values: Record | undefined, slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: GraphqlSourceBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ - slot: string; - value: GraphqlSourceBindingValue; - targetScope?: string; - }> = []; +): Record => { + const next: Record = {}; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - nextValues[name] = value; + next[name] = value; continue; } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ + next[name] = { kind: "binding", - slot, + slot: slotForName(name), prefix: value.prefix, - }); - 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 } - : {}), - }, - }); + }; + } + return next; +}; + +const resolveConfiguredValueMap = ( + values: Record | undefined, +): Record | undefined => { + if (!values) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") resolved[name] = value; } - return { values: nextValues, bindings }; + return Object.keys(resolved).length > 0 ? resolved : undefined; }; +const authFromOAuth2Source = (oauth2: OAuth2SourceConfig | undefined): GraphqlSourceAuth => + oauth2 ? { kind: "oauth2", connectionSlot: oauth2.connectionSlot } : { kind: "none" }; + const canonicalizeAuth = ( auth: GraphqlSourceAuthInput | undefined, ): { readonly auth: GraphqlSourceAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: GraphqlSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { - if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; - if ("connectionSlot" in auth) return { auth, bindings: [] }; + if (!auth || "kind" in auth || !auth.oauth2) return { auth: { kind: "none" }, bindings: [] }; + const connection = auth.oauth2.connection; return { auth: { kind: "oauth2", connectionSlot: GRAPHQL_OAUTH_CONNECTION_SLOT }, - bindings: [ - { - slot: GRAPHQL_OAUTH_CONNECTION_SLOT, - value: { - kind: "connection", - connectionId: ConnectionId.make(auth.connectionId), - }, - }, - ], + bindings: connection + ? [ + { + slot: GRAPHQL_OAUTH_CONNECTION_SLOT, + value: httpCredentialInputToBindingValue(connection), + }, + ] + : [], }; }; +const resolveInitialCredentialValueMap = ( + ctx: PluginCtx, + values: Record, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect | undefined, GraphqlIntrospectionError | StorageFailure> => + Effect.gen(function* () { + const bySlot = new Map(bindings.map((binding) => [binding.slot, binding.value] as const)); + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + resolved[name] = value; + continue; + } + const binding = bySlot.get(value.slot); + if (binding?.kind === "secret") { + const secret = yield* ctx.secrets + .getAtScope(binding.secretId, binding.secretScopeId ?? ScopeId.make(targetScope)) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new GraphqlIntrospectionError({ + message: `Secret not found for ${name}`, + }), + ), + ), + ); + if (secret === null) { + return yield* new GraphqlIntrospectionError({ + message: `Missing secret "${binding.secretId}" for ${name}`, + }); + } + resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; + continue; + } + if (binding?.kind === "text") { + resolved[name] = value.prefix ? `${value.prefix}${binding.text}` : binding.text; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; + }); + +const resolveInitialOAuthHeaders = ( + ctx: PluginCtx, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect | undefined, GraphqlIntrospectionError | StorageFailure> => + Effect.gen(function* () { + const connection = bindings.find( + (binding) => + binding.slot === GRAPHQL_OAUTH_CONNECTION_SLOT && binding.value.kind === "connection", + ); + if (!connection || connection.value.kind !== "connection") return undefined; + const connectionId = connection.value.connectionId; + const accessToken = yield* ctx.connections + .accessTokenAtScope(connectionId, ScopeId.make(targetScope)) + .pipe( + Effect.mapError( + ({ message }) => + new GraphqlIntrospectionError({ + message: `Failed to resolve OAuth connection "${connectionId}": ${message}`, + }), + ), + ); + return { Authorization: `Bearer ${accessToken}` }; + }); + const resolveGraphqlBindingValueMap = ( ctx: PluginCtx, values: Record | undefined, @@ -623,162 +641,81 @@ const makeGraphqlExtension = ( httpClientLayer: Layer.Layer, configFile: ConfigFileSink | undefined, ) => { - const resolveCredentialInputMap = ( - values: Record | undefined, - params: { - readonly sourceId: string; - readonly sourceScope: string; - readonly targetScope?: string; - readonly missingLabel: string; - readonly makeError: (message: string) => E; - }, - ): Effect.Effect | undefined, E | StorageFailure> => - Effect.gen(function* () { - if (!values) return undefined; - const resolved: Record = {}; - for (const [name, value] of Object.entries(values)) { - if (typeof value === "string") { - resolved[name] = value; - continue; - } - if ("kind" in value) { - const slotResolved = yield* resolveGraphqlBindingValueMap( - ctx, - { [name]: value }, - { - sourceId: params.sourceId, - sourceScope: params.sourceScope, - missingLabel: params.missingLabel, - makeError: params.makeError, - }, - ); - 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), secretScope) - .pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - params.makeError(`Secret not found for ${params.missingLabel} "${name}"`), - ), - ), - ); - if (secret === null) { - return yield* Effect.fail( - params.makeError( - `Missing secret "${value.secretId}" for ${params.missingLabel} "${name}"`, - ), - ); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; - }); - - const resolveOAuthInputHeader = ( - sourceId: string, - sourceScope: string, - targetScope: string | undefined, - auth: GraphqlSourceAuthInput | undefined, - ) => - Effect.gen(function* () { - if (!auth || auth.kind === "none") return undefined; - const connection = - "connectionId" in auth - ? { id: auth.connectionId, scope: targetScope ?? sourceScope } - : yield* Effect.gen(function* () { - const binding = yield* resolveGraphqlSourceBinding( - ctx, - sourceId, - sourceScope, - auth.connectionSlot, - ); - return binding?.value.kind === "connection" - ? { id: binding.value.connectionId, scope: binding.scopeId } - : null; - }); - if (connection === null) { - return yield* new GraphqlIntrospectionError({ - message: `Missing OAuth connection binding for "${sourceId}"`, - }); - } - const accessToken = yield* ctx.connections - .accessTokenAtScope(connection.id, connection.scope) - .pipe( - Effect.mapError( - () => - new GraphqlIntrospectionError({ - message: `Failed to resolve OAuth connection "${connection.id}"`, - }), - ), - ); - return { Authorization: `Bearer ${accessToken}` }; - }); - const addSourceInternal = (config: GraphqlSourceConfig) => ctx.transaction( Effect.gen(function* () { - const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint); - const canonicalHeaders = canonicalizeCredentialMap(config.headers, graphqlHeaderSlot); - const canonicalQueryParams = canonicalizeCredentialMap( + const namespace = config.namespace; + const canonicalHeaders = canonicalizeConfiguredValueMap(config.headers, graphqlHeaderSlot); + const canonicalQueryParams = canonicalizeConfiguredValueMap( config.queryParams, graphqlQueryParamSlot, ); - const canonicalAuth = canonicalizeAuth(config.auth); - const directBindings = [ - ...canonicalHeaders.bindings, - ...canonicalQueryParams.bindings, - ...canonicalAuth.bindings, + const initialHeaders = + config.credentials?.headers !== undefined + ? canonicalizeCredentialMap(config.credentials.headers, graphqlHeaderSlot) + : null; + const initialQueryParams = + config.credentials?.queryParams !== undefined + ? canonicalizeCredentialMap(config.credentials.queryParams, graphqlQueryParamSlot) + : null; + const initialAuth = + config.credentials?.auth !== undefined ? canonicalizeAuth(config.credentials.auth) : null; + const auth = config.oauth2 + ? authFromOAuth2Source(config.oauth2) + : (initialAuth?.auth ?? { kind: "none" }); + const initialBindings = [ + ...(initialHeaders?.bindings ?? []), + ...(initialQueryParams?.bindings ?? []), + ...(initialAuth?.bindings ?? []), ]; - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.credentialTargetScope, - binding, - ); + const initialScope = config.credentials?.scope; + if (initialScope && initialBindings.length > 0) { yield* validateGraphqlBindingTarget(ctx, { sourceId: namespace, sourceScope: config.scope, - targetScope: bindingTargetScope, + targetScope: initialScope, }); } - const targetScope = - directBindings[0] !== undefined - ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) - : undefined; let introspectionResult: IntrospectionResult; if (config.introspectionJson) { introspectionResult = yield* parseIntrospectionJson(config.introspectionJson); } else { - const resolvedHeaders = yield* resolveCredentialInputMap(config.headers, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "header", - makeError: (message) => new GraphqlIntrospectionError({ message }), - }); - const oauthHeader = yield* resolveOAuthInputHeader( - namespace, - config.scope, - targetScope, - config.auth, - ); - const resolvedQueryParams = yield* resolveCredentialInputMap(config.queryParams, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "query parameter", - makeError: (message) => new GraphqlIntrospectionError({ message }), - }); + const resolvedInitialHeaders = + initialHeaders && initialScope + ? yield* resolveInitialCredentialValueMap( + ctx, + canonicalHeaders, + initialHeaders.bindings, + initialScope, + ) + : undefined; + const resolvedOAuthHeaders = + initialAuth && initialScope + ? yield* resolveInitialOAuthHeaders(ctx, initialAuth.bindings, initialScope) + : undefined; + const resolvedHeaders = { + ...(resolveConfiguredValueMap(config.headers) ?? {}), + ...(resolvedInitialHeaders ?? {}), + ...(resolvedOAuthHeaders ?? {}), + }; + const resolvedInitialQueryParams = + initialQueryParams && initialScope + ? yield* resolveInitialCredentialValueMap( + ctx, + canonicalQueryParams, + initialQueryParams.bindings, + initialScope, + ) + : undefined; + const resolvedQueryParams = { + ...(resolveConfiguredValueMap(config.queryParams) ?? {}), + ...(resolvedInitialQueryParams ?? {}), + }; introspectionResult = yield* introspect( config.endpoint, - { ...(resolvedHeaders ?? {}), ...(oauthHeader ?? {}) }, - resolvedQueryParams, + Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined, + Object.keys(resolvedQueryParams).length > 0 ? resolvedQueryParams : undefined, ).pipe(Effect.provide(httpClientLayer)); } @@ -792,9 +729,9 @@ const makeGraphqlExtension = ( scope: config.scope, name: displayName, endpoint: config.endpoint, - headers: canonicalHeaders.values, - queryParams: canonicalQueryParams.values, - auth: canonicalAuth.auth, + headers: canonicalHeaders, + queryParams: canonicalQueryParams, + auth, }; const storedOps: StoredOperation[] = prepared.map((p) => ({ @@ -804,7 +741,6 @@ const makeGraphqlExtension = ( })); yield* ctx.storage.upsertSource(storedSource, storedOps); - yield* ctx.core.sources.register({ id: namespace, scope: config.scope, @@ -820,6 +756,23 @@ const makeGraphqlExtension = ( inputSchema: p.inputSchema, })), }); + if (initialScope && initialBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(initialScope), + pluginId: GRAPHQL_PLUGIN_ID, + sourceId: namespace, + sourceScope: ScopeId.make(config.scope), + slotPrefixes: [ + ...(config.credentials?.headers !== undefined ? ["header:"] : []), + ...(config.credentials?.queryParams !== undefined ? ["query_param:"] : []), + ...(config.credentials?.auth !== undefined ? ["auth:"] : []), + ], + bindings: initialBindings.map((binding) => ({ + slotKey: binding.slot, + value: binding.value, + })), + }); + } if (Object.keys(definitions).length > 0) { yield* ctx.core.definitions.register({ @@ -829,26 +782,70 @@ const makeGraphqlExtension = ( }); } - if (directBindings.length > 0) { - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.credentialTargetScope, - binding, - ); - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(bindingTargetScope), + return { toolCount: prepared.length, namespace }; + }), + ); + + const configureSource = ( + namespace: string, + scope: string, + targetScope: string, + input: Omit, + ) => + Effect.gen(function* () { + const existing = yield* ctx.storage.getSource(namespace, scope); + if (!existing) return; + const canonicalHeaders = + input.headers !== undefined + ? canonicalizeCredentialMap(input.headers, graphqlHeaderSlot) + : null; + const canonicalQueryParams = + input.queryParams !== undefined + ? canonicalizeCredentialMap(input.queryParams, graphqlQueryParamSlot) + : null; + const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; + const directBindings = [ + ...(canonicalHeaders?.bindings ?? []), + ...(canonicalQueryParams?.bindings ?? []), + ...(canonicalAuth?.bindings ?? []), + ]; + if (directBindings.length > 0) { + yield* validateGraphqlBindingTarget(ctx, { + sourceId: namespace, + sourceScope: scope, + targetScope, + }); + } + const affectedPrefixes = [ + ...(input.headers !== undefined ? ["header:"] : []), + ...(input.queryParams !== undefined ? ["query_param:"] : []), + ...(input.auth !== undefined ? ["auth:"] : []), + ]; + yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.updateSourceMeta(namespace, scope, { + name: input.name?.trim() || undefined, + endpoint: input.endpoint, + headers: canonicalHeaders?.values, + queryParams: canonicalQueryParams?.values, + auth: canonicalAuth?.auth, + }); + if (affectedPrefixes.length > 0 || directBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(targetScope), pluginId: GRAPHQL_PLUGIN_ID, sourceId: namespace, - sourceScope: ScopeId.make(config.scope), - slotKey: binding.slot, - value: binding.value, + sourceScope: ScopeId.make(scope), + slotPrefixes: affectedPrefixes, + bindings: directBindings.map((binding) => ({ + slotKey: binding.slot, + value: binding.value, + })), }); } - } - - return { toolCount: prepared.length, namespace }; - }), - ); + }), + ); + }); return { addSource: (config: GraphqlSourceConfig) => @@ -880,100 +877,10 @@ const makeGraphqlExtension = ( getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope), - updateSource: (namespace: string, scope: string, input: GraphqlUpdateSourceInput) => - Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); - if (!existing) return; - const canonicalHeaders = - input.headers !== undefined - ? canonicalizeCredentialMap(input.headers, graphqlHeaderSlot) - : null; - const canonicalQueryParams = - input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, graphqlQueryParamSlot) - : null; - const canonicalAuth = input.auth !== undefined ? canonicalizeAuth(input.auth) : null; - const directBindings = [ - ...(canonicalHeaders?.bindings ?? []), - ...(canonicalQueryParams?.bindings ?? []), - ...(canonicalAuth?.bindings ?? []), - ]; - const targetScope = yield* bindingTargetScope(input.credentialTargetScope, directBindings); - if (targetScope) { - yield* validateGraphqlBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, - targetScope, - }); - } - const affectedPrefixes = [ - ...(input.headers !== undefined ? ["header:"] : []), - ...(input.queryParams !== undefined ? ["query_param:"] : []), - ...(input.auth !== undefined ? ["auth:"] : []), - ]; - const replacementTargetScope = targetScope ?? input.credentialTargetScope ?? scope; - yield* ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.updateSourceMeta(namespace, scope, { - name: input.name?.trim() || undefined, - endpoint: input.endpoint, - headers: canonicalHeaders?.values, - queryParams: canonicalQueryParams?.values, - auth: canonicalAuth?.auth, - }); - if (affectedPrefixes.length > 0 || directBindings.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), - pluginId: GRAPHQL_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), - slotPrefixes: affectedPrefixes, - bindings: directBindings.map((binding) => ({ - slotKey: binding.slot, - value: binding.value, - })), - }); - } - }), - ); - }), - - listSourceBindings: (sourceId: string, sourceScope: string) => - listGraphqlSourceBindings(ctx, sourceId, sourceScope), + configureSource, - setSourceBinding: (input: GraphqlSourceBindingInput) => - Effect.gen(function* () { - yield* validateGraphqlBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: GRAPHQL_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToGraphqlBinding(binding); - }), - - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateGraphqlBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: GRAPHQL_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, - }); - }), + configure: (source: GraphqlSourceRef, input: GraphqlConfigureSourceInput) => + configureSource(source.id, source.scope, input.scope, input), }; }; @@ -993,6 +900,22 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { options?.configFile, ), + sourceConfigure: { + type: "graphql", + schema: SourceConfigureInputSchema, + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => + makeGraphqlExtension( + ctx, + options?.httpClientLayer ?? ctx.httpClientLayer, + options?.configFile, + ).configureSource( + sourceId, + sourceScope, + targetScope, + config as GraphqlConfigureSourceInput, + ), + }, + staticSources: (self) => [ { id: "graphql", @@ -1019,7 +942,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // graphql_operation + graphql_source rows live at the same + // GraphQL operation + source plugin-storage rows live at the same // scope, so pin every store lookup to it instead of relying on // stack-wide scope fall-through. const toolScope = toolRow.scope_id; diff --git a/packages/plugins/graphql/src/sdk/store.ts b/packages/plugins/graphql/src/sdk/store.ts index e02d044c4..5b6d96dee 100644 --- a/packages/plugins/graphql/src/sdk/store.ts +++ b/packages/plugins/graphql/src/sdk/store.ts @@ -1,76 +1,24 @@ -import { Effect, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { ConfiguredCredentialBinding, type FumaTables, - jsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; import { + GraphqlSourceAuth, OperationBinding, type ConfiguredGraphqlCredentialValue, - type GraphqlSourceAuth, } from "./types"; -// --------------------------------------------------------------------------- -// Schema — four tables: -// - graphql_source: endpoint + auth structure + display name per source. -// Auth carries a connection slot; concrete per-user/per-workspace -// connection ids live in core credential_binding rows. -// - graphql_source_header / graphql_source_query_param: one row per -// header/param entry. `kind` discriminates literal text from a -// credential slot binding. PK is `(scope_id, id)` where id is a JSON -// tuple `[source_id,name]` so user-provided separators cannot collide. -// - graphql_operation: per-tool OperationBinding blob. Operation -// bindings don't reference secrets/connections, so they stay as -// JSON — that's a legit JSON case (the binding shape is plugin- -// internal opaque data). -// --------------------------------------------------------------------------- - -export const graphqlSchema = { - graphql_source: scopedExecutorTable("graphql_source", { - name: textColumn("name"), - endpoint: textColumn("endpoint"), - auth_kind: textColumn("auth_kind").defaultTo("none"), - auth_connection_slot: nullableTextColumn("auth_connection_slot"), - }), - graphql_source_header: scopedExecutorTable("graphql_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - graphql_source_query_param: scopedExecutorTable("graphql_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - graphql_operation: scopedExecutorTable("graphql_operation", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - }), -} satisfies FumaTables; - +export const graphqlSchema = {} satisfies FumaTables; export type GraphqlSchema = typeof graphqlSchema; -// --------------------------------------------------------------------------- -// In-memory value shapes -// --------------------------------------------------------------------------- - export interface StoredGraphqlSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly endpoint: string; @@ -85,140 +33,123 @@ export interface StoredOperation { readonly binding: OperationBinding; } +const SOURCE_COLLECTION = "source"; +const OPERATION_COLLECTION = "operation"; + const OperationBindingFromJsonString = Schema.fromJsonString(OperationBinding); const decodeOperationBindingFromJsonString = Schema.decodeUnknownSync( OperationBindingFromJsonString, ); const decodeOperationBinding = Schema.decodeUnknownSync(OperationBinding); +const encodeBinding = Schema.encodeSync(OperationBinding); const decodeBinding = (value: unknown): OperationBinding => { - if (typeof value === "string") { - return decodeOperationBindingFromJsonString(value); - } + if (typeof value === "string") return decodeOperationBindingFromJsonString(value); return decodeOperationBinding(value); }; -const encodeBinding = Schema.encodeSync(OperationBinding); - const toJsonRecord = (value: unknown): Record => value as Record; -const SourceRow = Schema.Struct({ - id: Schema.String, - scope_id: Schema.String, - name: Schema.String, - endpoint: Schema.String, - auth_kind: Schema.Literals(["none", "oauth2"]), - auth_connection_slot: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), +const OptionalNullableString = Schema.optional(Schema.NullOr(Schema.String)); +const ConfiguredCredentialBindingStorage = Schema.Struct({ + kind: Schema.Literal("binding"), + slot: Schema.String, + prefix: OptionalNullableString, }); - -const ChildValueRow = Schema.Struct({ +const ConfiguredCredentialValueStorage = Schema.Union([ + Schema.String, + ConfiguredCredentialBindingStorage, +]); +const CredentialMapStorage = Schema.Record(Schema.String, ConfiguredCredentialValueStorage); +const SourceStorage = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, name: Schema.String, - kind: Schema.Literals(["text", "binding"]), - text_value: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), - slot_key: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), - prefix: Schema.NullOr(Schema.String).pipe(Schema.optionalKey), + endpoint: Schema.String, + headers: Schema.optional(CredentialMapStorage), + queryParams: Schema.optional(CredentialMapStorage), + auth: GraphqlSourceAuth, }); - -const OperationRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, +const OperationStorage = Schema.Struct({ + toolId: Schema.String, + sourceId: Schema.String, binding: Schema.Unknown, }); +const decodeSourceStorage = Schema.decodeUnknownOption(SourceStorage); +const decodeOperationStorage = Schema.decodeUnknownOption(OperationStorage); -const decodeSourceRow = Schema.decodeUnknownSync(SourceRow); -const decodeChildValueRow = Schema.decodeUnknownSync(ChildValueRow); -const decodeOperationRow = Schema.decodeUnknownSync(OperationRow); - -// Header / query-param rows: collapse the flat columns back into a source -// structure map keyed by header/param name. Concrete credential values are -// resolved through core credential_binding rows at invocation time. -const rowsToValueMap = ( - rows: readonly Record[], +const normalizeCredentialMap = ( + values: Readonly> | undefined, ): Record => { - const out: Record = {}; - for (const rawRow of rows) { - const row = decodeChildValueRow(rawRow); - const name = row.name; - if (row.kind === "binding" && typeof row.slot_key === "string") { - out[name] = - typeof row.prefix === "string" - ? ConfiguredCredentialBinding.make({ - kind: "binding", - slot: row.slot_key, - prefix: row.prefix, - }) - : ConfiguredCredentialBinding.make({ - kind: "binding", - slot: row.slot_key, - }); - } else if (row.kind === "text" && typeof row.text_value === "string") { - out[name] = row.text_value; + if (!values) return {}; + const normalized: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + normalized[name] = value; + continue; } + normalized[name] = + value.prefix != null + ? ConfiguredCredentialBinding.make({ + kind: "binding", + slot: value.slot, + prefix: value.prefix, + }) + : ConfiguredCredentialBinding.make({ + kind: "binding", + slot: value.slot, + }); } - return out; + return normalized; }; -interface GraphqlChildValueInsert { - id: string; - scope_id: string; - source_id: string; - name: string; - kind: "text" | "binding"; - text_value?: string; - slot_key?: string; - prefix?: string; -} +const sourceData = (source: StoredGraphqlSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + endpoint: source.endpoint, + headers: source.headers, + queryParams: source.queryParams, + auth: source.auth, +}); -// Encode one entry of a source credential map into a child row. Used by the -// writer for both `graphql_source_header` and `graphql_source_query_param`. -const valueToChildRow = ( - sourceId: string, - scope: string, - name: string, - value: ConfiguredGraphqlCredentialValue, -): GraphqlChildValueInsert => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - }; - } +const operationData = (operation: StoredOperation) => ({ + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: toJsonRecord(encodeBinding(operation.binding)), +}); + +const rowToSource = (row: PluginStorageEntry): StoredGraphqlSource | null => { + const decoded = decodeSourceStorage(row.data); + if (Option.isNone(decoded)) return null; + const source = decoded.value; return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - slot_key: value.slot, - prefix: value.prefix, + namespace: source.namespace, + scope: source.scope, + name: source.name, + endpoint: source.endpoint, + headers: normalizeCredentialMap(source.headers), + queryParams: normalizeCredentialMap(source.queryParams), + auth: source.auth, }; }; -const rowToAuth = (row: typeof SourceRow.Type): GraphqlSourceAuth => { - if (row.auth_kind === "oauth2" && typeof row.auth_connection_slot === "string") { - return { kind: "oauth2", connectionSlot: row.auth_connection_slot }; - } - return { kind: "none" }; +const rowToOperation = (row: PluginStorageEntry): StoredOperation | null => { + const decoded = decodeOperationStorage(row.data); + if (Option.isNone(decoded)) return null; + const operation = decoded.value; + return { + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: decodeBinding(operation.binding), + }; }; -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- - -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId) AND the owning `scope_id`. Scope is a normal FumaDB -// predicate here, not hidden behavior. export interface GraphqlStore { readonly upsertSource: ( input: StoredGraphqlSource, operations: readonly StoredOperation[], ) => Effect.Effect; - readonly updateSourceMeta: ( namespace: string, scope: string, @@ -230,258 +161,126 @@ export interface GraphqlStore { readonly auth?: GraphqlSourceAuth; }, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly getOperationByToolId: ( toolId: string, scope: string, ) => Effect.Effect; - readonly listOperationsBySource: ( sourceId: string, scope: string, ) => Effect.Effect; - readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -// --------------------------------------------------------------------------- -// Default store implementation -// --------------------------------------------------------------------------- - export const makeDefaultGraphqlStore = ({ - fuma, - scopes, + pluginStorage, }: StorageDeps): GraphqlStore => { - const scopeIds = scopes.map((scope) => String(scope.id)); - - const loadHeaders = (sourceId: string, scope: string) => - fuma - .use("graphql_source_header.findManyBySourceScope", (db) => - db.findMany("graphql_source_header", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(rowsToValueMap)); - - const loadQueryParams = (sourceId: string, scope: string) => - fuma - .use("graphql_source_query_param.findManyBySourceScope", (db) => - db.findMany("graphql_source_query_param", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(rowsToValueMap)); - - const rowToSourceWithChildren = ( - row: Record, - ): Effect.Effect => - Effect.gen(function* () { - const source = decodeSourceRow(row); - const sourceId = source.id; - const scope = source.scope_id; - const headers = yield* loadHeaders(sourceId, scope); - const queryParams = yield* loadQueryParams(sourceId, scope); - return { - namespace: sourceId, - scope, - name: source.name, - endpoint: source.endpoint, - headers, - queryParams, - auth: rowToAuth(source), - }; - }); - - const rowToOperation = (row: Record): StoredOperation => { - const operation = decodeOperationRow(row); - return { - toolId: operation.id, - sourceId: operation.source_id, - binding: decodeBinding(operation.binding), - }; - }; + const listOperationRowsForSourceScope = (sourceId: string, scope: string) => + pluginStorage + .list({ + collection: OPERATION_COLLECTION, + keyPrefix: `${sourceId}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter((row) => { + if (String(row.scopeId) !== scope) return false; + return rowToOperation(row)?.sourceId === sourceId; + }), + ), + ); - // Replace child rows for a source by deleting then bulk-inserting. Used - // by both upsertSource (full rewrite) and updateSourceMeta (partial - // patch when headers/queryParams is supplied). - const replaceChildren = ( - tableName: "graphql_source_header" | "graphql_source_query_param", - sourceId: string, - scope: string, - values: Record, - ) => + const removeOperationsForSourceScope = (sourceId: string, scope: string) => Effect.gen(function* () { - yield* fuma.use(`${tableName}.deleteManyBySourceScope`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const entries = Object.entries(values); - if (entries.length === 0) return; - yield* fuma - .use(`${tableName}.createMany`, (db) => - db.createMany( - tableName, - entries.map(([name, value]) => valueToChildRow(sourceId, scope, name, value)), - ), - ) - .pipe(Effect.asVoid); + const rows = yield* listOperationRowsForSourceScope(sourceId, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: OPERATION_COLLECTION, + key: row.key, + }); + } }); const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { - yield* fuma.use("graphql_operation.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_operation", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source_header.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_source_header", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source_query_param.deleteManyBySourceScope", (db) => - db.deleteMany("graphql_source_query_param", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* fuma.use("graphql_source.deleteManyByScopedId", (db) => - db.deleteMany("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeOperationsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }); return { upsertSource: (input, operations) => Effect.gen(function* () { yield* deleteSource(input.namespace, input.scope); - yield* fuma.use("graphql_source.create", (db) => - db.create("graphql_source", { - id: input.namespace, - scope_id: input.scope, - name: input.name, - endpoint: input.endpoint, - auth_kind: input.auth.kind, - auth_connection_slot: input.auth.kind === "oauth2" ? input.auth.connectionSlot : null, - }), - ); - yield* replaceChildren( - "graphql_source_header", - input.namespace, - input.scope, - input.headers, - ); - yield* replaceChildren( - "graphql_source_query_param", - input.namespace, - input.scope, - input.queryParams, - ); - if (operations.length > 0) { - yield* fuma - .use("graphql_operation.createMany", (db) => - db.createMany( - "graphql_operation", - operations.map((op) => ({ - id: op.toolId, - scope_id: input.scope, - source_id: op.sourceId, - binding: toJsonRecord(encodeBinding(op.binding)), - })), - ), - ) - .pipe(Effect.asVoid); + yield* pluginStorage.put({ + scope: input.scope, + collection: SOURCE_COLLECTION, + key: input.namespace, + data: sourceData(input), + }); + for (const operation of operations) { + yield* pluginStorage.put({ + scope: input.scope, + collection: OPERATION_COLLECTION, + key: operation.toolId, + data: operationData(operation), + }); } }), updateSourceMeta: (namespace, scope, patch) => Effect.gen(function* () { - const existing = yield* fuma.use("graphql_source.findFirstByScopedId", (db) => - db.findFirst("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + const existing = yield* pluginStorage.getAtScope({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); if (!existing) return; - const update: Partial<{ - name: string; - endpoint: string; - auth_kind: string; - auth_connection_slot: string | null; - }> = {}; - if (patch.name !== undefined) update.name = patch.name; - if (patch.endpoint !== undefined) update.endpoint = patch.endpoint; - if (patch.auth !== undefined) { - update.auth_kind = patch.auth.kind; - update.auth_connection_slot = - patch.auth.kind === "oauth2" ? patch.auth.connectionSlot : null; - } - if (Object.keys(update).length > 0) { - yield* fuma.use("graphql_source.updateManyByScopedId", (db) => - db.updateMany("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - set: update, - }), - ); - } - if (patch.headers !== undefined) { - yield* replaceChildren("graphql_source_header", namespace, scope, patch.headers); - } - if (patch.queryParams !== undefined) { - yield* replaceChildren("graphql_source_query_param", namespace, scope, patch.queryParams); - } + const source = rowToSource(existing); + if (!source) return; + yield* pluginStorage.put({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + data: sourceData({ + ...source, + name: patch.name ?? source.name, + endpoint: patch.endpoint ?? source.endpoint, + headers: patch.headers ?? source.headers, + queryParams: patch.queryParams ?? source.queryParams, + auth: patch.auth ?? source.auth, + }), + }); }), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("graphql_source.findFirstByScopedId", (db) => - db.findFirst("graphql_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* rowToSourceWithChildren(row); - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), listSources: () => - Effect.gen(function* () { - const rows = yield* fuma.use("graphql_source.findMany", (db) => - db.findMany("graphql_source", { - where: (b) => - scopeIds.length === 1 - ? b("scope_id", "=", scopeIds[0]!) - : b("scope_id", "in", [...scopeIds]), - }), - ); - return yield* Effect.forEach(rows, rowToSourceWithChildren, { - concurrency: "unbounded", - }); - }), + pluginStorage + .list({ collection: SOURCE_COLLECTION }) + .pipe(Effect.map((rows) => rows.map(rowToSource).filter(Predicate.isNotNull))), getOperationByToolId: (toolId, scope) => - fuma - .use("graphql_operation.findFirstByScopedId", (db) => - db.findFirst("graphql_operation", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ) + pluginStorage + .getAtScope({ scope, collection: OPERATION_COLLECTION, key: toolId }) .pipe(Effect.map((row) => (row ? rowToOperation(row) : null))), listOperationsBySource: (sourceId, scope) => - fuma - .use("graphql_operation.findManyBySourceScope", (db) => - db.findMany("graphql_operation", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map((rows) => rows.map(rowToOperation))), + listOperationRowsForSourceScope(sourceId, scope).pipe( + Effect.map((rows) => rows.map(rowToOperation).filter(Predicate.isNotNull)), + ), removeSource: (namespace, scope) => deleteSource(namespace, scope), }; diff --git a/packages/plugins/graphql/src/sdk/types.ts b/packages/plugins/graphql/src/sdk/types.ts index c6d55edb1..105cad22c 100644 --- a/packages/plugins/graphql/src/sdk/types.ts +++ b/packages/plugins/graphql/src/sdk/types.ts @@ -1,12 +1,10 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, - ScopedSecretCredentialInput, SecretBackedValue, - ScopeId, } from "@executor-js/sdk/shared"; +import { HttpConfiguredValueInput, HttpCredentialInput } from "@executor-js/plugin-http-source/sdk"; // --------------------------------------------------------------------------- // GraphQL operation kind @@ -73,11 +71,9 @@ export type QueryParamValue = typeof QueryParamValue.Type; export const ConfiguredGraphqlCredentialValue = ConfiguredCredentialValue; export type ConfiguredGraphqlCredentialValue = typeof ConfiguredGraphqlCredentialValue.Type; -export const GraphqlCredentialInput = Schema.Union([ - ScopedSecretCredentialInput, - HeaderValue, - ConfiguredGraphqlCredentialValue, -]); +export const GraphqlConfiguredValueInput = HttpConfiguredValueInput; +export type GraphqlConfiguredValueInput = typeof GraphqlConfiguredValueInput.Type; +export const GraphqlCredentialInput = HttpCredentialInput; export type GraphqlCredentialInput = typeof GraphqlCredentialInput.Type; export const graphqlHeaderSlot = (name: string): string => credentialSlotKey("header", name); @@ -99,37 +95,19 @@ export const GraphqlSourceAuth = Schema.Union([ export type GraphqlSourceAuth = typeof GraphqlSourceAuth.Type; export const GraphqlSourceAuthInput = Schema.Union([ - GraphqlSourceAuth, Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, + kind: Schema.Literal("none"), + }), + Schema.Struct({ + oauth2: Schema.optional( + Schema.Struct({ + connection: Schema.optional(HttpCredentialInput), + }), + ), }), ]); export type GraphqlSourceAuthInput = typeof GraphqlSourceAuthInput.Type; -export const GraphqlSourceBindingValue = CredentialBindingValue; -export type GraphqlSourceBindingValue = typeof GraphqlSourceBindingValue.Type; - -export const GraphqlSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: GraphqlSourceBindingValue, -}); -export type GraphqlSourceBindingInput = typeof GraphqlSourceBindingInput.Type; - -export const GraphqlSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: GraphqlSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type GraphqlSourceBindingRef = typeof GraphqlSourceBindingRef.Type; - export const InvocationConfig = Schema.Struct({ /** The GraphQL endpoint URL */ endpoint: Schema.String, diff --git a/packages/plugins/http-source/CHANGELOG.md b/packages/plugins/http-source/CHANGELOG.md new file mode 100644 index 000000000..60531db46 --- /dev/null +++ b/packages/plugins/http-source/CHANGELOG.md @@ -0,0 +1,7 @@ +# @executor-js/plugin-http-source + +## 1.4.29 + +### Patch Changes + +- Initial shared HTTP source helpers package. diff --git a/packages/plugins/http-source/package.json b/packages/plugins/http-source/package.json new file mode 100644 index 000000000..cca50ff7f --- /dev/null +++ b/packages/plugins/http-source/package.json @@ -0,0 +1,79 @@ +{ + "name": "@executor-js/plugin-http-source", + "version": "1.4.29", + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/http-source", + "bugs": { + "url": "https://github.com/RhysSullivan/executor/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/RhysSullivan/executor.git", + "directory": "packages/plugins/http-source" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sdk": "./src/sdk/index.ts", + "./react": "./src/react/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./sdk": { + "import": { + "types": "./dist/sdk.d.ts", + "default": "./dist/sdk.js" + } + }, + "./react": { + "import": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + } + } + } + }, + "scripts": { + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", + "typecheck": "tsgo --noEmit", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "@executor-js/react": "workspace:*", + "react": "catalog:" + }, + "peerDependenciesMeta": { + "@executor-js/react": { + "optional": true + }, + "react": { + "optional": true + } + } +} diff --git a/packages/plugins/http-source/src/index.ts b/packages/plugins/http-source/src/index.ts new file mode 100644 index 000000000..d0f24d3bb --- /dev/null +++ b/packages/plugins/http-source/src/index.ts @@ -0,0 +1 @@ +export * from "./sdk/index"; diff --git a/packages/plugins/http-source/src/react/http-credentials.test.ts b/packages/plugins/http-source/src/react/http-credentials.test.ts new file mode 100644 index 000000000..d859e32bc --- /dev/null +++ b/packages/plugins/http-source/src/react/http-credentials.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ScopeId, SecretId } from "@executor-js/sdk/shared"; + +import { httpCredentialsFromConfiguredCredentialBindings } from "./http-credentials"; + +describe("httpCredentialsFromConfiguredCredentialBindings", () => { + it("hydrates configured credentials with binding and secret scopes", () => { + const personalScope = ScopeId.make("user_1"); + const organizationScope = ScopeId.make("org_1"); + + const credentials = httpCredentialsFromConfiguredCredentialBindings({ + headers: { + Authorization: { + slot: "header:authorization", + prefix: "Bearer ", + }, + }, + queryParams: { + token: { + slot: "query_param:token", + }, + }, + bindings: [ + { + slotKey: "header:authorization", + scopeId: personalScope, + value: { + kind: "secret", + secretId: SecretId.make("personal-api-token"), + secretScopeId: organizationScope, + }, + }, + { + slotKey: "query_param:token", + scopeId: organizationScope, + value: { + kind: "text", + text: "literal-token", + }, + }, + ], + }); + + expect(credentials.headers).toEqual([ + { + name: "Authorization", + secretId: "personal-api-token", + valueKind: "secret", + prefix: "Bearer ", + presetKey: "bearer", + targetScope: personalScope, + secretScope: organizationScope, + }, + ]); + expect(credentials.queryParams).toEqual([ + { + name: "token", + secretId: null, + valueKind: "text", + literalValue: "literal-token", + }, + ]); + }); +}); diff --git a/packages/plugins/http-source/src/react/http-credentials.tsx b/packages/plugins/http-source/src/react/http-credentials.tsx new file mode 100644 index 000000000..108fdd5dc --- /dev/null +++ b/packages/plugins/http-source/src/react/http-credentials.tsx @@ -0,0 +1,430 @@ +import type { + ScopeId, + ScopedSecretCredentialInput, + SecretBackedValue, +} from "@executor-js/sdk/shared"; + +import { FieldLabel } from "@executor-js/react/components/field"; +import { HeadersList } from "@executor-js/react/plugins/headers-list"; +import { + headerValueToState, + headersFromState, + QueryParamCredentialValuePreview, + type HeaderAuthPreset, + type HeaderState, +} from "@executor-js/react/plugins/secret-header-auth"; +import type { CredentialTargetScopeOption } from "@executor-js/react/plugins/credential-target-scope"; +import { + type ConfiguredCredentialValueLike, + type CredentialBindingRefLike, +} from "@executor-js/react/plugins/credential-bindings"; +import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; + +export type { SecretBackedValue }; + +export type QueryParamState = { + name: string; + secretId: string | null; + valueKind?: "secret" | "text"; + prefix?: string; + literalValue?: string; + targetScope?: ScopeId; + secretScope?: ScopeId; +}; + +const queryParamPresets: readonly HeaderAuthPreset[] = [ + { key: "custom", label: "Query parameter", name: "" }, +]; + +export type HttpCredentialsState = { + headers: HeaderState[]; + queryParams: QueryParamState[]; +}; + +export const emptyHttpCredentials = (): HttpCredentialsState => ({ + headers: [], + queryParams: [], +}); + +export const httpCredentialsFromValues = (input: { + readonly headers?: Record | null; + readonly queryParams?: Record | null; +}): HttpCredentialsState => ({ + headers: Object.entries(input.headers ?? {}).map(([name, value]) => + headerValueToState(name, value), + ), + queryParams: Object.entries(input.queryParams ?? {}).map(([name, value]) => { + if (typeof value === "string") { + return { name, secretId: null, literalValue: value, valueKind: "text" as const }; + } + return { name, secretId: value.secretId, prefix: value.prefix, valueKind: "secret" as const }; + }), +}); + +export const serializeHeaderCredentials = ( + headers: readonly HeaderState[], +): Record => headersFromState(headers); + +export const serializeQueryCredentials = ( + queryParams: readonly QueryParamState[], +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + result[name] = { + secretId: param.secretId, + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +export const serializeHttpCredentials = ( + credentials: HttpCredentialsState, +): { + readonly headers: Record; + readonly queryParams: Record; +} => ({ + headers: serializeHeaderCredentials(credentials.headers), + queryParams: serializeQueryCredentials(credentials.queryParams), +}); + +export const serializeScopedHeaderCredentials = ( + headers: readonly HeaderState[], + fallbackTargetScope: ScopeId, +): Record => { + const result: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (!name || !header.secretId) continue; + const targetScope = header.targetScope ?? fallbackTargetScope; + result[name] = { + secretId: header.secretId, + targetScope, + ...(header.secretScope ? { secretScopeId: header.secretScope } : {}), + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } + return result; +}; + +export const serializeScopedQueryCredentials = ( + queryParams: readonly QueryParamState[], + fallbackTargetScope: ScopeId, +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + const targetScope = param.targetScope ?? fallbackTargetScope; + result[name] = { + secretId: param.secretId, + targetScope, + ...(param.secretScope ? { secretScopeId: param.secretScope } : {}), + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +export const serializeScopedHttpCredentials = ( + credentials: HttpCredentialsState, + fallbackTargetScope: ScopeId, +) => ({ + headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope), + queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), +}); + +export type HttpConfigureCredentialInput = + | string + | { + readonly kind: "text"; + readonly text: string; + readonly prefix?: string; + } + | { + readonly kind: "secret"; + readonly secretId: string; + readonly secretScope?: ScopeId; + readonly prefix?: string; + }; + +export const serializeConfigureHeaderCredentials = ( + headers: readonly HeaderState[], + fallbackSecretScope: ScopeId, +): Record => { + const result: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (!name) continue; + if (header.valueKind === "text") { + if (header.literalValue?.trim()) { + result[name] = header.literalValue.trim(); + } + continue; + } + if (!header.secretId) continue; + result[name] = { + kind: "secret", + secretId: header.secretId, + secretScope: header.secretScope ?? fallbackSecretScope, + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } + return result; +}; + +export const serializeConfigureQueryCredentials = ( + queryParams: readonly QueryParamState[], + fallbackSecretScope: ScopeId, +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + result[name] = { + kind: "secret", + secretId: param.secretId, + secretScope: param.secretScope ?? fallbackSecretScope, + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +export const serializeConfigureHttpCredentials = ( + credentials: HttpCredentialsState, + fallbackSecretScope: ScopeId, +) => ({ + headers: serializeConfigureHeaderCredentials(credentials.headers, fallbackSecretScope), + queryParams: serializeConfigureQueryCredentials(credentials.queryParams, fallbackSecretScope), +}); + +export type HttpTemplateCredentialInput = + | string + | { readonly kind: "secret"; readonly prefix?: string }; + +export const serializeTemplateHeaderCredentials = ( + headers: readonly HeaderState[], +): Record => { + const result: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (!name) continue; + if (header.valueKind === "text") { + if (header.literalValue?.trim()) { + result[name] = header.literalValue.trim(); + } + continue; + } + if (!header.secretId) continue; + result[name] = { + kind: "secret", + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } + return result; +}; + +export const serializeTemplateQueryCredentials = ( + queryParams: readonly QueryParamState[], +): Record => { + const result: Record = {}; + for (const param of queryParams) { + const name = param.name.trim(); + if (!name) continue; + if (param.secretId) { + result[name] = { + kind: "secret", + ...(param.prefix ? { prefix: param.prefix } : {}), + }; + continue; + } + if (param.literalValue?.trim()) { + result[name] = param.literalValue.trim(); + } + } + return result; +}; + +const bindingBySlot = ( + bindings: readonly CredentialBindingRefLike[], +): ReadonlyMap => + new Map(bindings.map((binding) => [binding.slotKey, binding])); + +const headerFromConfiguredCredential = ( + name: string, + value: ConfiguredCredentialValueLike, + bindings: ReadonlyMap, +): HeaderState | null => { + if (typeof value === "string") { + return headerValueToState(name, value); + } + + const binding = bindings.get(value.slot); + if (binding?.value.kind === "secret") { + return { + ...headerValueToState(name, { + secretId: binding.value.secretId, + prefix: value.prefix, + }), + targetScope: binding.scopeId, + secretScope: binding.value.secretScopeId, + }; + } + + if (binding?.value.kind === "text") { + return headerValueToState(name, binding.value.text); + } + + return null; +}; + +const queryParamFromConfiguredCredential = ( + name: string, + value: ConfiguredCredentialValueLike, + bindings: ReadonlyMap, +): QueryParamState | null => { + if (typeof value === "string") { + return { name, secretId: null, literalValue: value, valueKind: "text" }; + } + + const binding = bindings.get(value.slot); + if (binding?.value.kind === "secret") { + return { + name, + secretId: binding.value.secretId, + valueKind: "secret", + prefix: value.prefix, + targetScope: binding.scopeId, + secretScope: binding.value.secretScopeId, + }; + } + + if (binding?.value.kind === "text") { + return { name, secretId: null, literalValue: binding.value.text, valueKind: "text" }; + } + + return null; +}; + +export const httpCredentialsFromConfiguredCredentialBindings = (input: { + readonly headers?: Record | null; + readonly queryParams?: Record | null; + readonly bindings: readonly CredentialBindingRefLike[]; +}): HttpCredentialsState => { + const bindings = bindingBySlot(input.bindings); + + return { + headers: Object.entries(input.headers ?? {}).flatMap(([name, value]) => { + const state = headerFromConfiguredCredential(name, value, bindings); + return state ? [state] : []; + }), + queryParams: Object.entries(input.queryParams ?? {}).flatMap(([name, value]) => { + const state = queryParamFromConfiguredCredential(name, value, bindings); + return state ? [state] : []; + }), + }; +}; + +export const serializeTemplateHttpCredentials = (credentials: HttpCredentialsState) => ({ + headers: serializeTemplateHeaderCredentials(credentials.headers), + queryParams: serializeTemplateQueryCredentials(credentials.queryParams), +}); + +export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => + credentials.headers.every((header) => { + if (!header.name.trim()) return false; + return header.valueKind === "text" + ? Boolean(header.literalValue?.trim()) + : Boolean(header.secretId); + }) && + credentials.queryParams.every((param) => { + if (!param.name.trim()) return false; + return Boolean(param.secretId || param.literalValue?.trim()); + }); + +export function HttpCredentialsEditor(props: { + readonly credentials: HttpCredentialsState; + readonly onChange: (credentials: HttpCredentialsState) => void; + readonly existingSecrets: readonly SecretPickerSecret[]; + readonly sourceName?: string; + readonly targetScope: ScopeId; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly sections?: { + readonly headers?: boolean; + readonly queryParams?: boolean; + }; + readonly labels?: { + readonly headers?: string; + readonly queryParams?: string; + }; + readonly headerPresets?: readonly HeaderAuthPreset[]; +}) { + const showHeaders = props.sections?.headers ?? true; + const showQueryParams = props.sections?.queryParams ?? true; + + return ( +
+ {showHeaders && ( +
+ {props.labels?.headers ?? "Headers"} + props.onChange({ ...props.credentials, headers })} + existingSecrets={props.existingSecrets} + sourceName={props.sourceName} + targetScope={props.targetScope} + credentialScopeOptions={props.credentialScopeOptions} + bindingScopeOptions={props.bindingScopeOptions} + presets={props.headerPresets} + /> +
+ )} + + {showQueryParams && ( +
+ {props.labels?.queryParams ?? "Query parameters"} + props.onChange({ ...props.credentials, queryParams })} + existingSecrets={props.existingSecrets} + sourceName={props.sourceName} + targetScope={props.targetScope} + credentialScopeOptions={props.credentialScopeOptions} + bindingScopeOptions={props.bindingScopeOptions} + presets={queryParamPresets} + emptyLabel="No query parameters" + addLabel="Add query parameter" + addAriaLabel="Add query parameter" + rowCopy={{ + rowLabel: "Query parameter", + namePlaceholder: "token", + }} + rowPreviewComponent={QueryParamCredentialValuePreview} + /> +
+ )} +
+ ); +} diff --git a/packages/plugins/http-source/src/react/index.ts b/packages/plugins/http-source/src/react/index.ts new file mode 100644 index 000000000..bf1de8c2b --- /dev/null +++ b/packages/plugins/http-source/src/react/index.ts @@ -0,0 +1,24 @@ +export { + emptyHttpCredentials, + HttpCredentialsEditor, + httpCredentialsFromConfiguredCredentialBindings, + httpCredentialsFromValues, + httpCredentialsValid, + serializeHeaderCredentials, + serializeHttpCredentials, + serializeConfigureHttpCredentials, + serializeConfigureHeaderCredentials, + serializeConfigureQueryCredentials, + serializeTemplateHttpCredentials, + serializeTemplateHeaderCredentials, + serializeTemplateQueryCredentials, + serializeQueryCredentials, + serializeScopedHeaderCredentials, + serializeScopedHttpCredentials, + serializeScopedQueryCredentials, + type HttpCredentialsState, + type HttpConfigureCredentialInput, + type HttpTemplateCredentialInput, + type QueryParamState, + type SecretBackedValue, +} from "./http-credentials"; diff --git a/packages/plugins/http-source/src/sdk/configure.ts b/packages/plugins/http-source/src/sdk/configure.ts new file mode 100644 index 000000000..09a9520c5 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/configure.ts @@ -0,0 +1,110 @@ +import { + ConnectionId, + ConfiguredCredentialBinding, + type ConfiguredCredentialValue, + type CredentialBindingValue, + type ScopedSecretCredentialInput, + SecretId, + ScopeId, +} from "@executor-js/sdk/shared"; + +import type { HttpCredentialInput } from "./types"; + +export type HttpNamedCredentialInput = + | ConfiguredCredentialValue + | ScopedSecretCredentialInput + | { + readonly secretId: string; + readonly prefix?: string; + readonly targetScope?: string; + readonly secretScopeId?: string; + }; + +export interface CompiledHttpNamedCredentialBinding { + readonly slot: string; + readonly value: CredentialBindingValue; + readonly targetScope?: string; +} + +export const compileHttpNamedCredentialMap = ( + values: Record | undefined, + slotForName: (name: string) => string, +): { + readonly values: Record; + readonly bindings: readonly CompiledHttpNamedCredentialBinding[]; +} => { + const nextValues: Record = {}; + const bindings: CompiledHttpNamedCredentialBinding[] = []; + for (const [name, value] of Object.entries(values ?? {})) { + if (typeof value === "string") { + nextValues[name] = value; + continue; + } + if ("kind" in value) { + if (value.kind === "binding") { + nextValues[name] = value; + continue; + } + const slot = slotForName(name); + nextValues[name] = ConfiguredCredentialBinding.make({ + kind: "binding", + slot, + prefix: "prefix" in value ? value.prefix : undefined, + }); + bindings.push({ + slot, + value: httpCredentialInputToBindingValue(value), + }); + continue; + } + const slot = slotForName(name); + nextValues[name] = ConfiguredCredentialBinding.make({ + kind: "binding", + slot, + prefix: value.prefix, + }); + bindings.push({ + slot, + targetScope: "targetScope" in value ? value.targetScope : undefined, + value: { + kind: "secret", + secretId: SecretId.make(value.secretId), + ...("secretScopeId" in value && value.secretScopeId + ? { secretScopeId: ScopeId.make(value.secretScopeId) } + : {}), + }, + }); + } + return { values: nextValues, bindings }; +}; + +export const httpCredentialInputToBindingValue = ( + input: HttpCredentialInput, +): CredentialBindingValue => { + if (typeof input === "string") { + return { + kind: "text", + text: input, + }; + } + if (input.kind === "text") { + return { + kind: "text", + text: input.text, + }; + } + if (input.kind === "secret") { + return { + kind: "secret", + secretId: SecretId.make(input.secretId), + ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), + }; + } + if (input.kind === "connection") { + return { + kind: "connection", + connectionId: ConnectionId.make(input.connectionId), + }; + } + return input; +}; diff --git a/packages/plugins/http-source/src/sdk/index.ts b/packages/plugins/http-source/src/sdk/index.ts new file mode 100644 index 000000000..5768ccbb7 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/index.ts @@ -0,0 +1,31 @@ +export { + HttpCredentialInput, + HttpConfiguredValueInput, + HttpOAuthConfigureInput, + OAuth2Flow, + OAuth2SourceConfig, + type HttpCredentialInput as HttpCredentialInputType, + type HttpConfiguredValueInput as HttpConfiguredValueInputType, + type HttpOAuthConfigureInput as HttpOAuthConfigureInputType, + type OAuth2Flow as OAuth2FlowType, + type OAuth2SourceConfig as OAuth2SourceConfigType, +} from "./types"; + +export { + httpCredentialSlotKey, + httpHeaderSlotKey, + httpOAuthClientIdSlotKey, + httpOAuthClientSecretSlotKey, + httpOAuthConnectionSlotKey, + httpQuerySlotKey, + httpSectionSlotPrefix, + type HttpCredentialPlacement, + type HttpCredentialSection, +} from "./slots"; + +export { + compileHttpNamedCredentialMap, + httpCredentialInputToBindingValue, + type CompiledHttpNamedCredentialBinding, + type HttpNamedCredentialInput, +} from "./configure"; diff --git a/packages/plugins/http-source/src/sdk/slots.ts b/packages/plugins/http-source/src/sdk/slots.ts new file mode 100644 index 000000000..403f2bdd0 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/slots.ts @@ -0,0 +1,27 @@ +import { credentialSlotPart } from "@executor-js/sdk/shared"; + +export type HttpCredentialSection = "request" | "specFetch" | "introspection"; +export type HttpCredentialPlacement = "headers" | "query"; + +export const httpCredentialSlotKey = ( + section: HttpCredentialSection, + placement: HttpCredentialPlacement, + name: string, +): string => `${section}.${placement}.${credentialSlotPart(name)}`; + +export const httpHeaderSlotKey = (section: HttpCredentialSection, name: string): string => + httpCredentialSlotKey(section, "headers", name); + +export const httpQuerySlotKey = (section: HttpCredentialSection, name: string): string => + httpCredentialSlotKey(section, "query", name); + +export const httpOAuthConnectionSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.connection`; + +export const httpOAuthClientIdSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.clientId`; + +export const httpOAuthClientSecretSlotKey = (section: HttpCredentialSection): string => + `${section}.oauth.clientSecret`; + +export const httpSectionSlotPrefix = (section: HttpCredentialSection): string => `${section}.`; diff --git a/packages/plugins/http-source/src/sdk/types.ts b/packages/plugins/http-source/src/sdk/types.ts new file mode 100644 index 000000000..f57018a20 --- /dev/null +++ b/packages/plugins/http-source/src/sdk/types.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect"; + +export const HttpCredentialInput = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("text"), + text: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("secret"), + secretId: Schema.String, + secretScope: Schema.optional(Schema.String), + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("connection"), + connectionId: Schema.String, + }), +]); +export type HttpCredentialInput = typeof HttpCredentialInput.Type; + +export const HttpConfiguredValueInput = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("secret"), + prefix: Schema.optional(Schema.String), + }), +]); +export type HttpConfiguredValueInput = typeof HttpConfiguredValueInput.Type; + +export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); +export type OAuth2Flow = typeof OAuth2Flow.Type; + +export const OAuth2SourceConfig = Schema.Struct({ + kind: Schema.Literal("oauth2"), + securitySchemeName: Schema.String, + flow: OAuth2Flow, + tokenUrl: Schema.String, + authorizationUrl: Schema.NullOr(Schema.String), + issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), + clientIdSlot: Schema.String, + clientSecretSlot: Schema.NullOr(Schema.String), + connectionSlot: Schema.String, + scopes: Schema.Array(Schema.String), +}).annotate({ identifier: "OAuth2SourceConfig" }); +export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; + +export const HttpOAuthConfigureInput = Schema.Struct({ + clientId: Schema.optional(HttpCredentialInput), + clientSecret: Schema.optional(Schema.NullOr(HttpCredentialInput)), + connection: Schema.optional(HttpCredentialInput), +}).annotate({ identifier: "HttpOAuthConfigureInput" }); +export type HttpOAuthConfigureInput = typeof HttpOAuthConfigureInput.Type; diff --git a/packages/plugins/http-source/tsconfig.json b/packages/plugins/http-source/tsconfig.json new file mode 100644 index 000000000..1504bed72 --- /dev/null +++ b/packages/plugins/http-source/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["bun-types", "node"], + "noUnusedLocals": true, + "noImplicitOverride": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "@effect/language-service", + "ignoreEffectSuggestionsInTscExitCode": true, + "ignoreEffectWarningsInTscExitCode": true, + "diagnosticSeverity": {} + } + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/plugins/http-source/tsup.config.ts b/packages/plugins/http-source/tsup.config.ts new file mode 100644 index 000000000..285c3758d --- /dev/null +++ b/packages/plugins/http-source/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + sdk: "src/sdk/index.ts", + react: "src/react/index.ts", + }, + format: ["esm"], + dts: false, + sourcemap: true, + clean: true, + external: [/^@executor-js\//, /^effect/, /^@effect\//, /^react/], +}); diff --git a/packages/plugins/http-source/vitest.config.ts b/packages/plugins/http-source/vitest.config.ts new file mode 100644 index 000000000..324c1ee66 --- /dev/null +++ b/packages/plugins/http-source/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 8d1ce175c..4db5e8160 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -64,6 +64,7 @@ "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:" diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 697cb0ad8..86eec6e51 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -4,12 +4,8 @@ import { InternalError, ScopeId, SecretBackedMap } from "@executor-js/sdk/shared import { McpConnectionError, McpToolDiscoveryError } from "../sdk/errors"; import { McpStoredSourceSchema } from "../sdk/stored-source"; -import { - McpConnectionAuthInput, - McpCredentialInput, - McpSourceBindingInput, - McpSourceBindingRef, -} from "../sdk/types"; +import { McpConfiguredValueInput, McpConnectionAuthInput, McpCredentialInput } from "../sdk/types"; +import { OAuth2SourceConfig } from "@executor-js/plugin-http-source/sdk"; // --------------------------------------------------------------------------- // Params @@ -17,17 +13,6 @@ import { const ScopeParams = { scopeId: ScopeId }; const SourceParams = { scopeId: ScopeId, namespace: Schema.String }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - -// --------------------------------------------------------------------------- -// Auth payload (only for remote) -// --------------------------------------------------------------------------- - -const AuthPayload = McpConnectionAuthInput; const StringMap = Schema.Record(Schema.String, Schema.String); // --------------------------------------------------------------------------- @@ -35,20 +20,25 @@ const StringMap = Schema.Record(Schema.String, Schema.String); // --------------------------------------------------------------------------- const AddRemoteSourcePayload = Schema.Struct({ - targetScope: ScopeId, transport: Schema.Literal("remote"), name: Schema.String, endpoint: Schema.String, remoteTransport: Schema.optional(Schema.Literals(["streamable-http", "sse", "auto"])), namespace: Schema.optional(Schema.String), - queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - auth: Schema.optional(AuthPayload), - credentialTargetScope: Schema.optional(ScopeId), + queryParams: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional( + Schema.Struct({ + scope: ScopeId, + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), + }), + ), }); const AddStdioSourcePayload = Schema.Struct({ - targetScope: ScopeId, transport: Schema.Literal("stdio"), name: Schema.String, command: Schema.String, @@ -60,24 +50,6 @@ const AddStdioSourcePayload = Schema.Struct({ const AddSourcePayload = Schema.Union([AddRemoteSourcePayload, AddStdioSourcePayload]); -// --------------------------------------------------------------------------- -// Other payloads -// --------------------------------------------------------------------------- - -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), - credentialTargetScope: Schema.optional(ScopeId), - auth: Schema.optional(AuthPayload), -}); - -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); - const ProbeEndpointPayload = Schema.Struct({ endpoint: Schema.String, headers: Schema.optional(SecretBackedMap), @@ -98,13 +70,6 @@ const NamespacePayload = Schema.Struct({ namespace: Schema.String, }); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, - scope: ScopeId, -}); - // --------------------------------------------------------------------------- // Responses // --------------------------------------------------------------------------- @@ -177,41 +142,6 @@ export const McpGroup = HttpApiGroup.make("mcp") success: Schema.NullOr(McpStoredSourceSchema), error: [InternalError, McpConnectionError, McpToolDiscoveryError], }), - ) - .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/mcp/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - success: UpdateSourceResponse, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/mcp/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(McpSourceBindingRef), - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/mcp/source-bindings", { - params: ScopeParams, - payload: McpSourceBindingInput, - success: McpSourceBindingRef, - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/mcp/source-bindings/remove", { - params: ScopeParams, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, McpConnectionError, McpToolDiscoveryError], - }), ); // Errors declared once at the group level — every endpoint inherits. // Plugin domain errors carry their own HttpApiSchema status (4xx); diff --git a/packages/plugins/mcp/src/api/handlers.test.ts b/packages/plugins/mcp/src/api/handlers.test.ts index 8f3177566..b245e5d87 100644 --- a/packages/plugins/mcp/src/api/handlers.test.ts +++ b/packages/plugins/mcp/src/api/handlers.test.ts @@ -28,10 +28,6 @@ const failingExtension: McpPluginExtension = { removeSource: () => unused, refreshSource: () => unused, getSource: () => Effect.succeed(null), - updateSource: () => unused, - listSourceBindings: () => Effect.succeed([]), - setSourceBinding: () => unused, - removeSourceBinding: () => unused, }; const Api = addGroup(McpGroup); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 14ec9f3ae..bedb4e8ab 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -3,13 +3,9 @@ import { Context, Effect } from "effect"; import { addGroup, capture } from "@executor-js/api"; import { ScopeId } from "@executor-js/sdk/core"; -import type { - McpPluginExtension, - McpProbeEndpointInput, - McpSourceConfig, - McpUpdateSourceInput, -} from "../sdk/plugin"; -import type { McpCredentialInput } from "../sdk/types"; +import type { OAuth2SourceConfigType } from "@executor-js/plugin-http-source/sdk"; +import type { McpPluginExtension, McpProbeEndpointInput, McpSourceConfig } from "../sdk/plugin"; +import type { McpConfiguredValueInput } from "../sdk/types"; import { McpStoredSourceSchema } from "../sdk/stored-source"; import { McpGroup } from "./group"; @@ -66,11 +62,11 @@ const toSourceConfig = ( name: string; endpoint: string; remoteTransport?: "streamable-http" | "sse" | "auto"; - queryParams?: Record; - headers?: Record; + queryParams?: Record; + headers?: Record; namespace?: string; - auth?: { kind: string } & Record; - credentialTargetScope?: string; + oauth2?: OAuth2SourceConfigType; + credentials?: Extract["credentials"]; }; return { @@ -82,8 +78,8 @@ const toSourceConfig = ( queryParams: p.queryParams, headers: p.headers, namespace: p.namespace, - auth: p.auth as McpSourceConfig extends { auth?: infer A } ? A : never, - credentialTargetScope: p.credentialTargetScope, + oauth2: p.oauth2, + credentials: p.credentials, }; }; @@ -110,12 +106,12 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand }), ), ) - .handle("addSource", ({ payload }) => + .handle("addSource", ({ params: path, payload }) => capture( Effect.gen(function* () { const ext = yield* McpExtensionService; return yield* ext.addSource( - toSourceConfig(payload as Parameters[0], payload.targetScope), + toSourceConfig(payload as Parameters[0], path.scopeId), ); }), ), @@ -152,51 +148,5 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand : null; }), ), - ) - .handle("updateSource", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - endpoint: payload.endpoint, - headers: payload.headers, - queryParams: payload.queryParams, - credentialTargetScope: payload.credentialTargetScope, - auth: payload.auth as McpUpdateSourceInput["auth"], - }); - return { updated: true }; - }), - ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - return yield* ext.setSourceBinding(payload); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* McpExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; - }), - ), ), ); diff --git a/packages/plugins/mcp/src/promise.ts b/packages/plugins/mcp/src/promise.ts index 0334cb51b..223d18cff 100644 --- a/packages/plugins/mcp/src/promise.ts +++ b/packages/plugins/mcp/src/promise.ts @@ -6,5 +6,5 @@ export type { McpRemoteSourceConfig, McpStdioSourceConfig, McpProbeResult, - McpUpdateSourceInput, + McpConfigureSourceInput, } from "./sdk/plugin"; diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 6efbb94b0..3e5fe5fab 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,4 +1,4 @@ -import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useReducer, useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Match from "effect/Match"; @@ -10,23 +10,22 @@ import { Button } from "@executor-js/react/components/button"; import { CardStack, CardStackContent, - CardStackEntry, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; import { Spinner } from "@executor-js/react/components/spinner"; import { Textarea } from "@executor-js/react/components/textarea"; import { emptyHttpCredentials, httpCredentialsValid, HttpCredentialsEditor, - serializeScopedHttpCredentials, + serializeConfigureHttpCredentials, serializeHttpCredentials, -} from "@executor-js/react/plugins/http-credentials"; + serializeTemplateHttpCredentials, +} from "@executor-js/plugin-http-source/react"; import { sourceDisplayNameFromUrl, slugifyNamespace, @@ -45,13 +44,22 @@ import { CredentialUsageRow, useCredentialTargetScope, } from "@executor-js/react/plugins/credential-target-scope"; +import { + defaultHeaderAuthPresets, + type HeaderAuthPreset, +} from "@executor-js/react/plugins/secret-header-auth"; type RemoteAuthMode = "none" | "oauth2"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import { mcpPresets, type McpPreset } from "../sdk/presets"; -import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; +import type { McpConfiguredValueInput, McpCredentialInput } from "../sdk/types"; + +const mcpHeaderPresets: readonly HeaderAuthPreset[] = [ + { key: "text", label: "Plaintext header", name: "", valueKind: "text" }, + ...defaultHeaderAuthPresets, +]; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -94,11 +102,6 @@ type ProbeResult = { serverName: string | null; }; -type PlainHeader = { - name: string; - value: string; -}; - type State = | { step: "url"; url: string } | { step: "probing"; url: string; probe: ProbeResult | null } @@ -313,7 +316,6 @@ export default function AddMcpSource(props: { }); const [remoteAuthMode, setRemoteAuthMode] = useState("none"); - const [remoteHeaders, setRemoteHeaders] = useState([]); const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); const probe = "probe" in state ? state.probe : null; @@ -328,18 +330,10 @@ export default function AddMcpSource(props: { const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false; - const remoteHeadersComplete = remoteHeaders.every( - (header) => header.name.trim() && header.value.trim(), - ); const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; const canAdd = - Boolean(probe) && - authReady && - remoteHeadersComplete && - remoteCredentialsComplete && - !isAdding && - !isOAuthBusy; + Boolean(probe) && authReady && remoteCredentialsComplete && !isAdding && !isOAuthBusy; // Probe failures are shown inline on the URL field; other failures // (OAuth start, add source) render in the bottom error block. const probeError = state.step === "error" && state.probe === null ? state.error : null; @@ -437,49 +431,68 @@ export default function AddMcpSource(props: { const handleAddRemote = useCallback(async () => { if (!probe) return; dispatch({ type: "add-start" }); - const auth = - 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 = serializeScopedHttpCredentials( + const templateCredentials = serializeTemplateHttpCredentials(remoteCredentials); + const configureCredentials = serializeConfigureHttpCredentials( remoteCredentials, requestCredentialTargetScope, ); - const remoteRequestHeaders: Record = { - ...headers, - ...credentials.headers, - }; + const remoteRequestHeaders = templateCredentials.headers as Record< + string, + McpConfiguredValueInput + >; + const hasInitialCredentials = + Object.keys(configureCredentials.headers).length > 0 || + Object.keys(configureCredentials.queryParams).length > 0 || + (remoteAuthMode === "oauth2" && tokens); const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "remote" as const, name: displayName, namespace: slugNamespace || undefined, endpoint: state.url.trim(), - auth, - credentialTargetScope: - remoteAuthMode === "oauth2" && tokens - ? oauthCredentialTargetScope - : requestCredentialTargetScope, ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}), - ...(Object.keys(credentials.queryParams).length > 0 - ? { queryParams: credentials.queryParams } + ...(Object.keys(templateCredentials.queryParams).length > 0 + ? { + queryParams: templateCredentials.queryParams as Record< + string, + McpConfiguredValueInput + >, + } + : {}), + ...(hasInitialCredentials + ? { + credentials: { + scope: requestCredentialTargetScope, + ...(Object.keys(configureCredentials.headers).length > 0 + ? { + headers: configureCredentials.headers as Record, + } + : {}), + ...(Object.keys(configureCredentials.queryParams).length > 0 + ? { + queryParams: configureCredentials.queryParams as Record< + string, + McpCredentialInput + >, + } + : {}), + ...(remoteAuthMode === "oauth2" && tokens + ? { + auth: { + oauth2: { + connection: { + kind: "connection" as const, + connectionId: tokens.connectionId, + }, + }, + }, + } + : {}), + }, + } : {}), }, reactivityKeys: sourceWriteKeys, @@ -495,7 +508,6 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteHeaders, remoteCredentials, remoteIdentity, tokens, @@ -504,7 +516,6 @@ export default function AddMcpSource(props: { props, scopeId, requestCredentialTargetScope, - oauthCredentialTargetScope, ]); // ---- Stdio actions ---- @@ -560,7 +571,6 @@ export default function AddMcpSource(props: { const exit = await doAdd({ params: { scopeId }, payload: { - targetScope: scopeId, transport: "stdio" as const, name: displayName, namespace: slugNamespace || undefined, @@ -639,6 +649,7 @@ export default function AddMcpSource(props: { targetScope={requestCredentialTargetScope} credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={credentialScopeOptions} + headerPresets={mcpHeaderPresets} labels={{ headers: "Request headers", queryParams: "Query parameters", @@ -747,108 +758,6 @@ export default function AddMcpSource(props: { )} - {/* Additional headers */} - {probe && ( -
-
- -

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

-
- - - - {remoteHeaders.length === 0 ? ( - No headers} - onClick={() => - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - ) : ( - <> - {remoteHeaders.map((header, index) => ( - -
- - -
-
-
- - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - name: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="X-Organization-Id" - className="h-8 text-xs font-mono" - /> -
-
- - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - value: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="workspace-id" - className="h-8 text-xs font-mono" - /> -
-
-
- ))} - - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - - )} -
-
-
- )} - {/* Error (OAuth / add source). Probe errors show inline on the field. */} {otherError && (
@@ -969,29 +878,3 @@ export default function AddMcpSource(props: {
); } - -function AddPlainHeaderRow({ - onClick, - leading, -}: { - readonly onClick: () => void; - readonly leading?: ReactNode; -}) { - return ( - // oxlint-disable-next-line react/forbid-elements - - ); -} diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 00d48a859..b83f98187 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -1,40 +1,47 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; +import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; import { - mcpSourceAtom, - mcpSourceBindingsAtom, - setMcpSourceBinding, - updateMcpSource, -} from "./atoms"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; + configureSource, + connectionsAtom, + setSourceCredentialBinding, +} from "@executor-js/react/api/atoms"; +import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentialsEditor, + httpCredentialsFromConfiguredCredentialBindings, serializeHttpCredentials, - serializeScopedHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { - effectiveCredentialBindingForScope, - httpCredentialsFromConfiguredCredentialBindings, - initialCredentialTargetScope, -} from "@executor-js/react/plugins/credential-bindings"; -import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; +import { + SourceOAuthConnectionControl, + sourceOAuthConnectionUiState, +} from "@executor-js/react/plugins/source-oauth-connection"; import { Button } from "@executor-js/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, +} from "@executor-js/react/components/card-stack"; import { Badge } from "@executor-js/react/components/badge"; -import { ScopeId } from "@executor-js/sdk/shared"; -import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; +import { type CredentialBindingRef, ScopeId } from "@executor-js/sdk/shared"; import { - McpSourceBindingInput, - type McpCredentialInput, - type McpSourceBindingRef, -} from "../sdk/types"; + SecretCredentialSlotBindings, + secretCredentialSlotsFromHttpConfig, +} from "@executor-js/react/plugins/credential-slot-bindings"; +import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; // --------------------------------------------------------------------------- @@ -44,97 +51,87 @@ import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; function RemoteEditForm(props: { sourceId: string; initial: McpStoredSourceSchemaType & { config: { transport: "remote" } }; - bindings: readonly McpSourceBindingRef[]; + bindings: readonly CredentialBindingRef[]; onSave: () => void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); + const userScope = useUserScope(); const sourceScope = ScopeId.make(props.initial.scope); - const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ - sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), - }); const { credentialTargetScope: oauthCredentialTargetScope, setCredentialTargetScope: setOAuthCredentialTargetScope, } = useCredentialTargetScope({ sourceScope, - initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), + initialTargetScope: userScope, }); - const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); - const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); + const setConnectionBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); const identity = useSourceIdentity({ fallbackName: props.initial.name, fallbackNamespace: props.initial.namespace, }); const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); - const [credentials, setCredentials] = useState(() => - httpCredentialsFromConfiguredCredentialBindings({ - headers: props.initial.config.headers, - queryParams: props.initial.config.queryParams, - bindings: props.bindings, - }), + const credentials = useMemo( + () => + httpCredentialsFromConfiguredCredentialBindings({ + headers: props.initial.config.headers, + queryParams: props.initial.config.queryParams, + bindings: props.bindings, + }), + [props.bindings, props.initial.config.headers, props.initial.config.queryParams], ); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [credentialsDirty, setCredentialsDirty] = useState(false); + const { busyKey, setSecretBinding, clearBinding } = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + }); const identityDirty = identity.name.trim() !== props.initial.name.trim(); const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); - const dirty = metadataDirty || credentialsDirty; + const dirty = metadataDirty; const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); - const connectionBinding = oauth2 - ? effectiveCredentialBindingForScope( - props.bindings, - oauth2.connectionSlot, - oauthCredentialTargetScope, + const { credentialScopeOptions, secretBindingScopes, scopeRanks } = + useSourceCredentialBindingScopes({ sourceScope }); + const secretSlots = secretCredentialSlotsFromHttpConfig({ + headers: props.initial.config.headers, + queryParams: props.initial.config.queryParams, + }); + const oauthConnectionState = oauth2 + ? sourceOAuthConnectionUiState({ + bindings: props.bindings, + connectionSlot: oauth2.connectionSlot, + tokenScope: oauthCredentialTargetScope, scopeRanks, - ) + credentialScopeOptions, + connections, + }) : null; - const boundConnectionId = - connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; - const isConnected = - boundConnectionId !== null && - connections.some((connection) => connection.id === boundConnectionId); const oauthRequestCredentials = serializeHttpCredentials(credentials); - const handleCredentialsChange = (next: HttpCredentialsState) => { - setCredentials(next); - setCredentialsDirty(true); - }; - const handleSave = async () => { setSaving(true); setError(null); - const { headers, queryParams } = serializeScopedHttpCredentials( - credentials, - credentialTargetScope, - ); - const payload: { - sourceScope: ScopeId; + const config: { name?: string; endpoint?: string; - headers?: Record; - queryParams?: Record; - credentialTargetScope?: ScopeId; } = { - sourceScope, name: metadataDirty ? identity.name.trim() || undefined : undefined, endpoint: metadataDirty ? endpoint.trim() || undefined : undefined, }; - if (credentialsDirty) { - payload.headers = headers; - payload.queryParams = queryParams as Record; - payload.credentialTargetScope = credentialTargetScope; - } - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, - payload, + const exit = await doConfigure({ + params: { scopeId: displayScope }, + payload: { + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "mcp", + config, + }, reactivityKeys: sourceWriteKeys, }); if (Exit.isFailure(exit)) { @@ -142,7 +139,6 @@ function RemoteEditForm(props: { setSaving(false); return; } - setCredentialsDirty(false); setSaving(false); props.onSave(); }; @@ -178,17 +174,35 @@ function RemoteEditForm(props: { namespaceReadOnly /> - + {secretSlots.length > 0 && ( + + + + + Request credentials + + Headers and query parameters sent with every MCP request. + + + + + + + )} - {oauth2 && ( + {oauth2 && oauthConnectionState && ( { - await setBinding({ + await setConnectionBinding({ params: { scopeId: oauthCredentialTargetScope }, - payload: McpSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: oauthCredentialTargetScope, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} reconnectingLabel="Reconnecting…" + reconnectLabel="Reconnect" + signInLabel={oauthConnectionState.signInLabel} signingInLabel="Signing in…" /> )} @@ -290,6 +307,7 @@ export default function EditMcpSource({ readonly onSave: () => void; }) { const scopeId = useScope(); + const userScope = useUserScope(); const sourceResult = useAtomValue(mcpSourceAtom(scopeId, sourceId)) as AsyncResult.AsyncResult< McpStoredSourceSchemaType | null, unknown @@ -297,7 +315,7 @@ export default function EditMcpSource({ const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : scopeId; - const bindingsResult = useAtomValue(mcpSourceBindingsAtom(scopeId, sourceId, sourceScope)); + const bindingsResult = useAtomValue(mcpSourceBindingsAtom(userScope, sourceId, sourceScope)); if (!AsyncResult.isSuccess(sourceResult) || !source || !AsyncResult.isSuccess(bindingsResult)) { return ( diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index d57ce641d..b966d3611 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -4,14 +4,13 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { ScopeId } from "@executor-js/sdk/shared"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { connectionsAtom } from "@executor-js/react/api/atoms"; +import { connectionsAtom, setSourceCredentialBinding } from "@executor-js/react/api/atoms"; import { SourceOAuthSignInButton } from "@executor-js/react/plugins/oauth-sign-in"; import { slugifyNamespace } from "@executor-js/react/plugins/source-identity"; import { secretBackedValuesFromConfiguredCredentialBindings } from "@executor-js/react/plugins/credential-bindings"; -import { mcpSourceAtom, mcpSourceBindingsAtom, setMcpSourceBinding } from "./atoms"; +import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; -import { McpSourceBindingInput } from "../sdk/types"; // --------------------------------------------------------------------------- // McpSignInButton — top-bar action on the source detail page. @@ -34,7 +33,7 @@ export default function McpSignInButton(props: { sourceId: string }) { mcpSourceBindingsAtom(userScopeId, props.sourceId, sourceScope), ); const connectionsResult = useAtomValue(connectionsAtom(userScopeId)); - const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promise" }); const remote = source && source.config.transport === "remote" ? source.config : null; const oauth2 = remote && remote.auth.kind === "oauth2" ? remote.auth : null; @@ -43,7 +42,7 @@ export default function McpSignInButton(props: { sourceId: string }) { : null; const bindings = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : null; const connectionBinding = bindings?.find( - (binding) => binding.slot === oauth2?.connectionSlot && binding.value.kind === "connection", + (binding) => binding.slotKey === oauth2?.connectionSlot && binding.value.kind === "connection", ); const connectionId = connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; @@ -76,13 +75,12 @@ export default function McpSignInButton(props: { sourceId: string }) { onConnected={async (nextConnectionId) => { await setBinding({ params: { scopeId: userScopeId }, - payload: McpSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: { scope: userScopeId, - slot: oauth2.connectionSlot, + source: { id: props.sourceId, scope: sourceScope }, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: nextConnectionId }, - }), + }, reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], }); }} diff --git a/packages/plugins/mcp/src/react/McpSourceSummary.tsx b/packages/plugins/mcp/src/react/McpSourceSummary.tsx index dc90c1c69..777cce401 100644 --- a/packages/plugins/mcp/src/react/McpSourceSummary.tsx +++ b/packages/plugins/mcp/src/react/McpSourceSummary.tsx @@ -3,6 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { connectionsAtom } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { Button } from "@executor-js/react/components/button"; import { SourceCredentialNotice, SourceCredentialStatusBadge, @@ -12,6 +13,7 @@ import { import { ScopeId } from "@executor-js/sdk/shared"; import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; +import McpSignInButton from "./McpSignInButton"; import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; const sourceCredentialSlots = ( @@ -62,9 +64,9 @@ export default function McpSourceSummary(props: { AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; const sourceScope = source ? ScopeId.make(source.scope) : displayScope; const bindingsResult = useAtomValue( - mcpSourceBindingsAtom(displayScope, props.sourceId, sourceScope), + mcpSourceBindingsAtom(userScope, props.sourceId, sourceScope), ); - const connectionsResult = useAtomValue(connectionsAtom(displayScope)); + const connectionsResult = useAtomValue(connectionsAtom(userScope)); if (!source) return null; const slots = sourceCredentialSlots(source as McpStoredSourceSchemaType); @@ -86,7 +88,23 @@ export default function McpSourceSummary(props: { }); if (props.variant === "panel") { - return ; + const needsOAuth = missing.includes("OAuth sign-in"); + const needsConfiguration = missing.some((label) => label !== "OAuth sign-in"); + return ( + + {needsOAuth && } + {needsConfiguration && props.onAction && ( + + )} + + } + /> + ); } return ; diff --git a/packages/plugins/mcp/src/react/atoms.ts b/packages/plugins/mcp/src/react/atoms.ts index da0b59730..8a61977a0 100644 --- a/packages/plugins/mcp/src/react/atoms.ts +++ b/packages/plugins/mcp/src/react/atoms.ts @@ -1,7 +1,7 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { McpClient } from "./client"; @@ -20,12 +20,7 @@ export const mcpSourceBindingsAtom = ( scopeId: ScopeId, namespace: string, sourceScopeId: ScopeId, -) => - McpClient.query("mcp", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); +) => sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId); // --------------------------------------------------------------------------- // Mutation atoms @@ -41,7 +36,7 @@ export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; const source = { id, - scopeId: arg.payload.targetScope, + scopeId, kind: "mcp", pluginId: "mcp", name: arg.payload.name ?? id, @@ -61,6 +56,3 @@ export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => ); export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); -export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); -export const setMcpSourceBinding = McpClient.mutation("mcp", "setSourceBinding"); -export const removeMcpSourceBinding = McpClient.mutation("mcp", "removeSourceBinding"); diff --git a/packages/plugins/mcp/src/sdk/binding-store.ts b/packages/plugins/mcp/src/sdk/binding-store.ts index 8b9d8a9f0..adc6c40e1 100644 --- a/packages/plugins/mcp/src/sdk/binding-store.ts +++ b/packages/plugins/mcp/src/sdk/binding-store.ts @@ -1,98 +1,22 @@ -// --------------------------------------------------------------------------- -// MCP plugin storage — four tables: -// - mcp_source: per-source structural data (transport, endpoint, -// stdio command/args/env, etc.) plus the auth flattened into -// columns so source-owned credential slots are queryable. The non-ref -// structural data still lives in `config` as JSON because it's -// plugin-private and varies by transport (`remote` vs `stdio` -// have different shapes). -// - mcp_source_header / mcp_source_query_param: child tables for -// remote sources' headers and query_params SecretBackedMap entries. -// - mcp_binding: per-tool McpToolBinding (toolId/toolName/description/ -// input+output schemas/annotations). Stays JSON: it carries no -// refs, and `inputSchema` / `outputSchema` are arbitrary -// user-supplied JSON Schemas — a legitimate JSON case. -// OAuth session storage lives at the core level in `oauth2_session` -// and is owned by `ctx.oauth`. -// --------------------------------------------------------------------------- - -import { Effect, Option, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { - ConfiguredCredentialBinding, - dateColumn, type FumaTables, - jsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; -import { - McpToolBinding, - McpStoredSourceData, - type McpConnectionAuth, - type ConfiguredMcpCredentialValue, -} from "./types"; - -// --------------------------------------------------------------------------- -// Schema -// --------------------------------------------------------------------------- - -export const mcpSchema = { - mcp_source: scopedExecutorTable("mcp_source", { - name: textColumn("name"), - // Plugin-private structural data minus the ref-bearing fields - // (auth, headers, queryParams). For remote sources: transport, - // endpoint, remoteTransport. For stdio: transport, command, - // args, env, cwd. - config: jsonColumn("config"), - // Flattened McpConnectionAuth. The stored source only names slots; - // concrete per-user/per-workspace values live in core credential_binding. - auth_kind: textColumn("auth_kind").defaultTo("none"), - auth_header_name: nullableTextColumn("auth_header_name"), - auth_header_slot: nullableTextColumn("auth_header_slot"), - auth_header_prefix: nullableTextColumn("auth_header_prefix"), - auth_connection_slot: nullableTextColumn("auth_connection_slot"), - auth_client_id_slot: nullableTextColumn("auth_client_id_slot"), - auth_client_secret_slot: nullableTextColumn("auth_client_secret_slot"), - created_at: dateColumn("created_at"), - }), - mcp_source_header: scopedExecutorTable("mcp_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - mcp_source_query_param: scopedExecutorTable("mcp_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - mcp_binding: scopedExecutorTable("mcp_binding", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - created_at: dateColumn("created_at"), - }), -} satisfies FumaTables; +import { McpStoredSourceData, McpToolBinding } from "./types"; +export const mcpSchema = {} satisfies FumaTables; export type McpSchema = typeof mcpSchema; -// --------------------------------------------------------------------------- -// Serialization helpers — JSON columns round-trip as either plain objects -// or serialized strings depending on the backend. -// --------------------------------------------------------------------------- +const SOURCE_COLLECTION = "source"; +const BINDING_COLLECTION = "binding"; const decodeSourceData = Schema.decodeUnknownSync(McpStoredSourceData); const encodeSourceData = Schema.encodeSync(McpStoredSourceData); - const decodeBinding = Schema.decodeUnknownSync(McpToolBinding); const encodeBinding = Schema.encodeSync(McpToolBinding); const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); @@ -102,145 +26,13 @@ const coerceJson = (value: unknown): unknown => { return Option.getOrElse(decodeJson(value), () => value); }; -// --- auth column packing/unpacking ------------------------------------------ - -interface AuthColumns { - readonly auth_kind: "none" | "header" | "oauth2"; - readonly auth_header_name?: string; - readonly auth_header_slot?: string; - readonly auth_header_prefix?: string; - readonly auth_connection_slot?: string; - readonly auth_client_id_slot?: string; - readonly auth_client_secret_slot?: string; -} - -const authToColumns = (auth: McpConnectionAuth): AuthColumns => { - if (auth.kind === "header") { - return { - auth_kind: "header", - auth_header_name: auth.headerName, - auth_header_slot: auth.secretSlot, - auth_header_prefix: auth.prefix, - }; - } - if (auth.kind === "oauth2") { - return { - auth_kind: "oauth2", - auth_connection_slot: auth.connectionSlot, - auth_client_id_slot: auth.clientIdSlot, - auth_client_secret_slot: auth.clientSecretSlot, - }; - } - return { auth_kind: "none" }; -}; - -const columnsToAuth = (row: Record): McpConnectionAuth => { - const kind = row.auth_kind; - if (kind === "header" && typeof row.auth_header_slot === "string") { - const prefix = row.auth_header_prefix as string | null | undefined; - return { - kind: "header", - headerName: (row.auth_header_name as string | null) ?? "", - secretSlot: row.auth_header_slot, - ...(prefix ? { prefix } : {}), - }; - } - if (kind === "oauth2" && typeof row.auth_connection_slot === "string") { - const cid = row.auth_client_id_slot as string | null | undefined; - const csec = row.auth_client_secret_slot as string | null | undefined; - return { - kind: "oauth2", - connectionSlot: row.auth_connection_slot, - ...(cid ? { clientIdSlot: cid } : {}), - ...(csec ? { clientSecretSlot: csec } : {}), - }; - } - return { kind: "none" }; -}; - -// --- ConfiguredCredentialValue map <-> child rows --------------------------- - -interface ConfiguredCredentialRow { - readonly id: string; - readonly scope_id: string; - readonly source_id: string; - readonly name: string; - readonly kind: "text" | "binding"; - readonly text_value?: string; - readonly slot_key?: string; - readonly prefix?: string; - readonly [k: string]: unknown; -} - -const valueMapToRows = ( - sourceId: string, - scope: string, - values: Record | undefined, -): readonly ConfiguredCredentialRow[] => { - if (!values) return []; - return Object.entries(values).map(([name, value]) => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - }; - } - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - slot_key: value.slot, - prefix: value.prefix, - }; - }); -}; - -const rowsToValueMap = ( - rows: readonly Record[], -): Record => { - const out: Record = {}; - for (const row of rows) { - if (typeof row.name !== "string") continue; - const name = row.name; - if (row.kind === "binding" && typeof row.slot_key === "string") { - const prefix = row.prefix as string | undefined | null; - out[name] = prefix - ? ConfiguredCredentialBinding.make({ kind: "binding", slot: row.slot_key, prefix }) - : ConfiguredCredentialBinding.make({ kind: "binding", slot: row.slot_key }); - } else if (row.kind === "text" && typeof row.text_value === "string") { - out[name] = row.text_value; - } - } - return out; -}; - -// --------------------------------------------------------------------------- -// Stored source (decoded) — what callers see -// --------------------------------------------------------------------------- - export interface McpStoredSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly config: McpStoredSourceData; } -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- - -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId, sessionId) AND the owning `scope_id`. Scope is a -// normal FumaDB predicate here, not hidden behavior. export interface McpBindingStore { readonly listBindingsBySource: ( namespace: string, @@ -252,7 +44,6 @@ export interface McpBindingStore { }>, StorageFailure >; - readonly getBinding: ( toolId: string, scope: string, @@ -260,7 +51,6 @@ export interface McpBindingStore { { readonly binding: McpToolBinding; readonly namespace: string } | null, StorageFailure >; - readonly putBindings: ( namespace: string, scope: string, @@ -269,12 +59,10 @@ export interface McpBindingStore { readonly binding: McpToolBinding; }>, ) => Effect.Effect; - readonly removeBindingsByNamespace: ( namespace: string, scope: string, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, @@ -287,228 +75,154 @@ export interface McpBindingStore { readonly removeSource: (namespace: string, scope: string) => Effect.Effect; } -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- +const sourceData = (source: McpStoredSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + config: encodeSourceData(source.config), +}); + +const bindingData = ( + namespace: string, + entry: { + readonly toolId: string; + readonly binding: McpToolBinding; + }, +) => ({ + namespace, + toolId: entry.toolId, + binding: encodeBinding(entry.binding), +}); + +const rowToSource = (row: PluginStorageEntry): McpStoredSource | null => { + const raw = coerceJson(row.data); + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if ( + typeof record.namespace !== "string" || + typeof record.scope !== "string" || + typeof record.name !== "string" + ) { + return null; + } + return { + namespace: record.namespace, + scope: record.scope, + name: record.name, + config: decodeSourceData(coerceJson(record.config)), + }; +}; -export const makeMcpStore = ({ fuma }: StorageDeps): McpBindingStore => { +const rowToBinding = ( + row: PluginStorageEntry, +): { + readonly toolId: string; + readonly namespace: string; + readonly binding: McpToolBinding; +} | null => { + const raw = coerceJson(row.data); + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if (typeof record.toolId !== "string" || typeof record.namespace !== "string") return null; return { - listBindingsBySource: (namespace, scope) => - Effect.gen(function* () { - const rows = yield* fuma.use("mcp_binding.findManyBySourceScope", (db) => - db.findMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), + toolId: record.toolId, + namespace: record.namespace, + binding: decodeBinding(coerceJson(record.binding)), + }; +}; + +export const makeMcpStore = ({ pluginStorage }: StorageDeps): McpBindingStore => { + const listBindingRowsForSourceScope = (namespace: string, scope: string) => + pluginStorage + .list({ + collection: BINDING_COLLECTION, + keyPrefix: `${namespace}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter((row) => { + if (String(row.scopeId) !== scope) return false; + return rowToBinding(row)?.namespace === namespace; }), - ); - return rows.map((row) => ({ - toolId: String(row.id), - binding: decodeBinding(coerceJson(row.binding)), - })); - }), + ), + ); + + const removeBindingsForSourceScope = (namespace: string, scope: string) => + Effect.gen(function* () { + const rows = yield* listBindingRowsForSourceScope(namespace, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: BINDING_COLLECTION, + key: row.key, + }); + } + }); + + return { + listBindingsBySource: (namespace, scope) => + listBindingRowsForSourceScope(namespace, scope).pipe( + Effect.map((rows) => + rows + .map(rowToBinding) + .filter(Predicate.isNotNull) + .map((row) => ({ toolId: row.toolId, binding: row.binding })), + ), + ), getBinding: (toolId, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_binding.findFirstByScopedId", (db) => - db.findFirst("mcp_binding", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - const binding = decodeBinding(coerceJson(row.binding)); - return { binding, namespace: String(row.source_id) }; - }), + pluginStorage.getAtScope({ scope, collection: BINDING_COLLECTION, key: toolId }).pipe( + Effect.map((row) => { + const binding = row ? rowToBinding(row) : null; + return binding ? { binding: binding.binding, namespace: binding.namespace } : null; + }), + ), putBindings: (namespace, scope, entries) => Effect.gen(function* () { - if (entries.length === 0) return; - const now = new Date(); - yield* fuma - .use("mcp_binding.createMany", (db) => - db.createMany( - "mcp_binding", - entries.map((e) => ({ - id: e.toolId, - scope_id: scope, - source_id: namespace, - binding: encodeBinding(e.binding), - created_at: now, - })), - ), - ) - .pipe(Effect.asVoid); + for (const entry of entries) { + yield* pluginStorage.put({ + scope, + collection: BINDING_COLLECTION, + key: entry.toolId, + data: bindingData(namespace, entry), + }); + } }), - removeBindingsByNamespace: (namespace, scope) => - fuma - .use("mcp_binding.deleteManyBySourceScope", (db) => - db.deleteMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.asVoid), + removeBindingsByNamespace: (namespace, scope) => removeBindingsForSourceScope(namespace, scope), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_source.findFirstByScopedId", (db) => - db.findFirst("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return { - namespace: String(row.id), - scope: String(row.scope_id), - name: String(row.name), - config: yield* hydrateSourceData(row, namespace, scope), - }; - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), getSourceConfig: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("mcp_source.findFirstByScopedId", (db) => - db.findFirst("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* hydrateSourceData(row, namespace, scope); - }), + pluginStorage.getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }).pipe( + Effect.map((row) => { + const source = row ? rowToSource(row) : null; + return source?.config ?? null; + }), + ), putSource: (source) => - Effect.gen(function* () { - const now = new Date(); - // Drop the source row and its child rows; recreate. Two-step - // matches the existing put-overwrites-existing semantic. - yield* fuma.use("mcp_source.deleteManyByScopedId", (db) => - db.deleteMany("mcp_source", { - where: (b) => b.and(b("id", "=", source.namespace), b("scope_id", "=", source.scope)), - }), - ); - yield* deleteSourceChildren(source.namespace, source.scope); - - const auth: McpConnectionAuth = - source.config.transport === "remote" ? source.config.auth : { kind: "none" }; - const authCols = authToColumns(auth); - const headers = source.config.transport === "remote" ? source.config.headers : undefined; - const queryParams = - source.config.transport === "remote" ? source.config.queryParams : undefined; - - // The encoded config keeps every plugin-private field but - // strips auth/headers/queryParams — those moved to columns/ - // child tables. We round-trip through encodeSourceData so the - // remaining fields stay in the same JSON shape decode expects. - const encodedConfig = stripExtractedFields( - encodeSourceData(source.config) as Record, - ); - - yield* fuma.use("mcp_source.create", (db) => - db.create("mcp_source", { - id: source.namespace, - scope_id: source.scope, - name: source.name, - config: encodedConfig, - created_at: now, - ...authCols, - }), - ); - - const headerRows = valueMapToRows(source.namespace, source.scope, headers); - if (headerRows.length > 0) { - yield* fuma - .use("mcp_source_header.createMany", (db) => - db.createMany("mcp_source_header", [...headerRows]), - ) - .pipe(Effect.asVoid); - } - const paramRows = valueMapToRows(source.namespace, source.scope, queryParams); - if (paramRows.length > 0) { - yield* fuma - .use("mcp_source_query_param.createMany", (db) => - db.createMany("mcp_source_query_param", [...paramRows]), - ) - .pipe(Effect.asVoid); - } - }), + pluginStorage + .put({ + scope: source.scope, + collection: SOURCE_COLLECTION, + key: source.namespace, + data: sourceData(source), + }) + .pipe(Effect.asVoid), removeSource: (namespace, scope) => Effect.gen(function* () { - yield* fuma.use("mcp_binding.deleteManyBySourceScope", (db) => - db.deleteMany("mcp_binding", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - yield* deleteSourceChildren(namespace, scope); - yield* fuma.use("mcp_source.deleteManyByScopedId", (db) => - db.deleteMany("mcp_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeBindingsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }), }; - - // --------------------------------------------------------------------- - // Private helpers — depend on `fuma` so they live inside the closure. - // --------------------------------------------------------------------- - - function deleteSourceChildren(namespace: string, scope: string) { - return Effect.gen(function* () { - for (const model of ["mcp_source_header", "mcp_source_query_param"] as const) { - yield* fuma.use(`${model}.deleteManyBySourceScope`, (db) => - db.deleteMany(model, { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - } - }); - } - - function hydrateSourceData( - row: Record, - namespace: string, - scope: string, - ): Effect.Effect { - return Effect.gen(function* () { - // The stored JSON has auth/headers/queryParams stripped (those - // moved to columns / child tables). We must rehydrate the full - // shape BEFORE handing it to the schema decoder, because - // `McpRemoteSourceData.auth` is required. - const partial = coerceJson(row.config) as Record; - if (partial.transport !== "remote") { - // stdio sources have no extracted fields — decode as-is. - return decodeSourceData(partial); - } - const headerRows = yield* fuma.use("mcp_source_header.findManyBySourceScope", (db) => - db.findMany("mcp_source_header", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - const paramRows = yield* fuma.use("mcp_source_query_param.findManyBySourceScope", (db) => - db.findMany("mcp_source_query_param", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - const headers = rowsToValueMap(headerRows); - const queryParams = rowsToValueMap(paramRows); - const reassembled = { - ...partial, - ...(Object.keys(headers).length > 0 ? { headers } : {}), - ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), - auth: columnsToAuth(row), - }; - return decodeSourceData(reassembled); - }); - } -}; - -// Strip auth/headers/queryParams from the encoded source-data shape. -// Keeps the remaining structural fields (transport, endpoint, etc.) in -// the JSON config column. Per-transport: only the remote variant has -// these fields, so this is a no-op for stdio. -const stripExtractedFields = (encoded: Record): Record => { - if (encoded.transport !== "remote") return encoded; - const { auth, headers, queryParams, ...rest } = encoded; - void auth; - void headers; - void queryParams; - return rest; }; diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index fae9d4b51..e397f095d 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -6,7 +6,7 @@ export { type McpRemoteSourceConfig, type McpStdioSourceConfig, type McpProbeResult, - type McpUpdateSourceInput, + type McpConfigureSourceInput, } from "./plugin"; export { @@ -26,9 +26,6 @@ export { McpConnectionAuth, McpConnectionAuthInput, McpCredentialInput, - McpSourceBindingInput, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, - type McpSourceBindingValue, } from "./types"; diff --git a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts index fc75f6154..fc2f4c936 100644 --- a/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts +++ b/packages/plugins/mcp/src/sdk/per-user-auth-isolation.test.ts @@ -16,11 +16,27 @@ import { import { makeTestConfig, memorySecretsPlugin } from "@executor-js/sdk/testing"; import { mcpPlugin } from "./plugin"; +import { + MCP_OAUTH_CLIENT_ID_SLOT, + MCP_OAUTH_CLIENT_SECRET_SLOT, + MCP_OAUTH_CONNECTION_SLOT, +} from "./types"; import { makeEchoMcpServer, serveMcpServer } from "../testing"; const USER_A = ScopeId.make("user-a"); const USER_B = ScopeId.make("user-b"); const ORG = ScopeId.make("org"); +const mcpOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT, + clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT, + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, + scopes: [], +}; const scope = (id: ScopeId, name: string): Scope => Scope.make({ id, name, createdAt: new Date() }); @@ -97,11 +113,22 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: USER_A, name: "Shared MCP", endpoint: server.url, namespace: "iso_test", - auth: { kind: "oauth2", connectionId: sharedConnId }, + oauth2: mcpOAuth2Config, + }); + yield* execUserA.sources.configure({ + source: { id: "iso_test", scope: ORG }, + scope: USER_A, + type: "mcp", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId: sharedConnId }, + }, + }, + }, }); const userATools = yield* execUserA.tools.list(); @@ -167,15 +194,21 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: USER_A, name: "Shared MCP (header)", endpoint: server.url, namespace: "iso_header", - auth: { - kind: "header", - headerName: "Authorization", - secretId: secret, - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* execUserA.sources.configure({ + source: { id: "iso_header", scope: ORG }, + scope: USER_A, + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: secret, prefix: "Bearer " }, + }, }, }); @@ -238,15 +271,21 @@ describe("per-user MCP auth isolation", () => { yield* execUserA.mcp.addSource({ transport: "remote", scope: ORG, - credentialTargetScope: ORG, name: "Shared MCP org header", endpoint: server.url, namespace: "org_header", - auth: { - kind: "header", - headerName: "Authorization", - secretId, - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + }); + yield* execUserA.sources.configure({ + source: { id: "org_header", scope: ORG }, + scope: ORG, + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId, prefix: "Bearer " }, + }, }, }); diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 88ba85c50..03720c674 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -16,9 +16,25 @@ import { import { makeTestConfig } from "@executor-js/sdk/testing"; import { mcpPlugin, userFacingProbeMessage } from "./plugin"; -import { MCP_OAUTH_CONNECTION_SLOT } from "./types"; +import { + MCP_OAUTH_CLIENT_ID_SLOT, + MCP_OAUTH_CLIENT_SECRET_SLOT, + MCP_OAUTH_CONNECTION_SLOT, +} from "./types"; import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; -import { makeAnnotationsMcpServer, serveMcpServer } from "../testing"; +import { makeAnnotationsMcpServer, makeGreetingMcpServer, serveMcpServer } from "../testing"; + +const mcpOAuth2Config = { + kind: "oauth2" as const, + securitySchemeName: "OAuth2", + flow: "authorizationCode" as const, + tokenUrl: "https://auth.example.test/token", + authorizationUrl: "https://auth.example.test/authorize", + clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT, + clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT, + connectionSlot: MCP_OAUTH_CONNECTION_SLOT, + scopes: [], +}; // --------------------------------------------------------------------------- // Memory secrets plugin — without a writable provider in the stack, @@ -255,6 +271,106 @@ describe("mcpPlugin", () => { }), ); + it.effect("uses initial credential bindings for add-time tool discovery", () => + Effect.gen(function* () { + const server = yield* serveMcpServer(makeGreetingMcpServer, { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer secret-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }), + ); + yield* executor.secrets.set({ + id: SecretId.make("mcp-token"), + scope: ScopeId.make("test-scope"), + name: "MCP token", + value: "secret-token", + provider: "memory", + }); + + const result = yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "authenticated", + endpoint: server.endpoint, + namespace: "authenticated_mcp", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, + }, + credentials: { + scope: "test-scope", + headers: { + Authorization: { kind: "secret", secretId: "mcp-token" }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 1, namespace: "authenticated_mcp" }); + const requests = yield* server.requests; + expect(requests.some((request) => request.authorization === "Bearer secret-token")).toBe( + true, + ); + }), + ); + + it.effect("marks source oauth-backed when add-time credentials include oauth", () => + Effect.gen(function* () { + const server = yield* serveMcpServer(makeGreetingMcpServer, { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === "Bearer oauth-token"), + }, + }); + const executor = yield* createExecutor( + makeTestConfig({ plugins: [makeMemorySecretsPlugin()(), mcpPlugin()] as const }), + ); + const connectionId = ConnectionId.make("mcp-oauth2-initial"); + yield* executor.connections.create( + CreateConnectionInput.make({ + id: connectionId, + scope: ScopeId.make("test-scope"), + provider: OAUTH2_PROVIDER_KEY, + identityLabel: "Initial MCP OAuth", + accessToken: TokenMaterial.make({ + secretId: SecretId.make(`${connectionId}.access_token`), + name: "Initial MCP OAuth Access Token", + value: "oauth-token", + }), + refreshToken: null, + expiresAt: null, + oauthScope: null, + providerState: null, + }), + ); + + const result = yield* executor.mcp.addSource({ + transport: "remote", + scope: "test-scope", + name: "initial oauth", + endpoint: server.endpoint, + namespace: "initial_oauth_mcp", + credentials: { + scope: "test-scope", + auth: { + oauth2: { + connection: { kind: "connection", connectionId }, + }, + }, + }, + }); + + expect(result).toEqual({ toolCount: 1, namespace: "initial_oauth_mcp" }); + const stored = yield* executor.mcp.getSource("initial_oauth_mcp", "test-scope"); + expect(stored?.config.transport).toBe("remote"); + if (stored?.config.transport !== "remote") return; + expect(stored.config.auth.kind).toBe("oauth2"); + if (stored.config.auth.kind !== "oauth2") return; + expect(stored.config.auth.connectionSlot).toBe(MCP_OAUTH_CONNECTION_SLOT); + }), + ); + // ------------------------------------------------------------------------- // Multi-scope shadowing — regression suite covering the bug class where // store reads/writes that don't pin scope_id collapse onto whichever visible @@ -371,7 +487,7 @@ describe("mcpPlugin", () => { }), ); - it.effect("updateSource on user shadow does not mutate the org row", () => + it.effect("sources.configure on user shadow does not mutate the org row", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -391,9 +507,14 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/user-mcp", }); - yield* executor.mcp.updateSource("shared", USER_SCOPE, { - name: "User Renamed", - endpoint: "http://127.0.0.1:1/user-new-mcp", + yield* executor.sources.configure({ + source: { id: "shared", scope: ScopeId.make(USER_SCOPE) }, + scope: ScopeId.make(USER_SCOPE), + type: "mcp", + config: { + name: "User Renamed", + endpoint: "http://127.0.0.1:1/user-new-mcp", + }, }); const userView = yield* executor.mcp.getSource("shared", USER_SCOPE); @@ -410,7 +531,7 @@ describe("mcpPlugin", () => { }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const executor = yield* createExecutor( makeTestConfig({ @@ -433,33 +554,44 @@ describe("mcpPlugin", () => { name: "stale binding", endpoint: "http://127.0.0.1:1/mcp", namespace: "stale_binding", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "Authorization", - secretId: "old-token", - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "old-token", prefix: "Bearer " }, + }, + }, + }); - yield* executor.mcp.updateSource("stale_binding", "test-scope", { - auth: { kind: "none" }, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { headers: {} }, }); - const bindings = yield* executor.mcp.listSourceBindings("stale_binding", "test-scope"); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make("test-scope") }, + }); expect(bindings).toEqual([]); }), ); // ------------------------------------------------------------------------- - // updateSource must persist auth changes to the config file too — + // sources.configure must persist auth changes to the config file too — // otherwise the next boot replays the file's stale auth and silently // overwrites the DB. Symmetric with addSource/removeSource which // already write through. // ------------------------------------------------------------------------- - it.effect("updateSource writes auth changes back to the config file", () => + it.effect("sources.configure writes auth changes back to the config file", () => Effect.gen(function* () { const calls: Array<{ op: "upsert" | "remove"; payload: unknown }> = []; const stubSink = { @@ -496,25 +628,32 @@ describe("mcpPlugin", () => { name: "Sentry", endpoint: "http://127.0.0.1:1/sentry-mcp", namespace: "sentry", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "Authorization", - secretId: "sentry-token-old", - prefix: "Bearer ", + headers: { + Authorization: { kind: "secret", prefix: "Bearer " }, }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "sentry", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "sentry-token-old", prefix: "Bearer " }, + }, + }, + }); calls.length = 0; // ignore the addSource upsert; we're asserting on update - yield* executor.mcp.updateSource("sentry", "test-scope", { - credentialTargetScope: ScopeId.make("test-scope"), - auth: { - kind: "header", - headerName: "Authorization", - secretId: "sentry-token-new", - prefix: "Bearer ", + yield* executor.sources.configure({ + source: { id: "sentry", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + Authorization: { kind: "secret", secretId: "sentry-token-new", prefix: "Bearer " }, + }, }, }); @@ -524,11 +663,11 @@ describe("mcpPlugin", () => { kind: "mcp", transport: "remote", namespace: "sentry", - auth: { - kind: "header", - headerName: "Authorization", - secret: "secret-public-ref:sentry-token-new", - prefix: "Bearer ", + headers: { + Authorization: { + value: "secret-public-ref:sentry-token-new", + prefix: "Bearer ", + }, }, }); }), @@ -563,10 +702,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/deferred-mcp", remoteTransport: "auto", namespace: "deferred_oauth", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -608,10 +744,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/needs-auth-mcp", remoteTransport: "auto", namespace: "needs_auth", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -664,10 +797,7 @@ describe("mcpPlugin", () => { endpoint: "http://127.0.0.1:1/team-mcp", remoteTransport: "auto", namespace: "team_mcp", - auth: { - kind: "oauth2", - connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); @@ -705,11 +835,10 @@ describe("mcpPlugin", () => { }, }), ); - yield* executor.mcp.setSourceBinding({ - sourceId: "team_mcp", - sourceScope: ORG_SCOPE_ID, + yield* executor.sources.setBinding({ + source: { id: "team_mcp", scope: ORG_SCOPE_ID }, scope: USER_SCOPE_ID, - slot: MCP_OAUTH_CONNECTION_SLOT, + slotKey: MCP_OAUTH_CONNECTION_SLOT, value: { kind: "connection", connectionId }, }); @@ -770,21 +899,30 @@ describe("mcpPlugin", () => { name: "header-auth", endpoint: "http://127.0.0.1:1/mcp", namespace: "header_auth_source", - credentialTargetScope: "test-scope", - auth: { - kind: "header", - headerName: "X-API-Key", - secretId: "shared-key", + headers: { + "X-API-Key": { kind: "secret" }, + "X-Trace": { kind: "secret" }, }, - headers: { "X-Trace": { secretId: "shared-key" } }, - queryParams: { ping: { secretId: "other-secret" } }, + queryParams: { ping: { kind: "secret" } }, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "header_auth_source", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + headers: { + "X-API-Key": { kind: "secret", secretId: "shared-key" }, + "X-Trace": { kind: "secret", secretId: "shared-key" }, + }, + queryParams: { ping: { kind: "secret", secretId: "other-secret" } }, + }, + }); const usages = yield* executor.secrets.usages(SecretId.make("shared-key")); expect(usages.length).toBe(2); const slots = usages.map((u) => u.slot).sort(); - expect(slots).toEqual(["auth:header", "header:x-trace"]); + expect(slots).toEqual(["header:x-api-key", "header:x-trace"]); expect(usages.every((u) => u.pluginId === "mcp")).toBe(true); expect(usages.every((u) => u.ownerKind === "credential-binding")).toBe(true); @@ -832,10 +970,21 @@ describe("mcpPlugin", () => { name: "oauth-source", endpoint: "http://127.0.0.1:1/mcp", namespace: "oauth_ref", - credentialTargetScope: "test-scope", - auth: { kind: "oauth2", connectionId: "conn-xyz" }, + oauth2: mcpOAuth2Config, }) .pipe(Effect.result); + yield* executor.sources.configure({ + source: { id: "oauth_ref", scope: ScopeId.make("test-scope") }, + scope: ScopeId.make("test-scope"), + type: "mcp", + config: { + auth: { + oauth2: { + connection: { kind: "connection", connectionId: "conn-xyz" }, + }, + }, + }, + }); const usages = yield* executor.connections.usages(ConnectionId.make("conn-xyz")); expect(usages.length).toBe(1); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index ee13f962e..e437f30b6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -16,12 +16,9 @@ import type { HttpClient } from "effect/unstable/http"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { - ConfiguredCredentialBinding, - ConnectionId, type CredentialBindingRef, type CredentialBindingValue, ScopeId, - SecretId, SourceDetectionResult, ToolResult, definePlugin, @@ -31,6 +28,12 @@ import { StorageError, type ToolAnnotations, } from "@executor-js/sdk/core"; +import { + compileHttpNamedCredentialMap, + OAuth2SourceConfig, + httpCredentialInputToBindingValue, + type HttpConfiguredValueInput, +} from "@executor-js/plugin-http-source/sdk"; import { makeMcpStore, @@ -45,31 +48,29 @@ import { invokeMcpTool } from "./invoke"; import { deriveMcpNamespace, type McpToolManifest, type McpToolManifestEntry } from "./manifest"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; import { - MCP_HEADER_AUTH_SLOT, MCP_OAUTH_CLIENT_ID_SLOT, MCP_OAUTH_CLIENT_SECRET_SLOT, MCP_OAUTH_CONNECTION_SLOT, + McpConnectionAuthInput, + McpCredentialInput, McpToolBinding, - McpSourceBindingInput, - McpSourceBindingRef, mcpHeaderSlot, mcpQueryParamSlot, type McpConnectionAuth, - type McpConnectionAuthInput, - type McpCredentialInput, - type McpSourceBindingValue, + type McpConfiguredValueInput as McpConfiguredValueInputType, type SecretBackedValue, type McpStoredSourceData, type ConfiguredMcpCredentialValue, } from "./types"; import { - SECRET_REF_PREFIX, type ConfigFileSink, + type ConfigHeaderValue, type McpAuthConfig, type McpRemoteSourceConfig as McpRemoteConfigEntry, type McpStdioSourceConfig as McpStdioConfigEntry, type SourceConfig, + headerToConfigValue, } from "@executor-js/config"; // --------------------------------------------------------------------------- @@ -89,15 +90,11 @@ export interface McpRemoteSourceConfig extends McpSourceScopeField { readonly name: string; readonly endpoint: string; readonly remoteTransport?: "streamable-http" | "sse" | "auto"; - readonly queryParams?: Record; - readonly headers?: Record; + readonly queryParams?: Record; + readonly headers?: Record; readonly namespace?: string; - readonly auth?: McpConnectionAuthInput; - /** - * Scope that owns any direct credentials supplied on this call. Required - * whenever headers/queryParams/auth carry direct secret or connection ids. - */ - readonly credentialTargetScope?: string; + readonly oauth2?: OAuth2SourceConfig; + readonly credentials?: McpInitialCredentialsInput; } export interface McpStdioSourceConfig extends McpSourceScopeField { @@ -111,6 +108,15 @@ export interface McpStdioSourceConfig extends McpSourceScopeField { } export type McpSourceConfig = McpRemoteSourceConfig | McpStdioSourceConfig; +type McpConfigFileRemoteSourceConfig = Omit< + McpRemoteSourceConfig, + "headers" | "queryParams" | "oauth2" +> & { + readonly headers?: Record; + readonly queryParams?: Record; + readonly auth?: McpConnectionAuthInput; +}; +type McpConfigFileSourceConfig = McpConfigFileRemoteSourceConfig | McpStdioSourceConfig; // --------------------------------------------------------------------------- // Extension types @@ -130,14 +136,26 @@ export interface McpProbeResult { readonly serverName: string | null; } -export interface McpUpdateSourceInput { - readonly name?: string; - readonly endpoint?: string; - readonly headers?: Record; - readonly queryParams?: Record; - readonly credentialTargetScope?: string; - readonly auth?: McpConnectionAuthInput; -} +const McpConfigureSourcePayloadSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + endpoint: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), +}); +const McpConfigureSourceInputSchema = Schema.Struct({ + scope: Schema.String, + ...McpConfigureSourcePayloadSchema.fields, +}); +export type McpConfigureSourceInput = typeof McpConfigureSourceInputSchema.Type; + +const McpInitialCredentialsInputSchema = Schema.Struct({ + scope: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + queryParams: Schema.optional(Schema.Record(Schema.String, McpCredentialInput)), + auth: Schema.optional(McpConnectionAuthInput), +}); +type McpInitialCredentialsInput = typeof McpInitialCredentialsInputSchema.Type; export interface McpProbeEndpointInput { readonly endpoint: string; @@ -255,42 +273,12 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToMcpBinding = (binding: CredentialBindingRef): McpSourceBindingRef => - McpSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listMcpSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: MCP_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToMcpBinding); - }); - const resolveMcpSourceBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const sourceSourceRank = scopeRank(ranks, sourceScope); @@ -306,7 +294,7 @@ const resolveMcpSourceBinding = ( candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToMcpBinding(binding) : null; + return binding ?? null; }); const validateMcpBindingTarget = ( @@ -349,138 +337,85 @@ const validateMcpBindingTarget = ( } }); -const bindingTargetScope = ( - targetScope: string | undefined, - bindings: readonly unknown[], -): Effect.Effect => { - if (bindings.length === 0) return Effect.succeed(undefined); - if (targetScope) return Effect.succeed(targetScope); - return Effect.fail( - new McpConnectionError({ - transport: "remote", - message: "credentialTargetScope is required when adding direct MCP credentials", - }), - ); -}; +const canonicalizeCredentialMap = compileHttpNamedCredentialMap; -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 McpConnectionError({ - transport: "remote", - message: "credentialTargetScope is required when adding direct MCP credentials", - }), - ); -}; - -const canonicalizeCredentialMap = ( - values: Record | undefined, +const canonicalizeConfiguredValueMap = ( + values: Record | undefined, slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: McpSourceBindingValue; - readonly targetScope?: string; - }>; -} => { - const nextValues: Record = {}; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = []; +): Record => { + const next: Record = {}; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { - nextValues[name] = value; + next[name] = value; continue; } - if ("kind" in value) { - nextValues[name] = value; - continue; - } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ + next[name] = { kind: "binding", - slot, + slot: slotForName(name), prefix: value.prefix, - }); - 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 } - : {}), - }, - }); + }; } - return { values: nextValues, bindings }; + return next; }; +const resolveConfiguredValueMap = ( + values: Record | undefined, +): Record | undefined => { + if (!values) return undefined; + const resolved: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") resolved[name] = value; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +}; + +const authFromOAuth2Source = (oauth2: OAuth2SourceConfig | undefined): McpConnectionAuth => + oauth2 + ? { + kind: "oauth2", + connectionSlot: oauth2.connectionSlot, + clientIdSlot: oauth2.clientIdSlot, + ...(oauth2.clientSecretSlot ? { clientSecretSlot: oauth2.clientSecretSlot } : {}), + } + : { kind: "none" }; + const canonicalizeAuth = ( auth: McpConnectionAuthInput | undefined, ): { readonly auth: McpConnectionAuth; readonly bindings: ReadonlyArray<{ readonly slot: string; - readonly value: McpSourceBindingValue; + readonly value: CredentialBindingValue; readonly targetScope?: string; }>; } => { - if (!auth || auth.kind === "none") return { auth: { kind: "none" }, bindings: [] }; - if (auth.kind === "header") { - if ("secretSlot" in auth) return { auth, bindings: [] }; - return { - auth: { - kind: "header", - headerName: auth.headerName, - secretSlot: MCP_HEADER_AUTH_SLOT, - prefix: auth.prefix, - }, - bindings: [ - { - slot: MCP_HEADER_AUTH_SLOT, - targetScope: auth.targetScope, - value: { - kind: "secret", - secretId: SecretId.make(auth.secretId), - ...(auth.secretScopeId ? { secretScopeId: auth.secretScopeId } : {}), - }, - }, - ], - }; - } - if ("connectionSlot" in auth) return { auth, bindings: [] }; - const bindings: Array<{ slot: string; value: McpSourceBindingValue; targetScope?: string }> = [ - { + if (!auth || "kind" in auth || !auth.oauth2) return { auth: { kind: "none" }, bindings: [] }; + const oauth = auth.oauth2; + const bindings: Array<{ slot: string; value: CredentialBindingValue; targetScope?: string }> = []; + if (oauth.connection) { + bindings.push({ slot: MCP_OAUTH_CONNECTION_SLOT, - value: { - kind: "connection", - connectionId: ConnectionId.make(auth.connectionId), - }, - }, - ]; - if (auth.clientIdSecretId) { + value: httpCredentialInputToBindingValue(oauth.connection), + }); + } + if (oauth.clientId) { bindings.push({ slot: MCP_OAUTH_CLIENT_ID_SLOT, - value: { kind: "secret", secretId: SecretId.make(auth.clientIdSecretId) }, + value: httpCredentialInputToBindingValue(oauth.clientId), }); } - if (auth.clientSecretSecretId) { + if (oauth.clientSecret) { bindings.push({ slot: MCP_OAUTH_CLIENT_SECRET_SLOT, - value: { kind: "secret", secretId: SecretId.make(auth.clientSecretSecretId) }, + value: httpCredentialInputToBindingValue(oauth.clientSecret), }); } return { auth: { kind: "oauth2", connectionSlot: MCP_OAUTH_CONNECTION_SLOT, - ...(auth.clientIdSecretId ? { clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT } : {}), - ...(auth.clientSecretSecretId ? { clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT } : {}), + ...(oauth.clientId ? { clientIdSlot: MCP_OAUTH_CLIENT_ID_SLOT } : {}), + ...(oauth.clientSecret ? { clientSecretSlot: MCP_OAUTH_CLIENT_SECRET_SLOT } : {}), }, bindings, }; @@ -555,14 +490,25 @@ const resolveSecretBackedMap = ( ), ); -const plainStringMap = ( - values: Record | undefined, -): Record | undefined => { +const credentialInputMapToConfigValues = ( + values: Record | undefined, +): Record | undefined => { if (!values) return undefined; - const entries = Object.entries(values).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; + const out: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + out[name] = value; + continue; + } + if (value.kind === "secret" && "secretId" in value) { + out[name] = headerToConfigValue({ secretId: value.secretId, prefix: value.prefix }); + continue; + } + if (value.kind === "text") { + out[name] = value.prefix ? `${value.prefix}${value.text}` : value.text; + } + } + return Object.keys(out).length > 0 ? out : undefined; }; const resolveMcpBindingValueMap = ( @@ -621,60 +567,75 @@ const resolveMcpBindingValueMap = ( return Object.keys(resolved).length > 0 ? resolved : undefined; }); -const resolveMcpCredentialInputMap = ( +const resolveInitialMcpCredentialValueMap = ( ctx: PluginCtx, - values: Record | undefined, - params: { - readonly sourceId: string; - readonly sourceScope: string; - readonly targetScope?: string; - readonly missingLabel: string; - }, + values: Record, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, + missingLabel: string, ): Effect.Effect | undefined, McpConnectionError | StorageFailure> => Effect.gen(function* () { - if (!values) return undefined; + const bySlot = new Map(bindings.map((binding) => [binding.slot, binding.value] as const)); const resolved: Record = {}; for (const [name, value] of Object.entries(values)) { if (typeof value === "string") { resolved[name] = value; continue; } - if ("kind" in value) { - const slotResolved = yield* resolveMcpBindingValueMap( - ctx, - { [name]: value }, - { - sourceId: params.sourceId, - sourceScope: params.sourceScope, - missingLabel: params.missingLabel, - }, - ); - if (slotResolved?.[name] !== undefined) resolved[name] = slotResolved[name]; + const binding = bySlot.get(value.slot); + if (binding?.kind === "secret") { + const secret = yield* ctx.secrets + .getAtScope(binding.secretId, binding.secretScopeId ?? ScopeId.make(targetScope)) + .pipe( + Effect.catchTag("SecretOwnedByConnectionError", () => + Effect.fail( + new McpConnectionError({ + transport: "remote", + message: `Failed to resolve secret for ${missingLabel} "${name}"`, + }), + ), + ), + ); + if (secret === null) { + return yield* new McpConnectionError({ + transport: "remote", + message: `Missing secret "${binding.secretId}" for ${missingLabel} "${name}"`, + }); + } + resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; continue; } - const secretScope = - "secretScopeId" in value - ? (value.secretScopeId ?? value.targetScope) - : (params.targetScope ?? params.sourceScope); - const secret = yield* ctx.secrets.getAtScope(SecretId.make(value.secretId), secretScope).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( + if (binding?.kind === "text") { + resolved[name] = value.prefix ? `${value.prefix}${binding.text}` : binding.text; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; + }); + +const resolveInitialMcpOauthProvider = ( + ctx: PluginCtx, + bindings: ReadonlyArray<{ readonly slot: string; readonly value: CredentialBindingValue }>, + targetScope: string, +): Effect.Effect => + Effect.gen(function* () { + const connection = bindings.find( + (binding) => + binding.slot === MCP_OAUTH_CONNECTION_SLOT && binding.value.kind === "connection", + ); + if (!connection || connection.value.kind !== "connection") return undefined; + const connectionId = connection.value.connectionId; + const accessToken = yield* ctx.connections + .accessTokenAtScope(connectionId, ScopeId.make(targetScope)) + .pipe( + Effect.mapError( + ({ message }) => new McpConnectionError({ transport: "remote", - message: `Failed to resolve secret for ${params.missingLabel} "${name}"`, + message: `Failed to resolve OAuth connection "${connectionId}": ${message}`, }), - ), ), ); - if (secret === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Missing secret "${value.secretId}" for ${params.missingLabel} "${name}"`, - }); - } - resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; + return makeOAuthProvider(accessToken); }); const resolveMcpHeaderAuth = ( @@ -746,78 +707,6 @@ const resolveMcpStoredOauthProvider = ( return makeOAuthProvider(accessToken); }); -const resolveMcpInputAuth = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, - targetScope: string | undefined, - auth: McpConnectionAuthInput | undefined, -): Effect.Effect< - { readonly headers: Record; readonly authProvider?: OAuthClientProvider }, - McpConnectionError | StorageFailure -> => - Effect.gen(function* () { - if (!auth || auth.kind === "none") return { headers: {} }; - if (auth.kind === "header") { - if ("secretSlot" in auth) { - const headers = yield* resolveMcpHeaderAuth(ctx, sourceId, sourceScope, auth); - return { headers }; - } - const secretScope = auth.secretScopeId ?? auth.targetScope ?? targetScope ?? sourceScope; - const secret = yield* ctx.secrets.getAtScope(SecretId.make(auth.secretId), secretScope).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => - Effect.fail( - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret "${auth.secretId}"`, - }), - ), - ), - ); - if (secret === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Failed to resolve secret "${auth.secretId}"`, - }); - } - return { - headers: { [auth.headerName]: auth.prefix ? `${auth.prefix}${secret}` : secret }, - }; - } - const connection = - "connectionId" in auth - ? { id: ConnectionId.make(auth.connectionId), scope: targetScope ?? sourceScope } - : yield* Effect.gen(function* () { - const binding = yield* resolveMcpSourceBinding( - ctx, - sourceId, - sourceScope, - auth.connectionSlot, - ); - return binding?.value.kind === "connection" - ? { id: binding.value.connectionId, scope: binding.scopeId } - : null; - }); - if (connection === null) { - return yield* new McpConnectionError({ - transport: "remote", - message: `Missing OAuth connection binding for MCP source "${sourceId}"`, - }); - } - const accessToken = yield* ctx.connections - .accessTokenAtScope(connection.id, connection.scope) - .pipe( - Effect.mapError( - ({ message }) => - new McpConnectionError({ - transport: "remote", - message: `Failed to resolve OAuth connection "${connection.id}": ${message}`, - }), - ), - ); - return { headers: {}, authProvider: makeOAuthProvider(accessToken) }; - }); - // --------------------------------------------------------------------------- // Shared connector resolution — reads secrets, builds stdio/remote input // --------------------------------------------------------------------------- @@ -946,24 +835,16 @@ export interface McpPluginOptions { readonly configFile?: ConfigFileSink; } -const secretRef = (id: string): string => `${SECRET_REF_PREFIX}${id}`; - const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig | undefined => { if (!auth) return undefined; - if (auth.kind === "none") return { kind: "none" }; - if (auth.kind === "header") { - if (!("secretId" in auth)) return undefined; - return { - kind: "header", - headerName: auth.headerName, - secret: secretRef(auth.secretId), - prefix: auth.prefix, - }; + if ("kind" in auth) return { kind: "none" }; + const connection = auth.oauth2?.connection; + if (!connection || typeof connection === "string" || connection.kind !== "connection") { + return undefined; } - if (!("connectionId" in auth)) return undefined; return { kind: "oauth2", - connectionId: auth.connectionId, + connectionId: connection.connectionId, }; }; @@ -971,8 +852,8 @@ const authToConfig = (auth: McpConnectionAuthInput | undefined): McpAuthConfig | // Storage-form → input-form reconstruction // // `toMcpConfigEntry` consumes the `McpSourceConfig` *input* shape — the -// legacy form with `secretId` / `connectionId`, which `authToConfig` and -// `plainStringMap` know how to render into the file. Stored remote data +// configure form, which `authToConfig` and `credentialInputMapToConfigValues` +// know how to render into the file. Stored remote data // is in slot form (`secretSlot`, `{kind: "binding", slot}`), so writing // the file from a stored row needs the slot → secret/connection lookups // realized first. Walk the source's `credential_binding` rows and rebuild @@ -988,7 +869,9 @@ const toCredentialInput = ( if (!value) return undefined; if (value.kind === "secret") { return { + kind: "secret", secretId: value.secretId, + ...(value.secretScopeId ? { secretScope: value.secretScopeId } : {}), ...(configured.prefix ? { prefix: configured.prefix } : {}), }; } @@ -1019,15 +902,17 @@ const toAuthInput = ( const value = bySlot.get(auth.secretSlot); if (value?.kind !== "secret") return undefined; return { - kind: "header", - headerName: auth.headerName, - secretId: value.secretId, - prefix: auth.prefix, + kind: "none", }; } const connection = bySlot.get(auth.connectionSlot); - if (connection?.kind !== "connection") return undefined; - return { kind: "oauth2", connectionId: connection.connectionId }; + return { + oauth2: { + ...(connection?.kind === "connection" + ? { connection: { kind: "connection" as const, connectionId: connection.connectionId } } + : {}), + }, + }; }; const inputFormFromStored = ( @@ -1036,7 +921,7 @@ const inputFormFromStored = ( scope: string, sourceName: string, namespace: string, -): McpSourceConfig => { +): McpConfigFileSourceConfig => { if (stored.transport === "stdio") { return { transport: "stdio", @@ -1066,7 +951,7 @@ const inputFormFromStored = ( const toMcpConfigEntry = ( namespace: string, sourceName: string, - config: McpSourceConfig, + config: McpConfigFileSourceConfig, ): SourceConfig => { if (config.transport === "stdio") { const entry: McpStdioConfigEntry = { @@ -1087,8 +972,8 @@ const toMcpConfigEntry = ( name: sourceName, endpoint: config.endpoint, remoteTransport: config.remoteTransport, - queryParams: plainStringMap(config.queryParams), - headers: plainStringMap(config.headers), + queryParams: credentialInputMapToConfigValues(config.queryParams), + headers: credentialInputMapToConfigValues(config.headers), namespace, auth: authToConfig(config.auth), }; @@ -1239,43 +1124,58 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const canonicalRemote = config.transport === "remote" ? { - headers: canonicalizeCredentialMap(config.headers, mcpHeaderSlot), - queryParams: canonicalizeCredentialMap(config.queryParams, mcpQueryParamSlot), - auth: canonicalizeAuth(config.auth), + headers: canonicalizeConfiguredValueMap(config.headers, mcpHeaderSlot), + queryParams: canonicalizeConfiguredValueMap( + config.queryParams, + mcpQueryParamSlot, + ), } : null; - const directBindings = canonicalRemote - ? [ - ...canonicalRemote.headers.bindings, - ...canonicalRemote.queryParams.bindings, - ...canonicalRemote.auth.bindings, - ] - : []; - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.transport === "remote" ? config.credentialTargetScope : undefined, - binding, - ); + const initialRemote = + config.transport === "remote" && config.credentials + ? { + scope: config.credentials.scope, + headers: + config.credentials.headers !== undefined + ? canonicalizeCredentialMap(config.credentials.headers, mcpHeaderSlot) + : null, + queryParams: + config.credentials.queryParams !== undefined + ? canonicalizeCredentialMap(config.credentials.queryParams, mcpQueryParamSlot) + : null, + auth: + config.credentials.auth !== undefined + ? canonicalizeAuth(config.credentials.auth) + : null, + } + : null; + const remoteAuth = + config.transport === "remote" + ? config.oauth2 + ? authFromOAuth2Source(config.oauth2) + : (initialRemote?.auth?.auth ?? ({ kind: "none" } as McpConnectionAuth)) + : null; + const remoteCredentials = + canonicalRemote && remoteAuth + ? { + headers: canonicalRemote.headers, + queryParams: canonicalRemote.queryParams, + auth: remoteAuth, + } + : undefined; + const initialBindings = [ + ...(initialRemote?.headers?.bindings ?? []), + ...(initialRemote?.queryParams?.bindings ?? []), + ...(initialRemote?.auth?.bindings ?? []), + ]; + if (initialRemote && initialBindings.length > 0) { yield* validateMcpBindingTarget(ctx, { sourceId: namespace, sourceScope: config.scope, - targetScope: bindingTargetScope, + targetScope: initialRemote.scope, }); } - const targetScope = - config.transport === "remote" && directBindings[0] - ? yield* targetScopeForBinding(config.credentialTargetScope, directBindings[0]) - : undefined; - const sd = toStoredSourceData( - config, - canonicalRemote - ? { - headers: canonicalRemote.headers.values, - queryParams: canonicalRemote.queryParams.values, - auth: canonicalRemote.auth.auth, - } - : undefined, - ); + const sd = toStoredSourceData(config, remoteCredentials); // Stdio sources are gated — a resolver failure there is a // config error the admin must fix before the source makes @@ -1284,55 +1184,64 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // connection awaiting per-user sign-in, header secret // awaiting upload) but the source row should still land so // it shows up in the list and exposes a Sign-in affordance. - const resolved = yield* ( + const initialQueryParams = + initialRemote?.queryParams && + (yield* resolveInitialMcpCredentialValueMap( + ctx, + canonicalRemote?.queryParams ?? initialRemote.queryParams.values, + initialRemote.queryParams.bindings, + initialRemote.scope, + "query parameter", + )); + const initialHeaders = + initialRemote?.headers && + (yield* resolveInitialMcpCredentialValueMap( + ctx, + canonicalRemote?.headers ?? initialRemote.headers.values, + initialRemote.headers.bindings, + initialRemote.scope, + "header", + )); + const remoteQueryParams = { + ...(config.transport === "remote" + ? (resolveConfiguredValueMap(config.queryParams) ?? {}) + : {}), + ...(initialQueryParams || {}), + }; + const remoteHeaders = { + ...(config.transport === "remote" + ? (resolveConfiguredValueMap(config.headers) ?? {}) + : {}), + ...(initialHeaders || {}), + }; + const initialAuthProvider = + initialRemote?.auth !== null && initialRemote?.auth !== undefined + ? yield* resolveInitialMcpOauthProvider( + ctx, + initialRemote.auth.bindings, + initialRemote.scope, + ) + : undefined; + const resolved: Result.Result = config.transport === "remote" - ? Effect.gen(function* () { - const resolvedHeaders = yield* resolveMcpCredentialInputMap(ctx, config.headers, { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "header", - }); - const resolvedQueryParams = yield* resolveMcpCredentialInputMap( - ctx, - config.queryParams, - { - sourceId: namespace, - sourceScope: config.scope, - targetScope, - missingLabel: "query parameter", - }, - ); - const resolvedAuth = yield* resolveMcpInputAuth( - ctx, - namespace, - config.scope, - targetScope, - config.auth, - ); - const headers = { - ...(resolvedHeaders ?? {}), - ...resolvedAuth.headers, - }; - return { - transport: "remote" as const, - endpoint: config.endpoint, - remoteTransport: config.remoteTransport ?? "auto", - queryParams: resolvedQueryParams, - headers: Object.keys(headers).length > 0 ? headers : undefined, - authProvider: resolvedAuth.authProvider, - }; + ? Result.succeed({ + transport: "remote" as const, + endpoint: config.endpoint, + remoteTransport: config.remoteTransport ?? "auto", + queryParams: + Object.keys(remoteQueryParams).length > 0 ? remoteQueryParams : undefined, + headers: Object.keys(remoteHeaders).length > 0 ? remoteHeaders : undefined, + authProvider: initialAuthProvider, }) - : resolveConnectorInput(namespace, config.scope, sd, ctx, allowStdio) - ).pipe( - Effect.result, - Effect.withSpan("mcp.plugin.resolve_connector", { - attributes: { - "mcp.source.namespace": namespace, - "mcp.source.transport": sd.transport, - }, - }), - ); + : yield* resolveConnectorInput(namespace, config.scope, sd, ctx, allowStdio).pipe( + Effect.result, + Effect.withSpan("mcp.plugin.resolve_connector", { + attributes: { + "mcp.source.namespace": namespace, + "mcp.source.transport": sd.transport, + }, + }), + ); if (Result.isFailure(resolved) && sd.transport === "stdio") { return yield* Effect.fail(resolved.failure); @@ -1390,7 +1299,6 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { binding: toBinding(e), })), ); - yield* ctx.core.sources.register({ id: namespace, scope: config.scope, @@ -1407,22 +1315,22 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { outputSchema: e.outputSchema, })), }); - - if (directBindings.length > 0) { - for (const binding of directBindings) { - const bindingTargetScope = yield* targetScopeForBinding( - config.transport === "remote" ? config.credentialTargetScope : undefined, - binding, - ); - yield* ctx.credentialBindings.set({ - targetScope: ScopeId.make(bindingTargetScope), - pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(config.scope), + if (initialRemote && initialBindings.length > 0) { + yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(initialRemote.scope), + pluginId: MCP_PLUGIN_ID, + sourceId: namespace, + sourceScope: ScopeId.make(config.scope), + slotPrefixes: [ + ...(initialRemote.headers !== null ? ["header:"] : []), + ...(initialRemote.queryParams !== null ? ["query_param:"] : []), + ...(initialRemote.auth !== null ? ["auth:"] : []), + ], + bindings: initialBindings.map((binding) => ({ slotKey: binding.slot, value: binding.value, - }); - } + })), + }); } }), ) @@ -1567,9 +1475,29 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }), ); - const updateSource = (namespace: string, scope: string, input: McpUpdateSourceInput) => + const getSource = (namespace: string, scope: string) => + ctx.storage.getSource(namespace, scope).pipe( + Effect.withSpan("mcp.plugin.get_source", { + attributes: { "mcp.source.namespace": namespace }, + }), + ); + + return { + probeEndpoint, + addSource, + removeSource, + refreshSource, + getSource, + }; + }, + + sourceConfigure: { + type: "mcp", + schema: McpConfigureSourcePayloadSchema, + configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); + const input = config as Omit; + const existing = yield* ctx.storage.getSource(sourceId, sourceScope); if (!existing || existing.config.transport !== "remote") return; const canonicalHeaders = @@ -1586,48 +1514,42 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ...(canonicalQueryParams?.bindings ?? []), ...(canonicalAuth?.bindings ?? []), ]; - const targetScope = yield* bindingTargetScope( - input.credentialTargetScope, - directBindings, - ); - if (targetScope) { + if (directBindings.length > 0) { yield* validateMcpBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, + sourceId, + sourceScope, targetScope, }); } - const remote = existing.config; const updatedConfig: McpStoredSourceData = { - ...remote, + ...existing.config, ...(input.endpoint !== undefined ? { endpoint: input.endpoint } : {}), ...(canonicalHeaders ? { headers: canonicalHeaders.values } : {}), ...(canonicalAuth ? { auth: canonicalAuth.auth } : {}), ...(canonicalQueryParams ? { queryParams: canonicalQueryParams.values } : {}), }; - const sourceName = input.name?.trim() || existing.name; - const affectedPrefixes = [ ...(input.headers !== undefined ? ["header:"] : []), ...(input.queryParams !== undefined ? ["query_param:"] : []), ...(input.auth !== undefined ? ["auth:"] : []), ]; - const replacementTargetScope = targetScope ?? input.credentialTargetScope ?? scope; + + const sourceName = input.name?.trim() || existing.name; yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.putSource({ - namespace, - scope, + namespace: sourceId, + scope: sourceScope, name: sourceName, config: updatedConfig, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(replacementTargetScope), + targetScope: ScopeId.make(targetScope), pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceId, + sourceScope: ScopeId.make(sourceScope), slotPrefixes: affectedPrefixes, bindings: directBindings.map((binding) => ({ slotKey: binding.slot, @@ -1637,79 +1559,24 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { } }), ); - - if (configFile) { + if (options?.configFile) { const bindings = yield* ctx.credentialBindings.listForSource({ pluginId: MCP_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), + sourceId, + sourceScope: ScopeId.make(sourceScope), }); const inputForm = inputFormFromStored( bindings, updatedConfig, - scope, + sourceScope, sourceName, - namespace, + sourceId, ); - yield* configFile - .upsertSource(toMcpConfigEntry(namespace, sourceName, inputForm)) + yield* options.configFile + .upsertSource(toMcpConfigEntry(sourceId, sourceName, inputForm)) .pipe(Effect.withSpan("mcp.plugin.config_file.upsert")); } - }).pipe( - Effect.withSpan("mcp.plugin.update_source", { - attributes: { "mcp.source.namespace": namespace }, - }), - ); - - const getSource = (namespace: string, scope: string) => - ctx.storage.getSource(namespace, scope).pipe( - Effect.withSpan("mcp.plugin.get_source", { - attributes: { "mcp.source.namespace": namespace }, - }), - ); - - return { - probeEndpoint, - addSource, - removeSource, - refreshSource, - getSource, - updateSource, - listSourceBindings: (sourceId: string, sourceScope: string) => - listMcpSourceBindings(ctx, sourceId, sourceScope), - setSourceBinding: (input: McpSourceBindingInput) => - Effect.gen(function* () { - yield* validateMcpBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: MCP_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToMcpBinding(binding); - }), - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateMcpBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: MCP_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, - }); - }), - }; + }), }, invokeTool: ({ ctx, toolRow, args, elicit }) => @@ -1718,7 +1585,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // mcp_binding + mcp_source rows live at the same scope, so + // MCP binding + source plugin-storage rows live at the same scope, so // pin every store lookup to it instead of relying on stack-wide // scope fall-through. const toolScope = toolRow.scope_id; @@ -2002,22 +1869,4 @@ export interface McpPluginExtension { namespace: string, scope: string, ) => Effect.Effect; - readonly updateSource: ( - namespace: string, - scope: string, - input: McpUpdateSourceInput, - ) => Effect.Effect; - readonly listSourceBindings: ( - sourceId: string, - sourceScope: string, - ) => Effect.Effect; - readonly setSourceBinding: ( - input: McpSourceBindingInput, - ) => Effect.Effect; - readonly removeSourceBinding: ( - sourceId: string, - sourceScope: string, - slot: string, - scope: string, - ) => Effect.Effect; } diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index c908f7994..4e8b2730d 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -1,13 +1,15 @@ import { Effect, Schema } from "effect"; import { ConfiguredCredentialValue, - CredentialBindingValue, credentialSlotKey, - ScopedSecretCredentialInput, - ScopeId, SecretBackedMap, SecretBackedValue, } from "@executor-js/sdk/shared"; +import { + HttpConfiguredValueInput, + HttpCredentialInput, + HttpOAuthConfigureInput, +} from "@executor-js/plugin-http-source/sdk"; export { SecretBackedMap, SecretBackedValue }; @@ -25,11 +27,10 @@ export type McpTransport = typeof McpTransport.Type; export const ConfiguredMcpCredentialValue = ConfiguredCredentialValue; export type ConfiguredMcpCredentialValue = typeof ConfiguredMcpCredentialValue.Type; -export const McpCredentialInput = Schema.Union([ - ScopedSecretCredentialInput, - SecretBackedValue, - ConfiguredMcpCredentialValue, -]); +export const McpConfiguredValueInput = HttpConfiguredValueInput; +export type McpConfiguredValueInput = typeof McpConfiguredValueInput.Type; + +export const McpCredentialInput = HttpCredentialInput; export type McpCredentialInput = typeof McpCredentialInput.Type; export const mcpHeaderSlot = (name: string): string => credentialSlotKey("header", name); @@ -68,47 +69,15 @@ export const McpConnectionAuth = Schema.Union([ export type McpConnectionAuth = typeof McpConnectionAuth.Type; export const McpConnectionAuthInput = Schema.Union([ - McpConnectionAuth, Schema.Struct({ - kind: Schema.Literal("header"), - headerName: Schema.String, - secretId: Schema.String, - prefix: Schema.optional(Schema.String), - targetScope: Schema.optional(ScopeId), - secretScopeId: Schema.optional(ScopeId), + kind: Schema.Literal("none"), }), Schema.Struct({ - kind: Schema.Literal("oauth2"), - connectionId: Schema.String, - clientIdSecretId: Schema.optional(Schema.String), - clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), + oauth2: Schema.optional(HttpOAuthConfigureInput), }), ]); export type McpConnectionAuthInput = typeof McpConnectionAuthInput.Type; -export const McpSourceBindingValue = CredentialBindingValue; -export type McpSourceBindingValue = typeof McpSourceBindingValue.Type; - -export const McpSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: McpSourceBindingValue, -}); -export type McpSourceBindingInput = typeof McpSourceBindingInput.Type; - -export const McpSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: McpSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type McpSourceBindingRef = typeof McpSourceBindingRef.Type; - // --------------------------------------------------------------------------- // Stored source data — discriminated union on transport // --------------------------------------------------------------------------- diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 1d555d424..33ff33f8d 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -63,6 +63,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", + "@executor-js/plugin-http-source": "workspace:*", "@executor-js/sdk": "workspace:*", "effect": "catalog:", "openapi-types": "^12.1.3", diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index c38c2a201..62bf31c77 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -1,15 +1,17 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, ScopeId, SecretBackedValue } from "@executor-js/sdk/shared"; +import { + ConnectionId, + CredentialBindingRef, + InternalError, + ScopeId, + SecretBackedValue, +} from "@executor-js/sdk/shared"; import { OpenApiParseError, OpenApiExtractionError, OpenApiOAuthError } from "../sdk/errors"; import { SpecPreview } from "../sdk/preview"; import { StoredSourceSchema } from "../sdk/source-contracts"; -import { - OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, -} from "../sdk/types"; +import { OAuth2SourceConfig } from "../sdk/types"; // --------------------------------------------------------------------------- // Params @@ -31,12 +33,6 @@ const SourceParams = { namespace: Schema.String, }; -const SourceBindingParams = { - scopeId: ScopeId, - namespace: Schema.String, - sourceScopeId: ScopeId, -}; - const OpenApiSpecInputPayload = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), @@ -79,26 +75,49 @@ const PreviewSpecPayload = Schema.Struct({ specFetchCredentials: Schema.optional(PreviewSpecFetchCredentialsPayload), }); -const UpdateSourcePayload = Schema.Struct({ - sourceScope: ScopeId, - name: Schema.optional(Schema.String), - baseUrl: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), - queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValuePayload)), - // Set after a successful re-authenticate to refresh the source's - // stored OAuth2 metadata. - oauth2: Schema.optional(OAuth2SourceConfig), -}); +const ConfigureCredentialPayload = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("text"), + text: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("secret"), + secretId: Schema.String, + secretScope: Schema.optional(ScopeId), + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("connection"), + connectionId: ConnectionId, + }), +]); -const UpdateSourceResponse = Schema.Struct({ - updated: Schema.Boolean, -}); +const ConfigureCredentialMapPayload = Schema.Record(Schema.String, ConfigureCredentialPayload); -const RemoveBindingPayload = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - slot: Schema.String, +const ConfigurePayload = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: ScopeId, + }), scope: ScopeId, + headers: Schema.optional(ConfigureCredentialMapPayload), + queryParams: Schema.optional(ConfigureCredentialMapPayload), + specFetchCredentials: Schema.optional( + Schema.Struct({ + headers: Schema.optional(ConfigureCredentialMapPayload), + queryParams: Schema.optional(ConfigureCredentialMapPayload), + }), + ), + oauth2: Schema.optional( + Schema.Struct({ + clientId: Schema.optional(ConfigureCredentialPayload), + clientSecret: Schema.optional(ConfigureCredentialPayload), + connection: Schema.optional(ConfigureCredentialPayload), + }), + ), + oauth2Source: Schema.optional(OAuth2SourceConfig), }); // --------------------------------------------------------------------------- @@ -155,37 +174,10 @@ export const OpenApiGroup = HttpApiGroup.make("openapi") }), ) .add( - HttpApiEndpoint.patch("updateSource", "/scopes/:scopeId/openapi/sources/:namespace", { - params: SourceParams, - payload: UpdateSourcePayload, - success: UpdateSourceResponse, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.get( - "listSourceBindings", - "/scopes/:scopeId/openapi/sources/:namespace/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(OpenApiSourceBindingRef), - error: DomainErrors, - }, - ), - ) - .add( - HttpApiEndpoint.post("setSourceBinding", "/scopes/:scopeId/openapi/source-bindings", { - params: ScopeIdParam, - payload: OpenApiSourceBindingInput, - success: OpenApiSourceBindingRef, - error: DomainErrors, - }), - ) - .add( - HttpApiEndpoint.post("removeSourceBinding", "/scopes/:scopeId/openapi/source-bindings/remove", { + HttpApiEndpoint.post("configure", "/scopes/:scopeId/openapi/configure", { params: ScopeIdParam, - payload: RemoveBindingPayload, - success: Schema.Struct({ removed: Schema.Boolean }), + payload: ConfigurePayload, + success: Schema.Array(CredentialBindingRef), error: DomainErrors, }), ); diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index 53602c833..3be0cd284 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -4,12 +4,11 @@ import { Context, Effect } from "effect"; import { addGroup, capture } from "@executor-js/api"; import type { OpenApiConfiguredValueInput, + OpenApiConfigureInput, OpenApiPluginExtension, OpenApiPreviewSpecFetchCredentialsInput, OpenApiSpecFetchCredentialsInput, - OpenApiUpdateSourceInput, } from "../sdk/plugin"; -import { OpenApiSourceBindingInput } from "../sdk/types"; import { StoredSourceSchema } from "../sdk/store"; import { OpenApiGroup } from "./group"; @@ -110,50 +109,11 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope }), ), ) - .handle("updateSource", ({ params: path, payload }) => + .handle("configure", ({ payload }) => capture( Effect.gen(function* () { const ext = yield* OpenApiExtensionService; - yield* ext.updateSource(path.namespace, payload.sourceScope, { - name: payload.name, - baseUrl: payload.baseUrl, - headers: payload.headers as Record | undefined, - queryParams: payload.queryParams as - | Record - | undefined, - oauth2: payload.oauth2, - } as OpenApiUpdateSourceInput); - return { updated: true }; - }), - ), - ) - .handle("listSourceBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - return yield* ext.listSourceBindings(path.namespace, path.sourceScopeId); - }), - ), - ) - .handle("setSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - return yield* ext.setSourceBinding(OpenApiSourceBindingInput.make(payload)); - }), - ), - ) - .handle("removeSourceBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* OpenApiExtensionService; - yield* ext.removeSourceBinding( - payload.sourceId, - payload.sourceScope, - payload.slot, - payload.scope, - ); - return { removed: true }; + return yield* ext.configure(payload.source, payload as OpenApiConfigureInput); }), ), ), diff --git a/packages/plugins/openapi/src/promise.ts b/packages/plugins/openapi/src/promise.ts index c2eab908d..5de173010 100644 --- a/packages/plugins/openapi/src/promise.ts +++ b/packages/plugins/openapi/src/promise.ts @@ -3,6 +3,6 @@ export type { OpenApiPluginOptions, OpenApiPluginExtension, OpenApiSpecConfig, - OpenApiUpdateSourceInput, + OpenApiConfigureInput, HeaderValue, } from "./sdk/plugin"; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index aa113ff3b..4866a6cd4 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -6,8 +6,13 @@ import * as Match from "effect/Match"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/shared"; -import { startOAuth } from "@executor-js/react/api/atoms"; +import { + ConnectionId, + ScopeId, + SecretId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; +import { setSourceCredentialBinding, startOAuth } from "@executor-js/react/api/atoms"; import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -24,7 +29,7 @@ import { emptyHttpCredentials, serializeHttpCredentials, type HttpCredentialsState, -} from "@executor-js/react/plugins/http-credentials"; +} from "@executor-js/plugin-http-source/react"; import { oauthCallbackUrl, useOAuthPopupFlow, @@ -58,7 +63,7 @@ import { Textarea } from "@executor-js/react/components/textarea"; import { Checkbox } from "@executor-js/react/components/checkbox"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; -import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; +import { addOpenApiSpecOptimistic, previewOpenApiSpec } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { @@ -70,7 +75,7 @@ import { specFetchHeaderBindingSlot, specFetchQueryParamBindingSlot, } from "../sdk/source-contracts"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput, type ServerInfo } from "../sdk/types"; +import { OAuth2SourceConfig, type ServerInfo } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; @@ -271,28 +276,38 @@ export default function AddOpenApiSource(props: { const [oauthTokenTargetScope, setOAuthTokenTargetScope] = useState( defaultOAuthTokenTargetScope, ); - const sourceCredentialScopeOptions = useMemo( - () => credentialScopeOptions.filter((option) => option.scopeId === scopeId), - [credentialScopeOptions, scopeId], - ); useEffect(() => { if (!credentialScopeOptions.some((option) => option.scopeId === oauthTokenTargetScope)) { setOAuthTokenTargetScope(defaultOAuthTokenTargetScope); } }, [credentialScopeOptions, defaultOAuthTokenTargetScope, oauthTokenTargetScope]); + useEffect(() => { + if ( + oauth2ClientIdScope && + !credentialScopeOptions.some((option) => option.scopeId === oauth2ClientIdScope) + ) { + setOauth2ClientIdScope(null); + setOauth2ClientIdSecretId(null); + setOauth2AuthState(null); + } + if ( + oauth2ClientSecretScope && + !credentialScopeOptions.some((option) => option.scopeId === oauth2ClientSecretScope) + ) { + setOauth2ClientSecretScope(null); + setOauth2ClientSecretSecretId(null); + setOauth2AuthState(null); + } + }, [credentialScopeOptions, oauth2ClientIdScope, oauth2ClientSecretScope]); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); - const doSetBinding = useAtomSet(setOpenApiSourceBinding, { + const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); - const sourceCredentialSecrets = useMemo( - () => secretList.filter((secret) => secret.scopeId === String(scopeId)), - [scopeId, secretList], - ); const oauth = useOAuthPopupFlow({ popupName: OPENAPI_OAUTH_POPUP_NAME, popupBlockedMessage: "OAuth popup was blocked by the browser", @@ -349,11 +364,12 @@ export default function AddOpenApiSource(props: { const slot = headerBindingSlot(ch.name.trim()); configuredHeaders[ch.name.trim()] = { kind: "secret", prefix: ch.prefix }; if (ch.secretId) { + const targetScope = ch.targetScope ?? sourceScope; headerBindings.push({ slot, secretId: ch.secretId, - scope: sourceScope, - secretScope: ch.secretScope ?? sourceScope, + scope: targetScope, + secretScope: ch.secretScope ?? targetScope, }); } } @@ -362,12 +378,13 @@ export default function AddOpenApiSource(props: { if (!name) continue; if (param.secretId) { const slot = queryParamBindingSlot(name); + const targetScope = param.targetScope ?? sourceScope; configuredQueryParams[name] = { kind: "secret", prefix: param.prefix }; queryParamBindings.push({ slot, secretId: param.secretId, - scope: sourceScope, - secretScope: param.secretScope ?? sourceScope, + scope: targetScope, + secretScope: param.secretScope ?? targetScope, }); continue; } @@ -384,24 +401,26 @@ export default function AddOpenApiSource(props: { for (const header of specFetchCredentials.headers) { const name = header.name.trim(); if (!name || !header.secretId) continue; + const targetScope = header.targetScope ?? sourceScope; configuredSpecFetchHeaders[name] = { kind: "secret", prefix: header.prefix }; specFetchBindings.push({ slot: specFetchHeaderBindingSlot(name), secretId: header.secretId, - scope: sourceScope, - secretScope: header.secretScope ?? sourceScope, + scope: targetScope, + secretScope: header.secretScope ?? targetScope, }); } for (const param of specFetchCredentials.queryParams) { const name = param.name.trim(); if (!name) continue; if (param.secretId) { + const targetScope = param.targetScope ?? sourceScope; configuredSpecFetchQueryParams[name] = { kind: "secret", prefix: param.prefix }; specFetchBindings.push({ slot: specFetchQueryParamBindingSlot(name), secretId: param.secretId, - scope: sourceScope, - secretScope: param.secretScope ?? sourceScope, + scope: targetScope, + secretScope: param.secretScope ?? targetScope, }); continue; } @@ -594,6 +613,8 @@ export default function AddOpenApiSource(props: { const displayName = identity.name.trim() || selectedOAuth2Preset.securitySchemeName; const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); + const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; if (selectedOAuth2Preset.flow === "clientCredentials") { // RFC 6749 §4.4: no user-interactive consent step. The client_secret @@ -616,7 +637,9 @@ export default function AddOpenApiSource(props: { kind: "client-credentials", tokenEndpoint: tokenUrl, clientIdSecretId: oauth2ClientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScope), clientSecretSecretId: oauth2ClientSecretSecretId, + clientSecretSecretScopeId: String(clientSecretSecretScope), scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", @@ -663,7 +686,11 @@ export default function AddOpenApiSource(props: { tokenEndpoint: tokenUrl, issuerUrl, clientIdSecretId: oauth2ClientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScope), clientSecretSecretId: oauth2ClientSecretSecretId ?? null, + clientSecretSecretScopeId: oauth2ClientSecretSecretId + ? String(clientSecretSecretScope) + : null, scopes: [...oauth2SelectedScopes], }, pluginId: "openapi", @@ -710,6 +737,9 @@ export default function AddOpenApiSource(props: { selectedOAuth2Fingerprint, oauth, oauthTokenTargetScope, + oauth2ClientIdScope, + oauth2ClientSecretScope, + sourceScope, ]); const handleCancelOAuth2 = useCallback(() => { @@ -751,17 +781,16 @@ export default function AddOpenApiSource(props: { const sourceId = exit.value.namespace; const oauthTokenBindingScope = ScopeId.make(oauthTokenTargetScope); - const clientIdSecretScope = oauth2ClientIdScope ?? sourceScope; - const clientSecretSecretScope = oauth2ClientSecretScope ?? sourceScope; + const clientIdBindingScope = oauth2ClientIdScope ?? sourceScope; + const clientSecretBindingScope = oauth2ClientSecretScope ?? sourceScope; for (const binding of headerBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -780,11 +809,10 @@ export default function AddOpenApiSource(props: { for (const binding of queryParamBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -803,11 +831,10 @@ export default function AddOpenApiSource(props: { for (const binding of specFetchBindings) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: binding.scope, - slot: binding.slot, + slotKey: binding.slot, value: { kind: "secret", secretId: SecretId.make(binding.secretId), @@ -826,15 +853,14 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2ClientIdSecretId) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, - scope: sourceScope, - slot: configuredOAuth2.clientIdSlot, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, + scope: clientIdBindingScope, + slotKey: configuredOAuth2.clientIdSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientIdSecretId), - secretScopeId: clientIdSecretScope, + secretScopeId: clientIdBindingScope, }, }), reactivityKeys: bindingWriteKeys, @@ -849,15 +875,14 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, - scope: sourceScope, - slot: configuredOAuth2.clientSecretSlot, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, + scope: clientSecretBindingScope, + slotKey: configuredOAuth2.clientSecretSlot, value: { kind: "secret", secretId: SecretId.make(oauth2ClientSecretSecretId), - secretScopeId: clientSecretSecretScope, + secretScopeId: clientSecretBindingScope, }, }), reactivityKeys: bindingWriteKeys, @@ -872,11 +897,10 @@ export default function AddOpenApiSource(props: { if (configuredOAuth2 && oauth2Auth) { const bindingExit = await doSetBinding({ params: { scopeId }, - payload: OpenApiSourceBindingInput.make({ - sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: sourceId, scope: sourceScope }, scope: oauthTokenBindingScope, - slot: configuredOAuth2.connectionSlot, + slotKey: configuredOAuth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(oauth2Auth.connectionId), @@ -949,9 +973,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} - restrictSecretsToTargetScope + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} labels={{ headers: "Spec fetch headers", queryParams: "Spec fetch query parameters", @@ -1088,9 +1111,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} - restrictSecretsToTargetScope + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} emptyLabel="No credentials yet. Add the header value this method should use." /> @@ -1102,9 +1124,8 @@ export default function AddOpenApiSource(props: { existingSecrets={secretList} sourceName={identity.name} targetScope={sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} - bindingScopeOptions={sourceCredentialScopeOptions} - restrictSecretsToTargetScope + credentialScopeOptions={credentialScopeOptions} + bindingScopeOptions={credentialScopeOptions} sections={{ headers: false, queryParams: true }} labels={{ queryParams: "Runtime query parameters" }} /> @@ -1142,14 +1163,25 @@ export default function AddOpenApiSource(props: { setOauth2ClientIdScope(secretScopeId ?? sourceScope); setOauth2AuthState(null); }} - secrets={sourceCredentialSecrets} + secrets={secretList} sourceName={identity.name} secretLabel="Client ID" targetScope={oauth2ClientIdScope ?? sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} onCreatedScope={setOauth2ClientIdScope} /> + { + setOauth2ClientIdScope(targetScope); + setOauth2ClientIdSecretId(null); + setOauth2AuthState(null); + }} + label="Used by" + help="Choose where this OAuth client ID credential lives." + />
@@ -1174,14 +1206,25 @@ export default function AddOpenApiSource(props: { setOauth2ClientSecretScope(secretScopeId ?? sourceScope); setOauth2AuthState(null); }} - secrets={sourceCredentialSecrets} + secrets={secretList} sourceName={identity.name} secretLabel="Client Secret" targetScope={oauth2ClientSecretScope ?? sourceScope} - credentialScopeOptions={sourceCredentialScopeOptions} + credentialScopeOptions={credentialScopeOptions} onCreatedScope={setOauth2ClientSecretScope} />
+ { + setOauth2ClientSecretScope(targetScope); + setOauth2ClientSecretSecretId(null); + setOauth2AuthState(null); + }} + label="Used by" + help="Choose where this OAuth client secret credential lives." + />
diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index da0522c0f..b6dd8e5f8 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -5,8 +5,14 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { connectionsAtom, sourceAtom, startOAuth } from "@executor-js/react/api/atoms"; -import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; +import { + connectionsAtom, + configureSource, + setSourceCredentialBinding, + sourceAtom, + startOAuth, +} from "@executor-js/react/api/atoms"; +import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { Button } from "@executor-js/react/components/button"; import { CopyButton } from "@executor-js/react/components/copy-button"; @@ -16,19 +22,30 @@ import { CardStackEntry, CardStackEntryContent, CardStackEntryDescription, - CardStackEntryField, CardStackEntryTitle, + CardStackEntryField, } from "@executor-js/react/components/card-stack"; -import { FilterTabs } from "@executor-js/react/components/filter-tabs"; +import { FieldLabel } from "@executor-js/react/components/field"; +import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; import { Input } from "@executor-js/react/components/input"; import { sourceWriteKeys as openApiWriteKeys } from "@executor-js/react/api/reactivity-keys"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/shared"; +import { + ConnectionId, + CredentialBindingRef, + ScopeId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { oauthCallbackUrl, useOAuthPopupFlow, type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; +import { + CredentialControlField, + CredentialScopeDropdown, + CredentialUsageRow, +} from "@executor-js/react/plugins/credential-target-scope"; import { effectiveCredentialBindingForScope, exactCredentialBindingForScope, @@ -36,14 +53,13 @@ import { isSecretCredentialBindingValue, } from "@executor-js/react/plugins/credential-bindings"; import { SecretCredentialSlotBindings } from "@executor-js/react/plugins/credential-slot-bindings"; - import { - openApiSourceAtom, - openApiSourceBindingsAtom, - removeOpenApiSourceBinding, - setOpenApiSourceBinding, - updateOpenApiSource, -} from "./atoms"; + useSourceCredentialBindingScopes, + useSourceCredentialBindingWriter, +} from "@executor-js/react/plugins/source-credential-bindings"; +import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; + +import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { OPENAPI_OAUTH_CALLBACK_PATH, @@ -52,11 +68,7 @@ import { resolveOAuthUrl, } from "./AddOpenApiSource"; import { oauth2ClientSecretSlot } from "../sdk/source-contracts"; -import { - OAuth2SourceConfig, - OpenApiSourceBindingInput, - type OpenApiSourceBindingRef, -} from "../sdk/types"; +import { OAuth2SourceConfig } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); @@ -80,6 +92,8 @@ type SlotDef = readonly label: string; }; +type OpenApiCredentialBindingRow = CredentialBindingRef; + const slugify = (value: string): string => value .trim() @@ -115,7 +129,6 @@ export default function EditOpenApiSource(props: { readonly onSave: () => void; }) { const displayScope = useScope(); - const scopeStack = useScopeStack(); const userScope = useUserScope(); const sourceSummaryResult = useAtomValue(sourceAtom(props.sourceId, displayScope)); const sourceSummary = @@ -124,10 +137,13 @@ export default function EditOpenApiSource(props: { : null; const sourceScopeId = sourceSummary?.scopeId ?? displayScope; const sourceScope = ScopeId.make(sourceScopeId); - const scopeRanks = useMemo( - () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)), - [scopeStack], - ); + const { + credentialScopes, + credentialScopeOptions, + organizationCredentialScope, + secretBindingScopes, + scopeRanks, + } = useSourceCredentialBindingScopes({ sourceScope }); const sourceResult = useAtomValue(openApiSourceAtom(sourceScope, props.sourceId)); const bindingsResult = useAtomValue( @@ -136,11 +152,8 @@ export default function EditOpenApiSource(props: { const connectionsResult = useAtomValue(connectionsAtom(displayScope)); const secretList = useSecretPickerSecrets(); - const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promiseExit" }); - const doSetBinding = useAtomSet(setOpenApiSourceBinding, { - mode: "promiseExit", - }); - const doRemoveBinding = useAtomSet(removeOpenApiSourceBinding, { + const doConfigure = useAtomSet(configureSource, { mode: "promiseExit" }); + const doSetBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit", }); const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); @@ -152,7 +165,7 @@ export default function EditOpenApiSource(props: { const source = AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; - const bindingRows: readonly OpenApiSourceBindingRef[] = AsyncResult.isSuccess(bindingsResult) + const bindingRows: readonly OpenApiCredentialBindingRow[] = AsyncResult.isSuccess(bindingsResult) ? bindingsResult.value : []; const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; @@ -163,6 +176,12 @@ export default function EditOpenApiSource(props: { const [sourceSaveState, setSourceSaveState] = useState<"idle" | "saving" | "saved">("idle"); const [error, setError] = useState(null); const [busyKey, setBusyKey] = useState(null); + const sourceBindingWriter = useSourceCredentialBindingWriter({ + displayScope, + source: { id: props.sourceId, scope: sourceScope }, + onError: setError, + errorMessageFromExit, + }); const [pendingOAuthConnection, setPendingOAuthConnection] = useState<{ readonly scopeId: ScopeId; readonly slot: string; @@ -172,6 +191,11 @@ export default function EditOpenApiSource(props: { const [selectedOAuthTokenScope, setSelectedOAuthTokenScope] = useState( userScope !== sourceScopeId ? userScope : sourceScopeId, ); + const [selectedOAuthClientIdScope, setSelectedOAuthClientIdScope] = + useState(sourceScopeId); + const [selectedOAuthClientSecretScope, setSelectedOAuthClientSecretScope] = + useState(sourceScopeId); + const [oauthEndpointsOpen, setOAuthEndpointsOpen] = useState(false); const [oauth2AuthorizationUrl, setOAuth2AuthorizationUrl] = useState( source?.config.oauth2?.authorizationUrl ?? "", ); @@ -194,6 +218,8 @@ export default function EditOpenApiSource(props: { useEffect(() => { setSelectedOAuthTokenScope(userScope !== sourceScopeId ? userScope : sourceScopeId); + setSelectedOAuthClientIdScope(sourceScopeId); + setSelectedOAuthClientSecretScope(sourceScopeId); }, [sourceScopeId, userScope]); useEffect(() => { @@ -205,6 +231,7 @@ export default function EditOpenApiSource(props: { setOAuth2AuthorizationUrl(source.config.oauth2?.authorizationUrl ?? ""); setOAuth2TokenUrl(source.config.oauth2?.tokenUrl ?? ""); setOAuth2EndpointsSaveState("idle"); + setOAuthEndpointsOpen(false); setSourceSaveState("idle"); setLoadedSourceKey(sourceKey); }, [loadedSourceKey, source, sourceScopeId]); @@ -227,12 +254,17 @@ export default function EditOpenApiSource(props: { setSourceSaveState("saving"); setError(null); void (async () => { - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, + const exit = await doConfigure({ + params: { scopeId: displayScope }, payload: { - sourceScope, - name: nextName || undefined, - baseUrl: nextBaseUrl || undefined, + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "openapi", + config: { + scope: sourceScope, + name: nextName || undefined, + baseUrl: nextBaseUrl || undefined, + }, }, reactivityKeys: openApiWriteKeys, }); @@ -253,7 +285,7 @@ export default function EditOpenApiSource(props: { }, [ baseUrl, displayScope, - doUpdate, + doConfigure, loadedSourceKey, name, props.sourceId, @@ -302,39 +334,6 @@ export default function EditOpenApiSource(props: { return slots; }, [source]); - const credentialScopes = useMemo(() => { - const entries = [{ scopeId: ScopeId.make(sourceScopeId), label: "Organization" }]; - if (userScope !== sourceScopeId) { - entries.unshift({ scopeId: ScopeId.make(userScope), label: "Personal" }); - } else { - entries[0] = { - scopeId: ScopeId.make(sourceScopeId), - label: "Credentials", - }; - } - return entries; - }, [sourceScopeId, userScope]); - 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]!; @@ -349,57 +348,15 @@ export default function EditOpenApiSource(props: {
); } - - const setSecretBinding = async ( - targetScope: ScopeId, - slot: string, - secretId: string, - secretScope: ScopeId, - ) => { - const inputKey = `${targetScope}:${slot}`; - const trimmed = secretId.trim(); - if (!trimmed) return; - setBusyKey(inputKey); - setError(null); - const exit = await doSetBinding({ - params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, - scope: targetScope, - slot, - value: { - kind: "secret", - secretId: SecretId.make(trimmed), - secretScopeId: secretScope, - }, - }), - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(exit)) { - setError(errorMessageFromExit(exit, "Failed to save credential binding")); - } - setBusyKey(null); - }; - - const clearBinding = async (targetScope: ScopeId, slot: string) => { - setBusyKey(`${targetScope}:${slot}:clear`); - setError(null); - const exit = await doRemoveBinding({ - params: { scopeId: displayScope }, - payload: { - sourceId: props.sourceId, - sourceScope, - slot, - scope: targetScope, - }, - reactivityKeys: sourceWriteKeys, - }); - if (Exit.isFailure(exit)) { - setError(errorMessageFromExit(exit, "Failed to clear credential binding")); - } - setBusyKey(null); - }; + const oauthClientSecretSlot = source.config.oauth2 + ? effectiveClientSecretSlot(source.config.oauth2) + : null; + const nonOAuthSecretSlots = secretSlots.filter( + (slot) => + slot.kind === "secret" && + (!source.config.oauth2 || + (slot.slot !== source.config.oauth2.clientIdSlot && slot.slot !== oauthClientSecretSlot)), + ); const connectOAuth = async (targetScope: ScopeId) => { const oauth2 = source.config.oauth2; @@ -422,6 +379,7 @@ export default function EditOpenApiSource(props: { return; } const clientIdSecretId = clientIdBinding.value.secretId; + const clientIdSecretScopeId = clientIdBinding.value.secretScopeId ?? clientIdBinding.scopeId; if ( oauth2.flow === "clientCredentials" && (!clientSecretBinding || !isSecretCredentialBindingValue(clientSecretBinding.value)) @@ -435,6 +393,10 @@ export default function EditOpenApiSource(props: { isSecretCredentialBindingValue(clientSecretBinding.value) ? clientSecretBinding.value : null; + const clientSecretSecretScopeId = + clientSecretBinding && isSecretCredentialBindingValue(clientSecretBinding.value) + ? (clientSecretBinding.value.secretScopeId ?? clientSecretBinding.scopeId) + : null; const existingConnection = exactCredentialBindingForScope( bindingRows, @@ -472,7 +434,11 @@ export default function EditOpenApiSource(props: { kind: "client-credentials", tokenEndpoint: tokenUrl, clientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScopeId), clientSecretSecretId: clientSecretValue!.secretId, + clientSecretSecretScopeId: clientSecretSecretScopeId + ? String(clientSecretSecretScopeId) + : null, scopes: [...oauth2.scopes], }, pluginId: "openapi", @@ -490,11 +456,10 @@ export default function EditOpenApiSource(props: { } const setBindingExit = await doSetBinding({ params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(response.completedConnection.connectionId), @@ -529,10 +494,14 @@ export default function EditOpenApiSource(props: { tokenEndpoint: tokenUrl, issuerUrl, clientIdSecretId, + clientIdSecretScopeId: String(clientIdSecretScopeId), clientSecretSecretId: clientSecretBinding && isSecretCredentialBindingValue(clientSecretBinding.value) ? clientSecretBinding.value.secretId : null, + clientSecretSecretScopeId: clientSecretSecretScopeId + ? String(clientSecretSecretScopeId) + : null, scopes: [...oauth2.scopes], }, pluginId: "openapi", @@ -558,11 +527,10 @@ export default function EditOpenApiSource(props: { onSuccess: async (result) => { const setBindingExit = await doSetBinding({ params: { scopeId: displayScope }, - payload: OpenApiSourceBindingInput.make({ - sourceId: props.sourceId, - sourceScope, + payload: SetSourceCredentialBindingInput.make({ + source: { id: props.sourceId, scope: sourceScope }, scope: targetScope, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(result.connectionId), @@ -615,25 +583,32 @@ export default function EditOpenApiSource(props: { - - - Secrets - - - - slot.kind === "secret")} - bindingScopes={secretBindingScopes} - bindingRows={bindingRows} - scopeRanks={scopeRanks} - secrets={secretList} - sourceId={props.sourceId} - sourceName={source.name} - credentialScopeOptions={credentialScopeOptions} - busyKey={busyKey} - onSetSecretBinding={setSecretBinding} - onClearBinding={clearBinding} - /> + {nonOAuthSecretSlots.length > 0 && ( + <> + + + Request credentials + + Headers and query parameters sent with every API request. + + + + + + + )} {source.config.oauth2 && (() => { @@ -654,22 +629,27 @@ export default function EditOpenApiSource(props: { const seq = ++oauth2EndpointsSaveSeq.current; setOAuth2EndpointsSaveState("saving"); setError(null); - const exit = await doUpdate({ - params: { scopeId: displayScope, namespace: props.sourceId }, + const exit = await doConfigure({ + params: { scopeId: displayScope }, payload: { - sourceScope, - oauth2: OAuth2SourceConfig.make({ - kind: "oauth2", - securitySchemeName: oauth2.securitySchemeName, - flow: oauth2.flow, - tokenUrl: trimmedTokenUrl, - authorizationUrl: isAuthCode ? trimmedAuthUrl || null : null, - issuerUrl: oauth2.issuerUrl ?? null, - clientIdSlot: oauth2.clientIdSlot, - clientSecretSlot: oauth2.clientSecretSlot, - connectionSlot: oauth2.connectionSlot, - scopes: [...oauth2.scopes], - }), + source: { id: props.sourceId, scope: sourceScope }, + scope: sourceScope, + type: "openapi", + config: { + scope: sourceScope, + oauth2Source: OAuth2SourceConfig.make({ + kind: "oauth2", + securitySchemeName: oauth2.securitySchemeName, + flow: oauth2.flow, + tokenUrl: trimmedTokenUrl, + authorizationUrl: isAuthCode ? trimmedAuthUrl || null : null, + issuerUrl: oauth2.issuerUrl ?? null, + clientIdSlot: oauth2.clientIdSlot, + clientSecretSlot: oauth2.clientSecretSlot, + connectionSlot: oauth2.connectionSlot, + scopes: [...oauth2.scopes], + }), + }, }, reactivityKeys: openApiWriteKeys, }); @@ -730,15 +710,186 @@ export default function EditOpenApiSource(props: { : "Organization connection is missing" : `No ${activeOAuthTokenScopeLabel.toLowerCase()} connection`; const connectDisabled = isConnecting || endpointsDirty || saving; + const clientSecretSlot = effectiveClientSecretSlot(oauth2); + const renderAppSecret = (input: { + readonly slot: string; + readonly label: string; + readonly hint?: string; + readonly scopeId: ScopeId; + readonly onScopeChange: (scope: ScopeId) => void; + }) => { + const activeScope = + credentialScopes.find((entry) => entry.scopeId === input.scopeId) ?? + organizationCredentialScope; + const exactSecret = exactCredentialBindingForScope( + bindingRows, + input.slot, + activeScope.scopeId, + ); + const effectiveSecret = effectiveCredentialBindingForScope( + bindingRows, + input.slot, + activeScope.scopeId, + scopeRanks, + ); + const exactSecretId = + exactSecret && isSecretCredentialBindingValue(exactSecret.value) + ? exactSecret.value.secretId + : null; + const inheritedSecret = + !exactSecretId && + effectiveSecret && + effectiveSecret.scopeId !== activeScope.scopeId && + isSecretCredentialBindingValue(effectiveSecret.value) + ? effectiveSecret + : null; + const status = exactSecretId + ? `${activeScope.label} credential set` + : inheritedSecret + ? "Using organization credential" + : "Not set"; + const inputKey = `${activeScope.scopeId}:${input.slot}`; + const clearKey = `${activeScope.scopeId}:${input.slot}:clear`; + + return ( +
+ + {input.label}{" "} + {input.hint && · {input.hint}} + +
+
+
+ Secret + + Select or create the OAuth {input.label.toLowerCase()} secret. + + + {status} + +
+
+
+ + void sourceBindingWriter.setSecretBinding( + activeScope.scopeId, + input.slot, + secretId, + secretScopeId ?? activeScope.scopeId, + ) + } + secrets={secretList} + placeholder="Select or create a secret" + targetScope={activeScope.scopeId} + credentialScopeOptions={credentialScopeOptions} + suggestedId={`source-binding-${slugify(props.sourceId)}-${slugify( + input.slot, + )}-${slugify(activeScope.scopeId)}`} + sourceName={source.name} + secretLabel={input.label} + /> +
+ {exactSecretId && ( + + )} + {sourceBindingWriter.busyKey === inputKey && ( + Saving… + )} +
+
+ +
+
+ ); + }; return ( <> - OAuth Endpoints + OAuth - Override the URLs from the OpenAPI spec when a provider publishes the wrong - values. + Configure app credentials and connect accounts for this source. + + + + +
+ {renderAppSecret({ + slot: oauth2.clientIdSlot, + label: "Client ID", + scopeId: ScopeId.make(selectedOAuthClientIdScope), + onScopeChange: setSelectedOAuthClientIdScope, + })} + {renderAppSecret({ + slot: clientSecretSlot, + label: "Client secret", + hint: + oauth2.flow === "authorizationCode" + ? "Optional for public clients with PKCE" + : undefined, + scopeId: ScopeId.make(selectedOAuthClientSecretScope), + onScopeChange: setSelectedOAuthClientSecretScope, + })} +
+
+ + + +
+ + {statusText} + + +
+ {endpointsDirty && ( +

+ Save endpoint changes before reconnecting. +

+ )} +
+
+
+ + + Advanced endpoints + + Override provider URLs only when the OpenAPI spec is wrong.
@@ -748,78 +899,63 @@ export default function EditOpenApiSource(props: { )}
- {isAuthCode && ( - - - setOAuth2AuthorizationUrl((e.target as HTMLInputElement).value) - } - className="font-mono text-sm" - /> - - )} - - setOAuth2TokenUrl((e.target as HTMLInputElement).value)} - className="font-mono text-sm" - /> - - -
-
- {oauth2RedirectUrl} - -
-

- Add this to your OAuth app's allowed redirects. -

-
-
- {credentialScopes.length > 1 && ( - - - OAuth token - - Choose where the signed-in OAuth token is saved. - - - ({ - value: entry.scopeId, - label: entry.label, - }))} - value={activeOAuthTokenScopeId} - onChange={setSelectedOAuthTokenScope} - /> - - )} - -
-
{statusText}
- - {endpointsDirty && ( -

- Save endpoint changes before reconnecting. -

+ {oauthEndpointsOpen && ( + <> + {isAuthCode && ( + + + setOAuth2AuthorizationUrl((e.target as HTMLInputElement).value) + } + className="font-mono text-sm" + /> + )} -
-
+ + setOAuth2TokenUrl((e.target as HTMLInputElement).value)} + className="font-mono text-sm" + /> + + +
+
+ + {oauth2RedirectUrl} + + +
+

+ Add this to your OAuth app's allowed redirects. +

+
+
+ + + + Save endpoint changes before reconnecting. + + + + + + )} ); })()} diff --git a/packages/plugins/openapi/src/react/atoms.ts b/packages/plugins/openapi/src/react/atoms.ts index 2e64e7d21..4f885b509 100644 --- a/packages/plugins/openapi/src/react/atoms.ts +++ b/packages/plugins/openapi/src/react/atoms.ts @@ -1,7 +1,7 @@ import type { ScopeId } from "@executor-js/sdk/shared"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; +import { sourceCredentialBindingsAtom, sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; import { OpenApiClient } from "./client"; @@ -21,11 +21,12 @@ export const openApiSourceBindingsAtom = ( namespace: string, sourceScopeId: ScopeId, ) => - OpenApiClient.query("openapi", "listSourceBindings", { - params: { scopeId, namespace, sourceScopeId }, - timeToLive: "15 seconds", - reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], - }); + Atom.mapResult(sourceCredentialBindingsAtom(scopeId, namespace, sourceScopeId), (rows) => + rows.map((row) => ({ + ...row, + slot: row.slotKey, + })), + ); // --------------------------------------------------------------------------- // Mutation atoms @@ -61,9 +62,3 @@ export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => }), ), ); - -export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); - -export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding"); - -export const removeOpenApiSourceBinding = OpenApiClient.mutation("openapi", "removeSourceBinding"); diff --git a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts index e58d90fe0..e55d220ff 100644 --- a/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts @@ -19,6 +19,7 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -30,7 +31,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -213,12 +214,11 @@ describe("OpenAPI client_credentials OAuth", () => { namespace: "petstore", oauth2, }); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: userScope.id, + yield* userExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: userScope.id }, scope: userScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(completedConnection.connectionId), diff --git a/packages/plugins/openapi/src/sdk/credential-status.ts b/packages/plugins/openapi/src/sdk/credential-status.ts index 1114f04c8..e5a177cd6 100644 --- a/packages/plugins/openapi/src/sdk/credential-status.ts +++ b/packages/plugins/openapi/src/sdk/credential-status.ts @@ -1,12 +1,12 @@ -import type { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; +import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk/shared"; import { oauth2ClientSecretSlot } from "./source-contracts"; -import type { ConfiguredHeaderValue, OpenApiSourceBindingValue } from "./types"; +import type { ConfiguredHeaderValue } from "./types"; export type BindingRowForCredentialStatus = { readonly slot: string; readonly scopeId: ScopeId; - readonly value: OpenApiSourceBindingValue; + readonly value: CredentialBindingValue; }; export type SourceForCredentialStatus = { diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index b2f89c7b0..d51886795 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -4,9 +4,11 @@ export { invoke, invokeWithLayer, resolveHeaders, annotationsForOperation } from export { openApiPlugin, type OpenApiSpecConfig, + type OpenApiConfigureCredentialInput, + type OpenApiConfigureInput, type OpenApiPluginExtension, type OpenApiPluginOptions, - type OpenApiUpdateSourceInput, + type OpenApiSourceRef, } from "./plugin"; export { openapiSchema, @@ -50,9 +52,6 @@ export { InvocationResult, MediaBinding, OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, - OpenApiSourceBindingValue, OperationBinding, OperationParameter, OperationRequestBody, diff --git a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts index 35d648c7b..59523c9f4 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-bearer.test.ts @@ -29,6 +29,8 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, + RemoveSourceCredentialBindingInput, ToolInvocationError, type InvokeOptions, type SecretProvider, @@ -42,7 +44,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OpenApiSourceBindingInput } from "./types"; +import { ConfiguredHeaderBinding } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -223,48 +225,44 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { // their own scope. Same secret id, same source, different // binding owner and provider value. // ------------------------------------------------------------- - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:vercel_api_token", + slotKey: "auth:vercel_api_token", value: { kind: "secret", secretId: SecretId.make("vercel_api_token"), }, }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "query_param:vercel_team_token", + slotKey: "query_param:vercel_team_token", value: { kind: "secret", secretId: SecretId.make("vercel_team_token"), }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "auth:vercel_api_token", + slotKey: "auth:vercel_api_token", value: { kind: "secret", secretId: SecretId.make("vercel_api_token"), }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "query_param:vercel_team_token", + slotKey: "query_param:vercel_team_token", value: { kind: "secret", secretId: SecretId.make("vercel_team_token"), @@ -422,24 +420,22 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: bobScope.id, - slot: "auth:personal-token", + slotKey: "auth:personal-token", value: { kind: "secret", secretId: SecretId.make("bob_vercel_pat"), @@ -541,12 +537,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: orgScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("org_vercel_pat"), @@ -568,12 +563,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -589,11 +583,12 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { "Bearer alice-token", ); - yield* aliceExec.openapi.removeSourceBinding( - "vercel", - String(orgScope.id), - "auth:token", - String(aliceScope.id), + yield* aliceExec.sources.removeBinding( + RemoveSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, + slotKey: "auth:token", + scope: aliceScope.id, + }), ); const fallbackResult = unwrapInvocation( @@ -602,12 +597,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { expect(fallbackResult.error).toBeNull(); expect((fallbackResult.data as EchoHeaders | null)?.authorization).toBe("Bearer org-token"); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -628,10 +622,9 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }, }); - const bindingsAfterReadd = yield* aliceExec.openapi.listSourceBindings( - "vercel", - String(orgScope.id), - ); + const bindingsAfterReadd = yield* aliceExec.sources.listBindings({ + source: { id: "vercel", scope: orgScope.id }, + }); expect(bindingsAfterReadd).toEqual([]); const error = yield* Effect.flip( @@ -791,12 +784,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "alice-token", }), ); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: aliceScope.id, - slot: "auth:token", + slotKey: "auth:token", value: { kind: "secret", secretId: SecretId.make("alice_vercel_pat"), @@ -886,12 +878,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { value: "org-token", }), ); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: orgScope.id, - slot: "auth:shared-token", + slotKey: "auth:shared-token", value: { kind: "secret", secretId: SecretId.make("shared-token") }, }), ); @@ -989,12 +980,11 @@ describe("OpenAPI multi-scope bearer (Vercel-style)", () => { }), ); - yield* userExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "vercel", - sourceScope: orgScope.id, + yield* userExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "vercel", scope: orgScope.id }, scope: userScope.id, - slot: "auth:personal-choice", + slotKey: "auth:personal-choice", value: { kind: "secret", secretId: SecretId.make("org-choice-token"), diff --git a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts index 85cde6e4d..cdfce81e2 100644 --- a/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts +++ b/packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts @@ -21,6 +21,7 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -32,7 +33,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -280,12 +281,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: aliceScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: aliceScope.id }, scope: aliceScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth.connectionId) }, }), ); @@ -294,12 +294,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: bobScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: bobScope.id }, scope: bobScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth.connectionId) }, }), ); @@ -539,12 +538,11 @@ describe("OpenAPI multi-scope OAuth", () => { namespace: "petstore", oauth2, }); - yield* adminExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, + yield* adminExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: orgScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(adminAuth) }, }), ); @@ -555,21 +553,19 @@ describe("OpenAPI multi-scope OAuth", () => { // Bob signs in → no user-scope shadow, falls through to the // org defaults (`org-client`), writes at user-bob. const bobAuth = yield* startClientCredentials(bobExec, bobScope.id, startInput); - yield* aliceExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, + yield* aliceExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: aliceScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(aliceAuth) }, }), ); - yield* bobExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: orgScope.id, + yield* bobExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: orgScope.id }, scope: bobScope.id, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(bobAuth) }, }), ); diff --git a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts index 0ba99b4f7..98d1dd7cc 100644 --- a/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts +++ b/packages/plugins/openapi/src/sdk/oauth-refresh.test.ts @@ -24,6 +24,7 @@ import { SecretId, Scope, SetSecretInput, + SetSourceCredentialBindingInput, TokenMaterial, OAUTH2_PROVIDER_KEY, createExecutor, @@ -39,7 +40,7 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { OAuth2SourceConfig } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; @@ -205,12 +206,11 @@ const bindOAuthConnection = ( connectionId: string, oauth2: OAuth2SourceConfig, ) => - executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "petstore", - sourceScope: scopeId, + executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "petstore", scope: scopeId }, scope: scopeId, - slot: oauth2.connectionSlot, + slotKey: oauth2.connectionSlot, value: { kind: "connection", connectionId: ConnectionId.make(connectionId) }, }), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 7410a89cf..5adffef88 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -12,6 +12,8 @@ import { ScopeId, SecretId, SetSecretInput, + SetSourceCredentialBindingInput, + RemoveSourceCredentialBindingInput, type InvokeOptions, type SecretProvider, } from "@executor-js/sdk"; @@ -20,7 +22,7 @@ import type { ConfigFileSink } from "@executor-js/config"; const TEST_SCOPE = "test-scope"; import { openApiPlugin } from "./plugin"; -import { ConfiguredHeaderBinding, OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types"; +import { ConfiguredHeaderBinding, OAuth2SourceConfig } from "./types"; import { addOpenApiTestSource, makeOpenApiHttpApiTestSourceConfig, @@ -406,15 +408,14 @@ describe("OpenAPI Plugin", () => { yield* executor.openapi.addSpec(input); - const bindings = yield* executor.openapi.listSourceBindings( - "org_direct_user_credential", - String(orgScope), - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "org_direct_user_credential", scope: orgScope }, + }); expect(bindings).toEqual([]); }), ); - it.effect("updateSource removes bindings for credential slots no longer present", () => + it.effect("sources.configure removes bindings for credential slots no longer present", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -445,26 +446,30 @@ describe("OpenAPI Plugin", () => { }, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "stale_binding", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:x-old", + slotKey: "header:x-old", value: { kind: "secret", secretId: SecretId.make("old-token") }, }), ); - yield* executor.openapi.updateSource("stale_binding", TEST_SCOPE, { - headers: {}, + yield* executor.sources.configure({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "openapi", + config: { scope: TEST_SCOPE, headers: {} }, }); - const bindings = yield* executor.openapi.listSourceBindings("stale_binding", TEST_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_binding", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); - it.effect("updateSource removes stale OAuth2 bindings when the OAuth template changes", () => + it.effect("sources.configure removes stale OAuth2 bindings when the OAuth template changes", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -504,32 +509,42 @@ describe("OpenAPI Plugin", () => { oauth2: oldOAuth, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "stale_oauth", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: oldOAuth.clientIdSlot, + slotKey: oldOAuth.clientIdSlot, value: { kind: "secret", secretId: SecretId.make("old-client-id") }, }), ); - yield* executor.openapi.updateSource("stale_oauth", TEST_SCOPE, { - oauth2: OAuth2SourceConfig.make({ - kind: "oauth2", - securitySchemeName: "new", - flow: "authorizationCode", - tokenUrl: "https://auth.example.com/token", - authorizationUrl: "https://auth.example.com/authorize", - clientIdSlot: "oauth2:new:client-id", - clientSecretSlot: null, - connectionSlot: "oauth2:new:connection", - scopes: ["read"], - }), + yield* executor.sources.configure({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, + scope: ScopeId.make(TEST_SCOPE), + type: "openapi", + config: { + scope: TEST_SCOPE, + oauth2: { + clientId: { kind: "secret", secretId: "old-client-id" }, + }, + oauth2Source: OAuth2SourceConfig.make({ + kind: "oauth2", + securitySchemeName: "new", + flow: "authorizationCode", + tokenUrl: "https://auth.example.com/token", + authorizationUrl: "https://auth.example.com/authorize", + clientIdSlot: "oauth2:new:client-id", + clientSecretSlot: null, + connectionSlot: "oauth2:new:connection", + scopes: ["read"], + }), + }, }); - const bindings = yield* executor.openapi.listSourceBindings("stale_oauth", TEST_SCOPE); - expect(bindings.some((binding) => binding.slot === oldOAuth.clientIdSlot)).toBe(false); + const bindings = yield* executor.sources.listBindings({ + source: { id: "stale_oauth", scope: ScopeId.make(TEST_SCOPE) }, + }); + expect(bindings.some((binding) => binding.slotKey === oldOAuth.clientIdSlot)).toBe(false); }), ); @@ -561,24 +576,28 @@ describe("OpenAPI Plugin", () => { scope: TEST_SCOPE, namespace: "authed", headers: { - Authorization: { kind: "secret", prefix: "Bearer " }, "X-Static": "hello", }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "authed", - sourceScope: ScopeId.make(TEST_SCOPE), + const configured = yield* executor.openapi.configure( + { id: "authed", scope: TEST_SCOPE }, + { scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", - value: { kind: "secret", secretId: SecretId.make("test-api-token") }, - }), + headers: { + Authorization: { + kind: "secret", + secretId: "test-api-token", + prefix: "Bearer ", + }, + }, + }, ); const result = unwrapInvocation( yield* executor.tools.invoke("authed.items.echoHeaders", {}, autoApprove), ); + expect(configured.map((binding) => binding.slotKey)).toEqual(["header:authorization"]); expect(result.error).toBeNull(); const data = result.data as { authorization?: string; "x-static"?: string }; expect(data.authorization).toBe("Bearer secret-value-123"); @@ -622,10 +641,9 @@ describe("OpenAPI Plugin", () => { }), ); - const bindings = yield* executor.openapi.listSourceBindings( - "default_target_scope", - TEST_SCOPE, - ); + const bindings = yield* executor.sources.listBindings({ + source: { id: "default_target_scope", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -681,12 +699,11 @@ describe("OpenAPI Plugin", () => { }), }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "noauth", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "noauth", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("missing-token") }, }), ); @@ -902,7 +919,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("listSourceBindings returns [] for a removed source", () => + it.effect("source bindings list returns [] for a removed source", () => // Regression: the React bindings atom revalidates after a removeSpec // (sourceWriteKeys invalidate it) before unmount. The store used to // throw StorageError("source does not exist"), which surfaced to the @@ -926,7 +943,9 @@ describe("OpenAPI Plugin", () => { ); yield* executor.openapi.removeSpec("removable", TEST_SCOPE); - const bindings = yield* executor.openapi.listSourceBindings("removable", TEST_SCOPE); + const bindings = yield* executor.sources.listBindings({ + source: { id: "removable", scope: ScopeId.make(TEST_SCOPE) }, + }); expect(bindings).toEqual([]); }), ); @@ -1028,7 +1047,7 @@ describe("OpenAPI Plugin", () => { expect(userView?.config.baseUrl).toBe("https://org.example.com"); expect( - queryCalls.some((call) => call.method === "findMany" && call.table === "openapi_source"), + queryCalls.some((call) => call.method === "findMany" && call.table === "plugin_storage"), ).toBe(false); }), ); @@ -1074,7 +1093,7 @@ describe("OpenAPI Plugin", () => { }), ); - it.effect("updateSource on user shadow cannot override the inherited base URL", () => + it.effect("sources.configure on user shadow cannot override the inherited base URL", () => Effect.gen(function* () { const clientLayer = FetchHttpClient.layer; @@ -1105,10 +1124,16 @@ describe("OpenAPI Plugin", () => { }), ); - const updateResult = yield* executor.openapi - .updateSource("shared", String(USER_SCOPE), { - name: "User Renamed", - baseUrl: "https://user-new.example.com", + const updateResult = yield* executor.sources + .configure({ + source: { id: "shared", scope: USER_SCOPE }, + scope: USER_SCOPE, + type: "openapi", + config: { + scope: String(USER_SCOPE), + name: "User Renamed", + baseUrl: "https://user-new.example.com", + }, }) .pipe( Effect.match({ @@ -1120,7 +1145,7 @@ describe("OpenAPI Plugin", () => { const userView = yield* executor.openapi.getSource("shared", String(USER_SCOPE)); const orgView = yield* executor.openapi.getSource("shared", String(ORG_SCOPE)); - expect(updateResult).toMatchObject({ _tag: "OpenApiOAuthError" }); + expect(updateResult).toMatchObject({ _tag: "StorageError" }); expect(userView?.name).toBe("User Source"); expect(userView?.config.baseUrl).toBe("https://org.example.com"); expect(orgView?.name).toBe("Org Source"); @@ -1216,12 +1241,11 @@ describe("OpenAPI Plugin", () => { }, }, }); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "shared_spec_fetch", - sourceScope: ORG_SCOPE, + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "shared_spec_fetch", scope: ORG_SCOPE }, scope: ORG_SCOPE, - slot: "spec_fetch_header:x-spec-token", + slotKey: "spec_fetch_header:x-spec-token", value: { kind: "secret", secretId: SecretId.make("org-spec-token") }, }), ); @@ -1235,15 +1259,20 @@ describe("OpenAPI Plugin", () => { ); const userRowsBefore = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(USER_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); - expect(userRowsBefore).toEqual([]); + const userConfigBefore = userRowsBefore[0]?.data as + | { readonly config?: { readonly specFetchCredentials?: unknown } } + | undefined; + expect(userConfigBefore?.config?.specFetchCredentials).toBeUndefined(); const requestsBefore = server.requestCount(); yield* executor.sources.refresh({ @@ -1254,25 +1283,43 @@ describe("OpenAPI Plugin", () => { expect(server.requestCount()).toBeGreaterThan(requestsBefore); expect(server.lastToken()).toBe("org-token"); const orgRowsAfter = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(ORG_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); const userRowsAfter = yield* Effect.promise(() => - db.findMany("openapi_source_spec_fetch_header", { + db.findMany("plugin_storage", { where: (b) => b.and( b("scope_id", "=", String(USER_SCOPE)), - b("source_id", "=", "shared_spec_fetch"), + b("plugin_id", "=", "openapi"), + b("collection", "=", "source"), + b("key", "=", "shared_spec_fetch"), ), }), ); - expect(orgRowsAfter).toHaveLength(1); - expect(userRowsAfter).toEqual([]); + const orgConfigAfter = orgRowsAfter[0]?.data as + | { + readonly config?: { + readonly specFetchCredentials?: { + readonly headers?: Readonly>; + }; + }; + } + | undefined; + const userConfigAfter = userRowsAfter[0]?.data as + | { readonly config?: { readonly specFetchCredentials?: unknown } } + | undefined; + expect(orgConfigAfter?.config?.specFetchCredentials?.headers).toHaveProperty( + "X-Spec-Token", + ); + expect(userConfigAfter?.config?.specFetchCredentials).toBeUndefined(); }), ), ); @@ -1330,12 +1377,11 @@ describe("OpenAPI Plugin", () => { expect(stored?.config.oauth2?.connectionSlot).toBe("oauth2:oauth2:connection"); expect(stored?.config.oauth2?.clientIdSlot).toBe("oauth2:oauth2:client-id"); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "deferred", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: stored!.config.oauth2!.clientIdSlot, + slotKey: stored!.config.oauth2!.clientIdSlot, value: { kind: "secret", secretId: SecretId.make("acme-client-id"), @@ -1343,12 +1389,12 @@ describe("OpenAPI Plugin", () => { }), ); - const clientIdBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) + const clientIdBinding = yield* executor.sources + .listBindings({ source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) } }) .pipe( Effect.map( (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.clientIdSlot) ?? + bindings.find((binding) => binding.slotKey === stored!.config.oauth2!.clientIdSlot) ?? null, ), ); @@ -1358,13 +1404,14 @@ describe("OpenAPI Plugin", () => { secretScopeId: ScopeId.make(TEST_SCOPE), }); - const connectionBinding = yield* executor.openapi - .listSourceBindings("deferred", TEST_SCOPE) + const connectionBinding = yield* executor.sources + .listBindings({ source: { id: "deferred", scope: ScopeId.make(TEST_SCOPE) } }) .pipe( Effect.map( (bindings) => - bindings.find((binding) => binding.slot === stored!.config.oauth2!.connectionSlot) ?? - null, + bindings.find( + (binding) => binding.slotKey === stored!.config.oauth2!.connectionSlot, + ) ?? null, ), ); expect(connectionBinding).toBeNull(); @@ -1407,23 +1454,21 @@ describe("OpenAPI Plugin", () => { queryParams: { token: { kind: "secret" } }, }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "with_secret", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "query_param:token", + slotKey: "query_param:token", value: { kind: "secret", secretId: SecretId.make("api-key") }, }), ); // Configure a slot binding pointing at the same secret. - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "with_secret", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "with_secret", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("api-key") }, }), ); @@ -1459,12 +1504,11 @@ describe("OpenAPI Plugin", () => { baseUrl: "http://example.com", }), ); - yield* executor.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "ref", - sourceScope: ScopeId.make(TEST_SCOPE), + yield* executor.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, scope: ScopeId.make(TEST_SCOPE), - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId: SecretId.make("locked") }, }), ); @@ -1480,11 +1524,12 @@ describe("OpenAPI Plugin", () => { expect(Predicate.isTagged(failure, "SecretInUseError")).toBe(true); // Detach the binding, then remove succeeds. - yield* executor.openapi.removeSourceBinding( - "ref", - ScopeId.make(TEST_SCOPE), - "header:authorization", - ScopeId.make(TEST_SCOPE), + yield* executor.sources.removeBinding( + RemoveSourceCredentialBindingInput.make({ + source: { id: "ref", scope: ScopeId.make(TEST_SCOPE) }, + slotKey: "header:authorization", + scope: ScopeId.make(TEST_SCOPE), + }), ); yield* executor.secrets.remove( RemoveSecretInput.make({ diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 76cf1f885..c85ac60f1 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -3,13 +3,16 @@ import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; import { + ConnectionId, ScopeId, + SecretId, SourceDetectionResult, StorageError, ToolResult, definePlugin, tool, resolveSecretBackedMap, + type CredentialBindingValue, type CredentialBindingRef, type PluginCtx, type StorageFailure, @@ -41,9 +44,6 @@ import { HeaderValue as HeaderValueSchema, ConfiguredHeaderBinding, OAuth2SourceConfig, - OpenApiSourceBindingInput, - OpenApiSourceBindingRef, - type OpenApiSourceBindingValue, OperationBinding, type ConfiguredHeaderValue as ConfiguredHeaderValueValue, type HeaderValue as HeaderValueValue, @@ -170,16 +170,29 @@ export interface OpenApiSpecConfig { readonly oauth2?: OpenApiOAuthInput; } -export interface OpenApiUpdateSourceInput { - readonly name?: string; - readonly baseUrl?: string; - readonly headers?: Record; - readonly queryParams?: Record; - /** Refresh the source's stored OAuth2 metadata after a successful - * re-authenticate. */ - readonly oauth2?: OpenApiOAuthInput; +export interface OpenApiSourceRef { + readonly id: string; + readonly scope: string; } +export type OpenApiConfigureCredentialInput = + | string + | { + readonly kind: "text"; + readonly text: string; + readonly prefix?: string; + } + | { + readonly kind: "secret"; + readonly secretId: string; + readonly secretScope?: string; + readonly prefix?: string; + } + | { + readonly kind: "connection"; + readonly connectionId: string; + }; + /** * Errors any OpenAPI extension method may surface. The first three are * plugin-domain tagged errors that flow directly to clients (4xx, each @@ -214,24 +227,10 @@ export interface OpenApiPluginExtension { namespace: string, scope: string, ) => Effect.Effect; - readonly updateSource: ( - namespace: string, - scope: string, - input: OpenApiUpdateSourceInput, - ) => Effect.Effect; - readonly listSourceBindings: ( - sourceId: string, - sourceScope: string, - ) => Effect.Effect; - readonly setSourceBinding: ( - input: OpenApiSourceBindingInput, - ) => Effect.Effect; - readonly removeSourceBinding: ( - sourceId: string, - sourceScope: string, - slot: string, - scope: string, - ) => Effect.Effect; + readonly configure: ( + source: OpenApiSourceRef, + input: OpenApiConfigureInput, + ) => Effect.Effect; } // --------------------------------------------------------------------------- @@ -260,6 +259,48 @@ const OpenApiConfiguredValueInputSchema = Schema.Union([ Schema.String, OpenApiSecretShapeInputSchema, ]); +const OpenApiConfigureCredentialInputSchema = Schema.Union([ + Schema.String, + Schema.Struct({ + kind: Schema.Literal("text"), + text: Schema.String, + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("secret"), + secretId: Schema.String, + secretScope: Schema.optional(Schema.String), + prefix: Schema.optional(Schema.String), + }), + Schema.Struct({ + kind: Schema.Literal("connection"), + connectionId: Schema.String, + }), +]); +const OpenApiConfigureInputSchema = Schema.Struct({ + scope: Schema.String, + name: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + specFetchCredentials: Schema.optional( + Schema.Struct({ + headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), + queryParams: Schema.optional( + Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema), + ), + }), + ), + oauth2: Schema.optional( + Schema.Struct({ + clientId: Schema.optional(OpenApiConfigureCredentialInputSchema), + clientSecret: Schema.optional(OpenApiConfigureCredentialInputSchema), + connection: Schema.optional(OpenApiConfigureCredentialInputSchema), + }), + ), + oauth2Source: Schema.optional(OAuth2SourceConfig), +}); +export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; const OpenApiOAuthInputSchema = OAuth2SourceConfig; const AddSourceInputSchema = Schema.Struct({ @@ -444,18 +485,193 @@ const canonicalizeOAuth2 = ( oauth2: OpenApiOAuthInput | undefined, ): { readonly oauth2?: OAuth2SourceConfig; - readonly bindings: ReadonlyArray<{ - readonly slot: string; - readonly value: OpenApiSourceBindingValue; - }>; } => { - if (!oauth2) return { bindings: [] }; + if (!oauth2) return {}; return { oauth2, - bindings: [], }; }; +const configuredValueFromConfigureInput = ( + slot: string, + input: OpenApiConfigureCredentialInput, +): { + readonly configured: ConfiguredHeaderValue; + readonly value: CredentialBindingValue; +} => { + if (typeof input === "string") { + return { + configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), + value: { kind: "text", text: input }, + }; + } + if (input.kind === "text") { + return { + configured: ConfiguredHeaderBinding.make({ + kind: "binding", + slot, + prefix: input.prefix, + }), + value: { kind: "text", text: input.text }, + }; + } + if (input.kind === "secret") { + return { + configured: ConfiguredHeaderBinding.make({ + kind: "binding", + slot, + prefix: input.prefix, + }), + value: { + kind: "secret", + secretId: SecretId.make(input.secretId), + ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), + }, + }; + } + return { + configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), + value: { kind: "connection", connectionId: ConnectionId.make(input.connectionId) }, + }; +}; + +const configureMap = ( + values: Record | undefined, + slotForName: (name: string) => string, +): { + readonly configured: Record; + readonly bindings: ReadonlyArray<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }>; +} => { + const configured: Record = {}; + const bindings: Array<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }> = []; + for (const [name, input] of Object.entries(values ?? {})) { + const slotKey = slotForName(name); + const next = configuredValueFromConfigureInput(slotKey, input); + configured[name] = next.configured; + bindings.push({ slotKey, value: next.value }); + } + return { configured, bindings }; +}; + +const configureOpenApiSource = ( + ctx: PluginCtx, + source: OpenApiSourceRef, + input: OpenApiConfigureInput, +): Effect.Effect => + Effect.gen(function* () { + const existing = yield* ctx.storage.getSource(source.id, source.scope); + if (!existing) { + return yield* new StorageError({ + message: + `Cannot configure OpenAPI source "${source.id}" at scope "${source.scope}": ` + + "source is not visible.", + cause: undefined, + }); + } + + const headers = configureMap(input.headers, headerSlotFromName); + const queryParams = configureMap(input.queryParams, queryParamSlotFromName); + const specFetchHeaders = configureMap( + input.specFetchCredentials?.headers, + specFetchHeaderSlotFromName, + ); + const specFetchQueryParams = configureMap( + input.specFetchCredentials?.queryParams, + specFetchQueryParamSlotFromName, + ); + const oauth2 = existing.config.oauth2; + const oauth2Bindings: Array<{ + readonly slotKey: string; + readonly value: CredentialBindingValue; + }> = []; + if (oauth2 && input.oauth2?.clientId) { + oauth2Bindings.push({ + slotKey: oauth2.clientIdSlot, + value: configuredValueFromConfigureInput(oauth2.clientIdSlot, input.oauth2.clientId).value, + }); + } + if (oauth2?.clientSecretSlot && input.oauth2?.clientSecret) { + oauth2Bindings.push({ + slotKey: oauth2.clientSecretSlot, + value: configuredValueFromConfigureInput(oauth2.clientSecretSlot, input.oauth2.clientSecret) + .value, + }); + } + if (oauth2 && input.oauth2?.connection) { + oauth2Bindings.push({ + slotKey: oauth2.connectionSlot, + value: configuredValueFromConfigureInput(oauth2.connectionSlot, input.oauth2.connection) + .value, + }); + } + + const specFetchCredentials = + input.specFetchCredentials === undefined + ? existing.config.specFetchCredentials + : { + headers: specFetchHeaders.configured, + queryParams: specFetchQueryParams.configured, + }; + const affectedPrefixes = [ + ...(input.headers !== undefined ? ["header:"] : []), + ...(input.queryParams !== undefined ? ["query_param:"] : []), + ...(input.specFetchCredentials?.headers !== undefined ? ["spec_fetch:header:"] : []), + ...(input.specFetchCredentials?.queryParams !== undefined ? ["spec_fetch:query_param:"] : []), + ...(input.oauth2 !== undefined ? ["oauth2:"] : []), + ]; + const bindings = [ + ...headers.bindings, + ...queryParams.bindings, + ...specFetchHeaders.bindings, + ...specFetchQueryParams.bindings, + ...oauth2Bindings, + ]; + const nextConfiguredValues = ( + current: Record | undefined, + next: Record, + provided: boolean, + ): Record | undefined => { + if (!provided) return current; + if (Object.keys(next).length === 0) return {}; + return { ...(current ?? {}), ...next }; + }; + + return yield* ctx.transaction( + Effect.gen(function* () { + yield* ctx.storage.updateSourceMeta(source.id, source.scope, { + headers: nextConfiguredValues( + existing.config.headers, + headers.configured, + input.headers !== undefined, + ), + queryParams: nextConfiguredValues( + existing.config.queryParams, + queryParams.configured, + input.queryParams !== undefined, + ), + specFetchCredentials, + }); + if (affectedPrefixes.length > 0 || bindings.length > 0) { + return yield* ctx.credentialBindings.replaceForSource({ + targetScope: ScopeId.make(input.scope), + pluginId: OPENAPI_PLUGIN_ID, + sourceId: source.id, + sourceScope: ScopeId.make(source.scope), + slotPrefixes: affectedPrefixes, + bindings, + }); + } + return []; + }), + ); + }); + interface EffectiveSourceConfig { readonly config: SourceConfig; readonly headersSource: StoredSource; @@ -472,98 +688,17 @@ const scopeRanks = (ctx: PluginCtx): ReadonlyMap = const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; -const coreBindingToOpenApiBinding = (binding: CredentialBindingRef): OpenApiSourceBindingRef => - OpenApiSourceBindingRef.make({ - sourceId: binding.sourceId, - sourceScopeId: binding.sourceScopeId, - scopeId: binding.scopeId, - slot: binding.slotKey, - value: binding.value, - createdAt: binding.createdAt, - updatedAt: binding.updatedAt, - }); - -const listOpenApiSourceBindings = ( - ctx: PluginCtx, - sourceId: string, - sourceScope: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return []; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - return bindings - .filter((binding) => scopeRank(ranks, binding.scopeId) <= sourceSourceRank) - .map(coreBindingToOpenApiBinding); - }); - -const resolveOpenApiSourceBinding = ( +const resolveOpenApiCredentialBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, sourceScope); - if (sourceSourceRank === Infinity) return null; - const bindings = yield* ctx.credentialBindings.listForSource({ - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - }); - const binding = bindings - .filter( - (candidate) => - candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank, - ) - .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0]; - return binding ? coreBindingToOpenApiBinding(binding) : null; - }); - -const validateOpenApiBindingTarget = ( - ctx: PluginCtx, - input: { - readonly sourceScope: string; - readonly targetScope: string; - readonly sourceId: string; - }, -): Effect.Effect => - Effect.gen(function* () { - const ranks = scopeRanks(ctx); - const sourceSourceRank = scopeRank(ranks, input.sourceScope); - const targetRank = scopeRank(ranks, input.targetScope); - const scopeList = `[${ctx.scopes.map((s) => s.id).join(", ")}]`; - if (sourceSourceRank === Infinity) { - return yield* new StorageError({ - message: - `OpenAPI source binding references source scope "${input.sourceScope}" ` + - `which is not in the executor's scope stack ${scopeList}.`, - cause: undefined, - }); - } - if (targetRank === Infinity) { - return yield* new StorageError({ - message: - `OpenAPI source binding targets scope "${input.targetScope}" which is not ` + - `in the executor's scope stack ${scopeList}.`, - cause: undefined, - }); - } - if (targetRank > sourceSourceRank) { - return yield* new StorageError({ - message: - `OpenAPI source bindings for "${input.sourceId}" cannot be written at ` + - `outer scope "${input.targetScope}" because the base source lives at ` + - `"${input.sourceScope}"`, - cause: undefined, - }); - } +): Effect.Effect => + ctx.credentialBindings.resolveBinding({ + pluginId: OPENAPI_PLUGIN_ID, + sourceId, + sourceScope: ScopeId.make(sourceScope), + slotKey: slot, }); const findOuterSource = ( @@ -638,7 +773,7 @@ const resolveConfiguredValueMap = ( resolved[name] = value; continue; } - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -728,7 +863,7 @@ const resolveOAuthConnectionId = ( StorageFailure > => Effect.gen(function* () { - const binding = yield* resolveOpenApiSourceBinding( + const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, @@ -1059,99 +1194,38 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { }; }), - updateSource: (namespace: string, scope: string, input: OpenApiUpdateSourceInput) => - Effect.gen(function* () { - const existing = yield* ctx.storage.getSource(namespace, scope); - if (!existing) return; - const canonicalHeaders = - input.headers !== undefined ? canonicalizeHeaders(input.headers) : null; - const canonicalOAuth2 = - input.oauth2 !== undefined ? canonicalizeOAuth2(input.oauth2) : null; - const canonicalQueryParams = - input.queryParams !== undefined - ? canonicalizeCredentialMap(input.queryParams, queryParamSlotFromName) - : null; - const affectedPrefixes = [ - ...(input.headers !== undefined ? ["header:"] : []), - ...(input.queryParams !== undefined ? ["query_param:"] : []), - ...(input.oauth2 !== undefined ? ["oauth2:"] : []), - ]; - const targetScope = scope; + configure: (source: OpenApiSourceRef, input: OpenApiConfigureInput) => + configureOpenApiSource(ctx, source, input), + }; + }, + + sourceConfigure: { + type: "openapi", + schema: OpenApiConfigureInputSchema, + configure: ({ ctx, sourceId, sourceScope, config }) => + Effect.gen(function* () { + const input = config as OpenApiConfigureInput; + if ( + input.name !== undefined || + input.baseUrl !== undefined || + input.oauth2Source !== undefined + ) { if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { - const outerSource = yield* findOuterSource(ctx, namespace, scope); + const outerSource = yield* findOuterSource(ctx, sourceId, sourceScope); if (outerSource) { return yield* new OpenApiOAuthError({ message: "OpenAPI source shadows inherit the outer source base URL", }); } } - if (affectedPrefixes.length > 0) { - yield* validateOpenApiBindingTarget(ctx, { - sourceId: namespace, - sourceScope: scope, - targetScope, - }); - } - yield* ctx.transaction( - Effect.gen(function* () { - yield* ctx.storage.updateSourceMeta(namespace, scope, { - name: input.name?.trim() || undefined, - baseUrl: input.baseUrl, - headers: canonicalHeaders?.headers, - queryParams: canonicalQueryParams?.values, - oauth2: canonicalOAuth2?.oauth2, - }); - if (affectedPrefixes.length > 0) { - yield* ctx.credentialBindings.replaceForSource({ - targetScope: ScopeId.make(targetScope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId: namespace, - sourceScope: ScopeId.make(scope), - slotPrefixes: affectedPrefixes, - bindings: [], - }); - } - }), - ); - }), - - listSourceBindings: (sourceId: string, sourceScope: string) => - listOpenApiSourceBindings(ctx, sourceId, sourceScope), - - setSourceBinding: (input: OpenApiSourceBindingInput) => - Effect.gen(function* () { - yield* validateOpenApiBindingTarget(ctx, { - sourceId: input.sourceId, - sourceScope: input.sourceScope, - targetScope: input.scope, - }); - const binding = yield* ctx.credentialBindings.set({ - targetScope: input.scope, - pluginId: OPENAPI_PLUGIN_ID, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: input.slot, - value: input.value, - }); - return coreBindingToOpenApiBinding(binding); - }), - - removeSourceBinding: (sourceId: string, sourceScope: string, slot: string, scope: string) => - Effect.gen(function* () { - yield* validateOpenApiBindingTarget(ctx, { - sourceId, - sourceScope, - targetScope: scope, - }); - yield* ctx.credentialBindings.remove({ - targetScope: ScopeId.make(scope), - pluginId: OPENAPI_PLUGIN_ID, - sourceId, - sourceScope: ScopeId.make(sourceScope), - slotKey: slot, + yield* ctx.storage.updateSourceMeta(sourceId, sourceScope, { + name: input.name?.trim() || undefined, + baseUrl: input.baseUrl, + oauth2: input.oauth2Source, }); - }), - }; + } + return yield* configureOpenApiSource(ctx, { id: sourceId, scope: sourceScope }, input); + }), }, staticSources: (self) => [ @@ -1186,7 +1260,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching - // openapi_operation + openapi_source rows live at the same + // OpenAPI operation + source plugin-storage rows live at the same // scope, so pin every store lookup to it instead of relying on // stack-wide scope fall-through. const toolScope = toolRow.scope_id; diff --git a/packages/plugins/openapi/src/sdk/store.ts b/packages/plugins/openapi/src/sdk/store.ts index a4e22a73c..eb1d6e4eb 100644 --- a/packages/plugins/openapi/src/sdk/store.ts +++ b/packages/plugins/openapi/src/sdk/store.ts @@ -1,22 +1,17 @@ -import { Effect, Option, Schema } from "effect"; +import { Effect, Option, Predicate, Schema } from "effect"; import { - type FumaRow, type FumaTables, - jsonColumn, - nullableJsonColumn, - nullableTextColumn, - scopedExecutorTable, + type PluginStorageEntry, type StorageDeps, type StorageFailure, - textColumn, } from "@executor-js/sdk/core"; import { - ConfiguredHeaderValue, ConfiguredHeaderBinding, OAuth2SourceConfig, OperationBinding, + type ConfiguredHeaderValue, } from "./types"; export { StoredSourceSchema, @@ -28,87 +23,11 @@ export { queryParamBindingSlot, } from "./source-contracts"; -// --------------------------------------------------------------------------- -// Schema: -// - openapi_source: one row per onboarded spec (baseUrl, oauth2, ...) -// - openapi_operation: one row per operation binding keyed by tool id -// --------------------------------------------------------------------------- - -// Each of the source-owned credential-structure child tables (`openapi_source_header`, -// `openapi_source_query_param`, -// `openapi_source_spec_fetch_header`, -// `openapi_source_spec_fetch_query_param`) shares the same column shape: -// id/scope_id/source_id/name plus a `kind` enum that discriminates a -// literal text value from a credential slot binding (with optional prefix). -// The fields are inlined per-table because FumaDB's table type -// narrowing relies on the literal types staying on the original -// declaration site. - -export const openapiSchema = { - openapi_source: scopedExecutorTable("openapi_source", { - name: textColumn("name"), - spec: textColumn("spec"), - // Origin URL the spec was fetched from. Set when `addSpec` was - // invoked with an http(s) URL; null when the caller passed raw - // spec text. Drives `canRefresh` on the core source row and - // is the address re-fetched on `refreshSource`. - source_url: nullableTextColumn("source_url"), - base_url: nullableTextColumn("base_url"), - // OAuth2 stays JSON because it is one typed source-owned config object - // carrying slot names, not concrete secret/connection ids. - oauth2: nullableJsonColumn("oauth2"), - }), - openapi_operation: scopedExecutorTable("openapi_operation", { - source_id: textColumn("source_id"), - binding: jsonColumn("binding"), - }), - openapi_source_header: scopedExecutorTable("openapi_source_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_query_param: scopedExecutorTable("openapi_source_query_param", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_spec_fetch_header: scopedExecutorTable("openapi_source_spec_fetch_header", { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }), - openapi_source_spec_fetch_query_param: scopedExecutorTable( - "openapi_source_spec_fetch_query_param", - { - source_id: textColumn("source_id"), - name: textColumn("name"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - slot_key: nullableTextColumn("slot_key"), - prefix: nullableTextColumn("prefix"), - }, - ), -} satisfies FumaTables; - +export const openapiSchema = {} satisfies FumaTables; export type OpenapiSchema = typeof openapiSchema; -// --------------------------------------------------------------------------- -// In-memory shapes -// --------------------------------------------------------------------------- - export interface SourceConfig { readonly spec: string; - /** Origin URL when the spec was fetched from http(s). Absent for - * raw-text adds. Persisted so `refreshSource` can re-fetch. */ readonly sourceUrl?: string; readonly baseUrl?: string; readonly namespace?: string; @@ -125,8 +44,6 @@ export interface OpenApiSpecFetchCredentials { export interface StoredSource { readonly namespace: string; - /** Executor scope id this source row lives in. Writes stamp this on - * `scope_id`; reads choose scope explicitly in the FumaDB query. */ readonly scope: string; readonly name: string; readonly config: SourceConfig; @@ -138,10 +55,8 @@ export interface StoredOperation { readonly binding: OperationBinding; } -// --------------------------------------------------------------------------- -// Schema encode/decode — OperationBinding has Option fields, so we must use -// Schema.encode/decode rather than plain JSON to round-trip correctly. -// --------------------------------------------------------------------------- +const SOURCE_COLLECTION = "source"; +const OPERATION_COLLECTION = "operation"; const encodeBinding = Schema.encodeSync(OperationBinding); const decodeBinding = Schema.decodeUnknownSync(OperationBinding); @@ -155,107 +70,40 @@ const encodeOAuth2SourceConfig = Schema.encodeSync(OAuth2SourceConfig); const NullableString = Schema.NullOr(Schema.String); const OptionalNullableString = Schema.optional(NullableString); - -const ChildStorageRow = Schema.Struct({ - name: Schema.String, - kind: Schema.Literals(["text", "binding"]), - text_value: OptionalNullableString, - slot_key: OptionalNullableString, +const ConfiguredHeaderBindingStorage = Schema.Struct({ + kind: Schema.Literal("binding"), + slot: Schema.String, prefix: OptionalNullableString, }); -const decodeChildStorageRowOption = Schema.decodeUnknownOption(ChildStorageRow); - -const SourceStorageRow = Schema.Struct({ - id: Schema.String, - scope_id: Schema.String, - name: Schema.String, +const ConfiguredHeaderValueStorage = Schema.Union([Schema.String, ConfiguredHeaderBindingStorage]); +const ConfiguredHeaderMapStorage = Schema.Record(Schema.String, ConfiguredHeaderValueStorage); +const SpecFetchCredentialsStorage = Schema.Struct({ + headers: Schema.optional(ConfiguredHeaderMapStorage), + queryParams: Schema.optional(ConfiguredHeaderMapStorage), +}); +const SourceConfigStorage = Schema.Struct({ spec: Schema.String, - source_url: OptionalNullableString, - base_url: OptionalNullableString, + sourceUrl: Schema.optional(Schema.String), + baseUrl: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + headers: Schema.optional(ConfiguredHeaderMapStorage), + queryParams: Schema.optional(ConfiguredHeaderMapStorage), + specFetchCredentials: Schema.optional(SpecFetchCredentialsStorage), oauth2: Schema.optional(Schema.Unknown), }); -const decodeSourceStorageRow = Schema.decodeUnknownSync(SourceStorageRow); - -const OperationStorageRow = Schema.Struct({ - id: Schema.String, - source_id: Schema.String, +const SourceStorage = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, + name: Schema.String, + config: SourceConfigStorage, +}); +const OperationStorage = Schema.Struct({ + toolId: Schema.String, + sourceId: Schema.String, binding: Schema.Unknown, }); -const decodeOperationStorageRow = Schema.decodeUnknownSync(OperationStorageRow); - -type OpenapiSourceRow = FumaRow; -type OpenapiOperationRow = FumaRow; - -const openapiCredentialChildTables = [ - "openapi_source_header", - "openapi_source_query_param", - "openapi_source_spec_fetch_header", - "openapi_source_spec_fetch_query_param", -] as const satisfies readonly (keyof OpenapiSchema)[]; - -// Collapse a structural credential map into the flat child-table column -// shape used by openapi_source_header, openapi_source_query_param, and -// the two openapi_source_spec_fetch_* tables. Returns one record per entry. -const valueMapToChildRows = ( - sourceId: string, - scope: string, - values: Record | undefined, -) => { - if (!values) return []; - return Object.entries(values).map(([name, value]) => { - const id = JSON.stringify([sourceId, name]); - if (typeof value === "string") { - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "text", - text_value: value, - slot_key: null, - prefix: null, - }; - } - return { - id, - scope_id: scope, - source_id: sourceId, - name, - kind: "binding", - text_value: null, - slot_key: value.slot, - prefix: value.prefix ?? null, - }; - }); -}; - -const childRowsToValueMap = ( - rows: readonly Record[], -): Record => { - const out: Record = {}; - for (const row of rows) { - const decoded = decodeChildStorageRowOption(row); - if (Option.isSome(decoded)) { - const child = decoded.value; - if (child.kind === "binding" && child.slot_key != null) { - out[child.name] = - child.prefix != null - ? ConfiguredHeaderBinding.make({ - kind: "binding", - slot: child.slot_key, - prefix: child.prefix, - }) - : ConfiguredHeaderBinding.make({ - kind: "binding", - slot: child.slot_key, - }); - } else if (child.kind === "text" && child.text_value != null) { - out[child.name] = child.text_value; - } - } - } - return out; -}; +const decodeSourceStorage = Schema.decodeUnknownOption(SourceStorage); +const decodeOperationStorage = Schema.decodeUnknownOption(OperationStorage); const toJsonRecord = (value: unknown): Record => value as Record; @@ -265,25 +113,93 @@ const normalizeStoredOAuth2 = (value: unknown): OAuth2SourceConfig | undefined = typeof value === "string" ? decodeOAuth2SourceConfigJsonOption(value) : decodeOAuth2SourceConfigOption(value); - if (Option.isSome(sourceConfig)) { - return sourceConfig.value; - } + if (Option.isSome(sourceConfig)) return sourceConfig.value; return undefined; }; -// --------------------------------------------------------------------------- -// Store interface -// --------------------------------------------------------------------------- +const normalizeConfiguredMap = ( + values: Readonly> | undefined, +): Record | undefined => { + if (!values) return undefined; + const normalized: Record = {}; + for (const [name, value] of Object.entries(values)) { + if (typeof value === "string") { + normalized[name] = value; + } else { + normalized[name] = + value.prefix != null + ? ConfiguredHeaderBinding.make({ + kind: "binding", + slot: value.slot, + prefix: value.prefix, + }) + : ConfiguredHeaderBinding.make({ + kind: "binding", + slot: value.slot, + }); + } + } + return normalized; +}; + +const encodeSourceConfig = (config: SourceConfig): Record => ({ + spec: config.spec, + ...(config.sourceUrl ? { sourceUrl: config.sourceUrl } : {}), + ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.namespace ? { namespace: config.namespace } : {}), + ...(config.headers ? { headers: config.headers } : {}), + ...(config.queryParams ? { queryParams: config.queryParams } : {}), + ...(config.specFetchCredentials ? { specFetchCredentials: config.specFetchCredentials } : {}), + ...(config.oauth2 ? { oauth2: toJsonRecord(encodeOAuth2SourceConfig(config.oauth2)) } : {}), +}); + +const rowToSource = (row: PluginStorageEntry): StoredSource | null => { + const decoded = decodeSourceStorage(row.data); + if (Option.isNone(decoded)) return null; + const stored = decoded.value; + const oauth2 = normalizeStoredOAuth2(stored.config.oauth2); + return { + namespace: stored.namespace, + scope: stored.scope, + name: stored.name, + config: { + spec: stored.config.spec, + sourceUrl: stored.config.sourceUrl, + baseUrl: stored.config.baseUrl, + namespace: stored.config.namespace, + headers: normalizeConfiguredMap(stored.config.headers), + queryParams: normalizeConfiguredMap(stored.config.queryParams), + specFetchCredentials: stored.config.specFetchCredentials + ? { + headers: normalizeConfiguredMap(stored.config.specFetchCredentials.headers), + queryParams: normalizeConfiguredMap(stored.config.specFetchCredentials.queryParams), + } + : undefined, + oauth2, + }, + }; +}; + +const rowToOperation = (row: PluginStorageEntry): StoredOperation | null => { + const decoded = decodeOperationStorage(row.data); + if (Option.isNone(decoded)) return null; + const operation = decoded.value; + return { + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: decodeBinding( + typeof operation.binding === "string" + ? decodeBindingJson(operation.binding) + : operation.binding, + ), + }; +}; -// Every read/write that targets a single row pins BOTH the natural id -// (namespace, toolId, sessionId) AND the owning `scope_id`. Scope is a -// normal FumaDB predicate here, not hidden behavior. export interface OpenapiStore { readonly upsertSource: ( input: StoredSource, operations: readonly StoredOperation[], ) => Effect.Effect; - readonly updateSourceMeta: ( namespace: string, scope: string, @@ -292,296 +208,149 @@ export interface OpenapiStore { readonly baseUrl?: string; readonly headers?: Record; readonly queryParams?: Record; + readonly specFetchCredentials?: OpenApiSpecFetchCredentials; readonly oauth2?: OAuth2SourceConfig; }, ) => Effect.Effect; - readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; - readonly listSources: () => Effect.Effect; - readonly getOperationByToolId: ( toolId: string, scope: string, ) => Effect.Effect; - readonly listOperationsBySource: ( sourceId: string, scope: string, ) => Effect.Effect; - readonly removeSource: (namespace: string, scope: string) => Effect.Effect; - - // --------------------------------------------------------------------- - // Query params and spec-fetch credentials are source-owned structural - // rows only. Secret/connection ownership and usages live in core - // `credential_binding`. } -// --------------------------------------------------------------------------- -// Default store implementation -// --------------------------------------------------------------------------- - export const makeDefaultOpenapiStore = ({ - fuma, - scopes, + pluginStorage, }: StorageDeps): OpenapiStore => { - const scopeIds = scopes.map((scope) => String(scope.id)); - - const loadChildValueMap = ( - tableName: (typeof openapiCredentialChildTables)[number], - sourceId: string, - scope: string, - ) => - fuma - .use(`${tableName}.findMany`, (db) => - db.findMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map(childRowsToValueMap)); + const sourceData = (source: StoredSource) => ({ + namespace: source.namespace, + scope: source.scope, + name: source.name, + config: encodeSourceConfig(source.config), + }); - const rowToSource = (row: OpenapiSourceRow): Effect.Effect => - Effect.gen(function* () { - const sourceRow = decodeSourceStorageRow(row); - const sourceId = sourceRow.id; - const scope = sourceRow.scope_id; - const oauth2 = normalizeStoredOAuth2(sourceRow.oauth2); + const operationData = (operation: StoredOperation) => ({ + toolId: operation.toolId, + sourceId: operation.sourceId, + binding: toJsonRecord(encodeBinding(operation.binding)), + }); - const headers = yield* loadChildValueMap("openapi_source_header", sourceId, scope); - const queryParams = yield* loadChildValueMap("openapi_source_query_param", sourceId, scope); - const specFetchHeaders = yield* loadChildValueMap( - "openapi_source_spec_fetch_header", - sourceId, - scope, - ); - const specFetchQueryParams = yield* loadChildValueMap( - "openapi_source_spec_fetch_query_param", - sourceId, - scope, + const listOperationRowsForSourceScope = (sourceId: string, scope: string) => + pluginStorage + .list({ + collection: OPERATION_COLLECTION, + keyPrefix: `${sourceId}.`, + }) + .pipe( + Effect.map((rows) => + rows.filter( + (row) => String(row.scopeId) === scope && rowToOperation(row)?.sourceId === sourceId, + ), + ), ); - const specFetchCredentials: OpenApiSpecFetchCredentials | undefined = - Object.keys(specFetchHeaders).length === 0 && Object.keys(specFetchQueryParams).length === 0 - ? undefined - : { - ...(Object.keys(specFetchHeaders).length > 0 ? { headers: specFetchHeaders } : {}), - ...(Object.keys(specFetchQueryParams).length > 0 - ? { queryParams: specFetchQueryParams } - : {}), - }; - return { - namespace: sourceId, - scope, - name: sourceRow.name, - config: { - spec: sourceRow.spec, - sourceUrl: sourceRow.source_url ?? undefined, - baseUrl: sourceRow.base_url ?? undefined, - headers, - queryParams, - specFetchCredentials, - oauth2, - }, - }; - }); - - const rowToOperation = (row: OpenapiOperationRow): StoredOperation => { - const operationRow = decodeOperationStorageRow(row); - return { - toolId: operationRow.id, - sourceId: operationRow.source_id, - binding: decodeBinding( - typeof operationRow.binding === "string" - ? decodeBindingJson(operationRow.binding) - : operationRow.binding, - ), - }; - }; - - // Replace the rows of one child table for a source: delete then bulk - // insert. Single helper so upsertSource and updateSourceMeta both - // funnel through the same write path. - const replaceChildRows = ( - tableName: (typeof openapiCredentialChildTables)[number], - sourceId: string, - scope: string, - values: Record | undefined, - ) => + const removeOperationsForSourceScope = (sourceId: string, scope: string) => Effect.gen(function* () { - yield* fuma.use(`${tableName}.deleteMany`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ); - const rows = valueMapToChildRows(sourceId, scope, values); - if (rows.length === 0) return; - yield* fuma.use(`${tableName}.createMany`, (db) => db.createMany(tableName, rows)); + const rows = yield* listOperationRowsForSourceScope(sourceId, scope); + for (const row of rows) { + yield* pluginStorage.remove({ + scope, + collection: OPERATION_COLLECTION, + key: row.key, + }); + } }); const deleteSource = (namespace: string, scope: string) => Effect.gen(function* () { - yield* fuma.use("openapi_operation.deleteMany", (db) => - db.deleteMany("openapi_operation", { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - // Drop every child table's rows for this source/scope. - for (const tableName of openapiCredentialChildTables) { - yield* fuma.use(`${tableName}.deleteMany`, (db) => - db.deleteMany(tableName, { - where: (b) => b.and(b("source_id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - } - yield* fuma.use("openapi_source.deleteMany", (db) => - db.deleteMany("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); + yield* removeOperationsForSourceScope(namespace, scope); + yield* pluginStorage.remove({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); }); return { upsertSource: (input, operations) => Effect.gen(function* () { yield* deleteSource(input.namespace, input.scope); - yield* fuma.use("openapi_source.createMany", (db) => - db.createMany("openapi_source", [ - { - id: input.namespace, - scope_id: input.scope, - name: input.name, - spec: input.config.spec, - source_url: input.config.sourceUrl ?? null, - base_url: input.config.baseUrl ?? null, - oauth2: input.config.oauth2 - ? toJsonRecord(encodeOAuth2SourceConfig(input.config.oauth2)) - : null, - }, - ]), - ); - yield* replaceChildRows( - "openapi_source_header", - input.namespace, - input.scope, - input.config.headers, - ); - yield* replaceChildRows( - "openapi_source_query_param", - input.namespace, - input.scope, - input.config.queryParams, - ); - yield* replaceChildRows( - "openapi_source_spec_fetch_header", - input.namespace, - input.scope, - input.config.specFetchCredentials?.headers, - ); - yield* replaceChildRows( - "openapi_source_spec_fetch_query_param", - input.namespace, - input.scope, - input.config.specFetchCredentials?.queryParams, - ); - if (operations.length > 0) { - yield* fuma.use("openapi_operation.createMany", (db) => - db.createMany( - "openapi_operation", - operations.map((op) => ({ - id: op.toolId, - scope_id: input.scope, - source_id: op.sourceId, - binding: toJsonRecord(encodeBinding(op.binding)), - })), - ), - ); + yield* pluginStorage.put({ + scope: input.scope, + collection: SOURCE_COLLECTION, + key: input.namespace, + data: sourceData(input), + }); + for (const operation of operations) { + yield* pluginStorage.put({ + scope: input.scope, + collection: OPERATION_COLLECTION, + key: operation.toolId, + data: operationData(operation), + }); } }), updateSourceMeta: (namespace, scope, patch) => Effect.gen(function* () { - const existingRow = yield* fuma.use("openapi_source.findFirst", (db) => - db.findFirst("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!existingRow) return; - const existing = yield* rowToSource(existingRow); - - const nextName = patch.name?.trim() || existing.name; - const nextBaseUrl = patch.baseUrl !== undefined ? patch.baseUrl : existing.config.baseUrl; - const nextOAuth2 = patch.oauth2 !== undefined ? patch.oauth2 : existing.config.oauth2; - - yield* fuma.use("openapi_source.updateMany", (db) => - db.updateMany("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - set: { - name: nextName, - base_url: nextBaseUrl ?? null, - oauth2: nextOAuth2 ? toJsonRecord(encodeOAuth2SourceConfig(nextOAuth2)) : null, - }, - }), - ); - if (patch.headers !== undefined) { - yield* replaceChildRows("openapi_source_header", namespace, scope, patch.headers); - } - if (patch.queryParams !== undefined) { - yield* replaceChildRows( - "openapi_source_query_param", - namespace, - scope, - patch.queryParams, - ); - } + const existing = yield* pluginStorage.getAtScope({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + }); + if (!existing) return; + const source = rowToSource(existing); + if (!source) return; + const next: StoredSource = { + ...source, + name: patch.name?.trim() || source.name, + config: { + ...source.config, + ...(patch.baseUrl !== undefined ? { baseUrl: patch.baseUrl } : {}), + ...(patch.headers !== undefined ? { headers: patch.headers } : {}), + ...(patch.queryParams !== undefined ? { queryParams: patch.queryParams } : {}), + ...(patch.specFetchCredentials !== undefined + ? { specFetchCredentials: patch.specFetchCredentials } + : {}), + ...(patch.oauth2 !== undefined ? { oauth2: patch.oauth2 } : {}), + }, + }; + yield* pluginStorage.put({ + scope, + collection: SOURCE_COLLECTION, + key: namespace, + data: sourceData(next), + }); }), getSource: (namespace, scope) => - Effect.gen(function* () { - const row = yield* fuma.use("openapi_source.findFirst", (db) => - db.findFirst("openapi_source", { - where: (b) => b.and(b("id", "=", namespace), b("scope_id", "=", scope)), - }), - ); - if (!row) return null; - return yield* rowToSource(row); - }), + pluginStorage + .getAtScope({ scope, collection: SOURCE_COLLECTION, key: namespace }) + .pipe(Effect.map((row) => (row ? rowToSource(row) : null))), listSources: () => - Effect.gen(function* () { - const rows = yield* fuma.use("openapi_source.findMany", (db) => - db.findMany("openapi_source", { - where: (b) => - scopeIds.length === 1 - ? b("scope_id", "=", scopeIds[0]!) - : b("scope_id", "in", [...scopeIds]), - }), - ); - return yield* Effect.forEach(rows, rowToSource, { - concurrency: "unbounded", - }); - }), + pluginStorage + .list({ collection: SOURCE_COLLECTION }) + .pipe(Effect.map((rows) => rows.map(rowToSource).filter(Predicate.isNotNull))), getOperationByToolId: (toolId, scope) => - fuma - .use("openapi_operation.findFirst", (db) => - db.findFirst("openapi_operation", { - where: (b) => b.and(b("id", "=", toolId), b("scope_id", "=", scope)), - }), - ) + pluginStorage + .getAtScope({ scope, collection: OPERATION_COLLECTION, key: toolId }) .pipe(Effect.map((row) => (row ? rowToOperation(row) : null))), listOperationsBySource: (sourceId, scope) => - fuma - .use("openapi_operation.findMany", (db) => - db.findMany("openapi_operation", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scope)), - }), - ) - .pipe(Effect.map((rows) => rows.map(rowToOperation))), + listOperationRowsForSourceScope(sourceId, scope).pipe( + Effect.map((rows) => rows.map(rowToOperation).filter(Predicate.isNotNull)), + ), removeSource: (namespace, scope) => deleteSource(namespace, scope), }; diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 52e8a7a09..e89afa65e 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -1,11 +1,16 @@ import { Schema } from "effect"; +import { ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk/shared"; import { - ConnectionId, - ScopeId, - ScopedSecretCredentialInput, - SecretBackedValue, - SecretId, -} from "@executor-js/sdk/shared"; + OAuth2Flow as HttpOAuth2Flow, + OAuth2SourceConfig as SharedOAuth2SourceConfig, + type OAuth2FlowType, + type OAuth2SourceConfigType, +} from "@executor-js/plugin-http-source/sdk"; + +export const OAuth2Flow = HttpOAuth2Flow; +export type OAuth2Flow = OAuth2FlowType; +export const OAuth2SourceConfig = SharedOAuth2SourceConfig; +export type OAuth2SourceConfig = OAuth2SourceConfigType; // --------------------------------------------------------------------------- // Branded IDs @@ -165,43 +170,6 @@ export const OpenApiCredentialInput = Schema.Union([ ]); export type OpenApiCredentialInput = typeof OpenApiCredentialInput.Type; -export const OpenApiSourceBindingValue = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("secret"), - secretId: SecretId, - secretScopeId: Schema.optional(ScopeId), - }), - Schema.Struct({ - kind: Schema.Literal("connection"), - connectionId: ConnectionId, - }), - Schema.Struct({ - kind: Schema.Literal("text"), - text: Schema.String, - }), -]); -export type OpenApiSourceBindingValue = typeof OpenApiSourceBindingValue.Type; - -export const OpenApiSourceBindingInput = Schema.Struct({ - sourceId: Schema.String, - sourceScope: ScopeId, - scope: ScopeId, - slot: Schema.String, - value: OpenApiSourceBindingValue, -}); -export type OpenApiSourceBindingInput = typeof OpenApiSourceBindingInput.Type; - -export const OpenApiSourceBindingRef = Schema.Struct({ - sourceId: Schema.String, - sourceScopeId: ScopeId, - scopeId: ScopeId, - slot: Schema.String, - value: OpenApiSourceBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type OpenApiSourceBindingRef = typeof OpenApiSourceBindingRef.Type; - // --------------------------------------------------------------------------- // OAuth2 source config — carries source-owned slots and API-level config to // kick off a fresh sign-in from the source detail UI without needing any @@ -225,23 +193,6 @@ export type OpenApiSourceBindingRef = typeof OpenApiSourceBindingRef.Type; // copies can't drift under normal reconnect flows. // --------------------------------------------------------------------------- -export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); -export type OAuth2Flow = typeof OAuth2Flow.Type; - -export const OAuth2SourceConfig = Schema.Struct({ - kind: Schema.Literal("oauth2"), - securitySchemeName: Schema.String, - flow: OAuth2Flow, - tokenUrl: Schema.String, - authorizationUrl: Schema.NullOr(Schema.String), - issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), - clientIdSlot: Schema.String, - clientSecretSlot: Schema.NullOr(Schema.String), - connectionSlot: Schema.String, - scopes: Schema.Array(Schema.String), -}).annotate({ identifier: "OpenApiOAuth2SourceConfig" }); -export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; - export const InvocationResult = Schema.Struct({ status: Schema.Number, headers: Schema.Record(Schema.String, Schema.String), diff --git a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts index 54a10f1be..404d05a87 100644 --- a/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts +++ b/packages/plugins/openapi/src/sdk/usage-scope-isolation.test.ts @@ -13,6 +13,7 @@ import { type ConnectionProvider, type SecretProvider, SetSecretInput, + SetSourceCredentialBindingInput, definePlugin, } from "@executor-js/sdk"; import { makeTestWorkspaceLayer, TestWorkspace } from "@executor-js/sdk/testing"; @@ -22,7 +23,6 @@ import { } from "@executor-js/plugin-openapi/testing"; import { openApiPlugin } from "./plugin"; -import { OpenApiSourceBindingInput } from "./types"; const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( HttpApiEndpoint.get("ping", "/ping"), @@ -114,12 +114,11 @@ layer(makeTestWorkspaceLayer({ scopes: [orgA], plugins }), { timeout: "15 second scope: String(orgA.id), namespace: "secret_private_source", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "secret_private_source", - sourceScope: orgA.id, + yield* orgAExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "secret_private_source", scope: orgA.id }, scope: orgA.id, - slot: "header:authorization", + slotKey: "header:authorization", value: { kind: "secret", secretId }, }), ); @@ -181,12 +180,11 @@ layer(makeTestWorkspaceLayer({ scopes: [orgA], plugins }), { timeout: "15 second scope: String(orgA.id), namespace: "connection_private_source", }); - yield* orgAExec.openapi.setSourceBinding( - OpenApiSourceBindingInput.make({ - sourceId: "connection_private_source", - sourceScope: orgA.id, + yield* orgAExec.sources.setBinding( + SetSourceCredentialBindingInput.make({ + source: { id: "connection_private_source", scope: orgA.id }, scope: orgA.id, - slot: "oauth:connection", + slotKey: "oauth:connection", value: { kind: "connection", connectionId }, }), ); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 352226055..89a40fa2e 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -136,6 +136,23 @@ export const refreshSource = ExecutorApiClient.mutation("sources", "refresh"); export const detectSource = ExecutorApiClient.mutation("sources", "detect"); +export const configureSource = ExecutorApiClient.mutation("sources", "configure"); + +export const sourceCredentialBindingsAtom = ( + scopeId: ScopeId, + sourceId: string, + sourceScopeId: ScopeId, +) => + ExecutorApiClient.query("sources", "listBindings", { + params: { scopeId, sourceId, sourceScopeId }, + timeToLive: "15 seconds", + reactivityKeys: [ReactivityKey.sources, ReactivityKey.secrets, ReactivityKey.connections], + }); + +export const setSourceCredentialBinding = ExecutorApiClient.mutation("sources", "setBinding"); + +export const removeSourceCredentialBinding = ExecutorApiClient.mutation("sources", "removeBinding"); + // --------------------------------------------------------------------------- // OAuth — one atom pair drives sign-in for every plugin. The plugin's // `Add*Source` / `*SignInButton` component passes the `strategy` descriptor diff --git a/packages/react/src/plugins/credential-bindings.test.ts b/packages/react/src/plugins/credential-bindings.test.ts index 45523b9a7..2c743affb 100644 --- a/packages/react/src/plugins/credential-bindings.test.ts +++ b/packages/react/src/plugins/credential-bindings.test.ts @@ -2,68 +2,11 @@ import { describe, expect, it } from "@effect/vitest"; import { ScopeId, SecretId } from "@executor-js/sdk/shared"; import { - httpCredentialsFromConfiguredCredentialBindings, initialCredentialTargetScope, secretBackedValuesFromConfiguredCredentialBindings, } from "./credential-bindings"; describe("credential binding editor helpers", () => { - it("hydrates configured credentials with binding and secret scopes", () => { - const personalScope = ScopeId.make("user_1"); - const organizationScope = ScopeId.make("org_1"); - - const credentials = httpCredentialsFromConfiguredCredentialBindings({ - headers: { - Authorization: { - slot: "header:authorization", - prefix: "Bearer ", - }, - }, - queryParams: { - token: { - slot: "query_param:token", - }, - }, - bindings: [ - { - slot: "header:authorization", - scopeId: personalScope, - value: { - kind: "secret", - secretId: SecretId.make("personal-api-token"), - secretScopeId: organizationScope, - }, - }, - { - slot: "query_param:token", - scopeId: organizationScope, - value: { - kind: "text", - text: "literal-token", - }, - }, - ], - }); - - expect(credentials.headers).toEqual([ - { - name: "Authorization", - secretId: "personal-api-token", - prefix: "Bearer ", - presetKey: "bearer", - targetScope: personalScope, - secretScope: organizationScope, - }, - ]); - expect(credentials.queryParams).toEqual([ - { - name: "token", - secretId: null, - literalValue: "literal-token", - }, - ]); - }); - it("uses the first binding as the initial target scope", () => { const sourceScope = ScopeId.make("org_1"); const personalScope = ScopeId.make("user_1"); @@ -71,7 +14,7 @@ describe("credential binding editor helpers", () => { expect( initialCredentialTargetScope(sourceScope, [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: personalScope, value: { kind: "secret", @@ -95,7 +38,7 @@ describe("credential binding editor helpers", () => { }, [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId: ScopeId.make("user_1"), value: { kind: "secret", diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index 848aa9650..8fb296f7d 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -4,18 +4,15 @@ import { type SecretBackedValue, } from "@executor-js/sdk/shared"; -import type { HttpCredentialsState, QueryParamState } from "./http-credentials"; -import { headerValueToState, type HeaderState } from "./secret-header-auth"; - -type ConfiguredCredentialValueLike = +export type ConfiguredCredentialValueLike = | string | { readonly slot: string; readonly prefix?: string; }; -type CredentialBindingRefLike = { - readonly slot: string; +export type CredentialBindingRefLike = { + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -23,7 +20,7 @@ type CredentialBindingRefLike = { const bindingBySlot = ( bindings: readonly CredentialBindingRefLike[], ): ReadonlyMap => - new Map(bindings.map((binding) => [binding.slot, binding])); + new Map(bindings.map((binding) => [binding.slotKey, binding])); export const initialCredentialTargetScope = ( sourceScope: ScopeId, @@ -35,7 +32,7 @@ export const exactCredentialBindingForScope = ( slot: string, scopeId: ScopeId, ): CredentialBindingRefLike | null => - rows.find((row) => row.slot === slot && row.scopeId === scopeId) ?? null; + rows.find((row) => row.slotKey === slot && row.scopeId === scopeId) ?? null; const scopeRank = (ranks: ReadonlyMap, scopeId: ScopeId): number => ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER; @@ -47,7 +44,7 @@ export const effectiveCredentialBindingForScope = ( ranks: ReadonlyMap, ): CredentialBindingRefLike | null => rows.find( - (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), + (row) => row.slotKey === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), ) ?? null; export const isSecretCredentialBindingValue = ( @@ -59,61 +56,6 @@ export const isConnectionCredentialBindingValue = ( ): value is Extract => value.kind === "connection"; -const headerFromConfiguredCredential = ( - name: string, - value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, -): HeaderState | null => { - if (typeof value === "string") { - return headerValueToState(name, value); - } - - const binding = bindings.get(value.slot); - if (binding?.value.kind === "secret") { - return { - ...headerValueToState(name, { - secretId: binding.value.secretId, - prefix: value.prefix, - }), - targetScope: binding.scopeId, - secretScope: binding.value.secretScopeId, - }; - } - - if (binding?.value.kind === "text") { - return headerValueToState(name, binding.value.text); - } - - return null; -}; - -const queryParamFromConfiguredCredential = ( - name: string, - value: ConfiguredCredentialValueLike, - bindings: ReadonlyMap, -): QueryParamState | null => { - if (typeof value === "string") { - return { name, secretId: null, literalValue: value }; - } - - const binding = bindings.get(value.slot); - if (binding?.value.kind === "secret") { - return { - name, - secretId: binding.value.secretId, - prefix: value.prefix, - targetScope: binding.scopeId, - secretScope: binding.value.secretScopeId, - }; - } - - if (binding?.value.kind === "text") { - return { name, secretId: null, literalValue: binding.value.text }; - } - - return null; -}; - export const secretBackedValuesFromConfiguredCredentialBindings = ( values: Record | undefined | null, bindingsInput: readonly CredentialBindingRefLike[], @@ -140,22 +82,3 @@ export const secretBackedValuesFromConfiguredCredentialBindings = ( return Object.keys(out).length > 0 ? out : undefined; }; - -export const httpCredentialsFromConfiguredCredentialBindings = (input: { - readonly headers?: Record | null; - readonly queryParams?: Record | null; - readonly bindings: readonly CredentialBindingRefLike[]; -}): HttpCredentialsState => { - const bindings = bindingBySlot(input.bindings); - - return { - headers: Object.entries(input.headers ?? {}).flatMap(([name, value]) => { - const state = headerFromConfiguredCredential(name, value, bindings); - return state ? [state] : []; - }), - queryParams: Object.entries(input.queryParams ?? {}).flatMap(([name, value]) => { - const state = queryParamFromConfiguredCredential(name, value, bindings); - return state ? [state] : []; - }), - }; -}; diff --git a/packages/react/src/plugins/credential-slot-bindings.tsx b/packages/react/src/plugins/credential-slot-bindings.tsx index be222d75c..3dcde4838 100644 --- a/packages/react/src/plugins/credential-slot-bindings.tsx +++ b/packages/react/src/plugins/credential-slot-bindings.tsx @@ -11,6 +11,19 @@ import { import { CreatableSecretPicker } from "./secret-header-auth"; import type { SecretPickerSecret } from "./secret-picker"; +type ConfiguredHttpCredentialValue = + | string + | { + readonly kind: "binding"; + readonly slot: string; + readonly prefix?: string; + }; + +export type ConfiguredHttpCredentialMap = + | Readonly> + | undefined + | null; + export type SecretCredentialSlot = { readonly slot: string; readonly label: string; @@ -23,7 +36,7 @@ export type CredentialBindingScope = { }; type CredentialSlotBindingRef = { - readonly slot: string; + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -45,6 +58,30 @@ const rowTitle = (bindingScope: CredentialBindingScope, bindingScopeCount: numbe ? "Source credential" : "Organization default"; +export const secretCredentialSlotsFromHttpConfig = (input: { + readonly headers?: ConfiguredHttpCredentialMap; + readonly queryParams?: ConfiguredHttpCredentialMap; +}): readonly SecretCredentialSlot[] => { + const slots: SecretCredentialSlot[] = []; + for (const [name, value] of Object.entries(input.headers ?? {})) { + if (typeof value === "string") continue; + slots.push({ + slot: value.slot, + label: name, + hint: value.prefix ? `Prefix: ${value.prefix}` : undefined, + }); + } + for (const [name, value] of Object.entries(input.queryParams ?? {})) { + if (typeof value === "string") continue; + slots.push({ + slot: value.slot, + label: name, + hint: value.prefix ? `Prefix: ${value.prefix}` : undefined, + }); + } + return slots; +}; + export function SecretCredentialSlotBindings(props: { readonly slots: readonly SecretCredentialSlot[]; readonly bindingScopes: readonly CredentialBindingScope[]; diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx index a3d1540ab..585bd8832 100644 --- a/packages/react/src/plugins/headers-list.tsx +++ b/packages/react/src/plugins/headers-list.tsx @@ -9,6 +9,8 @@ import { CardStackEmpty, CardStackEntry, } from "../components/card-stack"; +import { Field, FieldGroup, FieldLabel } from "../components/field"; +import { Input } from "../components/input"; import { defaultHeaderAuthPresets, type HeaderAuthPreset, @@ -41,11 +43,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. */ + /** Scope choices available for where this source credential is used. */ readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; /** Scope choices for where this source credential is used. */ readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; + readonly defaultValueKind?: HeaderState["valueKind"]; } export function HeadersList({ @@ -61,9 +63,8 @@ export function HeadersList({ rowPreviewComponent, sourceName, targetScope, - credentialScopeOptions, bindingScopeOptions, - restrictSecretsToTargetScope, + defaultValueKind = "secret", }: HeadersListProps) { const [picking, setPicking] = useState(false); const canAddMore = !singleHeader || headers.length === 0; @@ -84,6 +85,7 @@ export function HeadersList({ prefix: preset.prefix, presetKey: preset.key, secretId: null, + valueKind: preset.valueKind ?? defaultValueKind, targetScope, }, ]); @@ -97,6 +99,8 @@ export function HeadersList({ secretId: string | null; prefix?: string; presetKey?: string; + valueKind?: HeaderState["valueKind"]; + literalValue?: string; targetScope?: ScopeId; secretScope?: ScopeId; }>, @@ -132,13 +136,10 @@ export function HeadersList({ ) : ( <> {headers.map((header, index) => ( - updateHeader(index, update)} onSelectSecret={(secretId, scopeId) => updateHeader(index, { @@ -149,10 +150,7 @@ export function HeadersList({ onRemove={singleHeader ? undefined : () => removeHeader(index)} existingSecrets={existingSecrets} sourceName={sourceName} - targetScope={header.targetScope ?? targetScope} - credentialScopeOptions={credentialScopeOptions} bindingScopeOptions={bindingScopeOptions} - restrictSecretsToTargetScope={restrictSecretsToTargetScope} copy={rowCopy} previewComponent={rowPreviewComponent} /> @@ -167,6 +165,116 @@ export function HeadersList({ ); } +function HeaderRow(props: { + readonly header: HeaderState; + readonly targetScope: ScopeId; + readonly onChange: ( + update: Partial<{ + name: string; + secretId: string | null; + prefix?: string; + presetKey?: string; + valueKind?: HeaderState["valueKind"]; + literalValue?: string; + targetScope?: ScopeId; + secretScope?: ScopeId; + }>, + ) => void; + readonly onSelectSecret: (secretId: string, scopeId?: ScopeId) => void; + readonly onRemove?: () => void; + readonly existingSecrets: readonly SecretPickerSecret[]; + readonly sourceName?: string; + readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly copy?: Partial; + readonly previewComponent?: SecretCredentialPreviewComponent; +}) { + if (props.header.valueKind === "text") { + return ( + props.onChange(update)} + onRemove={props.onRemove} + rowLabel={props.copy?.rowLabel} + nameLabel={props.copy?.nameLabel} + namePlaceholder={props.copy?.namePlaceholder} + /> + ); + } + + return ( + + ); +} + +function TextHeaderRow(props: { + readonly name: string; + readonly value: string; + readonly onChange: (update: { name?: string; literalValue?: string }) => void; + readonly onRemove?: () => void; + readonly rowLabel?: string; + readonly nameLabel?: string; + readonly namePlaceholder?: string; +}) { + return ( +
+
+ + {props.rowLabel ?? "Header"} + + {props.onRemove && ( + + )} +
+ + + {props.nameLabel ?? "Name"} + props.onChange({ name: (event.target as HTMLInputElement).value })} + placeholder={props.namePlaceholder ?? "X-Organization-Id"} + className="font-mono" + /> + + + Value + + props.onChange({ literalValue: (event.target as HTMLInputElement).value }) + } + placeholder="workspace-id" + className="font-mono" + /> + + +
+ ); +} + interface AddHeaderRowProps { readonly onClick: () => void; readonly leading?: ReactNode; diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx deleted file mode 100644 index 643da48d9..000000000 --- a/packages/react/src/plugins/http-credentials.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import type { - ScopeId, - ScopedSecretCredentialInput, - SecretBackedValue, -} from "@executor-js/sdk/shared"; - -import { FieldLabel } from "../components/field"; -import { HeadersList } from "./headers-list"; -import { - headerValueToState, - headersFromState, - QueryParamCredentialValuePreview, - type HeaderAuthPreset, - type HeaderState, -} from "./secret-header-auth"; -import type { CredentialTargetScopeOption } from "./credential-target-scope"; -import type { SecretPickerSecret } from "./secret-picker"; - -export type { SecretBackedValue }; - -export type QueryParamState = { - name: string; - secretId: string | null; - prefix?: string; - literalValue?: string; - targetScope?: ScopeId; - secretScope?: ScopeId; -}; - -const queryParamPresets: readonly HeaderAuthPreset[] = [ - { key: "custom", label: "Query parameter", name: "" }, -]; - -export type HttpCredentialsState = { - headers: HeaderState[]; - queryParams: QueryParamState[]; -}; - -export const emptyHttpCredentials = (): HttpCredentialsState => ({ - headers: [], - queryParams: [], -}); - -export const httpCredentialsFromValues = (input: { - readonly headers?: Record | null; - readonly queryParams?: Record | null; -}): HttpCredentialsState => ({ - headers: Object.entries(input.headers ?? {}).map(([name, value]) => - headerValueToState(name, value), - ), - queryParams: Object.entries(input.queryParams ?? {}).map(([name, value]) => { - if (typeof value === "string") { - return { name, secretId: null, literalValue: value }; - } - return { name, secretId: value.secretId, prefix: value.prefix }; - }), -}); - -export const serializeHeaderCredentials = ( - headers: readonly HeaderState[], -): Record => headersFromState(headers); - -export const serializeQueryCredentials = ( - queryParams: readonly QueryParamState[], -): Record => { - const result: Record = {}; - for (const param of queryParams) { - const name = param.name.trim(); - if (!name) continue; - if (param.secretId) { - result[name] = { - secretId: param.secretId, - ...(param.prefix ? { prefix: param.prefix } : {}), - }; - continue; - } - if (param.literalValue?.trim()) { - result[name] = param.literalValue.trim(); - } - } - return result; -}; - -export const serializeHttpCredentials = ( - credentials: HttpCredentialsState, -): { - readonly headers: Record; - readonly queryParams: Record; -} => ({ - headers: serializeHeaderCredentials(credentials.headers), - queryParams: serializeQueryCredentials(credentials.queryParams), -}); - -export const serializeScopedHeaderCredentials = ( - headers: readonly HeaderState[], - fallbackTargetScope: ScopeId, -): Record => { - const result: Record = {}; - for (const header of headers) { - const name = header.name.trim(); - if (!name || !header.secretId) continue; - const targetScope = header.targetScope ?? fallbackTargetScope; - result[name] = { - secretId: header.secretId, - targetScope, - ...(header.secretScope ? { secretScopeId: header.secretScope } : {}), - ...(header.prefix ? { prefix: header.prefix } : {}), - }; - } - return result; -}; - -export const serializeScopedQueryCredentials = ( - queryParams: readonly QueryParamState[], - fallbackTargetScope: ScopeId, -): Record => { - const result: Record = {}; - for (const param of queryParams) { - const name = param.name.trim(); - if (!name) continue; - if (param.secretId) { - const targetScope = param.targetScope ?? fallbackTargetScope; - result[name] = { - secretId: param.secretId, - targetScope, - ...(param.secretScope ? { secretScopeId: param.secretScope } : {}), - ...(param.prefix ? { prefix: param.prefix } : {}), - }; - continue; - } - if (param.literalValue?.trim()) { - result[name] = param.literalValue.trim(); - } - } - return result; -}; - -export const serializeScopedHttpCredentials = ( - credentials: HttpCredentialsState, - fallbackTargetScope: ScopeId, -) => ({ - headers: serializeScopedHeaderCredentials(credentials.headers, fallbackTargetScope), - queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), -}); - -export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => - credentials.headers.every((header) => header.name.trim() && header.secretId) && - credentials.queryParams.every((param) => { - if (!param.name.trim()) return false; - return Boolean(param.secretId || param.literalValue?.trim()); - }); - -export function HttpCredentialsEditor(props: { - readonly credentials: HttpCredentialsState; - readonly onChange: (credentials: HttpCredentialsState) => void; - readonly existingSecrets: readonly SecretPickerSecret[]; - readonly sourceName?: string; - readonly targetScope: ScopeId; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; - readonly sections?: { - readonly headers?: boolean; - readonly queryParams?: boolean; - }; - readonly labels?: { - readonly headers?: string; - readonly queryParams?: string; - }; -}) { - const showHeaders = props.sections?.headers ?? true; - const showQueryParams = props.sections?.queryParams ?? true; - - return ( -
- {showHeaders && ( -
- {props.labels?.headers ?? "Headers"} - props.onChange({ ...props.credentials, headers })} - existingSecrets={props.existingSecrets} - sourceName={props.sourceName} - targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} - bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} - /> -
- )} - - {showQueryParams && ( -
- {props.labels?.queryParams ?? "Query parameters"} - 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} - /> -
- )} -
- ); -} diff --git a/packages/react/src/plugins/oauth-sign-in.tsx b/packages/react/src/plugins/oauth-sign-in.tsx index 6cb4d4b90..ab20c77c7 100644 --- a/packages/react/src/plugins/oauth-sign-in.tsx +++ b/packages/react/src/plugins/oauth-sign-in.tsx @@ -386,6 +386,8 @@ export function SourceOAuthSignInButton(props: { readonly detectPopupClosed?: boolean; readonly reconnectingLabel?: string; readonly signingInLabel?: string; + readonly reconnectLabel?: string; + readonly signInLabel?: string; }) { const { connectionId, @@ -400,7 +402,9 @@ export function SourceOAuthSignInButton(props: { popupName, queryParams, reconnectingLabel, + reconnectLabel, signingInLabel, + signInLabel, sourceLabel, tokenScope, } = props; @@ -453,7 +457,9 @@ export function SourceOAuthSignInButton(props: { isConnected={isConnected} onSignIn={() => void handleSignIn()} reconnectingLabel={reconnectingLabel} + reconnectLabel={reconnectLabel} signingInLabel={signingInLabel} + signInLabel={signInLabel} /> ); } diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 40d495b54..065902e4a 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -21,10 +21,7 @@ import { } from "../components/select"; import { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; -import { - CredentialTargetScopeSelector, - type CredentialTargetScopeOption, -} from "./credential-target-scope"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; import { secretsForCredentialTarget } from "./secret-credential-scope"; export { secretsForCredentialTarget }; @@ -34,6 +31,7 @@ export interface HeaderAuthPreset { readonly label: string; readonly name: string; readonly prefix?: string; + readonly valueKind?: HeaderValueKind; } export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ @@ -63,29 +61,16 @@ function CreateSecretContent(props: { 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)} + scopeId={props.targetScope} + onCreated={(secretId) => props.onCreated(secretId, props.targetScope)} >
- {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( - - )}
@@ -113,7 +98,6 @@ export function InlineCreateSecret(props: { onCancel: () => void; fallbackId?: string; targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { return (
@@ -133,7 +117,6 @@ function CreateSecretDialog(props: { readonly onCreated: (secretId: string, scopeId: ScopeId) => void; readonly fallbackId?: string; readonly targetScope: ScopeId; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; }) { return ( @@ -151,7 +134,6 @@ function CreateSecretDialog(props: { onCreated={props.onCreated} onCancel={() => props.onOpenChange(false)} targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} /> @@ -203,6 +185,8 @@ export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewP export type HeaderState = { name: string; secretId: string | null; + valueKind?: HeaderValueKind; + literalValue?: string; prefix?: string; presetKey?: string; fromPreset?: boolean; @@ -212,6 +196,8 @@ export type HeaderState = { secretScope?: ScopeId; }; +export type HeaderValueKind = "secret" | "text"; + export function matchPresetKey(name: string, prefix?: string): string { const preset = defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === prefix) ?? @@ -257,11 +243,18 @@ export function headerValueToState( value: { secretId: string; prefix?: string } | string, ): HeaderState { if (typeof value === "string") { - return { name, secretId: null, presetKey: matchPresetKey(name, undefined) }; + return { + name, + secretId: null, + literalValue: value, + valueKind: "text", + presetKey: matchPresetKey(name, undefined), + }; } return { name, secretId: value.secretId, + valueKind: "secret", prefix: value.prefix, presetKey: matchPresetKey(name, value.prefix), }; @@ -269,11 +262,18 @@ export function headerValueToState( export function headersFromState( entries: readonly HeaderState[], -): Record { - const result: Record = {}; +): Record { + const result: Record = {}; for (const entry of entries) { const name = entry.name.trim(); - if (!name || !entry.secretId) continue; + if (!name) continue; + if (entry.valueKind === "text") { + if (entry.literalValue?.trim()) { + result[name] = entry.literalValue.trim(); + } + continue; + } + if (!entry.secretId) continue; result[name] = { secretId: entry.secretId, ...(entry.prefix ? { prefix: entry.prefix } : {}), @@ -313,9 +313,7 @@ export function SecretHeaderAuthRow(props: { */ sourceName?: string; targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - restrictSecretsToTargetScope?: boolean; }) { const [creating, setCreating] = useState(false); const nameInputId = useId(); @@ -335,9 +333,7 @@ export function SecretHeaderAuthRow(props: { previewComponent: PreviewComponent = HeaderCredentialValuePreview, sourceName, targetScope, - credentialScopeOptions, bindingScopeOptions, - restrictSecretsToTargetScope = false, } = props; const isCustom = presetKey === "custom" || presetKey === undefined; @@ -345,7 +341,6 @@ export function SecretHeaderAuthRow(props: { const headerLabel = name.trim() || "Custom Header"; const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); - const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets; return (
@@ -359,7 +354,6 @@ export function SecretHeaderAuthRow(props: { setCreating(false); }} targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} />
@@ -429,7 +423,7 @@ export function SecretHeaderAuthRow(props: { value={secretId} valueScopeId={secretScope ? String(secretScope) : undefined} onSelect={(id, scopeId) => onSelectSecret(id, ScopeId.make(scopeId))} - secrets={selectableSecrets} + secrets={scopedSecrets} onCreateNew={() => setCreating(true)} />
@@ -500,7 +494,6 @@ export function CreatableSecretPicker(props: { sourceName, secretLabel, targetScope, - credentialScopeOptions, onCreatedScope, suggestedId: suggestedIdProp, } = props; @@ -523,7 +516,6 @@ export function CreatableSecretPicker(props: { setCreating(false); }} targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} /> ); } @@ -533,7 +525,7 @@ export function CreatableSecretPicker(props: { value={value} valueScopeId={String(targetScope)} onSelect={(id, scopeId) => onSelect(id, ScopeId.make(scopeId))} - secrets={secrets} + secrets={scopedSecrets} placeholder={placeholder} onCreateNew={() => setCreating(true)} /> diff --git a/packages/react/src/plugins/source-credential-bindings.tsx b/packages/react/src/plugins/source-credential-bindings.tsx new file mode 100644 index 000000000..799a5da5c --- /dev/null +++ b/packages/react/src/plugins/source-credential-bindings.tsx @@ -0,0 +1,158 @@ +import { useMemo, useState } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import * as Exit from "effect/Exit"; + +import { + RemoveSourceCredentialBindingInput, + ScopeId, + SecretId, + SetSourceCredentialBindingInput, +} from "@executor-js/sdk/shared"; + +import { removeSourceCredentialBinding, setSourceCredentialBinding } from "../api/atoms"; +import { sourceWriteKeys } from "../api/reactivity-keys"; +import { useScopeStack, useUserScope } from "../api/scope-context"; +import type { CredentialBindingRefLike } from "./credential-bindings"; +import type { CredentialBindingScope } from "./credential-slot-bindings"; +import type { CredentialTargetScopeOption } from "./credential-target-scope"; + +export function useSourceCredentialBindingScopes(input: { readonly sourceScope: ScopeId }): { + readonly credentialScopes: readonly CredentialBindingScope[]; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly organizationCredentialScope: CredentialBindingScope; + readonly personalCredentialScope: CredentialBindingScope | null; + readonly secretBindingScopes: readonly CredentialBindingScope[]; + readonly scopeRanks: ReadonlyMap; +} { + const userScope = useUserScope(); + const scopeStack = useScopeStack(); + + const credentialScopes = useMemo(() => { + const entries: CredentialBindingScope[] = []; + if (userScope !== input.sourceScope) { + entries.push({ scopeId: ScopeId.make(userScope), label: "Personal" }); + } + entries.push({ + scopeId: input.sourceScope, + label: userScope === input.sourceScope ? "Credentials" : "Organization", + }); + return entries; + }, [input.sourceScope, userScope]); + + 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[credentialScopes.length - 1]!; + const personalCredentialScope = + credentialScopes.find((entry) => entry.label === "Personal") ?? null; + const secretBindingScopes = + personalCredentialScope && + personalCredentialScope.scopeId !== organizationCredentialScope.scopeId + ? [organizationCredentialScope, personalCredentialScope] + : [organizationCredentialScope]; + const scopeRanks = useMemo( + () => new Map(scopeStack.map((scope, index) => [scope.id, index] as const)), + [scopeStack], + ); + + return { + credentialScopes, + credentialScopeOptions, + organizationCredentialScope, + personalCredentialScope, + secretBindingScopes, + scopeRanks, + }; +} + +export const initialSourceCredentialScope = ( + sourceScope: ScopeId, + bindings: readonly CredentialBindingRefLike[], +): ScopeId => bindings[0]?.scopeId ?? sourceScope; + +export function useSourceCredentialBindingWriter(input: { + readonly displayScope: ScopeId; + readonly source: { + readonly id: string; + readonly scope: ScopeId; + }; + readonly onError: (message: string | null) => void; + readonly errorMessageFromExit?: (exit: Exit.Exit, fallback: string) => string; +}): { + readonly busyKey: string | null; + readonly setSecretBinding: ( + targetScope: ScopeId, + slot: string, + secretId: string, + secretScope: ScopeId, + ) => Promise; + readonly clearBinding: (targetScope: ScopeId, slot: string) => Promise; +} { + const [busyKey, setBusyKey] = useState(null); + const setBinding = useAtomSet(setSourceCredentialBinding, { mode: "promiseExit" }); + const removeBinding = useAtomSet(removeSourceCredentialBinding, { mode: "promiseExit" }); + const errorMessage = + input.errorMessageFromExit ?? + ((_exit: Exit.Exit, fallback: string) => fallback); + + const setSecretBinding = async ( + targetScope: ScopeId, + slot: string, + secretId: string, + secretScope: ScopeId, + ) => { + const inputKey = `${targetScope}:${slot}`; + const trimmed = secretId.trim(); + if (!trimmed) return; + setBusyKey(inputKey); + input.onError(null); + const exit = await setBinding({ + params: { scopeId: input.displayScope }, + payload: SetSourceCredentialBindingInput.make({ + source: input.source, + scope: targetScope, + slotKey: slot, + value: { + kind: "secret", + secretId: SecretId.make(trimmed), + secretScopeId: secretScope, + }, + }), + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { + input.onError(errorMessage(exit, "Failed to save credential binding")); + } + setBusyKey(null); + }; + + const clearBinding = async (targetScope: ScopeId, slot: string) => { + setBusyKey(`${targetScope}:${slot}:clear`); + input.onError(null); + const exit = await removeBinding({ + params: { scopeId: input.displayScope }, + payload: RemoveSourceCredentialBindingInput.make({ + source: input.source, + scope: targetScope, + slotKey: slot, + }), + reactivityKeys: sourceWriteKeys, + }); + if (Exit.isFailure(exit)) { + input.onError(errorMessage(exit, "Failed to clear credential binding")); + } + setBusyKey(null); + }; + + return { busyKey, setSecretBinding, clearBinding }; +} diff --git a/packages/react/src/plugins/source-credential-status-core.ts b/packages/react/src/plugins/source-credential-status-core.ts index b1ee2bf17..19c8c24a5 100644 --- a/packages/react/src/plugins/source-credential-status-core.ts +++ b/packages/react/src/plugins/source-credential-status-core.ts @@ -15,7 +15,7 @@ export type SourceCredentialSlot = }; export type SourceCredentialBindingRef = { - readonly slot: string; + readonly slotKey: string; readonly scopeId: ScopeId; readonly value: CredentialBindingValue; }; @@ -31,7 +31,8 @@ export const effectiveSourceCredentialBinding = ( ): SourceCredentialBindingRef | null => rows .filter( - (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), + (row) => + row.slotKey === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), ) .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0] ?? null; diff --git a/packages/react/src/plugins/source-credential-status.test.ts b/packages/react/src/plugins/source-credential-status.test.ts index 0cd61756c..f5f924c38 100644 --- a/packages/react/src/plugins/source-credential-status.test.ts +++ b/packages/react/src/plugins/source-credential-status.test.ts @@ -22,7 +22,7 @@ const slots: readonly SourceCredentialSlot[] = [ const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [ { - slot: "header:authorization", + slotKey: "header:authorization", scopeId, value: { kind: "secret", @@ -30,7 +30,7 @@ const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [ }, }, { - slot: "auth:oauth2:connection", + slotKey: "auth:oauth2:connection", scopeId, value: { kind: "connection", diff --git a/packages/react/src/plugins/source-credential-status.tsx b/packages/react/src/plugins/source-credential-status.tsx index 99ac68cf0..4c3d27e58 100644 --- a/packages/react/src/plugins/source-credential-status.tsx +++ b/packages/react/src/plugins/source-credential-status.tsx @@ -1,5 +1,6 @@ import { Badge } from "../components/badge"; import { Button } from "../components/button"; +import type { ReactNode } from "react"; export { effectiveSourceCredentialBinding, missingSourceCredentialLabels, @@ -31,6 +32,7 @@ export function SourceCredentialStatusBadge(props: { readonly missing: readonly export function SourceCredentialNotice(props: { readonly missing: readonly string[]; + readonly action?: ReactNode; readonly onAction?: () => void; }) { if (props.missing.length === 0) return null; @@ -44,11 +46,12 @@ export function SourceCredentialNotice(props: { Missing {props.missing.join(", ")}
- {props.onAction && ( - - )} + {props.action ?? + (props.onAction && ( + + ))}
); diff --git a/packages/react/src/plugins/source-oauth-connection.test.ts b/packages/react/src/plugins/source-oauth-connection.test.ts new file mode 100644 index 000000000..30dc02057 --- /dev/null +++ b/packages/react/src/plugins/source-oauth-connection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; + +import { sourceOAuthConnectionUiState } from "./source-oauth-connection"; + +describe("source OAuth connection UI state", () => { + it("shows when the selected scope is using an inherited connection", () => { + const personalScope = ScopeId.make("user_1"); + const organizationScope = ScopeId.make("org_1"); + const connectionId = ConnectionId.make("connection_1"); + + expect( + sourceOAuthConnectionUiState({ + bindings: [ + { + slotKey: "auth:oauth2:connection", + scopeId: organizationScope, + value: { kind: "connection", connectionId }, + }, + ], + connectionSlot: "auth:oauth2:connection", + tokenScope: personalScope, + scopeRanks: new Map([ + [personalScope, 0], + [organizationScope, 1], + ]), + credentialScopeOptions: [ + { + scopeId: personalScope, + label: "Personal", + description: "Saved only for your account.", + }, + { + scopeId: organizationScope, + label: "Organization", + description: "Shared with everyone who can use this source.", + }, + ], + connections: [{ id: connectionId }], + }), + ).toEqual({ + connectionId: null, + isConnected: true, + buttonIsConnected: false, + statusLabel: "Using Organization connection", + signInLabel: "Sign in personally", + }); + }); +}); diff --git a/packages/react/src/plugins/source-oauth-connection.tsx b/packages/react/src/plugins/source-oauth-connection.tsx index 3d41302dc..703d927cb 100644 --- a/packages/react/src/plugins/source-oauth-connection.tsx +++ b/packages/react/src/plugins/source-oauth-connection.tsx @@ -5,8 +5,70 @@ import { CredentialUsageRow, type CredentialTargetScopeOption, } from "./credential-target-scope"; +import { + effectiveCredentialBindingForScope, + exactCredentialBindingForScope, + type CredentialBindingRefLike, +} from "./credential-bindings"; import { SourceOAuthSignInButton } from "./oauth-sign-in"; +export const sourceOAuthConnectionUiState = (input: { + readonly bindings: readonly CredentialBindingRefLike[]; + readonly connectionSlot: string; + readonly tokenScope: ScopeId; + readonly scopeRanks: ReadonlyMap; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly connections: readonly { readonly id: ConnectionId }[]; +}): { + readonly connectionId: ConnectionId | null; + readonly isConnected: boolean; + readonly buttonIsConnected: boolean; + readonly statusLabel: string; + readonly signInLabel: string; +} => { + const effectiveBinding = effectiveCredentialBindingForScope( + input.bindings, + input.connectionSlot, + input.tokenScope, + input.scopeRanks, + ); + const exactBinding = exactCredentialBindingForScope( + input.bindings, + input.connectionSlot, + input.tokenScope, + ); + const effectiveConnectionId = + effectiveBinding?.value.kind === "connection" ? effectiveBinding.value.connectionId : null; + const exactConnectionId = + exactBinding?.value.kind === "connection" ? exactBinding.value.connectionId : null; + const isConnected = + effectiveConnectionId !== null && + input.connections.some((connection) => connection.id === effectiveConnectionId); + const buttonIsConnected = + exactConnectionId !== null && + input.connections.some((connection) => connection.id === exactConnectionId); + const selectedScopeLabel = + input.credentialScopeOptions.find((option) => option.scopeId === input.tokenScope)?.label ?? + "selected scope"; + const inheritedScopeLabel = + effectiveBinding && effectiveBinding.scopeId !== input.tokenScope + ? (input.credentialScopeOptions.find((option) => option.scopeId === effectiveBinding.scopeId) + ?.label ?? "Organization") + : null; + + return { + connectionId: exactConnectionId, + isConnected, + buttonIsConnected, + statusLabel: buttonIsConnected + ? `Connected in ${selectedScopeLabel}` + : isConnected && inheritedScopeLabel + ? `Using ${inheritedScopeLabel} connection` + : `No ${selectedScopeLabel} connection`, + signInLabel: inheritedScopeLabel ? "Sign in personally" : "Sign in", + }; +}; + export function SourceOAuthConnectionControl(props: { readonly popupName: string; readonly pluginId: string; @@ -21,11 +83,17 @@ export function SourceOAuthConnectionControl(props: { readonly headers?: Record; readonly queryParams?: Record; readonly isConnected: boolean; + readonly buttonIsConnected?: boolean; + readonly statusLabel?: string; readonly onConnected: (connectionId: ConnectionId) => void | Promise; readonly disabled?: boolean; readonly reconnectingLabel?: string; readonly signingInLabel?: string; + readonly reconnectLabel?: string; + readonly signInLabel?: string; }) { + const buttonIsConnected = props.buttonIsConnected ?? props.isConnected; + return ( {props.isConnected ? ( - Connected + {props.statusLabel ?? "Connected"} ) : ( - Not connected + + {props.statusLabel ?? "Not connected"} + )}