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
191 changes: 176 additions & 15 deletions apps/cloud/scripts/migrate-v1-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
* op run --env-file=apps/cloud/.env.production -- \
* bun apps/cloud/scripts/migrate-v1-v2.ts --apply --confirm-v1-v2-cutover
*
* Repair already-migrated OAuth authorization URLs from archived v1 metadata:
*
* op run --env-file=apps/cloud/.env.production -- \
* bun apps/cloud/scripts/migrate-v1-v2.ts --repair-oauth-authorization-urls
*
* op run --env-file=apps/cloud/.env.production -- \
* bun apps/cloud/scripts/migrate-v1-v2.ts --repair-oauth-authorization-urls --apply --confirm-oauth-authorization-url-repair
*
* Apply copies WorkOS Vault values into deterministic v2 item ids first, then
* runs the structural Postgres transaction: archive v1 executor tables as
* `v1_*`, create the v2 executor tables, and upsert the planned v2 rows.
Expand All @@ -33,14 +41,18 @@ import {
migrateOpenApiSourceConfig,
migrateV1PluginStorageRuntimeRow,
migrateV1ToolAnnotations,
migrationOAuthAuthorizationUrlFor as authorizationUrlFor,
migrationOAuthClientPlanKey as oauthClientPlanKey,
migrationSourceKey,
parseScope,
planMigration,
resolveMigrationOAuthAuthorizationUrls,
vaultV1LegacyObjectName,
vaultV1ObjectName,
vaultV2ObjectName,
type MigratedSourceConfig,
type MigrationInput,
type MigrationOAuthMetadataFetch,
type MigrationOwner,
type MigrationPlan,
type OwnerKeys,
Expand Down Expand Up @@ -132,11 +144,29 @@ export interface CloudMigrationResult {
};
}

export interface CloudOAuthAuthorizationUrlRepairRow {
readonly tenant: string;
readonly owner: MigrationOwner;
readonly subject: string;
readonly slug: string;
readonly currentAuthorizationUrl: string;
readonly repairedAuthorizationUrl: string;
readonly metadataUrl: string;
}

export interface CloudOAuthAuthorizationUrlRepairResult {
readonly applied: boolean;
readonly checked: number;
readonly changed: readonly CloudOAuthAuthorizationUrlRepairRow[];
}

export interface CloudMigrationOptions {
readonly sql: Pg;
readonly apply: boolean;
readonly confirmApply?: boolean;
readonly objectPrefix?: string;
readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch;
readonly oauthMetadataTimeoutMs?: number;
readonly workosCredentials?: {
readonly apiKey: string;
readonly clientId: string;
Expand All @@ -146,8 +176,22 @@ export interface CloudMigrationOptions {
readonly now?: Date;
}

export interface CloudOAuthAuthorizationUrlRepairOptions {
readonly sql: Pg;
readonly apply: boolean;
readonly confirmApply?: boolean;
readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch;
readonly oauthMetadataTimeoutMs?: number;
readonly log?: (message: string) => void;
readonly now?: Date;
}

const APPLY = process.argv.includes("--apply");
const CONFIRM_APPLY = process.argv.includes("--confirm-v1-v2-cutover");
const REPAIR_OAUTH_AUTHORIZATION_URLS = process.argv.includes("--repair-oauth-authorization-urls");
const CONFIRM_REPAIR_OAUTH_AUTHORIZATION_URLS = process.argv.includes(
"--confirm-oauth-authorization-url-repair",
);
const WORKOS_VAULT_PROVIDER = "workos-vault";
const WORKOS_VAULT_METADATA_PLUGIN_ID = "workos-vault";
const WORKOS_VAULT_METADATA_COLLECTION = "metadata";
Expand Down Expand Up @@ -509,9 +553,6 @@ const readV1Snapshot = async (
const ownerSubject = (owner: MigrationOwner, subject: string): string =>
owner === "org" ? "" : subject;

const oauthClientPlanKey = (client: MigrationPlan["oauthClients"][number]): string =>
`${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`;

const secretRefKey = (scopeId: string, secretId: string): string => `${scopeId}\0${secretId}`;

const secretNameByRef = (input: MigrationInput): ReadonlyMap<string, string> =>
Expand Down Expand Up @@ -681,6 +722,13 @@ const clientIdFor = (
values: ReadonlyMap<string, string>,
): string => client.clientId || values.get(oauthClientPlanKey(client)) || "";

const oauthClientStorageKey = (input: {
readonly tenant: string;
readonly owner: string;
readonly subject: string;
readonly slug: string;
}): string => `${input.tenant}\0${input.owner}\0${input.subject}\0${input.slug}`;

const jsonValue = (sql: Pg, value: unknown): unknown => (value == null ? null : sql.json(value));

const requiredJsonValue = (sql: Pg, value: unknown): unknown => sql.json(value ?? {});
Expand Down Expand Up @@ -902,6 +950,7 @@ const insertPlan = async (
snapshot: CloudV1Snapshot,
plan: MigrationPlan,
secretCopy: WorkosSecretCopyResult,
oauthAuthorizationUrls: ReadonlyMap<string, string>,
now: Date,
): Promise<void> => {
const connectionTargets = plan.connections.map((connection) => ({
Expand Down Expand Up @@ -933,7 +982,7 @@ const insertPlan = async (
for (const clientRow of plan.oauthClients) {
await sql`
insert into oauth_client (slug, authorization_url, token_url, grant, client_id, client_secret_item_id, resource, created_at, row_id, tenant, owner, subject)
values (${clientRow.slug}, ${clientRow.authorizationUrl}, ${clientRow.tokenUrl}, ${clientRow.grant}, ${clientIdFor(clientRow, secretCopy.oauthClientIdValues)}, ${clientRow.clientSecretItemId}, ${clientRow.resource}, ${now}, ${createId()}, ${clientRow.ownerKeys.tenant}, ${clientRow.ownerKeys.owner}, ${ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject)})
values (${clientRow.slug}, ${authorizationUrlFor(clientRow, oauthAuthorizationUrls)}, ${clientRow.tokenUrl}, ${clientRow.grant}, ${clientIdFor(clientRow, secretCopy.oauthClientIdValues)}, ${clientRow.clientSecretItemId}, ${clientRow.resource}, ${now}, ${createId()}, ${clientRow.ownerKeys.tenant}, ${clientRow.ownerKeys.owner}, ${ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject)})
on conflict (tenant, owner, subject, slug) do update set
authorization_url = excluded.authorization_url,
token_url = excluded.token_url,
Expand Down Expand Up @@ -1088,12 +1137,13 @@ const applyStructuralMigration = async (
snapshot: CloudV1Snapshot,
plan: MigrationPlan,
secretCopy: WorkosSecretCopyResult,
oauthAuthorizationUrls: ReadonlyMap<string, string>,
now: Date,
): Promise<void> => {
await sql.begin(async (tx) => {
await archiveV1Tables(tx as Pg);
await createV2Schema(tx as Pg);
await insertPlan(tx as Pg, snapshot, plan, secretCopy, now);
await insertPlan(tx as Pg, snapshot, plan, secretCopy, oauthAuthorizationUrls, now);
});
};

Expand Down Expand Up @@ -1121,6 +1171,94 @@ const printReport = (
for (const warning of r.warnings) log(` - ${warning}`);
};

const readCurrentOAuthAuthorizationUrls = async (sql: Pg): Promise<ReadonlyMap<string, string>> => {
const rows = await sql<
{
readonly tenant: string;
readonly owner: string;
readonly subject: string;
readonly slug: string;
readonly authorization_url: string;
}[]
>`
select tenant, owner, subject, slug, authorization_url
from oauth_client
`;
return new Map(rows.map((row) => [oauthClientStorageKey(row), row.authorization_url]));
};

export const repairCloudOAuthAuthorizationUrls = async (
options: CloudOAuthAuthorizationUrlRepairOptions,
): Promise<CloudOAuthAuthorizationUrlRepairResult> => {
const log = options.log ?? console.log;
const now = options.now ?? new Date();
const snapshot = await readV1Snapshot(options.sql, now, false, log);
log("plan: building OAuth authorization URL repair plan");
const plan = planMigration(snapshot.input);
const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, {
fetch: options.oauthMetadataFetch ?? fetch,
timeoutMs: options.oauthMetadataTimeoutMs,
});
const currentAuthorizationUrls = await readCurrentOAuthAuthorizationUrls(options.sql);

const changed: CloudOAuthAuthorizationUrlRepairRow[] = [];
let checked = 0;
for (const clientRow of plan.oauthClients) {
const metadataUrl = clientRow.authorizationServerMetadataUrl?.trim();
if (!metadataUrl) continue;
checked++;

const currentAuthorizationUrl = currentAuthorizationUrls.get(oauthClientPlanKey(clientRow));
if (currentAuthorizationUrl == null) {
log(
` - missing current oauth_client row for ${clientRow.ownerKeys.tenant}/${clientRow.slug}`,
);
continue;
}

const repairedAuthorizationUrl = authorizationUrlFor(clientRow, oauthAuthorizationUrls);
if (currentAuthorizationUrl === repairedAuthorizationUrl) continue;
changed.push({
tenant: clientRow.ownerKeys.tenant,
owner: clientRow.ownerKeys.owner,
subject: ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject),
slug: clientRow.slug,
currentAuthorizationUrl,
repairedAuthorizationUrl,
metadataUrl,
});
}

log(`oauth repair: ${checked} metadata-backed client(s) checked`);
log(`oauth repair: ${changed.length} client(s) need authorization_url update`);
for (const row of changed) {
log(
` - ${row.tenant}/${row.owner}/${row.subject || "<org>"}/${row.slug}: ${row.currentAuthorizationUrl} -> ${row.repairedAuthorizationUrl}`,
);
}

if (!options.apply) return { applied: false, checked, changed };
if (!options.confirmApply) {
throw new Error("Refusing OAuth authorization URL repair without confirmation flag.");
}

await options.sql.begin(async (tx) => {
for (const row of changed) {
await (tx as Pg)`
update oauth_client
set authorization_url = ${row.repairedAuthorizationUrl}
where tenant = ${row.tenant}
and owner = ${row.owner}
and subject = ${row.subject}
and slug = ${row.slug}
`;
}
});
log("oauth repair: complete");

return { applied: true, checked, changed };
};

export const runCloudV1V2Migration = async (
options: CloudMigrationOptions,
): Promise<CloudMigrationResult> => {
Expand Down Expand Up @@ -1164,7 +1302,22 @@ export const runCloudV1V2Migration = async (
);
}

await applyStructuralMigration(options.sql, snapshot, plan, secretCopy, now);
const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, {
fetch: options.oauthMetadataFetch ?? fetch,
timeoutMs: options.oauthMetadataTimeoutMs,
});
if (oauthAuthorizationUrls.size > 0) {
log(`oauth metadata: resolved ${oauthAuthorizationUrls.size} authorization URL(s)`);
}

await applyStructuralMigration(
options.sql,
snapshot,
plan,
secretCopy,
oauthAuthorizationUrls,
now,
);
log("apply: complete");
return {
applied: true,
Expand All @@ -1189,15 +1342,23 @@ const main = async (): Promise<void> => {
databaseSsl === "disable" || databaseSsl === "false" || databaseSsl === "0" ? false : "require";
const sql = postgres(databaseUrl, { max: 1, prepare: false, ssl }) as Pg;
try {
await runCloudV1V2Migration({
sql,
apply: APPLY,
confirmApply: CONFIRM_APPLY,
workosCredentials: {
apiKey: process.env.WORKOS_API_KEY ?? "",
clientId: process.env.WORKOS_CLIENT_ID ?? "",
},
});
if (REPAIR_OAUTH_AUTHORIZATION_URLS) {
await repairCloudOAuthAuthorizationUrls({
sql,
apply: APPLY,
confirmApply: CONFIRM_REPAIR_OAUTH_AUTHORIZATION_URLS,
});
} else {
await runCloudV1V2Migration({
sql,
apply: APPLY,
confirmApply: CONFIRM_APPLY,
workosCredentials: {
apiKey: process.env.WORKOS_API_KEY ?? "",
clientId: process.env.WORKOS_CLIENT_ID ?? "",
},
});
}
} finally {
await sql.end();
}
Expand Down
76 changes: 76 additions & 0 deletions apps/local/src/db/v1-v2-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const seedV1Db = async (
readonly includeMcpToolBinding?: boolean;
readonly jsonBlobs?: boolean;
readonly oauthConnectionProvider?: string;
readonly oauthProviderStateOverrides?: Record<string, unknown>;
} = {},
) => {
const client = await openLocalLibsql(dbPath);
Expand Down Expand Up @@ -541,6 +542,7 @@ const seedV1Db = async (
tokenEndpoint: "https://resolve.dealcloud.com/oauth/token",
resource: "https://api.dealcloud.com",
scopes: ["data"],
...(options.oauthProviderStateOverrides ?? {}),
}),
],
);
Expand Down Expand Up @@ -886,4 +888,78 @@ describe("local v1 -> v2 migration", () => {
expect(auth[clientSecretItemId]).toBe("dealcloud-secret");
expect(auth["dealcloud-client-id"]).toBeUndefined();
});

it("resolves v1 OAuth authorization-server metadata URLs before writing oauth_client rows", async () => {
const scopeId = "executor-workspace-abcd1234";
const tenantId = "executor-workspace-abcd1234";
const metadataUrl =
"https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale";
const dataDir = join(workDir, "data");
const dbPath = join(dataDir, "data.db");
mkdirSync(dataDir, { recursive: true });
await seedV1Db(dbPath, scopeId, {
includeSecretBackedOauth: true,
oauthConnectionProvider: "oauth2",
oauthProviderStateOverrides: {
kind: "dynamic-dcr",
authorizationServerUrl: "https://mcp.pscale.dev/mcp/planetscale",
authorizationServerMetadataUrl: metadataUrl,
resource: "https://mcp.pscale.dev/mcp/planetscale",
},
});

const authDir = join(process.env.XDG_DATA_HOME!, "executor");
mkdirSync(authDir, { recursive: true });
writeFileSync(
join(authDir, "auth.json"),
JSON.stringify({
[scopeId]: {
"dealcloud-access": "old-access-token",
"dealcloud-client-id": "dealcloud-client",
"dealcloud-client-secret": "dealcloud-secret",
},
}),
);

const seenMetadataUrls: string[] = [];
const oauthMetadataFetch: typeof globalThis.fetch = Object.assign(
async (input: RequestInfo | URL) => {
seenMetadataUrls.push(String(input));
return new Response(
JSON.stringify({
issuer: "https://api.planetscale.com",
authorization_endpoint: "https://app.planetscale.com/oauth/authorize",
token_endpoint: "https://auth.planetscale.com/oauth/token",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
{ preconnect: globalThis.fetch.preconnect },
);

const result = await migrateLocalV1ToV2IfNeeded({
sqlitePath: dbPath,
tables: collectTables(),
namespace: "executor_local",
tenantId,
oauthMetadataFetch,
});

expect(result.migrated).toBe(true);
expect(seenMetadataUrls).toEqual([metadataUrl]);

const client = await openLocalLibsql(dbPath);
const oauthClients = await client.execute(
"SELECT slug, grant, authorization_url, resource FROM oauth_client",
);
expect(oauthClients.rows).toEqual([
{
slug: "dealcloud",
grant: "authorization_code",
authorization_url: "https://app.planetscale.com/oauth/authorize",
resource: "https://mcp.pscale.dev/mcp/planetscale",
},
]);
client.close();
});
});
Loading
Loading