diff --git a/apps/cloud/scripts/repair-connection-identifiers.ts b/apps/cloud/scripts/repair-connection-identifiers.ts new file mode 100644 index 000000000..a89346e19 --- /dev/null +++ b/apps/cloud/scripts/repair-connection-identifiers.ts @@ -0,0 +1,165 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: one-shot operator repair script fails hard on unsafe preconditions */ +/** + * Repair persisted connection names so callable connection segments are valid + * JS identifiers. + * + * Dry-run: + * + * op run --env-file=apps/cloud/.env.production -- \ + * bun apps/cloud/scripts/repair-connection-identifiers.ts + * + * Apply: + * + * op run --env-file=apps/cloud/.env.production -- \ + * bun apps/cloud/scripts/repair-connection-identifiers.ts --apply --confirm-connection-identifier-repair + */ +import postgres, { type Sql } from "postgres"; + +import { connectionIdentifier, isConnectionIdentifier } from "@executor-js/sdk/shared"; + +type Pg = Sql>; + +interface ConnectionRow { + readonly tenant: string; + readonly owner: "org" | "user"; + readonly subject: string; + readonly integration: string; + readonly name: string; +} + +interface RepairRow { + readonly tenant: string; + readonly owner: "org" | "user"; + readonly subject: string; + readonly integration: string; + readonly currentName: string; + readonly repairedName: string; +} + +const APPLY = process.argv.includes("--apply"); +const CONFIRM = process.argv.includes("--confirm-connection-identifier-repair"); + +const repairRows = (rows: readonly ConnectionRow[]): readonly RepairRow[] => + rows + .filter((row) => !isConnectionIdentifier(row.name)) + .map((row) => ({ + tenant: row.tenant, + owner: row.owner, + subject: row.subject, + integration: row.integration, + currentName: row.name, + repairedName: String(connectionIdentifier(row.name)), + })); + +const assertNoCollisions = (rows: readonly ConnectionRow[]): void => { + const normalized = new Map>(); + for (const row of rows) { + const key = [ + row.tenant, + row.owner, + row.subject, + row.integration, + String(connectionIdentifier(row.name)), + ].join("\0"); + const names = normalized.get(key) ?? new Set(); + names.add(row.name); + normalized.set(key, names); + } + + const collisions = [...normalized.entries()].filter(([, names]) => names.size > 1); + if (collisions.length === 0) return; + + for (const [key, names] of collisions) { + console.error(`collision ${key.replaceAll("\0", "/")}: ${[...names].join(", ")}`); + } + throw new Error("Refusing repair because normalized connection names collide."); +}; + +const repair = async (sql: Pg): Promise => { + const rows = await sql` + select tenant, owner, subject, integration, name + from connection + order by tenant, owner, subject, integration, name + `; + const changes = repairRows(rows); + const policyRows = await sql<{ readonly count: number }[]>` + select count(*)::int as count + from tool_policy + where pattern ~ '-' + `; + + console.log(`connection repair: ${rows.length} connection(s) checked`); + console.log(`connection repair: ${changes.length} connection(s) need identifier rename`); + for (const row of changes) { + console.log( + ` - ${row.tenant}/${row.owner}/${row.subject || ""}/${row.integration}: ${row.currentName} -> ${row.repairedName}`, + ); + } + + assertNoCollisions(rows); + if ((policyRows[0]?.count ?? 0) > 0) { + throw new Error( + "Refusing repair because tool_policy has dash-containing patterns; policy rewrite needs to be explicit.", + ); + } + + if (!APPLY) return; + if (!CONFIRM) { + throw new Error("Refusing apply without --confirm-connection-identifier-repair."); + } + + const now = new Date(); + await sql.begin(async (tx) => { + for (const row of changes) { + await (tx as Pg)` + update tool + set connection = ${row.repairedName} + where tenant = ${row.tenant} + and owner = ${row.owner} + and subject = ${row.subject} + and integration = ${row.integration} + and connection = ${row.currentName} + `; + await (tx as Pg)` + update definition + set connection = ${row.repairedName} + where tenant = ${row.tenant} + and owner = ${row.owner} + and subject = ${row.subject} + and integration = ${row.integration} + and connection = ${row.currentName} + `; + await (tx as Pg)` + update connection + set name = ${row.repairedName}, updated_at = ${now} + where tenant = ${row.tenant} + and owner = ${row.owner} + and subject = ${row.subject} + and integration = ${row.integration} + and name = ${row.currentName} + `; + } + }); + console.log("connection repair: complete"); +}; + +const main = async (): Promise => { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL is not set (run via `op run --env-file=.env.production --`)."); + process.exit(1); + } + const databaseSsl = process.env.DATABASE_SSL?.trim().toLowerCase(); + const ssl = + databaseSsl === "disable" || databaseSsl === "false" || databaseSsl === "0" ? false : "require"; + const sql = postgres(databaseUrl, { max: 1, prepare: false, ssl }) as Pg; + try { + await repair(sql); + } finally { + await sql.end(); + } +}; + +if (import.meta.main) { + await main(); +} diff --git a/apps/local/src/db/v1-v2-migration.test.ts b/apps/local/src/db/v1-v2-migration.test.ts index 7ac701de4..9e39ec8d1 100644 --- a/apps/local/src/db/v1-v2-migration.test.ts +++ b/apps/local/src/db/v1-v2-migration.test.ts @@ -622,7 +622,7 @@ describe("local v1 -> v2 migration", () => { owner: "org", subject: "", integration: "stripe_api", - name: "stripe-key", + name: "stripeKey", provider: "file", item_ids: JSON.stringify({ token: itemId }), }, @@ -642,7 +642,7 @@ describe("local v1 -> v2 migration", () => { owner: "org", subject: "", integration: "stripe_api", - connection: "stripe-key", + connection: "stripeKey", plugin_id: "openapi", name: "charges.create", input_schema: JSON.stringify({ type: "object" }), @@ -658,7 +658,7 @@ describe("local v1 -> v2 migration", () => { owner: "org", subject: "", integration: "stripe_api", - connection: "stripe-key", + connection: "stripeKey", plugin_id: "openapi", name: "Charge", schema: JSON.stringify({ type: "object", properties: { id: { type: "string" } } }), @@ -784,7 +784,7 @@ describe("local v1 -> v2 migration", () => { ); expect(rows.rows).toHaveLength(1); expect(rows.rows[0]).toMatchObject({ - connection: "axiom-mcp-oauth", + connection: "axiomMcpOauth", name: "querydataset", }); @@ -871,7 +871,7 @@ describe("local v1 -> v2 migration", () => { expect(connections.rows).toHaveLength(1); expect(connections.rows[0]).toMatchObject({ integration: "dealcloud_api", - name: "dealcloud-api", + name: "dealcloudApi", template: "dealCloudOAuth", provider: "file", item_ids: JSON.stringify({ token: accessItemId }), diff --git a/packages/core/sdk/src/connection-name-identifier.ts b/packages/core/sdk/src/connection-name-identifier.ts new file mode 100644 index 000000000..e9f2056c2 --- /dev/null +++ b/packages/core/sdk/src/connection-name-identifier.ts @@ -0,0 +1,18 @@ +import { ConnectionName } from "./ids"; + +export const isConnectionIdentifier = (value: string): boolean => + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value); + +export const connectionIdentifier = (input: string, fallback = "connection"): ConnectionName => { + const words = input.toLowerCase().match(/[a-z0-9]+/g); + const base = + words + ?.map((word, index) => + index === 0 ? word : `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`, + ) + .join("") || fallback; + + return ConnectionName.make( + /^[A-Za-z_$]/.test(base) ? base : `${fallback}${base[0]?.toUpperCase() ?? ""}${base.slice(1)}`, + ); +}; diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index da02f500f..7cdcfa80e 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -99,6 +99,31 @@ describe("connections.create", () => { }), ); + it.effect("normalizes free-form names into JS-callable connection identifiers", () => + Effect.gen(function* () { + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("my-api-key"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + + expect(String(connection.name)).toBe("myApiKey"); + expect(String(connection.address)).toBe("tools.vercel.org.myApiKey"); + + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.address)).sort()).toEqual([ + "tools.vercel.org.myApiKey.deploy", + "tools.vercel.org.myApiKey.list", + ]); + + const value = yield* executor.demo.resolveValue("org", "myApiKey"); + expect(value).toBe("secret-token"); + }), + ); + it.effect("external `from` references a provider item without writing it", () => Effect.gen(function* () { const executor = yield* setup(); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 4231a444d..93ca87d7c 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -134,6 +134,7 @@ import { shouldRefreshToken, type OAuthEndpointUrlPolicy, } from "./oauth-helpers"; +import { connectionIdentifier } from "./connection-name-identifier"; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; @@ -1747,6 +1748,7 @@ export const createExecutor = => Effect.gen(function* () { + const name = connectionIdentifier(String(input.name)); yield* requireUserSubject(input.owner); const integrationRow = yield* findIntegrationRow(input.integration); if (!integrationRow) { @@ -1801,7 +1803,7 @@ export const createExecutor = = { template: String(input.template), @@ -1834,7 +1836,7 @@ export const createExecutor = { // Connections: an apiKey (stripe) + an oauth (linear). const stripe = plan.connections.find((c) => c.row.integration === "stripe_api"); const linear = plan.connections.find((c) => c.row.integration === "linear_mcp"); - expect(stripe?.row.name).toBe("stripe-key"); + expect(stripe?.row.name).toBe("stripeKey"); expect(stripe?.row.template).toBe("apiKey"); expect(stripe?.itemIds.token).toBe(migratedItemId("org_X", "stripe-key")); expect(stripe?.row.oauthClientSlug).toBeNull(); - expect(linear?.row.name).toBe("linear-mcp-oauth"); + expect(linear?.row.name).toBe("linearMcpOauth"); expect(linear?.row.template).toBe("oauth2"); expect(linear?.row.owner).toBe("user"); expect(linear?.itemIds.token).toBe(migratedItemId("user-org:user_U:org_X", "linear-access")); @@ -1126,7 +1126,7 @@ describe("planMigration (the weave)", () => { ]); expect(plan.connections).toHaveLength(1); expect(plan.connections[0]?.sourceScopeId).toBe("org_X"); - expect(plan.connections[0]?.row.name).toBe("shared-key"); + expect(plan.connections[0]?.row.name).toBe("sharedKey"); expect(plan.connections[0]?.row.owner).toBe("user"); expect(plan.connections[0]?.row.template).toBe("bearer"); expect(plan.connections[0]?.itemIds.token).toBe(migratedItemId("org_X", "shared-key")); diff --git a/packages/core/sdk/src/migration-spec.ts b/packages/core/sdk/src/migration-spec.ts index 6c7a4a110..d77cf44f1 100644 --- a/packages/core/sdk/src/migration-spec.ts +++ b/packages/core/sdk/src/migration-spec.ts @@ -11,6 +11,8 @@ import { createHash } from "node:crypto"; +import { connectionIdentifier } from "./connection-name-identifier"; + export { migrationOAuthAuthorizationUrlFor, migrationOAuthClientPlanKey, @@ -1176,10 +1178,7 @@ const bindingSecretScope = (binding: V1BindingRow): string => binding.secretScopeId ?? binding.scopeId; const slugifyName = (name: string, fallback = "account"): string => - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || fallback; + String(connectionIdentifier(name, fallback)); const secretRefKey = (scopeId: string, secretId: string): string => `${scopeId}\0${secretId}`; diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index c1cdfd43d..5bb5f90a5 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -68,7 +68,7 @@ describe("oauth.start / oauth.complete", () => { owner: "org", client: CLIENT, clientOwner: "org", - name: ConnectionName.make("main"), + name: ConnectionName.make("main-account"), integration: INTEG, template: TEMPLATE, }); @@ -86,8 +86,8 @@ describe("oauth.start / oauth.complete", () => { state: started.state, code: callback.code, }); - expect(String(connection.name)).toBe("main"); - expect(String(connection.address)).toBe("tools.acme.org.main"); + expect(String(connection.name)).toBe("mainAccount"); + expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); expect(connection.expiresAt).toBeGreaterThan(Date.now()); // The connection produced its tools. @@ -97,7 +97,7 @@ describe("oauth.start / oauth.complete", () => { // Executing the tool resolves the minted access token, which the AS // recognises as one it issued. const out = (yield* executor.execute( - ToolAddress.make("tools.acme.org.main.whoami"), + ToolAddress.make("tools.acme.org.mainAccount.whoami"), {}, )) as { token: string }; expect(out.token).toMatch(/^at_/); diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 97362ac18..16954347b 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -26,6 +26,7 @@ export { ToolAddress, ToolName, } from "./ids"; +export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier"; // Domain projections (types only — no runtime cost). export type { diff --git a/packages/plugins/graphql/src/react/defaults.test.ts b/packages/plugins/graphql/src/react/defaults.test.ts index 9bf98350c..42cf58d68 100644 --- a/packages/plugins/graphql/src/react/defaults.test.ts +++ b/packages/plugins/graphql/src/react/defaults.test.ts @@ -25,7 +25,7 @@ describe("graphqlApiKeyAuthTemplate", () => { describe("graphqlConnectionName", () => { it("is deterministic per integration + owner", () => { - expect(String(graphqlConnectionName("github_com", "user"))).toBe("github_com-user"); - expect(String(graphqlConnectionName("github_com", "org"))).toBe("github_com-org"); + expect(String(graphqlConnectionName("github_com", "user"))).toBe("githubComUser"); + expect(String(graphqlConnectionName("github_com", "org"))).toBe("githubComOrg"); }); }); diff --git a/packages/plugins/graphql/src/react/defaults.ts b/packages/plugins/graphql/src/react/defaults.ts index 54675a229..468df53ee 100644 --- a/packages/plugins/graphql/src/react/defaults.ts +++ b/packages/plugins/graphql/src/react/defaults.ts @@ -1,5 +1,5 @@ -import type { Owner } from "@executor-js/sdk/shared"; -import { ConnectionName } from "@executor-js/sdk/shared"; +import type { ConnectionName, Owner } from "@executor-js/sdk/shared"; +import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; // --------------------------------------------------------------------------- // v2 connection-create defaults for the GraphQL plugin. v1's HTTP-credentials @@ -31,4 +31,4 @@ export const graphqlApiKeyAuthTemplate = ( * single owner-scoped credential per integration so re-adding overwrites * rather than accumulating duplicates. */ export const graphqlConnectionName = (slug: string, owner: Owner): ConnectionName => - ConnectionName.make(`${slug}-${owner}`); + connectionIdentifier(`${slug} ${owner}`); diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index d9c94382d..115d70a92 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -5,13 +5,13 @@ import * as Exit from "effect/Exit"; import { AuthTemplateSlug, - ConnectionName, IntegrationSlug, OAuthClientSlug, type Owner, } from "@executor-js/sdk/shared"; import { createConnection } from "@executor-js/react/api/atoms"; import { connectionWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; import { CredentialControlField, CredentialUsageRow, @@ -80,7 +80,7 @@ function RemoteEdit(props: { const exit = await doCreate({ payload: { owner: credentialTargetOwner, - name: ConnectionName.make(`${server.slug}-key`), + name: connectionIdentifier(`${server.slug} key`), integration: server.slug, template: HEADER_TEMPLATE, identityLabel: server.description || String(server.slug), @@ -107,7 +107,7 @@ function RemoteEdit(props: { // MCP registers its client (DCR) under the connection owner. clientOwner: owner, owner, - name: ConnectionName.make(`${server.slug}-oauth`), + name: connectionIdentifier(`${server.slug} oauth`), integration: server.slug, template: OAUTH_TEMPLATE, identityLabel: `${server.description || String(server.slug)} OAuth`, diff --git a/packages/plugins/mcp/src/react/McpSignInButton.tsx b/packages/plugins/mcp/src/react/McpSignInButton.tsx index 60b101c2d..30a359861 100644 --- a/packages/plugins/mcp/src/react/McpSignInButton.tsx +++ b/packages/plugins/mcp/src/react/McpSignInButton.tsx @@ -4,13 +4,13 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, - ConnectionName, IntegrationSlug, OAuthClientSlug, type Connection, type Owner, } from "@executor-js/sdk/shared"; import { connectionsAllAtom } from "@executor-js/react/api/atoms"; +import { connectionIdentifier } from "@executor-js/react/lib/connection-name"; import { OAuthSignInButton, useOAuthPopupFlow } from "@executor-js/react/plugins/oauth-sign-in"; import { mcpServerAtom } from "./atoms"; @@ -59,7 +59,7 @@ export default function McpSignInButton(props: { sourceId: string; owner?: Owner // MCP registers its client (DCR) under the connection owner. clientOwner: targetOwner, owner: targetOwner, - name: ConnectionName.make(`${slug}-oauth`), + name: connectionIdentifier(`${slug} oauth`), integration: slug, template: OAUTH_TEMPLATE, identityLabel: `${server.description || String(slug)} OAuth`, diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index d4f02cc1e..fae45f5dd 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -9,6 +9,7 @@ import { import type { AuthMethod } from "../lib/auth-placements"; import { + connectionNameFrom, connectionLabel, connectionLabelForHost, createCredentialPayloadOrigin, @@ -71,6 +72,22 @@ describe("connectionLabel (name placeholder derivation)", () => { }); }); +describe("connectionNameFrom", () => { + it("derives the JS-callable name from the display name", () => { + expect(String(connectionNameFrom("Autumn Production", "user", "Autumn", "org_123"))).toBe( + "autumnProduction", + ); + expect(String(connectionNameFrom("linear-mcp-oauth", "user", "Linear MCP", "org_123"))).toBe( + "linearMcpOauth", + ); + }); + + it("derives a callable default from owner and integration when the display name is empty", () => { + expect(String(connectionNameFrom("", "org", "GitHub", "org_123"))).toBe("workspaceGithub"); + expect(String(connectionNameFrom("", "org", "GitHub", null))).toBe("localGithub"); + }); +}); + describe("DEFAULT_CONNECTION_OWNER", () => { // The 'saved to' owner defaults to Personal: a connection is most often a // personal credential. diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 9c5646cdb..af20af7b4 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -43,6 +43,7 @@ import { import { OAuthClientForm } from "./oauth-client-form"; import { AddCustomMethodModal, type CreateCustomMethod } from "./add-custom-method-modal"; import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; +import { connectionIdentifier } from "../lib/connection-name"; import { Badge } from "./badge"; import { Button } from "./button"; import { PlusIcon } from "lucide-react"; @@ -62,7 +63,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". // --------------------------------------------------------------------------- // Add-account modal — the connection-create form. // -// Field order: (1) authentication method · (2) credential · (3) connection name +// Field order: (1) display name · (2) authentication method · (3) credential // · (4) saved-to owner. A connection is immutable once created. Step 2 collects // one value per distinct input the method declares — usually one, but a // multi-input method (e.g. Datadog's two keys) shows one field per variable. @@ -374,19 +375,14 @@ export const mergeCustomMethods = ( return [...declared, ...created.filter((m: AuthMethod) => !ids.has(m.id))]; }; -/** Derive a stable-ish connection name slug from the label; the server canonicalizes. */ -const connectionNameFrom = ( +/** Derive a stable JS-identifier-safe callable connection name from the label. */ +export const connectionNameFrom = ( label: string, owner: Owner, integrationName: string, organizationId: string | null, ): ConnectionName => - ConnectionName.make( - connectionLabelForHost(label, owner, integrationName, organizationId) - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "connection", - ); + connectionIdentifier(connectionLabelForHost(label, owner, integrationName, organizationId)); // --------------------------------------------------------------------------- // Transparent DCR (RFC 7591) connect orchestration. @@ -673,6 +669,7 @@ export function AddAccountModal(props: { const savedToOptions = isOAuth && !dcrActive ? oauthSavedToOptions : scopeOptions; const savedToOwner = isOAuth && !dcrActive ? oauthConnectionOwner : owner; const showSavedToPicker = !oauthRegistering && savedToOptions.length > 1; + const callableName = connectionNameFrom(label, savedToOwner, integrationName, organizationId); const reset = () => { setMethodId(methods[0]?.id ?? ""); @@ -909,11 +906,11 @@ export function AddAccountModal(props: {
- {/* 1. connection name */} + {/* 1. display name */}
@@ -923,6 +920,10 @@ export function AddAccountModal(props: { value={label} onChange={(e: React.ChangeEvent) => setLabel(e.target.value)} /> +

+ This connection will be callable as{" "} + {String(callableName)}. +

{/* 2. method */} diff --git a/packages/react/src/lib/connection-name.test.ts b/packages/react/src/lib/connection-name.test.ts new file mode 100644 index 000000000..d3e8bda4b --- /dev/null +++ b/packages/react/src/lib/connection-name.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { connectionIdentifier } from "./connection-name"; + +describe("connectionIdentifier", () => { + it("converts display labels to lower camel case", () => { + expect(String(connectionIdentifier("Personal GitHub"))).toBe("personalGithub"); + expect(String(connectionIdentifier("github_com oauth"))).toBe("githubComOauth"); + expect(String(connectionIdentifier("axiom-mcp-oauth"))).toBe("axiomMcpOauth"); + }); + + it("uses a valid leading identifier character", () => { + expect(String(connectionIdentifier("123 key"))).toBe("connection123Key"); + }); + + it("uses the fallback for empty labels", () => { + expect(String(connectionIdentifier(" ", "apiKey"))).toBe("apiKey"); + }); +}); diff --git a/packages/react/src/lib/connection-name.ts b/packages/react/src/lib/connection-name.ts new file mode 100644 index 000000000..bcf639917 --- /dev/null +++ b/packages/react/src/lib/connection-name.ts @@ -0,0 +1 @@ +export { connectionIdentifier } from "@executor-js/sdk/shared";