// --------------------------------------------------------------------------- // 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_` → org-owned (shared); tenant = the org id // - `user-org:user_:org_` → 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//secrets/` (with a // per-scope KEK), url-encoding the segments — plus a LEGACY un-encoded fallback. // v2 drops the scope segment (flat KEK): `executor/secrets/`. 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 => typeof value === "object" && value !== null && !Array.isArray(value); // --------------------------------------------------------------------------- // Policy pattern migration. // // v1 patterns match a connection-AGNOSTIC `.` id. v2 matches the // FULL `...`, so a migrated policy applies // across ALL connections → wildcard the owner+connection segments. The // whole-integration (`.*`) 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) // - `` / `.*` → `` / `.*` (subtree) // - `.` → `.*.*.` (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, 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; } export interface V1RuntimeMetadataIndex { readonly mcpBindings: ReadonlyMap; } 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(); 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` // 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; readonly queryParams?: Record; 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; readonly queryParams?: Record; } 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; readonly staticQueryParams: Record; /** v1 binding `slot_key` → the v2 method `slug` that consumes it. The * connection pass uses this to set `connection.template`. */ readonly slotToTemplateSlug: Record; /** 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; 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 => 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 = {}; const staticQueryParams: Record = {}; const slotToTemplateSlug: Record = {}; const slotToVariable: Record = {}; 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(); const variableFor = (placement: Placement): string => { if (placements.length <= 1) return PRIMARY_INPUT_VARIABLE; const base = slugifyVariable(placement.name) || "input"; let candidate = base; let n = 2; while (taken.has(candidate)) candidate = `${base}_${n++}`; taken.add(candidate); return candidate; }; const apiKeyHeaders: Record = {}; const apiKeyQueryParams: Record = {}; for (const placement of placements) { const variable = variableFor(placement); const part: TemplatePart = { type: "variable", name: variable }; const parts: readonly TemplatePart[] = placement.prefix && placement.prefix.length > 0 ? [placement.prefix, part] : [part]; if (placement.carrier === "header") apiKeyHeaders[placement.name] = parts; else apiKeyQueryParams[placement.name] = parts; if (placement.slot) { slotToTemplateSlug[placement.slot] = API_KEY_TEMPLATE_SLUG; slotToVariable[placement.slot] = variable; } } if (placements.length > 0) { template.push({ slug: API_KEY_TEMPLATE_SLUG, type: "apiKey", ...(Object.keys(apiKeyHeaders).length > 0 ? { headers: apiKeyHeaders } : {}), ...(Object.keys(apiKeyQueryParams).length > 0 ? { queryParams: apiKeyQueryParams } : {}), }); } if (config.oauth2) { const o = config.oauth2; template.push({ slug: o.securitySchemeName, type: "oauth", authorizationUrl: o.authorizationUrl ?? "", tokenUrl: o.tokenUrl, scopes: o.scopes ?? [], }); // The oauth connection slot binds to the oauth method, not the apiKey one; // OAuth is single-input, so its value fills the `token` variable. for (const scheme of legacyOAuthSlotSchemeVariants(o.securitySchemeName)) { const slot = `oauth2:${scheme}:connection`; slotToTemplateSlug[slot] = o.securitySchemeName; slotToVariable[slot] = PRIMARY_INPUT_VARIABLE; } } return { authenticationTemplate: template, staticHeaders, staticQueryParams, slotToTemplateSlug, slotToVariable, warnings, }; }; // --------------------------------------------------------------------------- // OAuth grant mapping + C1a synthetic expiry. // // v1 `connection.provider_state.kind` is authorization-code / dynamic-dcr / // client-credentials; v2 collapses to two grants. A client_credentials token // re-mints with NO user, so a v1 connection whose provider omitted `expires_in` // (null v1 expiry) gets a short synthetic `expires_at` at migrate time — purely // so the v2 refresh gate fires and re-mints, NOT a general TTL policy. A // connection that carried a real v1 expiry keeps it. // --------------------------------------------------------------------------- export type V1ConnectionKind = "authorization-code" | "dynamic-dcr" | "client-credentials"; export type MigrationGrant = "authorization_code" | "client_credentials"; export const migrateGrant = (kind: V1ConnectionKind): MigrationGrant => kind === "client-credentials" ? "client_credentials" : "authorization_code"; /** 1h — synthetic TTL for client_credentials connections whose v1 provider * omitted `expires_in`. Re-mint is userless + cheap, so hourly churn is fine. */ export const SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS = 60 * 60 * 1000; export const migrateExpiresAt = (input: { readonly grant: MigrationGrant; readonly v1ExpiresAt: number | null; readonly nowMs: number; }): number | null => input.grant === "client_credentials" && input.v1ExpiresAt == null ? input.nowMs + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS : input.v1ExpiresAt; // --------------------------------------------------------------------------- // Source config → v2 integration config. // // v1 per-kind config lives in `plugin_storage[collection=source].data` (openapi // + mcp nest under `.data.config`; graphql is flat at `.data`). Structural fields // copy near-1:1; the only real conversion is v1 auth → v2 auth. openapi reuses // `migrateOpenApiAuthTemplate` (the static headers/queryParams + the template); // mcp/graphql carry only an `auth.kind` (none/oauth2) — the connection slot is // dropped (a v2 connection IS the credential). `namespace` is dropped (the // integration slug replaces it). // --------------------------------------------------------------------------- export interface V1OpenApiSourceConfig extends V1OpenApiAuthConfig { readonly spec?: string; readonly sourceUrl?: string; readonly baseUrl?: string; readonly googleDiscoveryUrls?: readonly string[]; } export interface V2OpenApiIntegrationConfig { readonly spec?: string; readonly specUrl?: string; readonly baseUrl?: string; readonly googleDiscoveryUrls?: readonly string[]; readonly headers?: Record; readonly queryParams?: Record; readonly authenticationTemplate?: readonly MigratedAuthentication[]; } export interface MigratedSourceConfig { /** The opaque v2 `integration.config` blob (openapi/mcp/graphql shaped). */ readonly config: unknown; /** v1 binding `slot_key` → the v2 method slug it feeds (connection migration). */ readonly slotToTemplateSlug: Record; /** v1 binding `slot_key` → the v2 input variable its secret fills. */ readonly slotToVariable: Record; readonly warnings: readonly string[]; } export const migrateOpenApiSourceConfig = (v1: V1OpenApiSourceConfig): MigratedSourceConfig => { const auth = migrateOpenApiAuthTemplate(v1); const config: V2OpenApiIntegrationConfig = { ...(v1.spec !== undefined ? { spec: v1.spec } : {}), ...(v1.sourceUrl !== undefined ? { specUrl: v1.sourceUrl } : {}), ...(v1.baseUrl !== undefined ? { baseUrl: v1.baseUrl } : {}), ...(v1.googleDiscoveryUrls !== undefined ? { googleDiscoveryUrls: v1.googleDiscoveryUrls } : {}), ...(Object.keys(auth.staticHeaders).length > 0 ? { headers: auth.staticHeaders } : {}), ...(Object.keys(auth.staticQueryParams).length > 0 ? { queryParams: auth.staticQueryParams } : {}), ...(auth.authenticationTemplate.length > 0 ? { authenticationTemplate: auth.authenticationTemplate } : {}), }; return { config, slotToTemplateSlug: auth.slotToTemplateSlug, slotToVariable: auth.slotToVariable, warnings: auth.warnings, }; }; /** v1 mcp/graphql `auth` block. The oauth2 form carries a `connectionSlot` that * bound a credential_binding; v2 drops it (the connection is the credential). */ export type V1SourceAuth = | { readonly kind: "none" } | { readonly kind: "oauth2"; readonly connectionSlot?: string }; export type V2SourceAuth = { readonly kind: "none" } | { readonly kind: "oauth2" }; export const migrateSourceAuth = (auth: V1SourceAuth | undefined): V2SourceAuth => auth?.kind === "oauth2" ? { kind: "oauth2" } : { kind: "none" }; // --------------------------------------------------------------------------- // Secret-role classification. // // Each v1 secret resolves to a v2 target keyed by HOW it is referenced. The // runner builds the reference graph (connection token columns, provider_state // client creds, credential_binding slots) and asks the classifier per secret. // Roles map to: oauth-access/apikey → `connection.item_id`; oauth-refresh → // `connection.refresh_item_id`; client-secret → `oauth_client.client_secret_item_id`; // client-id → `oauth_client.client_id` (a PLAINTEXT column, not a vault item); // orphan → a standalone re-keyed vault object (migrate-all default). // --------------------------------------------------------------------------- export type SecretRole = | "oauth-access" | "oauth-refresh" | "apikey" | "client-secret" | "client-id" | "orphan"; /** Classify a credential_binding `slot_key` into its v2 secret role. v1 slot * shapes: `header:` / `query_param:` / `spec_fetch_header:` * (api key), `oauth2::client-secret` / `:client-id` (BYO client creds), * `oauth2::connection` / `auth:oauth2:connection` (the oauth token — a * `kind=connection` binding, not a secret; classified oauth-access for the rare * cases it is secret-backed). */ export const classifyBindingSlot = (slotKey: string): SecretRole => { if (slotKey.includes("client-secret")) return "client-secret"; if (slotKey.includes("client-id")) return "client-id"; if ( slotKey.startsWith("header:") || slotKey.startsWith("query_param:") || slotKey.startsWith("spec_fetch_header:") ) { return "apikey"; } if (slotKey.endsWith(":connection")) return "oauth-access"; return "apikey"; }; const isOAuthClientCredentialSlot = (slotKey: string): boolean => slotKey.startsWith("oauth2:") && (slotKey.endsWith(":client-id") || slotKey.endsWith(":client-secret")); // --------------------------------------------------------------------------- // oauth_client dedup (190 → 173). // // v1 BYO apps are mostly distinct, but identical apps within an owner partition // collapse to one v2 `oauth_client`. When v1 already has a plaintext client ID, // the dedup key is `(owner-partition, clientId, tokenEndpoint)`. Older rows can // store the client ID as a secret; those dedupe by the source secret reference // until the runner resolves the plaintext value for `oauth_client.client_id`. // The assigned slug is derived from the token endpoint host, collision-suffixed // WITHIN the partition, and deterministic given input order — so a re-run // produces the same slugs. Each connection later points at its client by // `(slug, owner)`. // --------------------------------------------------------------------------- export interface SecretReadRef { readonly scopeId: string; readonly secretId: string; readonly provider: string; } /** A v1 OAuth app to fold into the v2 `oauth_client` set. `clientIdSecretRef` * carries legacy client IDs that v1 stored as secrets; runners resolve it into * the v2 plaintext `oauth_client.client_id` column. `clientSecretRef` is an * opaque handle (e.g. the v1 secret id/scope pair) the runner resolves to the * actual secret value when writing the vault item; null for public/PKCE apps. */ export interface PlannedOAuthClientInput { readonly ownerKeys: OwnerKeys; readonly clientId: string; readonly clientIdSecretRef?: SecretReadRef | null; readonly tokenUrl: string; readonly authorizationUrl: string; readonly authorizationServerMetadataUrl?: string | null; readonly grant: MigrationGrant; readonly resource: string | null; readonly clientSecretRef: string | null; } export interface DedupedOAuthClient extends PlannedOAuthClientInput { readonly slug: string; } export interface OAuthClientDedupResult { readonly clients: readonly DedupedOAuthClient[]; /** dedup key → assigned slug; the connection planner maps each v1 app to it. */ readonly slugByDedupKey: Record; } const hostSlug = (url: string): string => { // Parse defensively — a malformed URL falls back to a generic stem. const match = /^[a-z]+:\/\/([^/:]+)/i.exec(url); const host = match?.[1] ?? "client"; const label = host.split(".").length > 1 ? host.split(".").slice(-2, -1)[0] : host; return slugifyVariable(label ?? "client") || "client"; }; export const dedupeOAuthClients = ( inputs: readonly PlannedOAuthClientInput[], ): OAuthClientDedupResult => { const slugByDedupKey: Record = {}; const clients: DedupedOAuthClient[] = []; // Per-partition taken slugs, so two distinct apps in one partition disambiguate. const takenByPartition = new Map>(); for (const input of inputs) { const partition = ownerPartitionKey(input.ownerKeys); const key = oauthClientPlanDedupKey(input); if (slugByDedupKey[key]) continue; // already folded — identical app const taken = takenByPartition.get(partition) ?? new Set(); const base = hostSlug(input.tokenUrl); let slug = base; let n = 2; while (taken.has(slug)) slug = `${base}_${n++}`; taken.add(slug); takenByPartition.set(partition, taken); slugByDedupKey[key] = slug; clients.push({ ...input, slug }); } return { clients, slugByDedupKey }; }; const secretReadRefKey = (ref: SecretReadRef): string => `${ref.provider}\0${ref.scopeId}\0${ref.secretId}`; const oauthClientDedupeIdentity = (input: { readonly clientId: string; readonly clientIdSecretRef?: SecretReadRef | null; }): string => input.clientId.length > 0 ? `literal:${input.clientId}` : input.clientIdSecretRef ? `secret:${secretReadRefKey(input.clientIdSecretRef)}` : "missing:"; const oauthClientPlanDedupKey = (input: { readonly ownerKeys: OwnerKeys; readonly clientId: string; readonly clientIdSecretRef?: SecretReadRef | null; readonly tokenUrl: string; }): string => oauthClientDedupKey( ownerPartitionKey(input.ownerKeys), oauthClientDedupeIdentity(input), input.tokenUrl, ); /** The dedup key for a v1 app — call with the same parts to look its slug up in * `slugByDedupKey` when planning the connection that uses it. Secret-backed v1 * client IDs use their source secret reference so distinct BYO apps do not * collapse before the runner can resolve plaintext values. */ export const oauthClientSlugKey = (input: { readonly ownerKeys: OwnerKeys; readonly clientId: string; readonly clientIdSecretRef?: SecretReadRef | null; readonly tokenUrl: string; }): string => oauthClientPlanDedupKey(input); // --------------------------------------------------------------------------- // Integration + connection row assembly (the v2 rows, minus resolved secret // values — the runner fills `item_ids`/`client_secret` from its secret-op pass). // --------------------------------------------------------------------------- export interface PlannedIntegrationRow { readonly tenant: string; readonly owner: MigrationOwner; readonly subject: string; readonly slug: string; readonly plugin_id: string; readonly description: string; readonly config: unknown; } /** Build a v2 `integration` row from a v1 source + its migrated config. All prod * sources are org-owned, but we derive owner from the scope generically (a local * user-scoped source maps to owner=user). */ export const planIntegrationRow = (input: { readonly scopeId: string; readonly sourceId: string; readonly pluginId: string; readonly description: string; readonly config: unknown; readonly ownerForScope?: ScopeOwnerResolver; }): PlannedIntegrationRow | null => { const keys = (input.ownerForScope ?? parseScope)(input.scopeId); if (!keys) return null; // fail loud upstream — never silently mis-own return { tenant: keys.tenant, owner: keys.owner, subject: keys.subject, slug: input.sourceId, plugin_id: input.pluginId, description: input.description, config: input.config, }; }; export interface PlannedConnectionRow { readonly tenant: string; readonly owner: MigrationOwner; readonly subject: string; readonly integration: string; readonly name: string; readonly template: string; readonly provider: string; readonly identityLabel: string | null; readonly oauthClientSlug: string | null; readonly oauthClientOwner: MigrationOwner | null; readonly oauthScope: string | null; readonly expiresAt: number | null; } /** Build a v2 `connection` row (owner split, C1a expiry, oauth-client ref). The * runner attaches `item_ids`/`refresh_item_id` from its secret-op results. */ export const planConnectionRow = (input: { readonly scopeId: string; readonly integration: string; readonly name: string; readonly template: string; readonly provider: string; readonly identityLabel: string | null; readonly grant: MigrationGrant; readonly v1ExpiresAt: number | null; readonly oauthScopes: readonly string[]; readonly oauthClientSlug: string | null; readonly oauthClientOwner: OwnerKeys | null; readonly nowMs: number; readonly ownerForScope?: ScopeOwnerResolver; }): PlannedConnectionRow | null => { const keys = (input.ownerForScope ?? parseScope)(input.scopeId); if (!keys) return null; return { tenant: keys.tenant, owner: keys.owner, subject: keys.subject, integration: input.integration, name: input.name, template: input.template, provider: input.provider, identityLabel: input.identityLabel, oauthClientSlug: input.oauthClientSlug, oauthClientOwner: input.oauthClientOwner ? input.oauthClientOwner.owner : null, oauthScope: input.oauthScopes.length > 0 ? serializeOAuthScopes(input.oauthScopes) : null, expiresAt: migrateExpiresAt({ grant: input.grant, v1ExpiresAt: input.v1ExpiresAt, nowMs: input.nowMs, }), }; }; // --------------------------------------------------------------------------- // Deterministic v2 item id. // // A v2 vault item id is derived from the v1 `(scopeId, secretId)` pair (NOT // random) so a crashed/re-run migration produces the SAME id and the runner's // `provider.get(item_id)` skip-if-present idempotency holds. Distinct from any // v1 name, so it never collides with a not-yet-migrated object. The id carries // no legacy scope/secret names; the prefix only gives provider lists a stable // shape. // --------------------------------------------------------------------------- const stableMigrationHash = (...parts: readonly string[]): string => { const hash = createHash("sha256"); for (const part of parts) hash.update(part).update("\0"); return hash.digest("base64url"); }; export const migratedItemId = (scopeId: string, secretId: string): string => `secret_${stableMigrationHash(scopeId, secretId)}`; const fallbackPolicyId = (scopeId: string, pattern: string, action: string): string => `policy_${stableMigrationHash(scopeId, pattern, action)}`; // --------------------------------------------------------------------------- // mcp / graphql source config → v2 integration config. // // Verified against prod: mcp/graphql carry the SAME `{kind:"binding", slot, // prefix}` header/queryParam shape as openapi (so the apiKey template reuses // `migrateOpenApiAuthTemplate`), plus an `auth:{kind:"none"|"oauth2", // connectionSlot?}` block. The oauth method has no securitySchemeName (unlike // openapi), so its template slug is the conventional `oauth2`; its real // endpoints/scopes live on the connection's `provider_state`, not the config. // --------------------------------------------------------------------------- /** The conventional oauth method slug for mcp/graphql (which carry no * securitySchemeName). A connection's `template` points at it. */ export const OAUTH_TEMPLATE_SLUG = "oauth2"; export interface V1McpSourceConfig { readonly endpoint?: string; readonly transport?: string; readonly remoteTransport?: string; readonly headers?: Record; readonly queryParams?: Record; readonly auth?: V1SourceAuth; } export interface V1GraphqlSourceConfig { readonly endpoint?: string; readonly name?: string; readonly headers?: Record; readonly queryParams?: Record; readonly auth?: V1SourceAuth; } /** Fold a v1 `auth:{kind:oauth2, connectionSlot}` into the slot maps so the * connection that binds that slot resolves to the conventional oauth method. */ const withSourceAuthOauth = ( auth: V1SourceAuth | undefined, slotToTemplateSlug: Record, slotToVariable: Record, ): void => { if (auth?.kind === "oauth2" && auth.connectionSlot) { slotToTemplateSlug[auth.connectionSlot] = OAUTH_TEMPLATE_SLUG; slotToVariable[auth.connectionSlot] = PRIMARY_INPUT_VARIABLE; } }; export const migrateMcpSourceConfig = (v1: V1McpSourceConfig): MigratedSourceConfig => { const apikey = migrateOpenApiAuthTemplate({ headers: v1.headers, queryParams: v1.queryParams }); const slotToTemplateSlug = { ...apikey.slotToTemplateSlug }; const slotToVariable = { ...apikey.slotToVariable }; withSourceAuthOauth(v1.auth, slotToTemplateSlug, slotToVariable); const config = { ...(v1.endpoint !== undefined ? { endpoint: v1.endpoint } : {}), ...(v1.transport !== undefined ? { transport: v1.transport } : {}), ...(v1.remoteTransport !== undefined ? { remoteTransport: v1.remoteTransport } : {}), ...(Object.keys(apikey.staticHeaders).length > 0 ? { headers: apikey.staticHeaders } : {}), ...(Object.keys(apikey.staticQueryParams).length > 0 ? { queryParams: apikey.staticQueryParams } : {}), auth: migrateSourceAuth(v1.auth), ...(apikey.authenticationTemplate.length > 0 ? { authenticationTemplate: apikey.authenticationTemplate } : {}), }; return { config, slotToTemplateSlug, slotToVariable, warnings: apikey.warnings }; }; export const migrateGraphqlSourceConfig = (v1: V1GraphqlSourceConfig): MigratedSourceConfig => { const apikey = migrateOpenApiAuthTemplate({ headers: v1.headers, queryParams: v1.queryParams }); const slotToTemplateSlug = { ...apikey.slotToTemplateSlug }; const slotToVariable = { ...apikey.slotToVariable }; withSourceAuthOauth(v1.auth, slotToTemplateSlug, slotToVariable); const config = { ...(v1.endpoint !== undefined ? { endpoint: v1.endpoint } : {}), ...(v1.name !== undefined ? { name: v1.name } : {}), ...(Object.keys(apikey.staticHeaders).length > 0 ? { headers: apikey.staticHeaders } : {}), ...(Object.keys(apikey.staticQueryParams).length > 0 ? { queryParams: apikey.staticQueryParams } : {}), auth: migrateSourceAuth(v1.auth), ...(apikey.authenticationTemplate.length > 0 ? { authenticationTemplate: apikey.authenticationTemplate } : {}), }; return { config, slotToTemplateSlug, slotToVariable, warnings: apikey.warnings }; }; // =========================================================================== // planMigration — the WEAVE. // // Composes the pure transforms above into a complete, side-effect-free plan the // cloud/local runners execute. Built against the prod-verified model: exactly ONE // connection per (scope, source); a `credential_binding` ties them (kind=connection // → an oauth connection; kind=secret → a synthesized apiKey connection, 1–2 slots; // kind=text → inline); no (scope, source) is both oauth AND apiKey. Secret VALUES // are NOT resolved here — each `SecretOp` carries the v1 read descriptor + the // deterministic v2 item id, and the runner does the vault read/write. // =========================================================================== export interface V1SourceRow { readonly scopeId: string; readonly id: string; readonly pluginId: string; readonly name: string; } export interface V1ProviderState { readonly kind?: string; readonly clientId?: string; readonly clientIdSecretId?: string; readonly clientIdSecretScopeId?: string | null; readonly clientSecretSecretId?: string; readonly clientSecretSecretScopeId?: string | null; readonly tokenEndpoint?: string; readonly authorizationEndpoint?: string; readonly authorizationServerUrl?: string; readonly authorizationServerMetadataUrl?: string; readonly authorizationServerMetadata?: { readonly authorization_endpoint?: string; } | null; readonly issuerUrl?: string; readonly resource?: string | null; readonly scopes?: readonly string[]; readonly scope?: string; } export interface V1ConnectionRow { readonly id: string; readonly scopeId: string; readonly provider: string; readonly identityLabel: string | null; readonly accessTokenSecretId: string | null; readonly refreshTokenSecretId: string | null; readonly expiresAt: number | null; readonly providerState: V1ProviderState | null; } export interface V1BindingRow { readonly scopeId: string; readonly sourceScopeId?: string; readonly sourceId: string; readonly slotKey: string; readonly kind: "secret" | "connection" | "text"; readonly secretId: string | null; readonly secretScopeId?: string | null; readonly connectionId: string | null; readonly textValue: string | null; } export interface V1PolicyRow { readonly id?: string; readonly scopeId: string; readonly pattern: string; readonly action: string; readonly position?: string; } export interface V1SecretRow { readonly id: string; readonly scopeId: string; readonly name: string; readonly provider: string; readonly ownedByConnectionId: string | null; } /** A v1 secret value to (re-)materialize in the v2 store. The runner resolves * `fromSecret` via the provider (walking the scope-stack for client-* roles) or * writes `fromText` verbatim, to the deterministic `itemId` under `owner`. */ export interface SecretOp { readonly itemId: string; readonly role: SecretRole; readonly owner: OwnerKeys; readonly targetProvider: string; readonly fromSecret?: { readonly scopeId: string; readonly secretId: string; readonly provider: string; }; readonly fromText?: string; } export interface PlannedConnectionFull { readonly credentialScopeId: string; readonly sourceScopeId: string; readonly sourceId: string; readonly row: PlannedConnectionRow; /** variable → v2 item id (the connection's `item_ids` map). */ readonly itemIds: Record; readonly refreshItemId: string | null; } export interface PlannedOAuthClientFull extends DedupedOAuthClient { /** Vault item id for the client secret (when one existed); else null. */ readonly clientSecretItemId: string | null; } export interface PlannedPolicy { readonly owner: OwnerKeys; readonly id: string; readonly pattern: string; readonly action: string; readonly position: string; readonly status: "ok" | "static" | "dead-inert"; } export interface MigrationReport { readonly integrations: number; readonly connections: number; readonly oauthClients: number; readonly secretOps: number; /** v1 connection rows with no source binding — not migrated (stale residue). */ readonly staleConnections: number; readonly policies: { readonly ok: number; readonly static: number; readonly deadInert: number }; readonly warnings: readonly string[]; } export interface MigrationPlan { readonly integrations: readonly PlannedIntegrationRow[]; readonly oauthClients: readonly PlannedOAuthClientFull[]; readonly connections: readonly PlannedConnectionFull[]; readonly secretOps: readonly SecretOp[]; readonly policies: readonly PlannedPolicy[]; readonly report: MigrationReport; } export interface MigrationInput { readonly sources: readonly V1SourceRow[]; /** `${scopeId} ${sourceId}` → the migrated config + slot maps (the runner * builds these per kind via the `migrate*SourceConfig` assemblers). */ readonly migratedConfigs: ReadonlyMap; /** `${sourceScopeId} ${sourceId}` → live OAuth resource discovered from * protected-resource metadata. Runners populate this for MCP sources; the * pure planner stays deterministic and only consumes explicit overrides. */ readonly oauthResourceOverrides?: ReadonlyMap; readonly connections: readonly V1ConnectionRow[]; readonly bindings: readonly V1BindingRow[]; readonly secrets: readonly V1SecretRow[]; readonly policies: readonly V1PolicyRow[]; /** Tool `source_id`s with no `source` row (Class B orphans) — folded into the * slug map so their policies/tools survive. */ readonly toolSourceIds: readonly string[]; readonly nowMs: number; readonly ownerForScope?: ScopeOwnerResolver; readonly defaultWritableProvider?: string; } /** The map key joining a (scopeId, sourceId) pair. Exported so the runner keys * `migratedConfigs` identically to the weave's lookup. */ export const migrationSourceKey = (scopeId: string, sourceId: string): string => `${scopeId} ${sourceId}`; const sourceKey = migrationSourceKey; const bindingSourceScope = (binding: V1BindingRow): string => binding.sourceScopeId ?? binding.scopeId; const secretExists = ( secrets: readonly V1SecretRow[], scopeId: string, secretId: string, ): boolean => secrets.some((secret) => secret.scopeId === scopeId && secret.id === secretId); const resolveProviderStateSecretScope = ( secrets: readonly V1SecretRow[], options: { readonly explicitScopeId?: string | null; readonly connectionScopeId: string; readonly sourceScopeId: string; readonly secretId: string; }, ): string => { if (options.explicitScopeId) return options.explicitScopeId; if (secretExists(secrets, options.sourceScopeId, options.secretId)) return options.sourceScopeId; return options.connectionScopeId; }; const bindingSecretScope = (binding: V1BindingRow): string => binding.secretScopeId ?? binding.scopeId; const slugifyName = (name: string, fallback = "account"): string => String(connectionIdentifier(name, fallback)); const secretRefKey = (scopeId: string, secretId: string): string => `${scopeId}\0${secretId}`; const staticConnectionNameForSecretBindings = ( bindings: readonly V1BindingRow[], secrets: readonly V1SecretRow[], ): string => { const refs = new Map(); for (const binding of bindings) { if (!binding.secretId) continue; const scopeId = bindingSecretScope(binding); refs.set(secretRefKey(scopeId, binding.secretId), { scopeId, secretId: binding.secretId }); } if (refs.size !== 1) return "api-key"; const [ref] = refs.values(); if (!ref) return "api-key"; const secret = secrets.find((s) => s.scopeId === ref.scopeId && s.id === ref.secretId); const nameSlug = secret?.name.trim() ? slugifyName(secret.name, "") : ""; return nameSlug || slugifyName(ref.secretId, "api-key"); }; const scopesFromProviderState = (ps: V1ProviderState | null): readonly string[] => { if (!ps) return []; if (ps.scopes && ps.scopes.length > 0) return ps.scopes; if (ps.scope) return ps.scope.split(/\s+/).filter((s) => s.length > 0); return []; }; const nonEmptyString = (value: string | null | undefined): string | null => { const trimmed = value?.trim(); return trimmed ? trimmed : null; }; /** True when the URL has no meaningful path — a bare origin like * `https://login.microsoftonline.com`. A bare origin is never a usable * authorize endpoint: redirecting there signs the user in and strands them * (observed in prod with migrated Microsoft clients). */ const isBareOrigin = (url: string): boolean => /^https?:\/\/[^/]+\/?$/.test(url); const authorizationUrlFromProviderState = ( ps: V1ProviderState | null, grant: MigrationGrant, ): string => { const explicit = nonEmptyString(ps?.authorizationEndpoint) ?? nonEmptyString(ps?.authorizationServerMetadata?.authorization_endpoint); if (explicit) return explicit; const fallback = nonEmptyString(ps?.authorizationServerUrl) ?? nonEmptyString(ps?.issuerUrl) ?? ""; // Client-credentials clients have no browser leg — an empty authorization // URL is their correct shape; never derive one. if (grant === "client_credentials") return fallback; // v1 stored only an issuer/server origin for some providers and discovered // the authorize endpoint at runtime. v2 stores the endpoint itself, so a // bare origin would mint a broken client. When the token endpoint is a // same-origin `…/token` URL, its `…/authorize` sibling is the convention // (and exactly right for the Microsoft v2.0 endpoints that hit this path); // prefer that over a guaranteed-dead bare origin. const token = nonEmptyString(ps?.tokenEndpoint); if ( (fallback === "" || isBareOrigin(fallback)) && token?.endsWith("/token") && (fallback === "" || token.startsWith(fallback.replace(/\/$/, "") + "/")) ) { return token.replace(/\/token$/, "/authorize"); } return fallback; }; const secretOpDedupeKey = (op: SecretOp): string => `${op.targetProvider}\0${op.owner.tenant}\0${op.owner.owner}\0${op.owner.subject}\0${op.itemId}`; export const planMigration = (input: MigrationInput): MigrationPlan => { const warnings: string[] = []; const secretOps: SecretOp[] = []; const connections: PlannedConnectionFull[] = []; const ownerForScope = input.ownerForScope ?? parseScope; const defaultWritableProvider = input.defaultWritableProvider ?? "workos-vault"; // --- Integrations (one per source) + the policy slug map (source ∪ tool ids). const integrations: PlannedIntegrationRow[] = []; const slugMap = new Map(); const sourceIdsByTenant = new Map>(); const addSourceIdForOwner = (owner: OwnerKeys | null, sourceId: string): void => { if (!owner) return; const set = sourceIdsByTenant.get(owner.tenant) ?? new Set(); set.add(sourceId); sourceIdsByTenant.set(owner.tenant, set); }; for (const id of input.toolSourceIds) slugMap.set(id, id); for (const source of input.sources) { slugMap.set(source.id, source.id); addSourceIdForOwner(ownerForScope(source.scopeId), source.id); const migrated = input.migratedConfigs.get(sourceKey(source.scopeId, source.id)); const row = planIntegrationRow({ scopeId: source.scopeId, sourceId: source.id, pluginId: source.pluginId, description: source.name, config: migrated?.config ?? {}, ownerForScope, }); if (!row) { warnings.push(`Skipped source "${source.id}": unparseable scope "${source.scopeId}".`); continue; } integrations.push(row); for (const w of migrated?.warnings ?? []) warnings.push(`[${source.id}] ${w}`); } const policySlugMapForOwner = (owner: OwnerKeys): ReadonlyMap => { const tenantSourceIds = sourceIdsByTenant.get(owner.tenant); if ( tenantSourceIds?.has(MICROSOFT_GRAPH_CURATED_SLUG) && !tenantSourceIds.has(MICROSOFT_GRAPH_LEGACY_SLUG) ) { return new Map(slugMap).set(MICROSOFT_GRAPH_LEGACY_SLUG, MICROSOFT_GRAPH_CURATED_SLUG); } return slugMap; }; // --- Group bindings by (scope, source); each group → one connection. const groups = new Map(); for (const b of input.bindings) { const key = sourceKey(b.scopeId, b.sourceId); const list = groups.get(key) ?? []; list.push(b); groups.set(key, list); } const connectionById = new Map(); for (const c of input.connections) connectionById.set(`${c.scopeId} ${c.id}`, c); // Track which secrets a connection/oauth-client consumes, so the leftovers are // the orphans (migrate-all). Keyed `${scopeId} ${secretId}`. const consumed = new Set(); const consume = (scopeId: string, secretId: string): void => { consumed.add(`${scopeId} ${secretId}`); }; // Connection rows actually bound to a source (and thus migrated). v1 leaves // stale, unbound connection rows behind (disconnect/re-auth residue); they have // no source to attach to in v2, so they're not migrated — their tokens fall to // the orphan re-key (migrate-all). Tracked for transparency in the report. const boundConnections = new Set(); // OAuth client inputs collected for dedup; each connection records its dedup // key so it can pick up the assigned slug after dedup runs. const oauthClientInputs: PlannedOAuthClientInput[] = []; const clientSecretItemIdByKey = new Map(); // Defer the slug wire-up: store (connection plan, dedupKey) pairs. const pendingClientSlug: { readonly index: number; readonly key: string }[] = []; for (const [key, bindings] of groups) { const [scopeId, sourceId] = key.split(" "); if (!scopeId || !sourceId) continue; const sourceScopeId = bindings[0] ? bindingSourceScope(bindings[0]) : scopeId; const owner = ownerForScope(scopeId); if (!owner) { warnings.push(`Skipped binding group "${sourceId}": unparseable scope "${scopeId}".`); continue; } const config = input.migratedConfigs.get(sourceKey(sourceScopeId, sourceId)); const slotTemplate = (slot: string): string => config?.slotToTemplateSlug[slot] ?? API_KEY_TEMPLATE_SLUG; const slotVar = (slot: string): string => config?.slotToVariable[slot] ?? PRIMARY_INPUT_VARIABLE; const connBinding = bindings.find((b) => b.kind === "connection"); const secretBindings = bindings.filter( (b) => b.kind === "secret" && !isOAuthClientCredentialSlot(b.slotKey), ); const textBindings = bindings.filter((b) => b.kind === "text"); if (connBinding && connBinding.connectionId) { // OAuth connection. const conn = connectionById.get(`${scopeId} ${connBinding.connectionId}`); if (!conn) { warnings.push( `Connection "${connBinding.connectionId}" referenced by a binding is missing.`, ); continue; } boundConnections.add(`${conn.scopeId} ${conn.id}`); const ps = conn.providerState; const grant = migrateGrant((ps?.kind as V1ConnectionKind) ?? "authorization-code"); const oauthTargetProvider = conn.provider === "oauth2" ? defaultWritableProvider : conn.provider; const itemIds: Record = {}; let refreshItemId: string | null = null; if (conn.accessTokenSecretId) { const itemId = migratedItemId(scopeId, conn.accessTokenSecretId); const provider = providerForSecret(input.secrets, scopeId, conn.accessTokenSecretId); secretOps.push({ itemId, role: "oauth-access", owner, targetProvider: oauthTargetProvider, fromSecret: { scopeId, secretId: conn.accessTokenSecretId, provider }, }); itemIds[PRIMARY_INPUT_VARIABLE] = itemId; consume(scopeId, conn.accessTokenSecretId); } if (conn.refreshTokenSecretId) { refreshItemId = migratedItemId(scopeId, conn.refreshTokenSecretId); const provider = providerForSecret(input.secrets, scopeId, conn.refreshTokenSecretId); secretOps.push({ itemId: refreshItemId, role: "oauth-refresh", owner, targetProvider: oauthTargetProvider, fromSecret: { scopeId, secretId: conn.refreshTokenSecretId, provider }, }); consume(scopeId, conn.refreshTokenSecretId); } // OAuth client. let clientSecretItemId: string | null = null; if (ps?.clientSecretSecretId) { const clientSecretScopeId = resolveProviderStateSecretScope(input.secrets, { explicitScopeId: ps.clientSecretSecretScopeId, connectionScopeId: scopeId, sourceScopeId, secretId: ps.clientSecretSecretId, }); clientSecretItemId = migratedItemId(clientSecretScopeId, ps.clientSecretSecretId); const provider = providerForSecret( input.secrets, clientSecretScopeId, ps.clientSecretSecretId, ); secretOps.push({ itemId: clientSecretItemId, role: "client-secret", owner, targetProvider: oauthTargetProvider, fromSecret: { scopeId: clientSecretScopeId, secretId: ps.clientSecretSecretId, provider }, }); consume(clientSecretScopeId, ps.clientSecretSecretId); } const clientIdSecretRef = ps?.clientIdSecretId != null ? (() => { const clientIdScopeId = resolveProviderStateSecretScope(input.secrets, { explicitScopeId: ps.clientIdSecretScopeId, connectionScopeId: scopeId, sourceScopeId, secretId: ps.clientIdSecretId, }); return { scopeId: clientIdScopeId, secretId: ps.clientIdSecretId, provider: providerForSecret(input.secrets, clientIdScopeId, ps.clientIdSecretId), }; })() : null; if (clientIdSecretRef) consume(clientIdSecretRef.scopeId, clientIdSecretRef.secretId); if (!ps?.clientId && !clientIdSecretRef) { warnings.push( `OAuth connection "${conn.id}" has no client id or client-id secret reference; migrated oauth_client will need repair.`, ); } const clientInput: PlannedOAuthClientInput = { ownerKeys: owner, clientId: ps?.clientId ?? "", clientIdSecretRef, tokenUrl: ps?.tokenEndpoint ?? "", authorizationUrl: authorizationUrlFromProviderState(ps, grant), authorizationServerMetadataUrl: nonEmptyString(ps?.authorizationServerMetadataUrl), grant, resource: input.oauthResourceOverrides?.get(sourceKey(sourceScopeId, sourceId)) ?? ps?.resource ?? null, clientSecretRef: ps?.clientSecretSecretId ?? null, }; oauthClientInputs.push(clientInput); const dedupKey = oauthClientSlugKey({ ownerKeys: owner, clientId: clientInput.clientId, clientIdSecretRef: clientInput.clientIdSecretRef, tokenUrl: clientInput.tokenUrl, }); clientSecretItemIdByKey.set(dedupKey, clientSecretItemId); const row = planConnectionRow({ scopeId, integration: sourceId, name: slugifyName(conn.identityLabel ?? "account"), template: slotTemplate(connBinding.slotKey), provider: oauthTargetProvider, identityLabel: conn.identityLabel, grant, v1ExpiresAt: conn.expiresAt, oauthScopes: scopesFromProviderState(ps), oauthClientSlug: null, // wired after dedup oauthClientOwner: owner, nowMs: input.nowMs, ownerForScope, }); if (!row) continue; const index = connections.push({ credentialScopeId: scopeId, sourceScopeId, sourceId, row, itemIds, refreshItemId, }) - 1; pendingClientSlug.push({ index, key: dedupKey }); } else if (secretBindings.length > 0) { // apiKey connection (one or more distinct inputs into one connection). A // single-secret v1 binding keeps the user's secret label as the v2 account // name; multi-input methods keep the deterministic generic fallback. const itemIds: Record = {}; let template = API_KEY_TEMPLATE_SLUG; const providers = new Set(); let targetProvider: string | null = null; const connectionName = staticConnectionNameForSecretBindings(secretBindings, input.secrets); for (const b of secretBindings) { if (!b.secretId) continue; const secretScopeId = bindingSecretScope(b); const variable = slotVar(b.slotKey); const itemId = migratedItemId(secretScopeId, b.secretId); const provider = providerForSecret(input.secrets, secretScopeId, b.secretId); targetProvider ??= provider; providers.add(provider); secretOps.push({ itemId, role: "apikey", owner, targetProvider, fromSecret: { scopeId: secretScopeId, secretId: b.secretId, provider }, }); itemIds[variable] = itemId; template = slotTemplate(b.slotKey); consume(secretScopeId, b.secretId); } // All-null secret ids (malformed v1 rows) would plan a credentialed // connection with an empty `item_ids` map — a credential with no // credential that the runtime refuses to produce tools for. Skip loudly. if (Object.keys(itemIds).length === 0) { warnings.push( `Skipped binding group "${sourceId}" in scope "${scopeId}": its secret bindings reference no secrets.`, ); continue; } const provider = targetProvider ?? defaultWritableProvider; if (providers.size > 1) { warnings.push( `Connection "${sourceId}" in scope "${scopeId}" uses multiple secret providers; v2 supports one provider per connection and will use "${provider}".`, ); } const row = planConnectionRow({ scopeId, integration: sourceId, name: connectionName, template, provider, identityLabel: null, grant: "authorization_code", v1ExpiresAt: null, oauthScopes: [], oauthClientSlug: null, oauthClientOwner: null, nowMs: input.nowMs, ownerForScope, }); if (row) { connections.push({ credentialScopeId: scopeId, sourceScopeId, sourceId, row, itemIds, refreshItemId: null, }); } } else if (textBindings.length > 0) { // Inline text connection (rare — 2 in prod). const itemIds: Record = {}; let template = API_KEY_TEMPLATE_SLUG; for (const b of textBindings) { const variable = slotVar(b.slotKey); // The source id is part of the key: two sources in one scope can bind // the same slot (e.g. `header:authorization`) with different values — // without it they'd collide on one item id and one would silently // read the other's secret. const itemId = migratedItemId(scopeId, `text:${sourceId}:${b.slotKey}`); secretOps.push({ itemId, role: "apikey", owner, targetProvider: defaultWritableProvider, fromText: b.textValue ?? "", }); itemIds[variable] = itemId; template = slotTemplate(b.slotKey); } const row = planConnectionRow({ scopeId, integration: sourceId, name: "inline", template, provider: defaultWritableProvider, identityLabel: null, grant: "authorization_code", v1ExpiresAt: null, oauthScopes: [], oauthClientSlug: null, oauthClientOwner: null, nowMs: input.nowMs, ownerForScope, }); if (row) { connections.push({ credentialScopeId: scopeId, sourceScopeId, sourceId, row, itemIds, refreshItemId: null, }); } } else { // Nothing migratable in the group: a connection binding without a // connection id, or only OAuth client-credential slots (the app config // migrates via the bound connection's provider state — an app that was // configured but never connected has nothing to attach to). Say so // instead of dropping the group silently. warnings.push( `Skipped binding group "${sourceId}" in scope "${scopeId}": no migratable credential binding (its secrets fall to the orphan re-key).`, ); } } // --- No-auth sources (mcp/graphql `auth.kind === "none"`) have no credential // bindings, but v2 produces tools per CONNECTION — without one the migrated // integration is dead (no tools, nothing to invoke). Plan the canonical // no-auth connection: template "none", empty item_ids. (This is the planner // fix for the gap that required the prod `{}` backfill.) for (const source of input.sources) { if (groups.has(sourceKey(source.scopeId, source.id))) continue; const migrated = input.migratedConfigs.get(sourceKey(source.scopeId, source.id)); const auth = (migrated?.config as { auth?: { kind?: string } } | undefined)?.auth; if (auth?.kind !== "none") continue; const row = planConnectionRow({ scopeId: source.scopeId, integration: source.id, name: "workspace", template: "none", provider: defaultWritableProvider, identityLabel: null, grant: "authorization_code", v1ExpiresAt: null, oauthScopes: [], oauthClientSlug: null, oauthClientOwner: null, nowMs: input.nowMs, ownerForScope, }); if (row) { connections.push({ credentialScopeId: source.scopeId, sourceScopeId: source.scopeId, sourceId: source.id, row, itemIds: {}, refreshItemId: null, }); } } // --- Dedup oauth clients, then wire each connection's slug + secret item id. const dedup = dedupeOAuthClients(oauthClientInputs); const oauthClients: PlannedOAuthClientFull[] = dedup.clients.map((c) => { const dedupKey = oauthClientSlugKey({ ownerKeys: c.ownerKeys, clientId: c.clientId, clientIdSecretRef: c.clientIdSecretRef, tokenUrl: c.tokenUrl, }); return { ...c, clientSecretItemId: clientSecretItemIdByKey.get(dedupKey) ?? null }; }); for (const { index, key } of pendingClientSlug) { const slug = dedup.slugByDedupKey[key] ?? null; const existing = connections[index]; if (existing) { connections[index] = { ...existing, row: { ...existing.row, oauthClientSlug: slug } }; } } // --- Orphan secrets (migrate-all): everything not consumed above + not an // OAuth-token secret already handled. Re-keyed standalone under its scope owner. for (const s of input.secrets) { if (consumed.has(`${s.scopeId} ${s.id}`)) continue; const owner = ownerForScope(s.scopeId); if (!owner) { warnings.push(`Skipped orphan secret "${s.id}": unparseable scope "${s.scopeId}".`); continue; } secretOps.push({ itemId: migratedItemId(s.scopeId, s.id), role: "orphan", owner, targetProvider: s.provider, fromSecret: { scopeId: s.scopeId, secretId: s.id, provider: s.provider }, }); } // --- Policies: transform; dead-source ones KEEP INERT (decided) — emitted with // their original pattern, which matches no v2 4-segment address. const policies: PlannedPolicy[] = []; let ok = 0; let staticN = 0; let deadInert = 0; for (const p of input.policies) { const owner = ownerForScope(p.scopeId); if (!owner) { warnings.push(`Skipped policy "${p.pattern}": unparseable scope "${p.scopeId}".`); continue; } const result = migratePolicyPattern(p.pattern, policySlugMapForOwner(owner)); if (result.kind === "dead") { deadInert += 1; warnings.push( `Dead-source policy kept inert: "${p.pattern}" (slug "${result.slug}" removed).`, ); policies.push({ owner, id: p.id ?? fallbackPolicyId(p.scopeId, p.pattern, p.action), pattern: p.pattern, action: p.action, position: p.position ?? String(policies.length).padStart(6, "0"), status: "dead-inert", }); } else { if (result.kind === "static") staticN += 1; else ok += 1; policies.push({ owner, id: p.id ?? fallbackPolicyId(p.scopeId, p.pattern, p.action), pattern: result.pattern, action: p.action, position: p.position ?? String(policies.length).padStart(6, "0"), status: result.kind, }); } } // Stale (unbound) v1 connection rows — counted for transparency. const staleConnections = input.connections.filter( (c) => !boundConnections.has(`${c.scopeId} ${c.id}`), ).length; if (staleConnections > 0) { warnings.push( `${staleConnections} unbound v1 connection row(s) were not migrated (no source binding); their tokens migrate as orphan secrets.`, ); } // Dedupe secret ops by their provider + owner partition + deterministic item id. // WorkOS Vault values are globally named by item id, but the metadata sidecar is // owner-scoped; collapsing only by item id drops metadata for other owners. const dedupedSecretOps = [...new Map(secretOps.map((o) => [secretOpDedupeKey(o), o])).values()]; return { integrations, oauthClients, connections, secretOps: dedupedSecretOps, policies, report: { integrations: integrations.length, connections: connections.length, oauthClients: oauthClients.length, secretOps: dedupedSecretOps.length, staleConnections, policies: { ok, static: staticN, deadInert }, warnings, }, }; }; const providerForSecret = ( secrets: readonly V1SecretRow[], scopeId: string, secretId: string, ): string => secrets.find((s) => s.scopeId === scopeId && s.id === secretId)?.provider ?? "workos-vault";