import { Effect } from "effect"; import { definePlugin, tool, ToolName, ConnectionName, IntegrationSlug, AuthTemplateSlug, connectionIdentifier, type ResolveToolsInput, type ResolveToolsResult, type InvokeToolInput, type ToolDef,} from "@executor-js/sdk"; import type { AppsRuntime } from "./runtime";import { makeAppsStore } from "./store";import type { Bindings, ClientResolver } from "./bindings"; // ---------------------------------------------------------------------------// The apps source plugin. Published custom tools become catalog citizens: the// plugin registers one integration per scope (`apps`), and a connection to it// makes the published tools resolvable + invocable like any catalog tool, so// policy / approval / audit / toolkits / tools.list all apply unchanged.//// `resolveTools` projects the published descriptor into `ToolDef[]`.// `invokeTool` bundles + runs the tool in the sandbox with connections bound.// The plugin is thin: all logic lives in `AppsRuntime` (shared with the HTTP +// MCP surfaces). The runtime is supplied via options because it owns the seam// instances built at host boot.// --------------------------------------------------------------------------- export const APPS_INTEGRATION_SLUG = "apps";export const APPS_PLUGIN_ID = "apps"; /** The scope a single-tenant self-host serves when the caller names none. */const DEFAULT_CATALOG_SCOPE = "default"; // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: `cause` is a typed value with a `message` field, not an unknown errorconst taggedMessage = (cause: { readonly message: string }): string => cause.message;const pluginBoundaryError = (message: string): Error => { // oxlint-disable-next-line executor/no-error-constructor -- boundary: SDK tool/plugin callbacks surface failures as JavaScript Error values return new Error(message);}; export interface AppsPluginOptions { /** The shared runtime (seams + store). Built at host boot. */ readonly runtime: AppsRuntime; /** How a tool's declared connection roles are bound to the caller's * connections at invoke time. Self-host resolves these from the scope's * configured connections; a default binds each role to a connection of the * same name as the integration. */ readonly resolveBindings?: (input: { readonly scope: string; readonly tool: string; readonly declared: Readonly<Record<string, { kind: string; integration?: string }>>; }) => Bindings; /** Build a per-request ClientResolver from the invoking executor context. * This is how external integration calls route through the REAL per-request * path (connections resolved by the invoking owner, credentials injected at * the boundary), rather than the runtime's boot-time default resolver. The * `ctx` is the plugin's per-request `PluginCtx`; the type is kept structural * so this package does not depend on the host SDK's ctx shape. */ readonly makeResolver?: (input: { readonly ctx: unknown; readonly scope: string; readonly tool: string; }) => ClientResolver;} const defaultBindings = ( declared: Readonly<Record<string, { kind: string; integration?: string }>>,): Bindings => { const out: Record<string, Bindings[string]> = {}; for (const [role, decl] of Object.entries(declared)) { if (decl.kind === "array") { out[role] = { kind: "array", connections: [decl.integration ?? role] }; } else if (decl.kind === "catalog") { // no binding } else { out[role] = { kind: "single", connection: decl.integration ?? role }; } } return out;}; interface AppsStoreShape { readonly runtime: AppsRuntime;} export const appsPlugin = definePlugin((options?: AppsPluginOptions) => { if (!options?.runtime) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin construction must fail loudly when the host omits the runtime throw pluginBoundaryError( "appsPlugin requires a `runtime` (built from the five seams at host boot)", ); } const runtime = options.runtime; const resolveBindings = options.resolveBindings; const makeResolver = options.makeResolver; // Resolve the scope a connection addresses (Fix 9). Prefer the EXPLICIT stored // mapping (recorded at connect time under the normalized name), which is exact // and collision-free. Only when no mapping was recorded (e.g. a legacy // connection created before the mapping existed) fall back to reverse-parsing // the name — the fragile path this replaces. const scopeFor = (connectionName: string): Effect.Effect<string> => runtime.deps.store.getScopeForConnection(connectionName).pipe( Effect.orElseSucceed(() => null), Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)), ); return { id: APPS_PLUGIN_ID as "apps", packageName: "@executor-js/plugin-apps", // The plugin's store facade is host-owned plugin storage + blobs; the apps // runtime already holds its own store, so this is a thin passthrough kept // for the ctx shape (extension methods read the runtime). storage: (deps): AppsStoreShape => { void makeAppsStore({ pluginStorage: deps.pluginStorage, blobs: deps.blobs, }); return { runtime }; }, // Declare the plugin's storage collections so the host provisions them. pluginStorage: { published_descriptor: { name: "published_descriptor", schema: { Type: {} as Record<string, unknown> }, indexes: [], }, apps_scope_connection: { name: "apps_scope_connection", schema: { Type: {} as Record<string, unknown> }, indexes: [], }, }, extension: () => ({ runtime }), // A tiny built-in tool that wires the scope's published app into the // catalog: it registers the `apps` integration (idempotent) and creates the // `apps/<scope>` connection for the caller. After it runs, the scope's // published tools resolve as real catalog citizens (tools.list + execute // through the same policy/audit path). This runs with the REAL request-scoped // ctx (owner + core.integrations + connections), so it is the honest wiring // point rather than a boot-time singleton guess at the owner. staticSources: () => [ { id: APPS_PLUGIN_ID, kind: "executor", name: "Apps", tools: [ tool<AppsStoreShape>({ name: "connect_catalog", description: "Wire this scope's published app into the tool catalog: registers the `apps` " + "integration and creates the apps/<scope> connection so published tools become " + "catalog citizens. Idempotent; call once per scope after publishing.", execute: (args, { ctx }) => Effect.gen(function* () { // Single-tenant self-host serves one scope ("default"); a caller // may pass an explicit scope for other layouts. const scope = (args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE; const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG); const existing = yield* ctx.core.integrations .get(slug) .pipe(Effect.orElseSucceed(() => null)); if (!existing) { yield* ctx.core.integrations.register({ slug, name: "Apps", description: "User-authored, published custom tools, workflows, ui and skills.", config: {}, canRemove: false, canRefresh: true, }); } const connName = ConnectionName.make(connectionNameForScope(scope)); const conns = yield* ctx.connections .list({ integration: slug }) .pipe(Effect.orElseSucceed(() => [])); if (!conns.some((c) => String(c.name) === String(connName))) { yield* ctx.connections.create({ owner: "user", name: connName, integration: slug, template: AuthTemplateSlug.make("none"), value: "", }); } // Record the explicit connection-name -> scope mapping (Fix 9). // The executor normalizes connection names to identifiers, so we // record the mapping under BOTH the name we requested and its // normalized form (the name resolveTools/invokeTool actually // sees). This makes the scope lookup exact and collision-free: // "my-scope" and "my_scope" get DISTINCT stored mappings even if // a naive name-parse would collapse them. yield* runtime.deps.store .putScopeForConnection(String(connName), scope) .pipe(Effect.orElseSucceed(() => undefined)); const normalized = connectionIdentifier(String(connName)); if (normalized !== String(connName)) { yield* runtime.deps.store .putScopeForConnection(normalized, scope) .pipe(Effect.orElseSucceed(() => undefined)); } return { scope, connection: String(connName) }; }), }), tool<AppsStoreShape>({ name: "start_workflow", description: "Start a published workflow by name (manual start). Runs its durable body " + "with journal replay; `step.tool` external calls route through the caller's " + "connections. Returns the run view (status + output).", execute: (args, { ctx }) => Effect.gen(function* () { const a = (args ?? {}) as { workflow?: string; scope?: string; input?: unknown; bindings?: Bindings; runId?: string; }; if (!a.workflow) { return yield* Effect.fail( pluginBoundaryError("start_workflow requires a `workflow` name"), ); } const scope = a.scope ?? DEFAULT_CATALOG_SCOPE; // The per-request resolver (built from the invoking ctx) is what // lets the workflow's `step.tool` reach real integrations through // the caller's connections + credentials, rather than the runtime's // boot-time NotImplemented default. const resolver = makeResolver ? makeResolver({ ctx, scope, tool: a.workflow }) : undefined; const run = yield* runtime .startWorkflow({ scope, workflow: a.workflow, input: a.input ?? {}, bindings: a.bindings, runId: a.runId, resolver, }) .pipe(Effect.mapError((cause) => pluginBoundaryError(taggedMessage(cause)))); return run; }), }), ], }, ], // Per-connection tool production: project the published descriptor into // ToolDefs. Called at connection create/refresh; the SDK stamps addresses // and persists per connection. resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) => Effect.gen(function* () { // The connection name encodes the scope via the formalized mapping // (`apps/<scope>`). A scope with no published app yet legitimately // resolves to ZERO tools (the connection exists before the first // publish), so an empty descriptor here is an intentional empty result, // not the silently-swallowed missing-tool case invokeTool guards against. const scope = yield* scopeFor(String(connection.name)); const descriptor = yield* runtime.getDescriptor(scope); if (!descriptor) return { tools: [] } satisfies ResolveToolsResult; const tools: ToolDef[] = descriptor.tools.map((t) => ({ name: ToolName.make(t.name), description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema, annotations: { requiresApproval: t.annotations?.destructive === true, }, })); return { tools } satisfies ResolveToolsResult; }), invokeTool: ({ ctx, toolRow, args }: InvokeToolInput<AppsStoreShape>) => Effect.gen(function* () { const scope = yield* scopeFor(String(toolRow.connection)); const descriptor = yield* runtime.getDescriptor(scope); // A missing descriptor / unknown tool is a typed error, NOT a silent // `?? {}` default: invoking a tool that is not published in the scope // must fail loudly rather than run with empty connections. if (!descriptor) { return yield* Effect.fail( pluginBoundaryError( `apps scope "${scope}" has no published app (connection "${toolRow.connection}")`, ), ); } const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name); if (!toolDesc) { return yield* Effect.fail( pluginBoundaryError(`apps tool "${toolRow.name}" is not published in scope "${scope}"`), ); } const declared = toolDesc.connections; const bindings = resolveBindings ? resolveBindings({ scope, tool: toolRow.name, declared }) : defaultBindings(declared); // Build the per-request resolver from the invoking executor context so // external calls route through the real per-request path (connections + // credentials resolved at the boundary). Falls back to the runtime's // boot-time resolver when the host supplies no factory. const resolver = makeResolver ? makeResolver({ ctx, scope, tool: toolRow.name }) : undefined; return yield* runtime .invokeTool({ scope, tool: toolRow.name, args, bindings, resolver }) .pipe(Effect.mapError((cause) => pluginBoundaryError(taggedMessage(cause)))); }), };}); // ---------------------------------------------------------------------------// Formalized scope <-> connection mapping.//// The executor normalizes every connection name to a JS identifier (camelCase,// no slashes: `connectionIdentifier`). So an `apps/<scope>` form does NOT// survive create -> the row is stored as `appsDefault`, and resolveTools would// fail to recover the scope. The mapping is therefore identifier-native: the// connection name is `apps` + PascalCase(scope), which round-trips through the// normalizer unchanged. The inverse strips the `apps` prefix and lowercases the// first character. Scopes are lowercase slugs (self-host single-tenant uses// `default`), so `default <-> appsDefault` round-trips exactly. The legacy// slash form (`apps/<scope>`) is still parsed for back-compat.//// This is the ONE place the mapping lives; resolveTools, invokeTool, and the// host wiring all go through it.// ---------------------------------------------------------------------------export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG; const pascal = (value: string): string => value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`; /** The connection name that addresses a scope's published app. Identifier-safe, * so it survives the executor's connection-name normalization unchanged. */export const connectionNameForScope = (scope: string): string => `${APPS_CONNECTION_PREFIX}${pascal(scope)}`; /** The scope a connection name addresses (inverse of `connectionNameForScope`). * Handles the identifier form (`appsDefault`), the legacy slash form * (`apps/<scope>`), and a bare scope name. */export const scopeFromConnection = (connectionName: string): string => { // Legacy slash form. if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) { return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1); } // Identifier form: apps + PascalCase(scope). if ( connectionName.startsWith(APPS_CONNECTION_PREFIX) && connectionName.length > APPS_CONNECTION_PREFIX.length ) { const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length); return `${rest[0]!.toLowerCase()}${rest.slice(1)}`; } // Bare name IS the scope. const slash = connectionName.indexOf("/"); return slash === -1 ? connectionName : connectionName.slice(slash + 1);};
import { Effect } from "effect";
import {
definePlugin,
tool,
ToolName,
ConnectionName,
IntegrationSlug,
AuthTemplateSlug,
connectionIdentifier,
type ResolveToolsInput,
type ResolveToolsResult,
type InvokeToolInput,
type ToolDef,
} from "@executor-js/sdk";
import type { AppsRuntime } from "./runtime";
import { makeAppsStore } from "./store";
import type { Bindings, ClientResolver } from "./bindings";
// ---------------------------------------------------------------------------
// The apps source plugin. Published custom tools become catalog citizens: the
// plugin registers one integration per scope (`apps`), and a connection to it
// makes the published tools resolvable + invocable like any catalog tool, so
// policy / approval / audit / toolkits / tools.list all apply unchanged.
//
// `resolveTools` projects the published descriptor into `ToolDef[]`.
// `invokeTool` bundles + runs the tool in the sandbox with connections bound.
// The plugin is thin: all logic lives in `AppsRuntime` (shared with the HTTP +
// MCP surfaces). The runtime is supplied via options because it owns the seam
// instances built at host boot.
// ---------------------------------------------------------------------------
export const APPS_INTEGRATION_SLUG = "apps";
export const APPS_PLUGIN_ID = "apps";
/** The scope a single-tenant self-host serves when the caller names none. */
const DEFAULT_CATALOG_SCOPE = "default";
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: `cause` is a typed value with a `message` field, not an unknown error
const taggedMessage = (cause: { readonly message: string }): string => cause.message;
const pluginBoundaryError = (message: string): Error => {
// oxlint-disable-next-line executor/no-error-constructor -- boundary: SDK tool/plugin callbacks surface failures as JavaScript Error values
return new Error(message);
};
export interface AppsPluginOptions {
/** The shared runtime (seams + store). Built at host boot. */
readonly runtime: AppsRuntime;
/** How a tool's declared connection roles are bound to the caller's
* connections at invoke time. Self-host resolves these from the scope's
* configured connections; a default binds each role to a connection of the
* same name as the integration. */
readonly resolveBindings?: (input: {
readonly scope: string;
readonly tool: string;
readonly declared: Readonly<Record<string, { kind: string; integration?: string }>>;
}) => Bindings;
/** Build a per-request ClientResolver from the invoking executor context.
* This is how external integration calls route through the REAL per-request
* path (connections resolved by the invoking owner, credentials injected at
* the boundary), rather than the runtime's boot-time default resolver. The
* `ctx` is the plugin's per-request `PluginCtx`; the type is kept structural
* so this package does not depend on the host SDK's ctx shape. */
readonly makeResolver?: (input: {
readonly ctx: unknown;
readonly scope: string;
readonly tool: string;
}) => ClientResolver;
}
const defaultBindings = (
declared: Readonly<Record<string, { kind: string; integration?: string }>>,
): Bindings => {
const out: Record<string, Bindings[string]> = {};
for (const [role, decl] of Object.entries(declared)) {
if (decl.kind === "array") {
out[role] = { kind: "array", connections: [decl.integration ?? role] };
} else if (decl.kind === "catalog") {
// no binding
} else {
out[role] = { kind: "single", connection: decl.integration ?? role };
}
}
return out;
};
interface AppsStoreShape {
readonly runtime: AppsRuntime;
}
export const appsPlugin = definePlugin((options?: AppsPluginOptions) => {
if (!options?.runtime) {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin construction must fail loudly when the host omits the runtime
throw pluginBoundaryError(
"appsPlugin requires a `runtime` (built from the five seams at host boot)",
);
}
const runtime = options.runtime;
const resolveBindings = options.resolveBindings;
const makeResolver = options.makeResolver;
// Resolve the scope a connection addresses (Fix 9). Prefer the EXPLICIT stored
// mapping (recorded at connect time under the normalized name), which is exact
// and collision-free. Only when no mapping was recorded (e.g. a legacy
// connection created before the mapping existed) fall back to reverse-parsing
// the name — the fragile path this replaces.
const scopeFor = (connectionName: string): Effect.Effect<string> =>
runtime.deps.store.getScopeForConnection(connectionName).pipe(
Effect.orElseSucceed(() => null),
Effect.map((mapped) => mapped ?? scopeFromConnection(connectionName)),
);
return {
id: APPS_PLUGIN_ID as "apps",
packageName: "@executor-js/plugin-apps",
// The plugin's store facade is host-owned plugin storage + blobs; the apps
// runtime already holds its own store, so this is a thin passthrough kept
// for the ctx shape (extension methods read the runtime).
storage: (deps): AppsStoreShape => {
void makeAppsStore({
pluginStorage: deps.pluginStorage,
blobs: deps.blobs,
});
return { runtime };
},
// Declare the plugin's storage collections so the host provisions them.
pluginStorage: {
published_descriptor: {
name: "published_descriptor",
schema: { Type: {} as Record<string, unknown> },
indexes: [],
},
apps_scope_connection: {
name: "apps_scope_connection",
schema: { Type: {} as Record<string, unknown> },
indexes: [],
},
},
extension: () => ({ runtime }),
// A tiny built-in tool that wires the scope's published app into the
// catalog: it registers the `apps` integration (idempotent) and creates the
// `apps/<scope>` connection for the caller. After it runs, the scope's
// published tools resolve as real catalog citizens (tools.list + execute
// through the same policy/audit path). This runs with the REAL request-scoped
// ctx (owner + core.integrations + connections), so it is the honest wiring
// point rather than a boot-time singleton guess at the owner.
staticSources: () => [
{
id: APPS_PLUGIN_ID,
kind: "executor",
name: "Apps",
tools: [
tool<AppsStoreShape>({
name: "connect_catalog",
description:
"Wire this scope's published app into the tool catalog: registers the `apps` " +
"integration and creates the apps/<scope> connection so published tools become " +
"catalog citizens. Idempotent; call once per scope after publishing.",
execute: (args, { ctx }) =>
Effect.gen(function* () {
// Single-tenant self-host serves one scope ("default"); a caller
// may pass an explicit scope for other layouts.
const scope =
(args as { scope?: string } | undefined)?.scope ?? DEFAULT_CATALOG_SCOPE;
const slug = IntegrationSlug.make(APPS_INTEGRATION_SLUG);
const existing = yield* ctx.core.integrations
.get(slug)
.pipe(Effect.orElseSucceed(() => null));
if (!existing) {
yield* ctx.core.integrations.register({
slug,
name: "Apps",
description: "User-authored, published custom tools, workflows, ui and skills.",
config: {},
canRemove: false,
canRefresh: true,
});
}
const connName = ConnectionName.make(connectionNameForScope(scope));
const conns = yield* ctx.connections
.list({ integration: slug })
.pipe(Effect.orElseSucceed(() => []));
if (!conns.some((c) => String(c.name) === String(connName))) {
yield* ctx.connections.create({
owner: "user",
name: connName,
integration: slug,
template: AuthTemplateSlug.make("none"),
value: "",
});
}
// Record the explicit connection-name -> scope mapping (Fix 9).
// The executor normalizes connection names to identifiers, so we
// record the mapping under BOTH the name we requested and its
// normalized form (the name resolveTools/invokeTool actually
// sees). This makes the scope lookup exact and collision-free:
// "my-scope" and "my_scope" get DISTINCT stored mappings even if
// a naive name-parse would collapse them.
yield* runtime.deps.store
.putScopeForConnection(String(connName), scope)
.pipe(Effect.orElseSucceed(() => undefined));
const normalized = connectionIdentifier(String(connName));
if (normalized !== String(connName)) {
yield* runtime.deps.store
.putScopeForConnection(normalized, scope)
.pipe(Effect.orElseSucceed(() => undefined));
}
return { scope, connection: String(connName) };
}),
}),
tool<AppsStoreShape>({
name: "start_workflow",
description:
"Start a published workflow by name (manual start). Runs its durable body " +
"with journal replay; `step.tool` external calls route through the caller's " +
"connections. Returns the run view (status + output).",
execute: (args, { ctx }) =>
Effect.gen(function* () {
const a = (args ?? {}) as {
workflow?: string;
scope?: string;
input?: unknown;
bindings?: Bindings;
runId?: string;
};
if (!a.workflow) {
return yield* Effect.fail(
pluginBoundaryError("start_workflow requires a `workflow` name"),
);
}
const scope = a.scope ?? DEFAULT_CATALOG_SCOPE;
// The per-request resolver (built from the invoking ctx) is what
// lets the workflow's `step.tool` reach real integrations through
// the caller's connections + credentials, rather than the runtime's
// boot-time NotImplemented default.
const resolver = makeResolver
? makeResolver({ ctx, scope, tool: a.workflow })
: undefined;
const run = yield* runtime
.startWorkflow({
scope,
workflow: a.workflow,
input: a.input ?? {},
bindings: a.bindings,
runId: a.runId,
resolver,
})
.pipe(Effect.mapError((cause) => pluginBoundaryError(taggedMessage(cause))));
return run;
}),
}),
],
},
],
// Per-connection tool production: project the published descriptor into
// ToolDefs. Called at connection create/refresh; the SDK stamps addresses
// and persists per connection.
resolveTools: ({ connection }: ResolveToolsInput<AppsStoreShape>) =>
Effect.gen(function* () {
// The connection name encodes the scope via the formalized mapping
// (`apps/<scope>`). A scope with no published app yet legitimately
// resolves to ZERO tools (the connection exists before the first
// publish), so an empty descriptor here is an intentional empty result,
// not the silently-swallowed missing-tool case invokeTool guards against.
const scope = yield* scopeFor(String(connection.name));
const descriptor = yield* runtime.getDescriptor(scope);
if (!descriptor) return { tools: [] } satisfies ResolveToolsResult;
const tools: ToolDef[] = descriptor.tools.map((t) => ({
name: ToolName.make(t.name),
description: t.description,
inputSchema: t.inputSchema,
outputSchema: t.outputSchema,
annotations: {
requiresApproval: t.annotations?.destructive === true,
},
}));
return { tools } satisfies ResolveToolsResult;
}),
invokeTool: ({ ctx, toolRow, args }: InvokeToolInput<AppsStoreShape>) =>
Effect.gen(function* () {
const scope = yield* scopeFor(String(toolRow.connection));
const descriptor = yield* runtime.getDescriptor(scope);
// A missing descriptor / unknown tool is a typed error, NOT a silent
// `?? {}` default: invoking a tool that is not published in the scope
// must fail loudly rather than run with empty connections.
if (!descriptor) {
return yield* Effect.fail(
pluginBoundaryError(
`apps scope "${scope}" has no published app (connection "${toolRow.connection}")`,
),
);
}
const toolDesc = descriptor.tools.find((t) => t.name === toolRow.name);
if (!toolDesc) {
return yield* Effect.fail(
pluginBoundaryError(`apps tool "${toolRow.name}" is not published in scope "${scope}"`),
);
}
const declared = toolDesc.connections;
const bindings = resolveBindings
? resolveBindings({ scope, tool: toolRow.name, declared })
: defaultBindings(declared);
// Build the per-request resolver from the invoking executor context so
// external calls route through the real per-request path (connections +
// credentials resolved at the boundary). Falls back to the runtime's
// boot-time resolver when the host supplies no factory.
const resolver = makeResolver
? makeResolver({ ctx, scope, tool: toolRow.name })
: undefined;
return yield* runtime
.invokeTool({ scope, tool: toolRow.name, args, bindings, resolver })
.pipe(Effect.mapError((cause) => pluginBoundaryError(taggedMessage(cause))));
}),
};
});
// ---------------------------------------------------------------------------
// Formalized scope <-> connection mapping.
//
// The executor normalizes every connection name to a JS identifier (camelCase,
// no slashes: `connectionIdentifier`). So an `apps/<scope>` form does NOT
// survive create -> the row is stored as `appsDefault`, and resolveTools would
// fail to recover the scope. The mapping is therefore identifier-native: the
// connection name is `apps` + PascalCase(scope), which round-trips through the
// normalizer unchanged. The inverse strips the `apps` prefix and lowercases the
// first character. Scopes are lowercase slugs (self-host single-tenant uses
// `default`), so `default <-> appsDefault` round-trips exactly. The legacy
// slash form (`apps/<scope>`) is still parsed for back-compat.
//
// This is the ONE place the mapping lives; resolveTools, invokeTool, and the
// host wiring all go through it.
// ---------------------------------------------------------------------------
export const APPS_CONNECTION_PREFIX = APPS_INTEGRATION_SLUG;
const pascal = (value: string): string =>
value.length === 0 ? value : `${value[0]!.toUpperCase()}${value.slice(1)}`;
/** The connection name that addresses a scope's published app. Identifier-safe,
* so it survives the executor's connection-name normalization unchanged. */
export const connectionNameForScope = (scope: string): string =>
`${APPS_CONNECTION_PREFIX}${pascal(scope)}`;
/** The scope a connection name addresses (inverse of `connectionNameForScope`).
* Handles the identifier form (`appsDefault`), the legacy slash form
* (`apps/<scope>`), and a bare scope name. */
export const scopeFromConnection = (connectionName: string): string => {
// Legacy slash form.
if (connectionName.startsWith(`${APPS_INTEGRATION_SLUG}/`)) {
return connectionName.slice(APPS_INTEGRATION_SLUG.length + 1);
}
// Identifier form: apps + PascalCase(scope).
if (
connectionName.startsWith(APPS_CONNECTION_PREFIX) &&
connectionName.length > APPS_CONNECTION_PREFIX.length
) {
const rest = connectionName.slice(APPS_CONNECTION_PREFIX.length);
return `${rest[0]!.toLowerCase()}${rest.slice(1)}`;
}
// Bare name IS the scope.
const slash = connectionName.indexOf("/");
return slash === -1 ? connectionName : connectionName.slice(slash + 1);
};