diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index b38a57b53..93905cf03 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -6,6 +6,7 @@ import { CoreHandlers } from "@executor/api/server"; import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; import { OrgAuth } from "../auth/middleware"; import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; @@ -21,12 +22,14 @@ import { OrgHttpApi } from "../org/compose"; import { OrgHandlers } from "../org/handlers"; import { CoreSharedServices } from "./core-shared-services"; +import { SecretsUsageHandlers } from "./secrets-usage"; export { CoreSharedServices }; const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) .add(McpGroup) .add(GraphqlGroup) + .add(SecretsUsageApi) .middleware(OrgAuth); const DbLive = DbService.Live; @@ -49,6 +52,7 @@ export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe( OpenApiHandlers, McpHandlers, GraphqlHandlers, + SecretsUsageHandlers, OrgAuthLive, ), ), diff --git a/apps/cloud/src/api/protected-layers.ts b/apps/cloud/src/api/protected-layers.ts index 35e0afb25..f8b9d3bec 100644 --- a/apps/cloud/src/api/protected-layers.ts +++ b/apps/cloud/src/api/protected-layers.ts @@ -15,6 +15,7 @@ import { CoreHandlers } from "@executor/api/server"; import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; import { OrgAuth } from "../auth/middleware"; import { OrgAuthLive } from "../auth/middleware-live"; @@ -23,10 +24,12 @@ import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { DbService } from "../services/db"; import { ErrorCaptureLive } from "../observability"; +import { SecretsUsageHandlers } from "./secrets-usage"; export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) .add(McpGroup) .add(GraphqlGroup) + .add(SecretsUsageApi) .addError(InternalError) .middleware(OrgAuth); @@ -53,6 +56,7 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( OpenApiHandlers, McpHandlers, GraphqlHandlers, + SecretsUsageHandlers, ); // `ErrorCaptureLive` is provided above the handler + middleware layers diff --git a/apps/cloud/src/api/secrets-usage.ts b/apps/cloud/src/api/secrets-usage.ts new file mode 100644 index 000000000..d99207452 --- /dev/null +++ b/apps/cloud/src/api/secrets-usage.ts @@ -0,0 +1,48 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; + +import { addGroup, capture, InternalError } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; +import { buildSecretsUsage } from "@executor/api/secrets/usage"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; +import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; +import { McpExtensionService } from "@executor/plugin-mcp/api"; +import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; +import { collectOpenApiSecretIds } from "@executor/plugin-openapi"; +import { collectMcpSecretIds } from "@executor/plugin-mcp"; +import { collectGraphqlSecretIds } from "@executor/plugin-graphql"; + +const CloudApiWithSecretsUsage = addGroup(SecretsUsageApi).addError(InternalError); + +export const SecretsUsageHandlers = HttpApiBuilder.group( + CloudApiWithSecretsUsage, + "secretsUsage", + (handlers) => + handlers.handle("list", () => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + const openapi = yield* OpenApiExtensionService; + const mcp = yield* McpExtensionService; + const graphql = yield* GraphqlExtensionService; + + const sources = (yield* executor.sources.list().pipe( + Effect.catchAll(() => Effect.succeed([])), + )); + + return yield* buildSecretsUsage(sources, { + openapi: (sourceId, scopeId) => + openapi + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectOpenApiSecretIds(stored) : []))), + mcp: (sourceId, scopeId) => + mcp + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectMcpSecretIds(stored) : []))), + graphql: (sourceId, scopeId) => + graphql + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectGraphqlSecretIds(stored) : []))), + }); + })), + ), +); diff --git a/apps/cloud/src/openapi-source-summary.test.ts b/apps/cloud/src/openapi-source-summary.test.ts new file mode 100644 index 000000000..de9970b41 --- /dev/null +++ b/apps/cloud/src/openapi-source-summary.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ScopeId } from "@executor/sdk"; + +const mocks = vi.hoisted(() => ({ + useAtomValue: vi.fn(), + useScope: vi.fn(), + openApiSourceAtom: vi.fn(), +})); + +vi.mock("@effect-atom/atom-react", async () => { + const actual = await vi.importActual( + "@effect-atom/atom-react", + ); + return { + ...actual, + useAtomValue: mocks.useAtomValue, + Result: { + ...actual.Result, + isSuccess: (result: { _tag?: string }) => result?._tag === "Success", + }, + }; +}); + +vi.mock("@executor/react/api/scope-context", () => ({ + useScope: mocks.useScope, +})); + +vi.mock("../../../packages/plugins/openapi/src/react/atoms", () => ({ + openApiSourceAtom: mocks.openApiSourceAtom, +})); + +import OpenApiSourceSummary from "../../../packages/plugins/openapi/src/react/OpenApiSourceSummary"; + +describe("OpenApiSourceSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads source config from the owner scope when provided", () => { + const sourceAtom = Symbol("source-atom"); + mocks.useScope.mockReturnValue("user-scope"); + mocks.openApiSourceAtom.mockReturnValue(sourceAtom); + mocks.useAtomValue.mockReturnValue({ + _tag: "Success", + value: { + config: { + oauth2: { + accessTokenSecretId: "access_token", + }, + }, + }, + }); + + OpenApiSourceSummary({ sourceId: "shared", sourceScopeId: ScopeId.make("org-scope") }); + + expect(mocks.openApiSourceAtom).toHaveBeenCalledWith("org-scope", "shared"); + }); +}); diff --git a/apps/cloud/src/routes/secrets.tsx b/apps/cloud/src/routes/secrets.tsx index 84ce9a50e..7a59f9b84 100644 --- a/apps/cloud/src/routes/secrets.tsx +++ b/apps/cloud/src/routes/secrets.tsx @@ -1,13 +1,37 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useAtomValue } from "@effect-atom/atom-react"; +import { secretsAtom } from "@executor/react/api/atoms"; +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; +import { useScope } from "@executor/react/api/scope-context"; import { SecretsPage } from "@executor/react/pages/secrets"; +import { resolveSecretsRouteState } from "@executor/react/pages/secrets-route"; +import { CloudApiClient } from "../web/client"; -export const Route = createFileRoute("/secrets")({ - component: () => ( +const secretsUsageAtom = (scopeId: ReturnType) => + CloudApiClient.query("secretsUsage", "list", { + path: { scopeId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.sources], + }); + +function CloudSecretsRoute() { + const scopeId = useScope(); + const secrets = useAtomValue(secretsAtom(scopeId)); + const usage = useAtomValue(secretsUsageAtom(scopeId)); + const merged = resolveSecretsRouteState(secrets, usage); + + return ( - ), + ); +} + +export const Route = createFileRoute("/secrets")({ + component: CloudSecretsRoute, }); diff --git a/apps/cloud/src/services/secrets-api.node.test.ts b/apps/cloud/src/services/secrets-api.node.test.ts index f829b7ade..851821580 100644 --- a/apps/cloud/src/services/secrets-api.node.test.ts +++ b/apps/cloud/src/services/secrets-api.node.test.ts @@ -27,6 +27,7 @@ describe("secrets api (HTTP)", () => { client.secrets.list({ path: { scopeId: ScopeId.make(org) } }), ); expect(list.find((s) => s.id === id)?.name).toBe("My API Token"); + expect(list.find((s) => s.id === id)).not.toHaveProperty("usedBy"); const resolved = yield* asOrg(org, (client) => client.secrets.resolve({ diff --git a/apps/cloud/src/services/secrets-usage-api.node.test.ts b/apps/cloud/src/services/secrets-usage-api.node.test.ts new file mode 100644 index 000000000..a857b4898 --- /dev/null +++ b/apps/cloud/src/services/secrets-usage-api.node.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { ScopeId, SecretId } from "@executor/sdk"; + +import { asOrg, asUser } from "./__test-harness__/api-harness"; + +describe("secrets usage api (HTTP)", () => { + it.effect("lists source usage for secrets referenced by source config", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `api_${crypto.randomUUID().slice(0, 8)}`; + + yield* asOrg(org, (client) => + client.secrets.set({ + path: { scopeId: ScopeId.make(org) }, + payload: { id: SecretId.make(secretId), name: "Shared token", value: "sk-test-usage" }, + }), + ); + + yield* asOrg(org, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(org) }, + payload: { + spec: "https://openapi.vercel.sh", + namespace, + baseUrl: "https://api.vercel.com", + headers: { + Authorization: { + secretId, + prefix: "Bearer ", + }, + }, + }, + }), + ); + + const usage = yield* asOrg(org, (client) => + client.secretsUsage.list({ path: { scopeId: ScopeId.make(org) } }), + ); + + expect(usage).toEqual([ + { + secretId, + usedBy: [ + { + sourceId: namespace, + sourceName: "Vercel API", + sourceKind: "openapi", + }, + ], + }, + ]); + }), + ); + + it.effect("does not leak usage across orgs", () => + Effect.gen(function* () { + const orgA = `org_${crypto.randomUUID()}`; + const orgB = `org_${crypto.randomUUID()}`; + const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `api_${crypto.randomUUID().slice(0, 8)}`; + + yield* asOrg(orgA, (client) => + client.secrets.set({ + path: { scopeId: ScopeId.make(orgA) }, + payload: { id: SecretId.make(secretId), name: "Shared token", value: "sk-test-usage" }, + }), + ); + + yield* asOrg(orgA, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(orgA) }, + payload: { + spec: "https://openapi.vercel.sh", + namespace, + baseUrl: "https://api.vercel.com", + headers: { + Authorization: { + secretId, + prefix: "Bearer ", + }, + }, + }, + }), + ); + + const usage = yield* asOrg(orgB, (client) => + client.secretsUsage.list({ path: { scopeId: ScopeId.make(orgB) } }), + ); + + expect(usage).toEqual([]); + }), + ); + + it.effect("includes inherited org-scoped source usage for a member's user scope", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const userId = `user_${crypto.randomUUID().slice(0, 8)}`; + const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `api_${crypto.randomUUID().slice(0, 8)}`; + + yield* asOrg(org, (client) => + client.secrets.set({ + path: { scopeId: ScopeId.make(org) }, + payload: { id: SecretId.make(secretId), name: "Org shared token", value: "sk-shared" }, + }), + ); + + yield* asOrg(org, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(org) }, + payload: { + spec: "https://openapi.vercel.sh", + namespace, + baseUrl: "https://api.vercel.com", + headers: { + Authorization: { + secretId, + prefix: "Bearer ", + }, + }, + }, + }), + ); + + const usage = yield* asUser(userId, org, (client) => + client.secretsUsage.list({ + path: { scopeId: ScopeId.make(`user-org:${userId}:${org}`) }, + }), + ); + + expect(usage).toEqual([ + { + secretId, + usedBy: [ + { + sourceId: namespace, + sourceName: "Vercel API", + sourceKind: "openapi", + }, + ], + }, + ]); + }), + ); +}); diff --git a/apps/cloud/src/services/sources-api.node.test.ts b/apps/cloud/src/services/sources-api.node.test.ts index 1450c737a..fec7d06e0 100644 --- a/apps/cloud/src/services/sources-api.node.test.ts +++ b/apps/cloud/src/services/sources-api.node.test.ts @@ -10,7 +10,7 @@ import { resolve } from "node:path"; import { ScopeId } from "@executor/sdk"; -import { asOrg } from "./__test-harness__/api-harness"; +import { asOrg, asUser } from "./__test-harness__/api-harness"; const MINIMAL_OPENAPI_SPEC = JSON.stringify({ openapi: "3.0.0", @@ -59,6 +59,32 @@ describe("sources api (HTTP)", () => { client.sources.list({ path: { scopeId: ScopeId.make(org) } }), ); expect(sources.map((s) => s.id)).toContain(namespace); + expect(sources.find((s) => s.id === namespace)?.scopeId).toBe(org); + expect(sources.find((s) => s.id === "openapi")?.scopeId).toBeUndefined(); + }), + ); + + it.effect("sources.list carries owner scope for inherited sources", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const userId = `user_${crypto.randomUUID().slice(0, 8)}`; + const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + + yield* asOrg(org, (client) => + client.openapi.addSpec({ + path: { scopeId: ScopeId.make(org) }, + payload: { spec: MINIMAL_OPENAPI_SPEC, namespace }, + }), + ); + + const sources = yield* asUser(userId, org, (client) => + client.sources.list({ + path: { scopeId: ScopeId.make(`user-org:${userId}:${org}`) }, + }), + ); + + expect(sources.find((s) => s.id === namespace)?.scopeId).toBe(org); + expect(sources.find((s) => s.id === "openapi")?.scopeId).toBeUndefined(); }), ); diff --git a/apps/cloud/src/web/client.tsx b/apps/cloud/src/web/client.tsx index 57a872a03..40ebede6b 100644 --- a/apps/cloud/src/web/client.tsx +++ b/apps/cloud/src/web/client.tsx @@ -1,6 +1,7 @@ import { AtomHttpApi } from "@effect-atom/atom-react"; import { FetchHttpClient } from "@effect/platform"; import { addGroup } from "@executor/api"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; import { getBaseUrl } from "@executor/react/api/base-url"; import { CloudAuthApi } from "../auth/api"; import { OrgApi } from "../org/api"; @@ -9,7 +10,7 @@ import { OrgApi } from "../org/api"; // Cloud API client — core API + cloud auth + org // --------------------------------------------------------------------------- -const CloudApi = addGroup(CloudAuthApi).add(OrgApi); +const CloudApi = addGroup(CloudAuthApi).add(OrgApi).add(SecretsUsageApi); class CloudApiClient extends AtomHttpApi.Tag()("CloudApiClient", { api: CloudApi, diff --git a/apps/local/src/routes/secrets.tsx b/apps/local/src/routes/secrets.tsx index c5165a3bd..42ebad9a9 100644 --- a/apps/local/src/routes/secrets.tsx +++ b/apps/local/src/routes/secrets.tsx @@ -1,9 +1,37 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useAtomValue } from "@effect-atom/atom-react"; +import { secretsAtom } from "@executor/react/api/atoms"; +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; +import { useScope } from "@executor/react/api/scope-context"; import { SecretsPage } from "@executor/react/pages/secrets"; +import { resolveSecretsRouteState } from "@executor/react/pages/secrets-route"; import { onePasswordSecretProviderPlugin } from "@executor/plugin-onepassword/react"; +import { LocalApiClient } from "../web/client"; const secretProviderPlugins = [onePasswordSecretProviderPlugin]; +const secretsUsageAtom = (scopeId: ReturnType) => + LocalApiClient.query("secretsUsage", "list", { + path: { scopeId }, + timeToLive: "30 seconds", + reactivityKeys: [ReactivityKey.sources], + }); + +function LocalSecretsRoute() { + const scopeId = useScope(); + const secrets = useAtomValue(secretsAtom(scopeId)); + const usage = useAtomValue(secretsUsageAtom(scopeId)); + const merged = resolveSecretsRouteState(secrets, usage); + + return ( + + ); +} + export const Route = createFileRoute("/secrets")({ - component: () => , + component: LocalSecretsRoute, }); diff --git a/apps/local/src/server/main.ts b/apps/local/src/server/main.ts index d7abe464b..7131a47e5 100644 --- a/apps/local/src/server/main.ts +++ b/apps/local/src/server/main.ts @@ -11,6 +11,7 @@ import { addGroup, observabilityMiddleware } from "@executor/api"; import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; import { createExecutionEngine } from "@executor/execution"; import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; import { OpenApiGroup, OpenApiHandlers, @@ -35,6 +36,7 @@ import { import { getExecutor } from "./executor"; import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; import { ErrorCaptureLive } from "./observability"; +import { SecretsUsageHandlers } from "./secrets-usage"; // --------------------------------------------------------------------------- // Local server API — core + all plugin groups @@ -45,15 +47,16 @@ const LocalApi = addGroup(OpenApiGroup) .add(GoogleDiscoveryGroup) .add(OnePasswordGroup) .add(GraphqlGroup); +const LocalApiWithSecretsUsage = LocalApi.add(SecretsUsageApi); // `ErrorCaptureLive` logs causes to the console and returns a short // correlation id. Provided above the handler + middleware layers so // both the `withCapture` typed-channel translation AND the // `observabilityMiddleware` defect catchall see the same // implementation. -const LocalObservability = observabilityMiddleware(LocalApi); +const LocalObservability = observabilityMiddleware(LocalApiWithSecretsUsage); -const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( +const LocalApiBase = HttpApiBuilder.api(LocalApiWithSecretsUsage).pipe( Layer.provide(CoreHandlers), Layer.provide( Layer.mergeAll( @@ -62,6 +65,7 @@ const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( GoogleDiscoveryHandlers, OnePasswordHandlers, GraphqlHandlers, + SecretsUsageHandlers, ), ), Layer.provide(LocalObservability), diff --git a/apps/local/src/server/secrets-usage.test.ts b/apps/local/src/server/secrets-usage.test.ts new file mode 100644 index 000000000..a1d150956 --- /dev/null +++ b/apps/local/src/server/secrets-usage.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +import { Effect, Layer } from "effect"; +import { FetchHttpClient, HttpApiClient } from "@effect/platform"; + +import { addGroup } from "@executor/api"; +import { ScopeId, SecretId } from "@executor/sdk"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; +import { OpenApiGroup } from "@executor/plugin-openapi/api"; + +import { createServerHandlers } from "./main"; +import { disposeExecutor } from "./executor"; + +const TEST_BASE_URL = "http://local.test"; + +const makeScopeId = (cwd: string): string => { + const folder = basename(cwd) || cwd; + const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8); + return `${folder}-${hash}`; +}; + +const LocalTestApi = addGroup(SecretsUsageApi).add(OpenApiGroup); + +let dataDir: string; +let scopeDir: string; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), "executor-local-data-")); + scopeDir = mkdtempSync(join(tmpdir(), "executor-local-scope-")); + process.env.EXECUTOR_DATA_DIR = dataDir; + process.env.EXECUTOR_SCOPE_DIR = scopeDir; +}); + +afterEach(async () => { + await disposeExecutor(); + delete process.env.EXECUTOR_DATA_DIR; + delete process.env.EXECUTOR_SCOPE_DIR; + rmSync(dataDir, { recursive: true, force: true }); + rmSync(scopeDir, { recursive: true, force: true }); +}); + +describe("local secrets usage api", () => { + it("lists source usage for secrets referenced by local sources", async () => { + const handlers = await createServerHandlers(); + const fetchImpl: typeof globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + return handlers.api.handler(request); + }) as typeof globalThis.fetch; + + const clientEffect = Effect.gen(function* () { + const client = yield* HttpApiClient.make(LocalTestApi, { baseUrl: TEST_BASE_URL }); + const scopeId = ScopeId.make(makeScopeId(scopeDir)); + const secretId = SecretId.make("local_usage_secret"); + + yield* client.secrets.set({ + path: { scopeId }, + payload: { + id: secretId, + name: "Local token", + value: "sk-local-usage", + }, + }); + + yield* client.openapi.addSpec({ + path: { scopeId }, + payload: { + spec: "https://openapi.vercel.sh", + namespace: "local_vercel", + baseUrl: "https://api.vercel.com", + headers: { + Authorization: { + secretId: secretId, + prefix: "Bearer ", + }, + }, + }, + }); + + return yield* client.secretsUsage.list({ path: { scopeId } }); + }).pipe( + Effect.provide( + FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchImpl)), + ), + ), + ); + + await expect(Effect.runPromise(clientEffect)).resolves.toEqual([ + { + secretId: "local_usage_secret", + usedBy: [ + { + sourceId: "local_vercel", + sourceName: "Vercel API", + sourceKind: "openapi", + }, + ], + }, + ]); + + await handlers.api.dispose(); + await handlers.mcp.close(); + }); +}); diff --git a/apps/local/src/server/secrets-usage.ts b/apps/local/src/server/secrets-usage.ts new file mode 100644 index 000000000..9d0697740 --- /dev/null +++ b/apps/local/src/server/secrets-usage.ts @@ -0,0 +1,57 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; + +import { addGroup, capture, InternalError } from "@executor/api"; +import { ExecutorService } from "@executor/api/server"; +import { buildSecretsUsage } from "@executor/api/secrets/usage"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; +import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; +import { McpExtensionService } from "@executor/plugin-mcp/api"; +import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; +import { GoogleDiscoveryExtensionService } from "@executor/plugin-google-discovery/api"; +import { collectOpenApiSecretIds } from "@executor/plugin-openapi"; +import { collectMcpSecretIds } from "@executor/plugin-mcp"; +import { collectGraphqlSecretIds } from "@executor/plugin-graphql"; +import { collectGoogleDiscoverySecretIds } from "@executor/plugin-google-discovery"; + +const LocalApiWithSecretsUsage = addGroup(SecretsUsageApi).addError(InternalError); + +export const SecretsUsageHandlers = HttpApiBuilder.group( + LocalApiWithSecretsUsage, + "secretsUsage", + (handlers) => + handlers.handle("list", () => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + const openapi = yield* OpenApiExtensionService; + const mcp = yield* McpExtensionService; + const graphql = yield* GraphqlExtensionService; + const googleDiscovery = yield* GoogleDiscoveryExtensionService; + + const sources = (yield* executor.sources.list().pipe( + Effect.catchAll(() => Effect.succeed([])), + )); + + return yield* buildSecretsUsage(sources, { + openapi: (sourceId, scopeId) => + openapi + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectOpenApiSecretIds(stored) : []))), + mcp: (sourceId, scopeId) => + mcp + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectMcpSecretIds(stored) : []))), + graphql: (sourceId, scopeId) => + graphql + .getSource(sourceId, scopeId) + .pipe(Effect.map((stored) => (stored ? collectGraphqlSecretIds(stored) : []))), + googleDiscovery: (sourceId, scopeId) => + googleDiscovery + .getSource(sourceId, scopeId) + .pipe( + Effect.map((stored) => (stored ? collectGoogleDiscoverySecretIds(stored) : [])), + ), + }); + })), + ), +); diff --git a/apps/local/src/web/client.tsx b/apps/local/src/web/client.tsx new file mode 100644 index 000000000..88f575f44 --- /dev/null +++ b/apps/local/src/web/client.tsx @@ -0,0 +1,15 @@ +import { AtomHttpApi } from "@effect-atom/atom-react"; +import { FetchHttpClient } from "@effect/platform"; +import { addGroup } from "@executor/api"; +import { SecretsUsageApi } from "@executor/react/api/secrets-usage"; +import { getBaseUrl } from "@executor/react/api/base-url"; + +const LocalAppApi = addGroup(SecretsUsageApi); + +class LocalApiClient extends AtomHttpApi.Tag()("LocalApiClient", { + api: LocalAppApi, + httpClient: FetchHttpClient.layer, + baseUrl: getBaseUrl(), +}) {} + +export { LocalApiClient }; diff --git a/packages/core/api/package.json b/packages/core/api/package.json index a4adacbbe..10a0d15e5 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./server": "./src/server.ts" + "./server": "./src/server.ts", + "./secrets/usage": "./src/secrets/usage.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/core/api/src/handlers/secrets.ts b/packages/core/api/src/handlers/secrets.ts index d311c49f1..87d8ab052 100644 --- a/packages/core/api/src/handlers/secrets.ts +++ b/packages/core/api/src/handlers/secrets.ts @@ -20,7 +20,7 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han capture(Effect.gen(function* () { const executor = yield* ExecutorService; const refs = yield* executor.secrets.list(); - return refs.map(refToResponse); + return refs.map((ref) => refToResponse(ref)); })), ) .handle("status", ({ path }) => @@ -50,7 +50,7 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han const executor = yield* ExecutorService; const value = yield* executor.secrets.get(path.secretId); if (value === null) { - return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); + return yield* new SecretNotFoundError({ secretId: path.secretId }); } return { secretId: path.secretId, value }; })), diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts index 5ab8f9d26..5e07d8c67 100644 --- a/packages/core/api/src/handlers/sources.ts +++ b/packages/core/api/src/handlers/sources.ts @@ -16,6 +16,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han id: s.id, name: s.name, kind: s.kind, + scopeId: s.scopeId, url: s.url, runtime: s.runtime, canRemove: s.canRemove, diff --git a/packages/core/api/src/secrets/usage.ts b/packages/core/api/src/secrets/usage.ts new file mode 100644 index 000000000..783e59ac7 --- /dev/null +++ b/packages/core/api/src/secrets/usage.ts @@ -0,0 +1,63 @@ +import { Effect } from "effect"; +import type { Source } from "@executor/sdk"; + +export type SecretUsage = { + readonly sourceId: string; + readonly sourceName: string; + readonly sourceKind: string; +}; + +export type SecretUsageEntry = { + readonly secretId: string; + readonly usedBy: readonly SecretUsage[]; +}; + +type SecretIdResolver = (sourceId: string, scopeId: string) => Effect.Effect; + +export type SecretUsageResolvers = Partial>; + +const sortUsage = (usedBy: readonly SecretUsage[]): readonly SecretUsage[] => + [...usedBy].sort((left, right) => left.sourceName.localeCompare(right.sourceName)); + +const addUsage = ( + usageIndex: Map>, + secretId: string, + usage: SecretUsage, +) => { + const entries = usageIndex.get(secretId) ?? new Map(); + entries.set(usage.sourceId, usage); + usageIndex.set(secretId, entries); +}; + +export const buildSecretsUsage = ( + sources: readonly Source[], + resolvers: SecretUsageResolvers, +): Effect.Effect => + Effect.gen(function* () { + const usageIndex = new Map>(); + + for (const source of sources) { + if (!source.scopeId) continue; + const resolve = resolvers[source.pluginId]; + if (!resolve) continue; + + const secretIds = yield* resolve(source.id, source.scopeId).pipe( + Effect.catchAll(() => Effect.succeed([] as readonly string[])), + ); + + for (const secretId of secretIds) { + addUsage(usageIndex, secretId, { + sourceId: source.id, + sourceName: source.name, + sourceKind: source.kind, + }); + } + } + + return [...usageIndex.entries()] + .map(([secretId, usedBy]) => ({ + secretId, + usedBy: sortUsage([...usedBy.values()]), + })) + .sort((left, right) => left.secretId.localeCompare(right.secretId)); + }); diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts index 795850184..eebf0fdf1 100644 --- a/packages/core/api/src/sources/api.ts +++ b/packages/core/api/src/sources/api.ts @@ -19,6 +19,7 @@ const SourceResponse = Schema.Struct({ id: Schema.String, name: Schema.String, kind: Schema.String, + scopeId: Schema.optional(ScopeId), url: Schema.optional(Schema.String), runtime: Schema.optional(Schema.Boolean), canRemove: Schema.optional(Schema.Boolean), diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 9c64fe10a..7b0546c8f 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1377,11 +1377,10 @@ describe("cross-scope read precedence + remove isolation (SDK)", () => { ); it.effect( - "sources.list dedupes by id, keeping the innermost row", - () => - Effect.gen(function* () { - const { execOuter, execInner, innerId } = - yield* makeMarkerExecutors(); + "sources.list dedupes by id, keeping the innermost row", + () => + Effect.gen(function* () { + const { execOuter, execInner } = yield* makeMarkerExecutors(); yield* execOuter.marker.register("shared", "outer-name"); yield* execInner.marker.register("shared", "inner-name"); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index af5e40518..761e41769 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -226,6 +226,7 @@ const rowToSource = (row: SourceRow): Source => ({ id: row.id, kind: row.kind, name: row.name, + scopeId: ScopeId.make(row.scope_id as string), url: row.url ?? undefined, pluginId: row.plugin_id, canRemove: Boolean(row.can_remove), diff --git a/packages/core/sdk/src/types.ts b/packages/core/sdk/src/types.ts index e75d44915..fa0577c98 100644 --- a/packages/core/sdk/src/types.ts +++ b/packages/core/sdk/src/types.ts @@ -7,12 +7,14 @@ import { Schema } from "effect"; import type { ToolAnnotations } from "./core-schema"; -import { ToolId } from "./ids"; +import { ToolId, type ScopeId } from "./ids"; export interface Source { readonly id: string; readonly kind: string; readonly name: string; + /** Owning scope for persisted sources. Omitted for static sources. */ + readonly scopeId?: ScopeId; readonly url?: string; /** Which plugin owns this source. */ readonly pluginId: string; diff --git a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx index fbb87f8bf..a94e8a7b9 100644 --- a/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx @@ -1,4 +1,5 @@ import { useAtomValue, Result } from "@effect-atom/atom-react"; +import type { ScopeId } from "@executor/sdk"; import { useScope } from "@executor/react/api/scope-context"; import { Badge } from "@executor/react/components/badge"; import { Button } from "@executor/react/components/button"; @@ -7,13 +8,17 @@ import { googleDiscoverySourceAtom } from "./atoms"; export default function EditGoogleDiscoverySource({ sourceId, + sourceScopeId, onSave, }: { readonly sourceId: string; + readonly sourceScopeId?: ScopeId; readonly onSave: () => void; }) { - const scopeId = useScope(); - const sourceResult = useAtomValue(googleDiscoverySourceAtom(scopeId, sourceId)); + const currentScopeId = useScope(); + const sourceResult = useAtomValue( + googleDiscoverySourceAtom(sourceScopeId ?? currentScopeId, sourceId), + ); const source = Result.isSuccess(sourceResult) ? sourceResult.value : null; const config = source?.config; diff --git a/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx b/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx index 5b48c17d1..19e8405c4 100644 --- a/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx +++ b/packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx @@ -1,6 +1,12 @@ import { Badge } from "@executor/react/components/badge"; +import type { ScopeId } from "@executor/sdk"; -export default function GoogleDiscoverySourceSummary({ sourceId }: { readonly sourceId: string }) { +export default function GoogleDiscoverySourceSummary({ + sourceId, +}: { + readonly sourceId: string; + readonly sourceScopeId?: ScopeId; +}) { return ( diff --git a/packages/plugins/google-discovery/src/sdk/index.ts b/packages/plugins/google-discovery/src/sdk/index.ts index a3e39075f..6e9ca30c2 100644 --- a/packages/plugins/google-discovery/src/sdk/index.ts +++ b/packages/plugins/google-discovery/src/sdk/index.ts @@ -14,6 +14,7 @@ export { makeGoogleDiscoveryStore, GOOGLE_DISCOVERY_OAUTH_SESSION_TTL_MS, } from "./binding-store"; +export { collectGoogleDiscoverySecretIds } from "./secret-usage"; export type { GoogleDiscoveryStore, GoogleDiscoveryStoredSource, diff --git a/packages/plugins/google-discovery/src/sdk/secret-usage.test.ts b/packages/plugins/google-discovery/src/sdk/secret-usage.test.ts new file mode 100644 index 000000000..92f02659e --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/secret-usage.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import type { GoogleDiscoveryStoredSource } from "./binding-store"; +import { collectGoogleDiscoverySecretIds } from "./secret-usage"; + +describe("collectGoogleDiscoverySecretIds", () => { + it("collects oauth2 secret ids", () => { + const source: GoogleDiscoveryStoredSource = { + namespace: "google_calendar", + scope: "org_test", + name: "Google Calendar", + config: { + name: "Google Calendar", + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", + service: "calendar", + version: "v3", + rootUrl: "https://www.googleapis.com/", + servicePath: "calendar/v3/", + auth: { + kind: "oauth2", + clientIdSecretId: "client_id_secret", + clientSecretSecretId: "client_secret_secret", + accessTokenSecretId: "access_token_secret", + refreshTokenSecretId: "refresh_token_secret", + tokenType: "Bearer", + expiresAt: null, + scope: null, + scopes: ["https://www.googleapis.com/auth/calendar.readonly"], + }, + }, + }; + + expect(collectGoogleDiscoverySecretIds(source)).toEqual([ + "client_id_secret", + "client_secret_secret", + "access_token_secret", + "refresh_token_secret", + ]); + }); +}); diff --git a/packages/plugins/google-discovery/src/sdk/secret-usage.ts b/packages/plugins/google-discovery/src/sdk/secret-usage.ts new file mode 100644 index 000000000..40e393a32 --- /dev/null +++ b/packages/plugins/google-discovery/src/sdk/secret-usage.ts @@ -0,0 +1,15 @@ +import type { GoogleDiscoveryStoredSource } from "./binding-store"; + +export const collectGoogleDiscoverySecretIds = ( + source: GoogleDiscoveryStoredSource, +): readonly string[] => { + const auth = source.config.auth; + if (auth.kind !== "oauth2") return []; + + return [ + auth.clientIdSecretId, + auth.clientSecretSecretId, + auth.accessTokenSecretId, + auth.refreshTokenSecretId, + ].filter((value): value is string => typeof value === "string" && value.length > 0); +}; diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 02ec52277..99b14797d 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -1,3 +1,4 @@ +import type { ScopeId } from "@executor/sdk"; import { useState } from "react"; import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; @@ -35,10 +36,10 @@ type EditableSource = Omit; function EditForm(props: { sourceId: string; + sourceScopeId: ScopeId; initial: EditableSource; onSave: () => void; }) { - const scopeId = useScope(); const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" }); const secretList = useSecretPickerSecrets(); @@ -68,7 +69,7 @@ function EditForm(props: { setError(null); try { await doUpdate({ - path: { scopeId, namespace: props.sourceId }, + path: { scopeId: props.sourceScopeId, namespace: props.sourceId }, payload: { name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, @@ -153,9 +154,14 @@ function EditForm(props: { // Main component // --------------------------------------------------------------------------- -export default function EditGraphqlSource(props: { sourceId: string; onSave: () => void }) { - const scopeId = useScope(); - const sourceResult = useAtomValue(graphqlSourceAtom(scopeId, props.sourceId)); +export default function EditGraphqlSource(props: { + sourceId: string; + sourceScopeId?: ScopeId; + onSave: () => void; +}) { + const currentScopeId = useScope(); + const sourceScopeId = props.sourceScopeId ?? currentScopeId; + const sourceResult = useAtomValue(graphqlSourceAtom(sourceScopeId, props.sourceId)); if (!Result.isSuccess(sourceResult) || !sourceResult.value) { return ( @@ -168,5 +174,12 @@ export default function EditGraphqlSource(props: { sourceId: string; onSave: () ); } - return ; + return ( + + ); } diff --git a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx index a6bf3e6c8..7b0316ee9 100644 --- a/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx +++ b/packages/plugins/graphql/src/react/GraphqlSourceSummary.tsx @@ -1,3 +1,8 @@ -export default function GraphqlSourceSummary(props: { sourceId: string }) { +import type { ScopeId } from "@executor/sdk"; + +export default function GraphqlSourceSummary(props: { + sourceId: string; + sourceScopeId?: ScopeId; +}) { return GraphQL · {props.sourceId}; } diff --git a/packages/plugins/graphql/src/sdk/index.ts b/packages/plugins/graphql/src/sdk/index.ts index 241e9eff3..caec33e1b 100644 --- a/packages/plugins/graphql/src/sdk/index.ts +++ b/packages/plugins/graphql/src/sdk/index.ts @@ -16,6 +16,7 @@ export { type StoredGraphqlSource, type StoredOperation, } from "./store"; +export { collectGraphqlSecretIds } from "./secret-usage"; export { GraphqlIntrospectionError, diff --git a/packages/plugins/graphql/src/sdk/secret-usage.test.ts b/packages/plugins/graphql/src/sdk/secret-usage.test.ts new file mode 100644 index 000000000..58f83dfb6 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/secret-usage.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import type { StoredGraphqlSource } from "./store"; +import { collectGraphqlSecretIds } from "./secret-usage"; + +describe("collectGraphqlSecretIds", () => { + it("collects secret-backed header ids", () => { + const source: StoredGraphqlSource = { + namespace: "github", + scope: "org_test", + name: "GitHub GraphQL", + endpoint: "https://api.github.com/graphql", + headers: { + Authorization: { + secretId: "github_pat", + prefix: "Bearer ", + }, + "X-Static": "static", + }, + }; + + expect(collectGraphqlSecretIds(source)).toEqual(["github_pat"]); + }); +}); diff --git a/packages/plugins/graphql/src/sdk/secret-usage.ts b/packages/plugins/graphql/src/sdk/secret-usage.ts new file mode 100644 index 000000000..eb324ee33 --- /dev/null +++ b/packages/plugins/graphql/src/sdk/secret-usage.ts @@ -0,0 +1,17 @@ +import type { StoredGraphqlSource } from "./store"; + +export const collectGraphqlSecretIds = (source: StoredGraphqlSource): readonly string[] => { + const secretIds = new Set(); + for (const value of Object.values(source.headers)) { + if ( + value && + typeof value === "object" && + "secretId" in value && + typeof value.secretId === "string" && + value.secretId.length > 0 + ) { + secretIds.add(value.secretId); + } + } + return [...secretIds]; +}; diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 17d52e469..b8629bcd2 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -1,3 +1,4 @@ +import type { ScopeId } from "@executor/sdk"; import { useState } from "react"; import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; import { mcpSourceAtom, updateMcpSource } from "./atoms"; @@ -33,10 +34,10 @@ type HeaderEntry = { function RemoteEditForm(props: { sourceId: string; + sourceScopeId: ScopeId; initial: McpStoredSourceSchemaType & { config: { transport: "remote" } }; onSave: () => void; }) { - const scopeId = useScope(); const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); const identity = useSourceIdentity({ @@ -84,7 +85,7 @@ function RemoteEditForm(props: { } await doUpdate({ - path: { scopeId, namespace: props.sourceId }, + path: { scopeId: props.sourceScopeId, namespace: props.sourceId }, payload: { name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, @@ -233,13 +234,15 @@ function StdioReadOnly(props: { export default function EditMcpSource({ sourceId, + sourceScopeId, onSave, }: { readonly sourceId: string; + readonly sourceScopeId?: ScopeId; readonly onSave: () => void; }) { - const scopeId = useScope(); - const sourceResult = useAtomValue(mcpSourceAtom(scopeId, sourceId)); + const currentScopeId = useScope(); + const sourceResult = useAtomValue(mcpSourceAtom(sourceScopeId ?? currentScopeId, sourceId)); if (!Result.isSuccess(sourceResult) || !sourceResult.value) { return ( @@ -267,6 +270,7 @@ export default function EditMcpSource({ return ( diff --git a/packages/plugins/mcp/src/react/McpSourceSummary.tsx b/packages/plugins/mcp/src/react/McpSourceSummary.tsx index 1d89e4775..a82ce34ce 100644 --- a/packages/plugins/mcp/src/react/McpSourceSummary.tsx +++ b/packages/plugins/mcp/src/react/McpSourceSummary.tsx @@ -1,10 +1,16 @@ import { Badge } from "@executor/react/components/badge"; +import type { ScopeId } from "@executor/sdk"; // --------------------------------------------------------------------------- // MCP Source Summary — shown in the source list // --------------------------------------------------------------------------- -export default function McpSourceSummary({ sourceId }: { readonly sourceId: string }) { +export default function McpSourceSummary({ + sourceId, +}: { + readonly sourceId: string; + readonly sourceScopeId?: ScopeId; +}) { return ( diff --git a/packages/plugins/mcp/src/sdk/index.ts b/packages/plugins/mcp/src/sdk/index.ts index a358244b5..055705237 100644 --- a/packages/plugins/mcp/src/sdk/index.ts +++ b/packages/plugins/mcp/src/sdk/index.ts @@ -20,3 +20,4 @@ export { type McpSchema, type McpStoredSource, } from "./binding-store"; +export { collectMcpSecretIds } from "./secret-usage"; diff --git a/packages/plugins/mcp/src/sdk/secret-usage.test.ts b/packages/plugins/mcp/src/sdk/secret-usage.test.ts new file mode 100644 index 000000000..a72b80cb8 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/secret-usage.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import type { McpStoredSource } from "./binding-store"; +import { collectMcpSecretIds } from "./secret-usage"; + +describe("collectMcpSecretIds", () => { + it("collects header auth secret ids for remote sources", () => { + const source: McpStoredSource = { + namespace: "remote", + scope: "org_test", + name: "Remote MCP", + config: { + transport: "remote", + endpoint: "https://mcp.example.com", + remoteTransport: "auto", + auth: { + kind: "header", + headerName: "Authorization", + secretId: "remote_header_secret", + prefix: "Bearer ", + }, + }, + }; + + expect(collectMcpSecretIds(source)).toEqual(["remote_header_secret"]); + }); + + it("collects oauth2 secret ids for remote sources", () => { + const source: McpStoredSource = { + namespace: "oauth", + scope: "org_test", + name: "OAuth MCP", + config: { + transport: "remote", + endpoint: "https://mcp.example.com", + remoteTransport: "auto", + auth: { + kind: "oauth2", + accessTokenSecretId: "access_token_secret", + refreshTokenSecretId: "refresh_token_secret", + tokenType: "Bearer", + expiresAt: null, + scope: null, + }, + }, + }; + + expect(collectMcpSecretIds(source)).toEqual([ + "access_token_secret", + "refresh_token_secret", + ]); + }); + + it("ignores stdio sources", () => { + const source: McpStoredSource = { + namespace: "stdio", + scope: "org_test", + name: "Local MCP", + config: { + transport: "stdio", + command: "node", + }, + }; + + expect(collectMcpSecretIds(source)).toEqual([]); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/secret-usage.ts b/packages/plugins/mcp/src/sdk/secret-usage.ts new file mode 100644 index 000000000..e65c4a8b0 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/secret-usage.ts @@ -0,0 +1,19 @@ +import type { McpStoredSource } from "./binding-store"; + +export const collectMcpSecretIds = (source: McpStoredSource): readonly string[] => { + if (source.config.transport !== "remote") return []; + + const auth = source.config.auth; + if (!auth) return []; + + switch (auth.kind) { + case "header": + return typeof auth.secretId === "string" && auth.secretId.length > 0 ? [auth.secretId] : []; + case "oauth2": + return [auth.accessTokenSecretId, auth.refreshTokenSecretId].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + default: + return []; + } +}; diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx index 205d7b8f9..657ba3010 100644 --- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx @@ -7,7 +7,7 @@ import { } from "@effect-atom/atom-react"; import { Option } from "effect"; -import { SecretId } from "@executor/sdk"; +import { SecretId, type ScopeId } from "@executor/sdk"; import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/react"; import { @@ -351,10 +351,10 @@ function ConnectionsSection(props: { function EditForm(props: { sourceId: string; + sourceScopeId: ScopeId; initial: StoredSourceSchemaType; onSave: () => void; }) { - const scopeId = useScope(); const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); const secretList = useSecretPickerSecrets(); @@ -391,7 +391,7 @@ function EditForm(props: { setError(null); try { await doUpdate({ - path: { scopeId, namespace: props.sourceId }, + path: { scopeId: props.sourceScopeId, namespace: props.sourceId }, payload: { name: identity.name.trim() || undefined, baseUrl: baseUrl.trim() || undefined, @@ -484,9 +484,14 @@ function EditForm(props: { // Main component // --------------------------------------------------------------------------- -export default function EditOpenApiSource(props: { sourceId: string; onSave: () => void }) { - const scopeId = useScope(); - const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId)); +export default function EditOpenApiSource(props: { + sourceId: string; + sourceScopeId?: ScopeId; + onSave: () => void; +}) { + const currentScopeId = useScope(); + const sourceScopeId = props.sourceScopeId ?? currentScopeId; + const sourceResult = useAtomValue(openApiSourceAtom(sourceScopeId, props.sourceId)); if (!Result.isSuccess(sourceResult) || !sourceResult.value) { return ( @@ -499,5 +504,12 @@ export default function EditOpenApiSource(props: { sourceId: string; onSave: () ); } - return ; + return ( + + ); } diff --git a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx index 10a224977..0dcb62c81 100644 --- a/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx +++ b/packages/plugins/openapi/src/react/OpenApiSourceSummary.tsx @@ -1,6 +1,6 @@ import { Result, useAtomValue } from "@effect-atom/atom-react"; -import { SecretId } from "@executor/sdk"; +import { SecretId, type ScopeId } from "@executor/sdk"; import { useScope } from "@executor/react/api/scope-context"; import { secretStatusAtom } from "@executor/react/api/atoms"; import { Badge } from "@executor/react/components/badge"; @@ -41,9 +41,13 @@ function ConnectedBadge(props: { accessTokenSecretId: string }) { // component only contributes extras — specifically, an OAuth status // badge when the source has OAuth2 configured. Non-OAuth sources // render nothing. -export default function OpenApiSourceSummary(props: { sourceId: string }) { - const scopeId = useScope(); - const sourceResult = useAtomValue(openApiSourceAtom(scopeId, props.sourceId)); +export default function OpenApiSourceSummary(props: { + sourceId: string; + sourceScopeId?: ScopeId; +}) { + const currentScopeId = useScope(); + const sourceScopeId = props.sourceScopeId ?? currentScopeId; + const sourceResult = useAtomValue(openApiSourceAtom(sourceScopeId, props.sourceId)); const oauth2 = Result.isSuccess(sourceResult) && sourceResult.value diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts index 1798bf964..28a22143f 100644 --- a/packages/plugins/openapi/src/sdk/index.ts +++ b/packages/plugins/openapi/src/sdk/index.ts @@ -25,6 +25,7 @@ export { type SourceConfig, makeDefaultOpenapiStore, } from "./store"; +export { collectOpenApiSecretIds } from "./secret-usage"; export { previewSpec, SecurityScheme, diff --git a/packages/plugins/openapi/src/sdk/secret-usage.test.ts b/packages/plugins/openapi/src/sdk/secret-usage.test.ts new file mode 100644 index 000000000..287606f4e --- /dev/null +++ b/packages/plugins/openapi/src/sdk/secret-usage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import type { StoredSource } from "./store"; +import { collectOpenApiSecretIds } from "./secret-usage"; + +describe("collectOpenApiSecretIds", () => { + it("collects header and oauth2 secret ids", () => { + const source: StoredSource = { + namespace: "vercel", + scope: "org_test", + name: "Vercel API", + config: { + spec: "https://openapi.vercel.sh", + headers: { + Authorization: { + secretId: "header_secret", + prefix: "Bearer ", + }, + "X-Static": "static", + }, + oauth2: { + kind: "oauth2", + securitySchemeName: "oauth2", + flow: "authorizationCode", + tokenUrl: "https://example.com/token", + clientIdSecretId: "client_id_secret", + clientSecretSecretId: "client_secret_secret", + accessTokenSecretId: "access_token_secret", + refreshTokenSecretId: "refresh_token_secret", + tokenType: "Bearer", + expiresAt: null, + scope: null, + scopes: ["read"], + }, + }, + invocationConfig: { + baseUrl: "https://api.vercel.com", + headers: {}, + oauth2: undefined as never, + }, + }; + + expect(collectOpenApiSecretIds(source)).toEqual([ + "header_secret", + "client_id_secret", + "client_secret_secret", + "access_token_secret", + "refresh_token_secret", + ]); + }); +}); diff --git a/packages/plugins/openapi/src/sdk/secret-usage.ts b/packages/plugins/openapi/src/sdk/secret-usage.ts new file mode 100644 index 000000000..be9acff0c --- /dev/null +++ b/packages/plugins/openapi/src/sdk/secret-usage.ts @@ -0,0 +1,36 @@ +import type { StoredSource } from "./store"; + +const collectHeaderSecretIds = (headers: StoredSource["config"]["headers"]): readonly string[] => { + if (!headers) return []; + const secretIds = new Set(); + for (const value of Object.values(headers)) { + if ( + value && + typeof value === "object" && + "secretId" in value && + typeof value.secretId === "string" && + value.secretId.length > 0 + ) { + secretIds.add(value.secretId); + } + } + return [...secretIds]; +}; + +export const collectOpenApiSecretIds = (source: StoredSource): readonly string[] => { + const secretIds = new Set(collectHeaderSecretIds(source.config.headers)); + const oauth2 = source.config.oauth2; + if (oauth2?.kind === "oauth2") { + for (const secretId of [ + oauth2.clientIdSecretId, + oauth2.clientSecretSecretId, + oauth2.accessTokenSecretId, + oauth2.refreshTokenSecretId, + ]) { + if (typeof secretId === "string" && secretId.length > 0) { + secretIds.add(secretId); + } + } + } + return [...secretIds]; +}; diff --git a/packages/react/src/api/secrets-usage.tsx b/packages/react/src/api/secrets-usage.tsx new file mode 100644 index 000000000..5e8d35b80 --- /dev/null +++ b/packages/react/src/api/secrets-usage.tsx @@ -0,0 +1,25 @@ +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; +import { Schema } from "effect"; +import { ScopeId } from "@executor/sdk"; + +const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); + +export const SecretUsage = Schema.Struct({ + sourceId: Schema.String, + sourceName: Schema.String, + sourceKind: Schema.String, +}); + +export const SecretUsageEntry = Schema.Struct({ + secretId: Schema.String, + usedBy: Schema.Array(SecretUsage), +}); + +export class SecretsUsageApi extends HttpApiGroup.make("secretsUsage").add( + HttpApiEndpoint.get("list")`/scopes/${scopeIdParam}/secrets/usage`.addSuccess( + Schema.Array(SecretUsageEntry), + ), +) {} + +export type SecretUsage = typeof SecretUsage.Type; +export type SecretUsageEntry = typeof SecretUsageEntry.Type; diff --git a/packages/react/src/pages/secrets-route.tsx b/packages/react/src/pages/secrets-route.tsx new file mode 100644 index 000000000..75dff2678 --- /dev/null +++ b/packages/react/src/pages/secrets-route.tsx @@ -0,0 +1,45 @@ +import { Result } from "@effect-atom/atom-react"; + +import type { SecretsPageSecret, SecretsPageUsage } from "./secrets"; + +export type SecretsListEntry = { + readonly id: string; + readonly name: string; + readonly provider?: string; +}; + +export type SecretsUsageEntry = { + readonly secretId: string; + readonly usedBy: readonly SecretsPageUsage[]; +}; + +export type SecretsRouteState = { + readonly state: "loading" | "error" | "ready"; + readonly secrets: readonly SecretsPageSecret[]; +}; + +export const mergeSecretsWithUsage = ( + secrets: readonly SecretsListEntry[], + usageEntries: readonly SecretsUsageEntry[], +): readonly SecretsPageSecret[] => { + const usageBySecretId = new Map(usageEntries.map((entry) => [entry.secretId, entry.usedBy])); + return secrets.map((secret) => ({ + id: secret.id, + name: secret.name, + provider: secret.provider ? String(secret.provider) : undefined, + usedBy: usageBySecretId.get(secret.id) ?? [], + })); +}; + +export const resolveSecretsRouteState = ( + secrets: Result.Result, + usage: Result.Result, +): SecretsRouteState => + Result.match(secrets, { + onInitial: () => ({ state: "loading", secrets: [] as readonly SecretsPageSecret[] }), + onFailure: () => ({ state: "error", secrets: [] as readonly SecretsPageSecret[] }), + onSuccess: ({ value }) => ({ + state: "ready" as const, + secrets: mergeSecretsWithUsage(value, Result.isSuccess(usage) ? usage.value : []), + }), + }); diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index e186158e5..d5121d88a 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,9 +1,11 @@ import { useState, Suspense } from "react"; -import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; -import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; +import { Link } from "@tanstack/react-router"; +import { useAtomSet } from "@effect-atom/atom-react"; +import { setSecret, removeSecret } from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; import { SecretId } from "@executor/sdk"; +import { ChevronDownIcon } from "lucide-react"; import { useScope } from "../hooks/use-scope"; import { Dialog, @@ -230,10 +232,21 @@ function AddSecretDialog(props: { function SecretRow(props: { showProvider: boolean; - secret: { id: string; name: string; provider?: string }; + secret: { + id: string; + name: string; + provider?: string; + usedBy: readonly { + sourceId: string; + sourceName: string; + sourceKind: string; + }[]; + }; onRemove: () => void; }) { const { secret, showProvider } = props; + const usageLabel = + secret.usedBy.length === 1 ? "Used by 1 source" : `Used by ${secret.usedBy.length} sources`; return ( @@ -244,6 +257,33 @@ function SecretRow(props: { {showProvider && secret.provider && {secret.provider}} + {secret.usedBy.length > 0 && ( + + + + + +
{usageLabel}
+ {secret.usedBy.map((usage) => ( + + + {usage.sourceName} + + {usage.sourceKind} + + + + ))} +
+
+ )} +
+
+ ) : ( + props.secrets.map((s) => ( + handleRemove(s.id)} + /> + )) + )} + + + ); + } + }; + return (
@@ -349,58 +464,7 @@ export function SecretsPage(props: { )} {/* Secrets list */} - {Result.match(secrets, { - onInitial: () => ( -
-
-

Loading secrets…

-
- ), - onFailure: () => ( -
-

Failed to load secrets

-
- ), - onSuccess: ({ value }) => ( - - Secrets - - {value.length === 0 ? ( - - - - Add API keys and credentials to authenticate your sources. - - - - - - - ) : ( - value.map((s) => ( - handleRemove(s.id)} - /> - )) - )} - - - ), - })} + {renderSecretsList()}
}> - +
@@ -217,7 +222,7 @@ export function SourceDetailPage(props: { toolId={selectedTool.id} toolName={selectedTool.name} toolDescription={selectedTool.description} - scopeId={scopeId} + scopeId={currentScopeId} /> ) : ( 0} /> diff --git a/packages/react/src/pages/sources.tsx b/packages/react/src/pages/sources.tsx index f0c312773..d51499f4e 100644 --- a/packages/react/src/pages/sources.tsx +++ b/packages/react/src/pages/sources.tsx @@ -1,6 +1,7 @@ import { Suspense, useState, useCallback, useMemo } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; import { Result, useAtomSet } from "@effect-atom/atom-react"; +import type { ScopeId } from "@executor/sdk"; import { detectSource } from "../api/atoms"; import { useSourcesWithPending } from "../api/optimistic"; import { useScope } from "../hooks/use-scope"; @@ -273,6 +274,7 @@ function SourceGrid(props: { id: string; name: string; kind: string; + scopeId?: ScopeId; url?: string; runtime?: boolean; }[]; @@ -305,7 +307,7 @@ function SourceGrid(props: { {SummaryComponent && ( - + )} {s.runtime && built-in} diff --git a/packages/react/src/plugins/source-plugin.tsx b/packages/react/src/plugins/source-plugin.tsx index e8ee7ef69..e42aa7668 100644 --- a/packages/react/src/plugins/source-plugin.tsx +++ b/packages/react/src/plugins/source-plugin.tsx @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import type { ScopeId } from "@executor/sdk"; /** * A curated preset — a well-known API/service that can be added with one click. @@ -63,6 +64,7 @@ export interface SourcePlugin { */ readonly edit: ComponentType<{ readonly sourceId: string; + readonly sourceScopeId?: ScopeId; readonly onSave: () => void; }>; @@ -72,6 +74,7 @@ export interface SourcePlugin { */ readonly summary?: ComponentType<{ readonly sourceId: string; + readonly sourceScopeId?: ScopeId; }>; /** Curated presets shown on the sources page for quick-add */