1812 lines · 70.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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.