Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion packages/core/api/src/connections/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Schema } from "effect";

import {
ConnectionId,
ConnectionInUseError,
ScopeId,
Usage,
} from "@executor-js/sdk";

import { InternalError } from "../observability";
Expand Down Expand Up @@ -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", {
Expand All @@ -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,
},
),
);
8 changes: 8 additions & 0 deletions packages/core/api/src/handlers/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}),
),
),
);
6 changes: 6 additions & 0 deletions packages/core/api/src/handlers/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})),
),
);
12 changes: 11 additions & 1 deletion packages/core/api/src/secrets/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { Schema } from "effect";
import {
ScopeId,
SecretId,
SecretInUseError,
SecretNotFoundError,
SecretOwnedByConnectionError,
SecretResolutionError,
Usage,
} from "@executor-js/sdk";

import { InternalError } from "../observability";
Expand Down Expand Up @@ -52,6 +54,7 @@ const SecretResolution = SecretResolutionError.annotate(
const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate(
{ httpApiStatus: 409 },
);
const SecretInUse = SecretInUseError.annotate({ httpApiStatus: 409 });

// ---------------------------------------------------------------------------
// Group
Expand Down Expand Up @@ -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,
}),
);
2 changes: 2 additions & 0 deletions packages/core/execution/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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: {
Expand Down
29 changes: 28 additions & 1 deletion packages/core/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ export class SecretOwnedByConnectionError extends Schema.TaggedErrorClass<Secret
},
) {}

/** Raised when `secrets.remove(id)` is called on a secret that's still
* referenced by one or more sources / bindings across plugins. The UI's
* "Used by" list tells the user which sources to detach first. App-
* level RESTRICT — the codebase doesn't enforce DB-level FKs because
* composite `(scope_id, id)` PKs make single-column references
* impossible to constrain in sqlite. `usageCount` is a hint for the
* caller; the full list is queryable via `secrets.usages(id)`. */
export class SecretInUseError extends Schema.TaggedErrorClass<SecretInUseError>()(
"SecretInUseError",
{
secretId: SecretId,
usageCount: Schema.Number,
},
) {}

// ---------------------------------------------------------------------------
// Connections
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -141,6 +156,16 @@ export class ConnectionReauthRequiredError extends Schema.TaggedErrorClass<Conne
},
) {}

/** Raised when `connections.remove(id)` is called on a connection that's
* still referenced by sources / bindings. Mirrors `SecretInUseError`. */
export class ConnectionInUseError extends Schema.TaggedErrorClass<ConnectionInUseError>()(
"ConnectionInUseError",
{
connectionId: ConnectionId,
usageCount: Schema.Number,
},
) {}

// ---------------------------------------------------------------------------
// Union type for convenience in signatures.
// ---------------------------------------------------------------------------
Expand All @@ -156,7 +181,9 @@ export type ExecutorError =
| SecretNotFoundError
| SecretResolutionError
| SecretOwnedByConnectionError
| SecretInUseError
| ConnectionNotFoundError
| ConnectionProviderNotRegisteredError
| ConnectionRefreshNotSupportedError
| ConnectionReauthRequiredError;
| ConnectionReauthRequiredError
| ConnectionInUseError;
120 changes: 115 additions & 5 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ import {
type ElicitationRequest,
} from "./elicitation";
import {
ConnectionInUseError,
ConnectionNotFoundError,
ConnectionProviderNotRegisteredError,
ConnectionReauthRequiredError,
ConnectionRefreshNotSupportedError,
NoHandlerError,
PluginNotLoadedError,
SecretInUseError,
SecretOwnedByConnectionError,
SourceRemovalNotAllowedError,
ToolBlockedError,
Expand Down Expand Up @@ -84,6 +86,7 @@ import {
SetSecretInput,
type SecretProvider,
} from "./secrets";
import { Usage } from "./usages";
import {
ToolSchema,
type Source,
Expand Down Expand Up @@ -211,11 +214,22 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = {
) => Effect.Effect<SecretRef, StorageFailure>;
/** 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<void, SecretOwnedByConnectionError | StorageFailure>;
) => Effect.Effect<
void,
SecretOwnedByConnectionError | SecretInUseError | StorageFailure
>;
readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
/** 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 Usage[], StorageFailure>;
readonly providers: () => Effect.Effect<readonly string[]>;
};

Expand Down Expand Up @@ -251,7 +265,16 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = {
| ConnectionRefreshError
| StorageFailure
>;
readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
/** Refuses with `ConnectionInUseError` if any plugin reports the
* connection as in use. */
readonly remove: (
id: string,
) => Effect.Effect<void, ConnectionInUseError | StorageFailure>;
/** All places this connection is referenced — fans out across every
* plugin's `usagesForConnection`. */
readonly usages: (
id: string,
) => Effect.Effect<readonly Usage[], StorageFailure>;
readonly providers: () => Effect.Effect<readonly string[]>;
};

Expand Down Expand Up @@ -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<readonly Usage[], StorageFailure> =>
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<readonly Usage[], StorageFailure> =>
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<void, SecretOwnedByConnectionError | StorageFailure> =>
): 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
Expand All @@ -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]!;

Expand Down Expand Up @@ -1488,10 +1585,21 @@ export const createExecutor = <

const connectionsRemove = (
id: string,
): Effect.Effect<void, StorageFailure> =>
): Effect.Effect<void, ConnectionInUseError | StorageFailure> =>
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* () {
Expand Down Expand Up @@ -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[],
Expand All @@ -2813,6 +2922,7 @@ export const createExecutor = <
setIdentityLabel: connectionsSetIdentityLabel,
accessToken: connectionsAccessToken,
remove: connectionsRemove,
usages: connectionsUsages,
providers: () =>
Effect.sync(
() =>
Expand Down
9 changes: 9 additions & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ export {
SecretNotFoundError,
SecretResolutionError,
SecretOwnedByConnectionError,
SecretInUseError,
ConnectionNotFoundError,
ConnectionProviderNotRegisteredError,
ConnectionRefreshNotSupportedError,
ConnectionReauthRequiredError,
ConnectionInUseError,
type ExecutorError,
} from "./errors";

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading