Skip to content
Merged
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
165 changes: 165 additions & 0 deletions apps/cloud/scripts/repair-connection-identifiers.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;

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<string, Set<string>>();
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<string>();
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<void> => {
const rows = await sql<ConnectionRow[]>`
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 || "<org>"}/${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<void> => {
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();
}
10 changes: 5 additions & 5 deletions apps/local/src/db/v1-v2-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
Expand All @@ -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" }),
Expand All @@ -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" } } }),
Expand Down Expand Up @@ -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",
});

Expand Down Expand Up @@ -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 }),
Expand Down
18 changes: 18 additions & 0 deletions packages/core/sdk/src/connection-name-identifier.ts
Original file line number Diff line number Diff line change
@@ -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)}`,
);
};
25 changes: 25 additions & 0 deletions packages/core/sdk/src/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 13 additions & 10 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1747,6 +1748,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
IntegrationNotFoundError | CredentialProviderNotRegisteredError | StorageFailure
> =>
Effect.gen(function* () {
const name = connectionIdentifier(String(input.name));
yield* requireUserSubject(input.owner);
const integrationRow = yield* findIntegrationRow(input.integration);
if (!integrationRow) {
Expand Down Expand Up @@ -1801,7 +1803,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
}
providerKey = String(provider.key);
for (const i of pasted) {
const itemId = `connection:${input.owner}:${input.integration}:${input.name}:${i.variable}`;
const itemId = `connection:${input.owner}:${input.integration}:${name}:${i.variable}`;
if ("value" in i.origin && provider.set) {
yield* provider.set(ProviderItemId.make(itemId), i.origin.value);
}
Expand All @@ -1819,7 +1821,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
const existing = yield* findConnectionRow({
owner: input.owner,
integration: input.integration,
name: input.name,
name,
});
const set: Record<string, unknown> = {
template: String(input.template),
Expand All @@ -1834,7 +1836,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
b.and(
byOwner(input.owner)(b),
b("integration", "=", String(input.integration)),
b("name", "=", String(input.name)),
b("name", "=", String(name)),
),
set,
});
Expand All @@ -1844,7 +1846,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
owner: keys.owner,
subject: keys.subject,
integration: String(input.integration),
name: String(input.name),
name: String(name),
template: String(input.template),
provider: providerKey,
item_ids: itemIds,
Expand All @@ -1864,7 +1866,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
const ref: ConnectionRef = {
owner: input.owner,
integration: input.integration,
name: input.name,
name,
};
// Produce + persist tools for the new connection.
yield* produceConnectionTools(integrationRow, ref).pipe(
Expand All @@ -1879,7 +1881,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
owner: keys.owner,
subject: keys.subject,
integration: String(input.integration),
name: String(input.name),
name: String(name),
template: String(input.template),
provider: providerKey,
item_ids: itemIds,
Expand All @@ -1902,6 +1904,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
input: MintOAuthConnectionInput,
): Effect.Effect<Connection, StorageFailure> =>
Effect.gen(function* () {
const name = connectionIdentifier(String(input.name));
yield* requireUserSubject(input.owner);
const integrationRow = yield* findIntegrationRow(input.integration);
if (!integrationRow) {
Expand All @@ -1918,7 +1921,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
const ref: ConnectionRef = {
owner: input.owner,
integration: input.integration,
name: input.name,
name,
};
yield* transaction(
Effect.gen(function* () {
Expand All @@ -1941,7 +1944,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
b.and(
byOwner(input.owner)(b),
b("integration", "=", String(input.integration)),
b("name", "=", String(input.name)),
b("name", "=", String(name)),
),
set,
});
Expand All @@ -1951,7 +1954,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
owner: keys.owner,
subject: keys.subject,
integration: String(input.integration),
name: String(input.name),
name: String(name),
template: String(input.template),
provider: input.provider,
item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
Expand Down Expand Up @@ -1983,7 +1986,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
owner: keys.owner,
subject: keys.subject,
integration: String(input.integration),
name: String(input.name),
name: String(name),
template: String(input.template),
provider: input.provider,
item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId },
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export {
Subject,
Owner,
} from "./ids";
export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier";

// Errors (tagged) — the ExecuteError set + integration lifecycle.
export {
Expand Down
Loading
Loading