// ---------------------------------------------------------------------------// v1.4.x → v2 migration — pure transform building blocks.//// These are the schema-STABLE, side-effect-free pieces of the migration: the// scope→owner split, the policy-pattern remap, the WorkOS-Vault object naming// (v1 read name vs v2 write name), the oauth_client dedup key, and OAuth scope// serialization. The cloud/local RUNNERS read old rows + resolve secret values// and call these; keeping them pure makes the risky part unit-testable without// a database. See `personal-notes/migration-notes-5.md` for the full design.// --------------------------------------------------------------------------- import { createHash } from "node:crypto"; import { connectionIdentifier } from "./connection-name-identifier"; export { migrationOAuthAuthorizationUrlFor, migrationOAuthClientAuthorizationUrlResolutionSource, migrationOAuthClientNeedsAuthorizationUrlResolution, migrationOAuthClientPlanKey, resolveMigrationOAuthAuthorizationUrls, type MigrationOAuthMetadataFetch, type ResolveMigrationOAuthAuthorizationUrlsOptions,} from "./migration-oauth-metadata"; export type MigrationOwner = "org" | "user"; /** v2 owner partition for a migrated row. `subject` is "" (ORG_SUBJECT) for org. */export interface OwnerKeys { readonly owner: MigrationOwner; readonly subject: string; readonly tenant: string;} const ORG_SUBJECT = ""; // ---------------------------------------------------------------------------// Scope → (owner, subject, tenant)//// v1 scope ids are EXACTLY two shapes in prod (verified across 1,079 rows):// - `org_<id>` → org-owned (shared); tenant = the org id// - `user-org:user_<u>:org_<o>` → a user's personal scope within an org// Returns null for any other shape so the runner FAILS LOUD rather than// silently mis-owning a row (a scope leak is a security bug, not a data bug).// --------------------------------------------------------------------------- export const parseScope = (scopeId: string): OwnerKeys | null => { if (scopeId.startsWith("user-org:")) { const parts = scopeId.split(":"); if (parts.length !== 3) return null; const [, user, org] = parts; if (!user || !org || !user.startsWith("user_") || !org.startsWith("org_")) return null; return { owner: "user", subject: user, tenant: org }; } if (scopeId.startsWith("org_") && !scopeId.includes(":")) { return { owner: "org", subject: ORG_SUBJECT, tenant: scopeId }; } return null;}; export type ScopeOwnerResolver = (scopeId: string) => OwnerKeys | null; /** Stable string identifying the partition an oauth_client is deduped within — * shared org apps collapse across the org, personal apps stay per-user. */export const ownerPartitionKey = (keys: OwnerKeys): string => keys.owner === "org" ? `org:${keys.tenant}` : `user:${keys.subject}:${keys.tenant}`; // ---------------------------------------------------------------------------// WorkOS Vault object naming.//// v1 stored a secret value at `executor/<scopeId>/secrets/<secretId>` (with a// per-scope KEK), url-encoding the segments — plus a LEGACY un-encoded fallback.// v2 drops the scope segment (flat KEK): `executor/secrets/<itemId>`. The names// differ, so id-reuse is impossible — the runner reads the v1 object and// re-writes a fresh v2 object (+ a `plugin_storage[metadata]` row).// --------------------------------------------------------------------------- export const DEFAULT_VAULT_PREFIX = "executor";const enc = encodeURIComponent; export const vaultV1ObjectName = (prefix: string, scopeId: string, secretId: string): string => `${prefix}/${enc(scopeId)}/secrets/${enc(secretId)}`; /** The legacy un-url-encoded variant v1 falls back to on a 404. */export const vaultV1LegacyObjectName = ( prefix: string, scopeId: string, secretId: string,): string => `${prefix}/${scopeId}/secrets/${secretId}`; export const vaultV2ObjectName = (prefix: string, itemId: string): string => `${prefix}/secrets/${enc(itemId)}`; // ---------------------------------------------------------------------------// oauth_client dedup key — collapse identical apps (BYO means mostly distinct,// so this only merges a handful). Keyed on the owner partition + client id +// token endpoint. NUL-separated so no value can forge a collision.// --------------------------------------------------------------------------- export const oauthClientDedupKey = ( partition: string, clientId: string, tokenEndpoint: string,): string => `${partition} ${clientId} ${tokenEndpoint}`; // ---------------------------------------------------------------------------// OAuth scope serialization — v1 `provider_state.scopes` is a JSON array;// v2 `connection.oauth_scope` is a single space-joined string (round-trips:// split on `\s+` at refresh). Order-preserving de-dupe; space-join is lossless// because no scope value contains a space.// --------------------------------------------------------------------------- export const serializeOAuthScopes = (scopes: readonly string[]): string => [...new Set(scopes.filter((s) => s.length > 0))].join(" "); const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); // ---------------------------------------------------------------------------// Policy pattern migration.//// v1 patterns match a connection-AGNOSTIC `<source>.<tool>` id. v2 matches the// FULL `<integration>.<owner>.<connection>.<tool>`, so a migrated policy applies// across ALL connections → wildcard the owner+connection segments. The// whole-integration (`<slug>.*`) and bare-slug forms already cover the deeper// segments via their trailing `*`, so they only get the slug remapped.//// - `*` → `*` (universal)// - static ns (`executor.*`) → unchanged (pass-through)// - `<slug>` / `<slug>.*` → `<newSlug>` / `<newSlug>.*` (subtree)// - `<slug>.<rest>` → `<newSlug>.*.*.<rest>` (insert wildcards)// - unknown first segment → DEAD (source removed) — flag, never silently drop// --------------------------------------------------------------------------- export const DEFAULT_STATIC_NAMESPACES: readonly string[] = ["executor", "openapi"]; const MICROSOFT_GRAPH_LEGACY_SLUG = "microsoft_graph";const MICROSOFT_GRAPH_CURATED_SLUG = "microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated"; export type PolicyTransformResult = | { readonly kind: "ok"; readonly pattern: string } | { readonly kind: "static"; readonly pattern: string } | { readonly kind: "dead"; readonly slug: string }; export const migratePolicyPattern = ( pattern: string, slugMap: ReadonlyMap<string, string>, staticNamespaces: readonly string[] = DEFAULT_STATIC_NAMESPACES,): PolicyTransformResult => { if (pattern === "*") return { kind: "ok", pattern: "*" }; const firstDot = pattern.indexOf("."); const slug = firstDot === -1 ? pattern : pattern.slice(0, firstDot); if (staticNamespaces.includes(slug)) return { kind: "static", pattern }; const newSlug = slugMap.get(slug); if (newSlug === undefined) return { kind: "dead", slug }; const rest = firstDot === -1 ? "" : pattern.slice(firstDot + 1); if (rest === "") return { kind: "ok", pattern: newSlug }; if (rest === "*") return { kind: "ok", pattern: `${newSlug}.*` }; return { kind: "ok", pattern: `${newSlug}.*.*.${rest}` };}; // ---------------------------------------------------------------------------// Plugin runtime metadata migration.//// v1 plugins persisted invocation metadata in plugin-specific storage alongside// source rows. v2 puts catalog-level operation metadata under org-owned// `plugin_storage[operation]`, and MCP stores the raw upstream tool name on the// tool row annotations. These helpers are shared by local + cloud runners so the// migration produces the same v2-native runtime shape everywhere.// --------------------------------------------------------------------------- const GRAPHQL_GREENFIELD_V1_PLUGIN_ID = "graphql-greenfield";const GRAPHQL_V2_PLUGIN_ID = "graphql";const OPENAPI_PLUGIN_ID = "openapi";const MCP_PLUGIN_ID = "mcp";const OPERATION_COLLECTION = "operation";const MCP_BINDING_COLLECTION = "binding"; const normalizeRuntimePluginId = (pluginId: string): string => pluginId === GRAPHQL_GREENFIELD_V1_PLUGIN_ID ? GRAPHQL_V2_PLUGIN_ID : pluginId; export interface V1ToolRuntimeMetadataRow { readonly scopeId: string; readonly sourceId: string; readonly pluginId: string; readonly name: string; readonly annotations: unknown;} export interface V1PluginStorageRuntimeRow { readonly scopeId: string; readonly pluginId: string; readonly collection: string; readonly key: string; readonly data: unknown;} export interface LegacyMcpToolBinding { readonly toolName: string; readonly annotations?: Record<string, unknown>;} export interface V1RuntimeMetadataIndex { readonly mcpBindings: ReadonlyMap<string, LegacyMcpToolBinding>;} export type MigratedPluginStorageOwner = "source" | "catalog"; export interface MigratedPluginStorageRuntimeRow { readonly pluginId: string; readonly collection: string; readonly key: string; readonly data: unknown; /** `catalog` rows are v2 integration metadata and must be org-owned. */ readonly owner: MigratedPluginStorageOwner;} const runtimeStorageKey = (scopeId: string, key: string): string => `${scopeId}\0${key}`; const fullToolKey = (sourceId: string, toolName: string): string => `${sourceId}.${toolName}`; const stripToolPrefix = (sourceId: string, toolId: string): string => toolId.startsWith(`${sourceId}.`) ? toolId.slice(sourceId.length + 1) : toolId; const legacyMcpToolBinding = (data: unknown): LegacyMcpToolBinding | null => { if (!isRecord(data) || !isRecord(data.binding)) return null; const toolName = data.binding.toolName; if (typeof toolName !== "string" || toolName.length === 0) return null; const annotations = isRecord(data.binding.annotations) ? data.binding.annotations : undefined; return { toolName, ...(annotations ? { annotations } : {}), };}; export const buildV1RuntimeMetadataIndex = ( rows: readonly V1PluginStorageRuntimeRow[],): V1RuntimeMetadataIndex => { const mcpBindings = new Map<string, LegacyMcpToolBinding>(); for (const row of rows) { if (normalizeRuntimePluginId(row.pluginId) !== MCP_PLUGIN_ID) continue; if (row.collection !== MCP_BINDING_COLLECTION) continue; const binding = legacyMcpToolBinding(row.data); if (!binding) continue; mcpBindings.set(runtimeStorageKey(row.scopeId, row.key), binding); } return { mcpBindings };}; export const migrateV1ToolAnnotations = ( tool: V1ToolRuntimeMetadataRow, index: V1RuntimeMetadataIndex,): unknown => { if (normalizeRuntimePluginId(tool.pluginId) !== MCP_PLUGIN_ID) return tool.annotations; if (isRecord(tool.annotations) && isRecord(tool.annotations.mcp)) return tool.annotations; const binding = index.mcpBindings.get( runtimeStorageKey(tool.scopeId, fullToolKey(tool.sourceId, tool.name)), ); if (!binding) return tool.annotations; const base = isRecord(tool.annotations) ? tool.annotations : {}; const destructive = binding.annotations?.destructiveHint === true; return { ...base, requiresApproval: base.requiresApproval ?? destructive, ...(destructive && base.approvalDescription === undefined ? { approvalDescription: binding.annotations?.title ?? binding.toolName } : {}), mcp: { toolName: binding.toolName, ...(binding.annotations ? { upstream: binding.annotations } : {}), }, };}; const migrateOperationStorageRow = ( row: V1PluginStorageRuntimeRow,): MigratedPluginStorageRuntimeRow | null => { const pluginId = normalizeRuntimePluginId(row.pluginId); if ( row.collection !== OPERATION_COLLECTION || (pluginId !== OPENAPI_PLUGIN_ID && pluginId !== GRAPHQL_V2_PLUGIN_ID) ) { return null; } if (!isRecord(row.data)) return null; const existingIntegration = row.data.integration; const existingToolName = row.data.toolName; if ( typeof existingIntegration === "string" && typeof existingToolName === "string" && "binding" in row.data ) { return { pluginId, collection: row.collection, key: fullToolKey(existingIntegration, existingToolName), data: { integration: existingIntegration, toolName: existingToolName, binding: row.data.binding, }, owner: "catalog", }; } const sourceId = row.data.sourceId; const toolId = row.data.toolId; if (typeof sourceId !== "string" || typeof toolId !== "string" || !("binding" in row.data)) { return null; } const toolName = stripToolPrefix(sourceId, toolId); return { pluginId, collection: row.collection, key: fullToolKey(sourceId, toolName), data: { integration: sourceId, toolName, binding: row.data.binding, }, owner: "catalog", };}; export const migrateV1PluginStorageRuntimeRow = ( row: V1PluginStorageRuntimeRow,): MigratedPluginStorageRuntimeRow => { const operation = migrateOperationStorageRow(row); if (operation) return operation; return { pluginId: normalizeRuntimePluginId(row.pluginId), collection: row.collection, key: row.key, data: row.data, owner: "source", };}; // ---------------------------------------------------------------------------// OpenAPI auth template migration — v1 source `config` auth → v2// `authenticationTemplate` (Authentication[]) + the static header/query// passthrough + the slot→method-slug map the connection pass needs.//// v1 shapes (verified against prod):// - `config.headers` / `config.queryParams`: `Record<name, V1ConfiguredValue>`// where a value is either a literal string (a STATIC header) or a credential// placement `{ kind, slot?, prefix? }`. v1 applied EVERY configured credential// placement on every request, rendering `prefix ? prefix+value : value`.// - `config.oauth2`: the user-configured OAuth method (real urls/scopes/scheme).//// v2 model: each connection holds ONE value, rendered ONLY into the `token`// variable (any other variable name renders to ""). So a source with a SINGLE// credential placement maps cleanly to one apiKey method `{name: [prefix, token]}`;// a source with >1 distinct credential placement (Datadog dd-api-key +// dd-application-key; GitHub authorization + user-agent) has no faithful// single-connection v2 form — we flag `needsReview` and never silently emit a// template that would render a second credential as "".// --------------------------------------------------------------------------- /** A v1 `config.headers` / `config.queryParams` entry. A bare string is a static * value; the object forms are credential placements that pull from a binding / * secret / inline text, optionally with a literal `prefix` (e.g. `"Bearer "`). */export type V1ConfiguredValue = | string | { readonly kind: "binding"; readonly slot: string; readonly prefix?: string } | { readonly kind: "secret"; readonly secretId?: string; readonly prefix?: string } | { readonly kind: "text"; readonly text: string; readonly prefix?: string }; export interface V1OAuth2Config { readonly securitySchemeName: string; readonly flow?: string; readonly authorizationUrl?: string; readonly tokenUrl: string; readonly scopes?: readonly string[];} export interface V1OpenApiAuthConfig { readonly headers?: Record<string, V1ConfiguredValue>; readonly queryParams?: Record<string, V1ConfiguredValue>; readonly oauth2?: V1OAuth2Config;} /** A part-array template value: `prefix ? [prefix, token] : [token]`. */export type TemplatePart = string | { readonly type: "variable"; readonly name: string }; export interface MigratedApiKeyAuth { readonly slug: string; readonly type: "apiKey"; readonly headers?: Record<string, readonly TemplatePart[]>; readonly queryParams?: Record<string, readonly TemplatePart[]>;} export interface MigratedOAuthAuth { readonly slug: string; readonly type: "oauth"; readonly authorizationUrl: string; readonly tokenUrl: string; readonly scopes: readonly string[];} export type MigratedAuthentication = MigratedApiKeyAuth | MigratedOAuthAuth; export interface OpenApiAuthTemplateResult { readonly authenticationTemplate: readonly MigratedAuthentication[]; /** Literal-string header/query entries — passed through to v2 `config`. */ readonly staticHeaders: Record<string, string>; readonly staticQueryParams: Record<string, string>; /** v1 binding `slot_key` → the v2 method `slug` that consumes it. The * connection pass uses this to set `connection.template`. */ readonly slotToTemplateSlug: Record<string, string>; /** v1 binding `slot_key` → the v2 input variable its resolved secret fills. The * connection migration writes one `item_ids` entry per (variable → secret), so * a two-secret source (e.g. Datadog) lands both keys on one connection. */ readonly slotToVariable: Record<string, string>; readonly warnings: readonly string[];} /** The single apiKey method slug — one apiKey method per source, so a constant * is unique within the integration and every credential slot maps to it. */export const API_KEY_TEMPLATE_SLUG = "apiKey"; /** The canonical variable for a single-input source. Multiple inputs derive * distinct names instead (so a connection can carry both of e.g. Datadog's * keys). Matches the runtime default in `connection.ts` / the plugins. */export const PRIMARY_INPUT_VARIABLE = "token"; const isCredentialPlacement = ( value: V1ConfiguredValue,): value is Exclude<V1ConfiguredValue, string> => typeof value !== "string"; /** Slugify a header/query name into a stable variable identifier: * `DD-API-KEY` → `dd_api_key`. */const slugifyVariable = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); interface Placement { readonly carrier: "header" | "query"; readonly name: string; readonly prefix?: string; readonly slot?: string;} const legacyOAuthSlotSchemeVariants = (securitySchemeName: string): readonly string[] => { const lower = securitySchemeName.toLowerCase(); const hyphenated = lower.replaceAll("_", "-"); return [...new Set([lower, hyphenated])];}; export const migrateOpenApiAuthTemplate = ( config: V1OpenApiAuthConfig,): OpenApiAuthTemplateResult => { const template: MigratedAuthentication[] = []; const staticHeaders: Record<string, string> = {}; const staticQueryParams: Record<string, string> = {}; const slotToTemplateSlug: Record<string, string> = {}; const slotToVariable: Record<string, string> = {}; const warnings: string[] = []; // First pass — separate credential placements from static literals. const placements: Placement[] = []; for (const [name, value] of Object.entries(config.headers ?? {})) { if (isCredentialPlacement(value)) { placements.push({ carrier: "header", name, prefix: value.prefix, slot: value.kind === "binding" ? value.slot : undefined, }); } else { staticHeaders[name] = value; } } for (const [name, value] of Object.entries(config.queryParams ?? {})) { if (isCredentialPlacement(value)) { placements.push({ carrier: "query", name, prefix: value.prefix, slot: value.kind === "binding" ? value.slot : undefined, }); } else { staticQueryParams[name] = value; } } // Variable per placement: a lone input is the canonical `token`; multiple // inputs each get a distinct slugified variable (collision-suffixed) so a // connection carries one value per key. const taken = new Set<string>(); const variableFor = (placement: Placement): string => { if (placements.length <= 1) return PRIMARY_INPUT_VARIABLE; const base = slugifyVariable(placement.name) || "input";Lines 1–500 of 1812. Load more
// --------------------------------------------------------------------------- // v1.4.x → v2 migration — pure transform building blocks. // // These are the schema-STABLE, side-effect-free pieces of the migration: the // scope→owner split, the policy-pattern remap, the WorkOS-Vault object naming // (v1 read name vs v2 write name), the oauth_client dedup key, and OAuth scope // serialization. The cloud/local RUNNERS read old rows + resolve secret values // and call these; keeping them pure makes the risky part unit-testable without // a database. See `personal-notes/migration-notes-5.md` for the full design. // --------------------------------------------------------------------------- import { createHash } from "node:crypto"; import { connectionIdentifier } from "./connection-name-identifier"; export { migrationOAuthAuthorizationUrlFor, migrationOAuthClientAuthorizationUrlResolutionSource, migrationOAuthClientNeedsAuthorizationUrlResolution, migrationOAuthClientPlanKey, resolveMigrationOAuthAuthorizationUrls, type MigrationOAuthMetadataFetch, type ResolveMigrationOAuthAuthorizationUrlsOptions, } from "./migration-oauth-metadata"; export type MigrationOwner = "org" | "user"; /** v2 owner partition for a migrated row. `subject` is "" (ORG_SUBJECT) for org. */ export interface OwnerKeys { readonly owner: MigrationOwner; readonly subject: string; readonly tenant: string; } const ORG_SUBJECT = ""; // --------------------------------------------------------------------------- // Scope → (owner, subject, tenant) // // v1 scope ids are EXACTLY two shapes in prod (verified across 1,079 rows): // - `org_<id>` → org-owned (shared); tenant = the org id // - `user-org:user_<u>:org_<o>` → a user's personal scope within an org // Returns null for any other shape so the runner FAILS LOUD rather than // silently mis-owning a row (a scope leak is a security bug, not a data bug). // --------------------------------------------------------------------------- export const parseScope = (scopeId: string): OwnerKeys | null => { if (scopeId.startsWith("user-org:")) { const parts = scopeId.split(":"); if (parts.length !== 3) return null; const [, user, org] = parts; if (!user || !org || !user.startsWith("user_") || !org.startsWith("org_")) return null; return { owner: "user", subject: user, tenant: org }; } if (scopeId.startsWith("org_") && !scopeId.includes(":")) { return { owner: "org", subject: ORG_SUBJECT, tenant: scopeId }; } return null; }; export type ScopeOwnerResolver = (scopeId: string) => OwnerKeys | null; /** Stable string identifying the partition an oauth_client is deduped within — * shared org apps collapse across the org, personal apps stay per-user. */ export const ownerPartitionKey = (keys: OwnerKeys): string => keys.owner === "org" ? `org:${keys.tenant}` : `user:${keys.subject}:${keys.tenant}`; // --------------------------------------------------------------------------- // WorkOS Vault object naming. // // v1 stored a secret value at `executor/<scopeId>/secrets/<secretId>` (with a // per-scope KEK), url-encoding the segments — plus a LEGACY un-encoded fallback. // v2 drops the scope segment (flat KEK): `executor/secrets/<itemId>`. The names // differ, so id-reuse is impossible — the runner reads the v1 object and // re-writes a fresh v2 object (+ a `plugin_storage[metadata]` row). // --------------------------------------------------------------------------- export const DEFAULT_VAULT_PREFIX = "executor"; const enc = encodeURIComponent; export const vaultV1ObjectName = (prefix: string, scopeId: string, secretId: string): string => `${prefix}/${enc(scopeId)}/secrets/${enc(secretId)}`; /** The legacy un-url-encoded variant v1 falls back to on a 404. */ export const vaultV1LegacyObjectName = ( prefix: string, scopeId: string, secretId: string, ): string => `${prefix}/${scopeId}/secrets/${secretId}`; export const vaultV2ObjectName = (prefix: string, itemId: string): string => `${prefix}/secrets/${enc(itemId)}`; // --------------------------------------------------------------------------- // oauth_client dedup key — collapse identical apps (BYO means mostly distinct, // so this only merges a handful). Keyed on the owner partition + client id + // token endpoint. NUL-separated so no value can forge a collision. // --------------------------------------------------------------------------- export const oauthClientDedupKey = ( partition: string, clientId: string, tokenEndpoint: string, ): string => `${partition} ${clientId} ${tokenEndpoint}`; // --------------------------------------------------------------------------- // OAuth scope serialization — v1 `provider_state.scopes` is a JSON array; // v2 `connection.oauth_scope` is a single space-joined string (round-trips: // split on `\s+` at refresh). Order-preserving de-dupe; space-join is lossless // because no scope value contains a space. // --------------------------------------------------------------------------- export const serializeOAuthScopes = (scopes: readonly string[]): string => [...new Set(scopes.filter((s) => s.length > 0))].join(" "); const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); // --------------------------------------------------------------------------- // Policy pattern migration. // // v1 patterns match a connection-AGNOSTIC `<source>.<tool>` id. v2 matches the // FULL `<integration>.<owner>.<connection>.<tool>`, so a migrated policy applies // across ALL connections → wildcard the owner+connection segments. The // whole-integration (`<slug>.*`) and bare-slug forms already cover the deeper // segments via their trailing `*`, so they only get the slug remapped. // // - `*` → `*` (universal) // - static ns (`executor.*`) → unchanged (pass-through) // - `<slug>` / `<slug>.*` → `<newSlug>` / `<newSlug>.*` (subtree) // - `<slug>.<rest>` → `<newSlug>.*.*.<rest>` (insert wildcards) // - unknown first segment → DEAD (source removed) — flag, never silently drop // --------------------------------------------------------------------------- export const DEFAULT_STATIC_NAMESPACES: readonly string[] = ["executor", "openapi"]; const MICROSOFT_GRAPH_LEGACY_SLUG = "microsoft_graph"; const MICROSOFT_GRAPH_CURATED_SLUG = "microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated"; export type PolicyTransformResult = | { readonly kind: "ok"; readonly pattern: string } | { readonly kind: "static"; readonly pattern: string } | { readonly kind: "dead"; readonly slug: string }; export const migratePolicyPattern = ( pattern: string, slugMap: ReadonlyMap<string, string>, staticNamespaces: readonly string[] = DEFAULT_STATIC_NAMESPACES, ): PolicyTransformResult => { if (pattern === "*") return { kind: "ok", pattern: "*" }; const firstDot = pattern.indexOf("."); const slug = firstDot === -1 ? pattern : pattern.slice(0, firstDot); if (staticNamespaces.includes(slug)) return { kind: "static", pattern }; const newSlug = slugMap.get(slug); if (newSlug === undefined) return { kind: "dead", slug }; const rest = firstDot === -1 ? "" : pattern.slice(firstDot + 1); if (rest === "") return { kind: "ok", pattern: newSlug }; if (rest === "*") return { kind: "ok", pattern: `${newSlug}.*` }; return { kind: "ok", pattern: `${newSlug}.*.*.${rest}` }; }; // --------------------------------------------------------------------------- // Plugin runtime metadata migration. // // v1 plugins persisted invocation metadata in plugin-specific storage alongside // source rows. v2 puts catalog-level operation metadata under org-owned // `plugin_storage[operation]`, and MCP stores the raw upstream tool name on the // tool row annotations. These helpers are shared by local + cloud runners so the // migration produces the same v2-native runtime shape everywhere. // --------------------------------------------------------------------------- const GRAPHQL_GREENFIELD_V1_PLUGIN_ID = "graphql-greenfield"; const GRAPHQL_V2_PLUGIN_ID = "graphql"; const OPENAPI_PLUGIN_ID = "openapi"; const MCP_PLUGIN_ID = "mcp"; const OPERATION_COLLECTION = "operation"; const MCP_BINDING_COLLECTION = "binding"; const normalizeRuntimePluginId = (pluginId: string): string => pluginId === GRAPHQL_GREENFIELD_V1_PLUGIN_ID ? GRAPHQL_V2_PLUGIN_ID : pluginId; export interface V1ToolRuntimeMetadataRow { readonly scopeId: string; readonly sourceId: string; readonly pluginId: string; readonly name: string; readonly annotations: unknown; } export interface V1PluginStorageRuntimeRow { readonly scopeId: string; readonly pluginId: string; readonly collection: string; readonly key: string; readonly data: unknown; } export interface LegacyMcpToolBinding { readonly toolName: string; readonly annotations?: Record<string, unknown>; } export interface V1RuntimeMetadataIndex { readonly mcpBindings: ReadonlyMap<string, LegacyMcpToolBinding>; } export type MigratedPluginStorageOwner = "source" | "catalog"; export interface MigratedPluginStorageRuntimeRow { readonly pluginId: string; readonly collection: string; readonly key: string; readonly data: unknown; /** `catalog` rows are v2 integration metadata and must be org-owned. */ readonly owner: MigratedPluginStorageOwner; } const runtimeStorageKey = (scopeId: string, key: string): string => `${scopeId}\0${key}`; const fullToolKey = (sourceId: string, toolName: string): string => `${sourceId}.${toolName}`; const stripToolPrefix = (sourceId: string, toolId: string): string => toolId.startsWith(`${sourceId}.`) ? toolId.slice(sourceId.length + 1) : toolId; const legacyMcpToolBinding = (data: unknown): LegacyMcpToolBinding | null => { if (!isRecord(data) || !isRecord(data.binding)) return null; const toolName = data.binding.toolName; if (typeof toolName !== "string" || toolName.length === 0) return null; const annotations = isRecord(data.binding.annotations) ? data.binding.annotations : undefined; return { toolName, ...(annotations ? { annotations } : {}), }; }; export const buildV1RuntimeMetadataIndex = ( rows: readonly V1PluginStorageRuntimeRow[], ): V1RuntimeMetadataIndex => { const mcpBindings = new Map<string, LegacyMcpToolBinding>(); for (const row of rows) { if (normalizeRuntimePluginId(row.pluginId) !== MCP_PLUGIN_ID) continue; if (row.collection !== MCP_BINDING_COLLECTION) continue; const binding = legacyMcpToolBinding(row.data); if (!binding) continue; mcpBindings.set(runtimeStorageKey(row.scopeId, row.key), binding); } return { mcpBindings }; }; export const migrateV1ToolAnnotations = ( tool: V1ToolRuntimeMetadataRow, index: V1RuntimeMetadataIndex, ): unknown => { if (normalizeRuntimePluginId(tool.pluginId) !== MCP_PLUGIN_ID) return tool.annotations; if (isRecord(tool.annotations) && isRecord(tool.annotations.mcp)) return tool.annotations; const binding = index.mcpBindings.get( runtimeStorageKey(tool.scopeId, fullToolKey(tool.sourceId, tool.name)), ); if (!binding) return tool.annotations; const base = isRecord(tool.annotations) ? tool.annotations : {}; const destructive = binding.annotations?.destructiveHint === true; return { ...base, requiresApproval: base.requiresApproval ?? destructive, ...(destructive && base.approvalDescription === undefined ? { approvalDescription: binding.annotations?.title ?? binding.toolName } : {}), mcp: { toolName: binding.toolName, ...(binding.annotations ? { upstream: binding.annotations } : {}), }, }; }; const migrateOperationStorageRow = ( row: V1PluginStorageRuntimeRow, ): MigratedPluginStorageRuntimeRow | null => { const pluginId = normalizeRuntimePluginId(row.pluginId); if ( row.collection !== OPERATION_COLLECTION || (pluginId !== OPENAPI_PLUGIN_ID && pluginId !== GRAPHQL_V2_PLUGIN_ID) ) { return null; } if (!isRecord(row.data)) return null; const existingIntegration = row.data.integration; const existingToolName = row.data.toolName; if ( typeof existingIntegration === "string" && typeof existingToolName === "string" && "binding" in row.data ) { return { pluginId, collection: row.collection, key: fullToolKey(existingIntegration, existingToolName), data: { integration: existingIntegration, toolName: existingToolName, binding: row.data.binding, }, owner: "catalog", }; } const sourceId = row.data.sourceId; const toolId = row.data.toolId; if (typeof sourceId !== "string" || typeof toolId !== "string" || !("binding" in row.data)) { return null; } const toolName = stripToolPrefix(sourceId, toolId); return { pluginId, collection: row.collection, key: fullToolKey(sourceId, toolName), data: { integration: sourceId, toolName, binding: row.data.binding, }, owner: "catalog", }; }; export const migrateV1PluginStorageRuntimeRow = ( row: V1PluginStorageRuntimeRow, ): MigratedPluginStorageRuntimeRow => { const operation = migrateOperationStorageRow(row); if (operation) return operation; return { pluginId: normalizeRuntimePluginId(row.pluginId), collection: row.collection, key: row.key, data: row.data, owner: "source", }; }; // --------------------------------------------------------------------------- // OpenAPI auth template migration — v1 source `config` auth → v2 // `authenticationTemplate` (Authentication[]) + the static header/query // passthrough + the slot→method-slug map the connection pass needs. // // v1 shapes (verified against prod): // - `config.headers` / `config.queryParams`: `Record<name, V1ConfiguredValue>` // where a value is either a literal string (a STATIC header) or a credential // placement `{ kind, slot?, prefix? }`. v1 applied EVERY configured credential // placement on every request, rendering `prefix ? prefix+value : value`. // - `config.oauth2`: the user-configured OAuth method (real urls/scopes/scheme). // // v2 model: each connection holds ONE value, rendered ONLY into the `token` // variable (any other variable name renders to ""). So a source with a SINGLE // credential placement maps cleanly to one apiKey method `{name: [prefix, token]}`; // a source with >1 distinct credential placement (Datadog dd-api-key + // dd-application-key; GitHub authorization + user-agent) has no faithful // single-connection v2 form — we flag `needsReview` and never silently emit a // template that would render a second credential as "". // --------------------------------------------------------------------------- /** A v1 `config.headers` / `config.queryParams` entry. A bare string is a static * value; the object forms are credential placements that pull from a binding / * secret / inline text, optionally with a literal `prefix` (e.g. `"Bearer "`). */ export type V1ConfiguredValue = | string | { readonly kind: "binding"; readonly slot: string; readonly prefix?: string } | { readonly kind: "secret"; readonly secretId?: string; readonly prefix?: string } | { readonly kind: "text"; readonly text: string; readonly prefix?: string }; export interface V1OAuth2Config { readonly securitySchemeName: string; readonly flow?: string; readonly authorizationUrl?: string; readonly tokenUrl: string; readonly scopes?: readonly string[]; } export interface V1OpenApiAuthConfig { readonly headers?: Record<string, V1ConfiguredValue>; readonly queryParams?: Record<string, V1ConfiguredValue>; readonly oauth2?: V1OAuth2Config; } /** A part-array template value: `prefix ? [prefix, token] : [token]`. */ export type TemplatePart = string | { readonly type: "variable"; readonly name: string }; export interface MigratedApiKeyAuth { readonly slug: string; readonly type: "apiKey"; readonly headers?: Record<string, readonly TemplatePart[]>; readonly queryParams?: Record<string, readonly TemplatePart[]>; } export interface MigratedOAuthAuth { readonly slug: string; readonly type: "oauth"; readonly authorizationUrl: string; readonly tokenUrl: string; readonly scopes: readonly string[]; } export type MigratedAuthentication = MigratedApiKeyAuth | MigratedOAuthAuth; export interface OpenApiAuthTemplateResult { readonly authenticationTemplate: readonly MigratedAuthentication[]; /** Literal-string header/query entries — passed through to v2 `config`. */ readonly staticHeaders: Record<string, string>; readonly staticQueryParams: Record<string, string>; /** v1 binding `slot_key` → the v2 method `slug` that consumes it. The * connection pass uses this to set `connection.template`. */ readonly slotToTemplateSlug: Record<string, string>; /** v1 binding `slot_key` → the v2 input variable its resolved secret fills. The * connection migration writes one `item_ids` entry per (variable → secret), so * a two-secret source (e.g. Datadog) lands both keys on one connection. */ readonly slotToVariable: Record<string, string>; readonly warnings: readonly string[]; } /** The single apiKey method slug — one apiKey method per source, so a constant * is unique within the integration and every credential slot maps to it. */ export const API_KEY_TEMPLATE_SLUG = "apiKey"; /** The canonical variable for a single-input source. Multiple inputs derive * distinct names instead (so a connection can carry both of e.g. Datadog's * keys). Matches the runtime default in `connection.ts` / the plugins. */ export const PRIMARY_INPUT_VARIABLE = "token"; const isCredentialPlacement = ( value: V1ConfiguredValue, ): value is Exclude<V1ConfiguredValue, string> => typeof value !== "string"; /** Slugify a header/query name into a stable variable identifier: * `DD-API-KEY` → `dd_api_key`. */ const slugifyVariable = (name: string): string => name .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, ""); interface Placement { readonly carrier: "header" | "query"; readonly name: string; readonly prefix?: string; readonly slot?: string; } const legacyOAuthSlotSchemeVariants = (securitySchemeName: string): readonly string[] => { const lower = securitySchemeName.toLowerCase(); const hyphenated = lower.replaceAll("_", "-"); return [...new Set([lower, hyphenated])]; }; export const migrateOpenApiAuthTemplate = ( config: V1OpenApiAuthConfig, ): OpenApiAuthTemplateResult => { const template: MigratedAuthentication[] = []; const staticHeaders: Record<string, string> = {}; const staticQueryParams: Record<string, string> = {}; const slotToTemplateSlug: Record<string, string> = {}; const slotToVariable: Record<string, string> = {}; const warnings: string[] = []; // First pass — separate credential placements from static literals. const placements: Placement[] = []; for (const [name, value] of Object.entries(config.headers ?? {})) { if (isCredentialPlacement(value)) { placements.push({ carrier: "header", name, prefix: value.prefix, slot: value.kind === "binding" ? value.slot : undefined, }); } else { staticHeaders[name] = value; } } for (const [name, value] of Object.entries(config.queryParams ?? {})) { if (isCredentialPlacement(value)) { placements.push({ carrier: "query", name, prefix: value.prefix, slot: value.kind === "binding" ? value.slot : undefined, }); } else { staticQueryParams[name] = value; } } // Variable per placement: a lone input is the canonical `token`; multiple // inputs each get a distinct slugified variable (collision-suffixed) so a // connection carries one value per key. const taken = new Set<string>(); const variableFor = (placement: Placement): string => { if (placements.length <= 1) return PRIMARY_INPUT_VARIABLE; const base = slugifyVariable(placement.name) || "input";