From cea63e9b96fbf458cca1b8155ccaf3f2ebee4b74 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 4 May 2026 11:25:23 -0700 Subject: [PATCH] Add secret/connection usages SDK surface + UI placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins gain optional `usagesForSecret` / `usagesForConnection` callbacks that the executor fans out across via `executor.secrets.usages(id)` and `executor.connections.usages(id)`. Secret/connection removal is now RESTRICT — refuses with `SecretInUseError` / `ConnectionInUseError` when any plugin reports the id as in use, surfacing the count to the caller. API gains GET `/secrets/:id/usages` and `/connections/:id/usages` endpoints. React atoms and a small "Used by …" footer on each row in the Secrets and Connections tabs round out the surface. No plugin implements `usagesForSecret`/`usagesForConnection` yet, so the footer renders nothing and remove still succeeds for everything. Plugin migration lands in follow-up commits. --- bun.lock | 2 +- packages/core/api/src/connections/api.ts | 17 ++- packages/core/api/src/handlers/connections.ts | 8 ++ packages/core/api/src/handlers/secrets.ts | 6 + packages/core/api/src/secrets/api.ts | 12 +- packages/core/execution/src/promise.ts | 2 + packages/core/sdk/src/errors.ts | 29 ++++- packages/core/sdk/src/executor.ts | 120 +++++++++++++++++- packages/core/sdk/src/index.ts | 9 ++ packages/core/sdk/src/plugin.ts | 39 +++++- packages/core/sdk/src/usages.ts | 42 ++++++ packages/react/src/api/atoms.tsx | 36 +++++- packages/react/src/pages/connections.tsx | 48 ++++++- packages/react/src/pages/secrets.tsx | 44 ++++++- 14 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 packages/core/sdk/src/usages.ts diff --git a/bun.lock b/bun.lock index bc304ce02..1b8f01805 100644 --- a/bun.lock +++ b/bun.lock @@ -1939,7 +1939,7 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], - "@rhyssul/portless": ["@rhyssul/portless@0.13.0", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-6Hc5jcNVmEmbQUK4YT6hGNxNbDob8ZrB6izFLxZOq8P9ExS6eeBJQA9J55K67unNGWOiA77xUaSBut3SzqcYgA=="], + "@rhyssul/portless": ["@rhyssul/portless@0.13.3", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-oPvwXJIIkRg2i1CUEUsz8sAdFSf0Fzzv+NmaacAsi6ZZTHxDOofbO6fGInVnbESIFOu4k8a/rXOZRF0aqLAUgw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="], diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index 9ca04fc91..2f56ca585 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -3,7 +3,9 @@ import { Schema } from "effect"; import { ConnectionId, + ConnectionInUseError, ScopeId, + Usage, } from "@executor-js/sdk"; import { InternalError } from "../observability"; @@ -34,6 +36,8 @@ const ConnectionRefResponse = Schema.Struct({ // Group // --------------------------------------------------------------------------- +const ConnectionInUse = ConnectionInUseError.annotate({ httpApiStatus: 409 }); + export const ConnectionsApi = HttpApiGroup.make("connections") .add( HttpApiEndpoint.get("list", "/scopes/:scopeId/connections", { @@ -46,6 +50,17 @@ export const ConnectionsApi = HttpApiGroup.make("connections") HttpApiEndpoint.delete("remove", "/scopes/:scopeId/connections/:connectionId", { params: ConnectionParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: InternalError, + error: [InternalError, ConnectionInUse], }), + ) + .add( + HttpApiEndpoint.get( + "usages", + "/scopes/:scopeId/connections/:connectionId/usages", + { + params: ConnectionParams, + success: Schema.Array(Usage), + error: InternalError, + }, + ), ); diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index 98b359c46..012eb3b64 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -40,5 +40,13 @@ export const ConnectionsHandlers = HttpApiBuilder.group( return { removed: true }; }), ), + ) + .handle("usages", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.connections.usages(path.connectionId); + }), + ), ), ); diff --git a/packages/core/api/src/handlers/secrets.ts b/packages/core/api/src/handlers/secrets.ts index 390aed438..99aaa254b 100644 --- a/packages/core/api/src/handlers/secrets.ts +++ b/packages/core/api/src/handlers/secrets.ts @@ -51,5 +51,11 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han yield* executor.secrets.remove(path.secretId); return { removed: true }; })), + ) + .handle("usages", ({ params: path }) => + capture(Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.secrets.usages(path.secretId); + })), ), ); diff --git a/packages/core/api/src/secrets/api.ts b/packages/core/api/src/secrets/api.ts index daf39d751..0b30ab5dd 100644 --- a/packages/core/api/src/secrets/api.ts +++ b/packages/core/api/src/secrets/api.ts @@ -3,9 +3,11 @@ import { Schema } from "effect"; import { ScopeId, SecretId, + SecretInUseError, SecretNotFoundError, SecretOwnedByConnectionError, SecretResolutionError, + Usage, } from "@executor-js/sdk"; import { InternalError } from "../observability"; @@ -52,6 +54,7 @@ const SecretResolution = SecretResolutionError.annotate( const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate( { httpApiStatus: 409 }, ); +const SecretInUse = SecretInUseError.annotate({ httpApiStatus: 409 }); // --------------------------------------------------------------------------- // Group @@ -84,6 +87,13 @@ export const SecretsApi = HttpApiGroup.make("secrets") HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", { params: SecretParams, success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, SecretNotFound, SecretOwnedByConnection], + error: [InternalError, SecretNotFound, SecretOwnedByConnection, SecretInUse], + }), + ) + .add( + HttpApiEndpoint.get("usages", "/scopes/:scopeId/secrets/:secretId/usages", { + params: SecretParams, + success: Schema.Array(Usage), + error: InternalError, }), ); diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index ca6ed8389..8e316721f 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -101,6 +101,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ set: (input) => fromPromise(() => pe.secrets.set(input)), remove: (id) => fromPromise(() => pe.secrets.remove(id)), list: () => fromPromise(() => pe.secrets.list()), + usages: (id) => fromPromise(() => pe.secrets.usages(id)), providers: () => fromPromise(() => pe.secrets.providers()), }, connections: { @@ -111,6 +112,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ setIdentityLabel: (id, label) => fromPromise(() => pe.connections.setIdentityLabel(id, label)), accessToken: (id) => fromPromise(() => pe.connections.accessToken(id)), remove: (id) => fromPromise(() => pe.connections.remove(id)), + usages: (id) => fromPromise(() => pe.connections.usages(id)), providers: () => fromPromise(() => pe.connections.providers()), }, oauth: { diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index d625aaf78..4b68a1ddb 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -98,6 +98,21 @@ export class SecretOwnedByConnectionError extends Schema.TaggedErrorClass()( + "SecretInUseError", + { + secretId: SecretId, + usageCount: Schema.Number, + }, +) {} + // --------------------------------------------------------------------------- // Connections // --------------------------------------------------------------------------- @@ -141,6 +156,16 @@ export class ConnectionReauthRequiredError extends Schema.TaggedErrorClass()( + "ConnectionInUseError", + { + connectionId: ConnectionId, + usageCount: Schema.Number, + }, +) {} + // --------------------------------------------------------------------------- // Union type for convenience in signatures. // --------------------------------------------------------------------------- @@ -156,7 +181,9 @@ export type ExecutorError = | SecretNotFoundError | SecretResolutionError | SecretOwnedByConnectionError + | SecretInUseError | ConnectionNotFoundError | ConnectionProviderNotRegisteredError | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError; + | ConnectionReauthRequiredError + | ConnectionInUseError; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 69588c743..ee4986f86 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -44,12 +44,14 @@ import { type ElicitationRequest, } from "./elicitation"; import { + ConnectionInUseError, ConnectionNotFoundError, ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, NoHandlerError, PluginNotLoadedError, + SecretInUseError, SecretOwnedByConnectionError, SourceRemovalNotAllowedError, ToolBlockedError, @@ -84,6 +86,7 @@ import { SetSecretInput, type SecretProvider, } from "./secrets"; +import { Usage } from "./usages"; import { ToolSchema, type Source, @@ -211,11 +214,22 @@ export type Executor = { ) => Effect.Effect; /** Delete a bare (non-connection-owned) secret. Connection-owned * secrets are rejected with `SecretOwnedByConnectionError` — use - * `connections.remove` instead. */ + * `connections.remove` instead. Refuses with `SecretInUseError` + * if any plugin reports the secret as in use; the caller should + * show the `usages(id)` list and ask the user to detach first. */ readonly remove: ( id: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + SecretOwnedByConnectionError | SecretInUseError | StorageFailure + >; readonly list: () => Effect.Effect; + /** All places this secret is referenced — fans out across every + * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by" + * list and by `remove` for its RESTRICT check. */ + readonly usages: ( + id: string, + ) => Effect.Effect; readonly providers: () => Effect.Effect; }; @@ -251,7 +265,16 @@ export type Executor = { | ConnectionRefreshError | StorageFailure >; - readonly remove: (id: string) => Effect.Effect; + /** Refuses with `ConnectionInUseError` if any plugin reports the + * connection as in use. */ + readonly remove: ( + id: string, + ) => Effect.Effect; + /** All places this connection is referenced — fans out across every + * plugin's `usagesForConnection`. */ + readonly usages: ( + id: string, + ) => Effect.Effect; readonly providers: () => Effect.Effect; }; @@ -945,9 +968,69 @@ export const createExecutor = < }); }); + // Fan out across every plugin that contributes `usagesForSecret`. Each + // plugin queries its own normalized columns through its scoped adapter, + // so scope filtering is automatic. We swallow per-plugin errors to a + // logWarning rather than letting one buggy plugin take out the whole + // call — usage queries should never block the user. + const secretsUsages = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const secretId = SecretId.make(id); + const perPlugin = yield* Effect.all( + [...runtimes.values()] + .filter((r) => r.plugin.usagesForSecret) + .map((r) => + r.plugin.usagesForSecret!({ + ctx: r.ctx, + args: { secretId }, + }).pipe( + Effect.catchCause((cause: unknown) => + Effect.logWarning( + `usagesForSecret failed for plugin ${r.plugin.id}`, + cause, + ).pipe(Effect.as([] as readonly Usage[])), + ), + ), + ), + { concurrency: "unbounded" }, + ); + return perPlugin.flat(); + }); + + const connectionsUsages = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const connectionId = ConnectionId.make(id); + const perPlugin = yield* Effect.all( + [...runtimes.values()] + .filter((r) => r.plugin.usagesForConnection) + .map((r) => + r.plugin.usagesForConnection!({ + ctx: r.ctx, + args: { connectionId }, + }).pipe( + Effect.catchCause((cause: unknown) => + Effect.logWarning( + `usagesForConnection failed for plugin ${r.plugin.id}`, + cause, + ).pipe(Effect.as([] as readonly Usage[])), + ), + ), + ), + { concurrency: "unbounded" }, + ); + return perPlugin.flat(); + }); + const secretsRemove = ( id: string, - ): Effect.Effect => + ): Effect.Effect< + void, + SecretOwnedByConnectionError | SecretInUseError | StorageFailure + > => Effect.gen(function* () { // Remove is shadowing-aware: drop only the innermost-scope row. // Removing a user-scope override on a secret that also has an @@ -973,6 +1056,20 @@ export const createExecutor = < }), ); } + // RESTRICT: refuse if any source/binding still references this + // secret. App-level FK enforcement — sqlite can't enforce a real + // FK on the composite-PK `secret` table from a single column. + // Caller's UI shows `usages(id)` so the user knows what to detach + // before retrying. + const usages = yield* secretsUsages(id); + if (usages.length > 0) { + return yield* Effect.fail( + new SecretInUseError({ + secretId: SecretId.make(id), + usageCount: usages.length, + }), + ); + } const targetScope = (target?.scope_id as string | undefined) ?? scopeIds[0]!; @@ -1488,10 +1585,21 @@ export const createExecutor = < const connectionsRemove = ( id: string, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const row = yield* findInnermostConnectionRow(id); if (!row) return; + // RESTRICT: refuse if any source/binding still references the + // connection. Same rationale as `secretsRemove`. + const usages = yield* connectionsUsages(id); + if (usages.length > 0) { + return yield* Effect.fail( + new ConnectionInUseError({ + connectionId: ConnectionId.make(id), + usageCount: usages.length, + }), + ); + } const scope = row.scope_id as string; yield* adapter.transaction(() => Effect.gen(function* () { @@ -2800,6 +2908,7 @@ export const createExecutor = < set: secretsSet, remove: secretsRemove, list: secretsList, + usages: secretsUsages, providers: () => Effect.sync( () => Array.from(secretProviders.keys()) as readonly string[], @@ -2813,6 +2922,7 @@ export const createExecutor = < setIdentityLabel: connectionsSetIdentityLabel, accessToken: connectionsAccessToken, remove: connectionsRemove, + usages: connectionsUsages, providers: () => Effect.sync( () => diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 67a1957bf..092b2a15a 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -55,10 +55,12 @@ export { SecretNotFoundError, SecretResolutionError, SecretOwnedByConnectionError, + SecretInUseError, ConnectionNotFoundError, ConnectionProviderNotRegisteredError, ConnectionRefreshNotSupportedError, ConnectionReauthRequiredError, + ConnectionInUseError, type ExecutorError, } from "./errors"; @@ -118,6 +120,13 @@ export { type ResolveSecretBackedMapOptions, } from "./secret-backed-value"; +// Usage tracking — secret/connection refs across plugins +export { + Usage, + type UsagesForSecretInput, + type UsagesForConnectionInput, +} from "./usages"; + // Connections export { ConnectionRef, diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 0f690f561..8dfc33c5f 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -29,15 +29,18 @@ import type { ElicitationResponse, } from "./elicitation"; import type { + ConnectionInUseError, ConnectionNotFoundError, ConnectionProviderNotRegisteredError, ConnectionReauthRequiredError, ConnectionRefreshNotSupportedError, + SecretInUseError, SecretOwnedByConnectionError, } from "./errors"; import type { OAuthService } from "./oauth"; import type { Scope } from "./scope"; import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; +import type { Usage, UsagesForConnectionInput, UsagesForSecretInput } from "./usages"; // --------------------------------------------------------------------------- // StorageDeps — backing passed to a plugin's `storage` factory. The only @@ -160,10 +163,15 @@ export interface PluginCtx { /** Delete a secret from its pinned provider and the core table. * Rejects with `SecretOwnedByConnectionError` if the row is owned * by a connection — callers must go through `connections.remove` - * to drop the whole sign-in. */ + * to drop the whole sign-in. Rejects with `SecretInUseError` if + * any plugin reports the secret as in use; the caller should ask + * the user to detach the listed sources first. */ readonly remove: ( id: string, - ) => Effect.Effect; + ) => Effect.Effect< + void, + SecretOwnedByConnectionError | SecretInUseError | StorageFailure + >; }; /** Connections — product-level sign-in state. Owns backing secret @@ -206,7 +214,12 @@ export interface PluginCtx { | ConnectionRefreshError | StorageFailure >; - readonly remove: (id: string) => Effect.Effect; + /** Refuses with `ConnectionInUseError` if any plugin reports the + * connection as in use. Caller surfaces the `usages` list to the + * user. */ + readonly remove: ( + id: string, + ) => Effect.Effect; }; /** Shared OAuth service. Plugins use this to probe/start/complete OAuth @@ -446,6 +459,26 @@ export interface PluginSpec< readonly toolRows: readonly ToolRow[]; }) => Effect.Effect, unknown>; + /** Find every place a secret id is referenced by this plugin's stored + * rows. Implementations query their normalized columns (e.g. + * `WHERE secret_id = $1`) and return one `Usage` per hit, with + * `ownerKind` / `slot` tagging the location. The executor fans out + * across all plugins and the result powers the Secrets-tab "Used + * by" list and the deletion-blocking check in `secrets.remove`. + * + * Plugins that never store secret refs (secret-provider-only + * plugins like keychain / file-secrets / 1password) omit this. */ + readonly usagesForSecret?: (input: { + readonly ctx: PluginCtx; + readonly args: UsagesForSecretInput; + }) => Effect.Effect; + + /** Same shape as `usagesForSecret`, but for connection refs. */ + readonly usagesForConnection?: (input: { + readonly ctx: PluginCtx; + readonly args: UsagesForConnectionInput; + }) => Effect.Effect; + /** Called when `executor.sources.remove(id)` targets a source owned * by this plugin. Plugin-side cleanup only; the executor deletes * the core source/tool rows after this callback returns, inside diff --git a/packages/core/sdk/src/usages.ts b/packages/core/sdk/src/usages.ts new file mode 100644 index 000000000..981e50528 --- /dev/null +++ b/packages/core/sdk/src/usages.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect"; + +import { ScopeId, SecretId, ConnectionId } from "./ids"; + +// --------------------------------------------------------------------------- +// Usage — one row per place a secret or connection is referenced. Each +// plugin contributes its own usages via `usagesForSecret` / +// `usagesForConnection`; the executor fans out and concatenates. +// +// `pluginId` identifies the plugin that owns the reference. `ownerKind` +// is plugin-defined (e.g. "openapi-source-oauth2", "mcp-source-auth", +// "graphql-source-header"); the UI groups by it for a "used in N +// sources / M bindings" summary. `slot` describes which field within +// the owner holds the ref ("oauth2.client_secret", "header:Authorization", +// "binding:value") so the user can locate it. +// +// `ownerName` is resolved by JOIN at query time from the parent source / +// binding row. It's nullable because a plugin may have an owner that has +// no human-readable name (e.g. an unnamed binding row). +// +// `scopeId` is the scope the owner row lives in — plugins query through +// their scoped adapter (which auto-filters by `scope_id IN (stack)`), so +// usages from outer scopes naturally surface alongside inner ones; the +// UI uses the scope to render a per-scope label next to each entry. +// --------------------------------------------------------------------------- + +export class Usage extends Schema.Class("Usage")({ + pluginId: Schema.String, + scopeId: ScopeId, + ownerKind: Schema.String, + ownerId: Schema.String, + ownerName: Schema.NullOr(Schema.String), + slot: Schema.String, +}) {} + +export interface UsagesForSecretInput { + readonly secretId: SecretId; +} + +export interface UsagesForConnectionInput { + readonly connectionId: ConnectionId; +} diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 8678d5aa4..f9779a712 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -1,4 +1,10 @@ -import { PolicyId, type ScopeId, type ToolId, type SecretId } from "@executor-js/sdk"; +import { + PolicyId, + type ConnectionId, + type ScopeId, + type SecretId, + type ToolId, +} from "@executor-js/sdk"; import * as Atom from "effect/unstable/reactivity/Atom"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; @@ -72,6 +78,34 @@ export const connectionsAtom = (scopeId: ScopeId) => reactivityKeys: [ReactivityKey.connections], }); +export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => + ExecutorApiClient.query("secrets", "usages", { + params: { scopeId, secretId }, + timeToLive: "30 seconds", + // Refresh whenever any source / connection / secret changes — adding + // an oauth source pulls in a new connection-secret link and we want + // the usage list to reflect it. + reactivityKeys: [ + ReactivityKey.secrets, + ReactivityKey.sources, + ReactivityKey.connections, + ], + }); + +export const connectionUsagesAtom = ( + scopeId: ScopeId, + connectionId: ConnectionId, +) => + ExecutorApiClient.query("connections", "usages", { + params: { scopeId, connectionId }, + timeToLive: "30 seconds", + reactivityKeys: [ + ReactivityKey.connections, + ReactivityKey.sources, + ReactivityKey.secrets, + ], + }); + export const policiesAtom = (scopeId: ScopeId) => ExecutorApiClient.query("policies", "list", { params: { scopeId }, diff --git a/packages/react/src/pages/connections.tsx b/packages/react/src/pages/connections.tsx index 9d565f93b..a5570d777 100644 --- a/packages/react/src/pages/connections.tsx +++ b/packages/react/src/pages/connections.tsx @@ -1,9 +1,10 @@ -import { useAtomSet } from "@effect/atom-react"; +import { Suspense } from "react"; +import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { ConnectionId } from "@executor-js/sdk"; +import { ConnectionId, type ScopeId } from "@executor-js/sdk"; import { toast } from "sonner"; -import { removeConnection } from "../api/atoms"; +import { connectionUsagesAtom, removeConnection } from "../api/atoms"; import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic"; import { connectionWriteKeys } from "../api/reactivity-keys"; import { useScope, useScopeStack } from "../hooks/use-scope"; @@ -50,11 +51,45 @@ const connectionScopeLabel = ( return "Scoped"; }; +// --------------------------------------------------------------------------- +// Used-by footer — same shape as the secrets page. Returns null when a +// connection isn't referenced anywhere so newly-created connections +// don't get a stray "Used by 0" line before any source binds to them. +// --------------------------------------------------------------------------- + +function ConnectionUsageFooter(props: { + scopeId: ScopeId; + connectionId: ConnectionId; +}) { + const usages = useAtomValue( + connectionUsagesAtom(props.scopeId, props.connectionId), + ); + return AsyncResult.match(usages, { + onInitial: () => null, + onFailure: () => null, + onSuccess: ({ value }) => { + if (value.length === 0) return null; + const labels = value + .map((u) => u.ownerName ?? u.ownerId) + .filter((s, i, a) => a.indexOf(s) === i); + const visible = labels.slice(0, 3); + const hidden = labels.length - visible.length; + return ( + + Used by {visible.join(", ")} + {hidden > 0 ? ` +${hidden} more` : ""} + + ); + }, + }); +} + // --------------------------------------------------------------------------- // Connection row // --------------------------------------------------------------------------- function ConnectionRow(props: { + scopeId: ScopeId; connection: { id: string; scopeId: string; @@ -80,6 +115,12 @@ function ConnectionRow(props: { {displayProvider(connection.provider)} + + + {scopeLabel} @@ -185,6 +226,7 @@ export function ConnectionsPage() { }) => ( null, + onFailure: () => null, + onSuccess: ({ value }) => { + if (value.length === 0) return null; + const labels = value + .map((u) => u.ownerName ?? u.ownerId) + .filter((s, i, a) => a.indexOf(s) === i); + const visible = labels.slice(0, 3); + const hidden = labels.length - visible.length; + return ( + + Used by {visible.join(", ")} + {hidden > 0 ? ` +${hidden} more` : ""} + + ); + }, + }); +} + // --------------------------------------------------------------------------- // Secret row // --------------------------------------------------------------------------- function SecretRow(props: { + scopeId: ScopeId; showProvider: boolean; secret: { id: string; name: string; provider?: string }; onRemove: () => void; @@ -147,6 +180,12 @@ function SecretRow(props: { {secret.id} + + + {showProvider && secret.provider && {secret.provider}} @@ -306,6 +345,7 @@ export function SecretsPage(props: { }) => (