| @@ -1,16 +1,16 @@ |
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // OpenAPI OAuth legacy → Connection backfill (local) | 2 | // OAuth legacy → Connection backfill (local) |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // | 4 | // |
| 5 | // Runs at boot time right after `migrate()`. For every `openapi_source` | 5 | // Runs at boot time right after `migrate()`. For every row still on the |
| 6 | // row still on the pre-refactor OAuth2 shape, mints a Connection row, | 6 | // pre-refactor inline-OAuth shape (openapi_source, mcp_source, |
| 7 | // re-parents the referenced secret(s) to it, and rewrites both the | 7 | // google_discovery_source), mints a Connection row, re-parents the |
| 8 | // top-level `oauth2` column and the nested `invocation_config.oauth2` | 8 | // referenced secret(s) to it, and rewrites the source's stored auth to |
| 9 | // copy to the new pointer shape. | 9 | // the new pointer shape. |
| 10 | // | 10 | // |
| 11 | // Self-contained: the only plugin imports are current-shape parsing | 11 | // Self-contained: the only plugin imports are current-shape parsing |
| 12 | // helpers. The pre-refactor shape is defined inline — this file is the | 12 | // helpers. Each legacy shape is defined inline — this file is the last |
| 13 | // last place in the codebase that still needs to know about it. | 13 | // place in the codebase that still needs to know about them. |
| 14 | | 14 | |
| 15 | import { Database } from "bun:sqlite"; | 15 | import { Database } from "bun:sqlite"; |
| 16 | import { randomUUID } from "node:crypto"; | 16 | import { randomUUID } from "node:crypto"; |
| @@ -21,10 +21,150 @@ import { · 4 unmodified lines |
| 21 | resolveSpecText, | 21 | resolveSpecText, |
| 22 | OAuth2Auth, | 22 | OAuth2Auth, |
| 23 | } from "@executor/plugin-openapi"; | 23 | } from "@executor/plugin-openapi"; |
| | 24 | import { McpConnectionAuth } from "@executor/plugin-mcp"; |
| | 25 | |
| | 26 | // --------------------------------------------------------------------------- |
| | 27 | // Shared helpers |
| | 28 | // --------------------------------------------------------------------------- |
| | 29 | |
| | 30 | const isRecord = (v: unknown): v is Record<string, unknown> => |
| | 31 | typeof v === "object" && v !== null && !Array.isArray(v); |
| | 32 | const isString = (v: unknown): v is string => typeof v === "string"; |
| | 33 | |
| | 34 | const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown }); |
| | 35 | |
| | 36 | /** Pre-flight: bail unless the drizzle migration that added the Connection |
| | 37 | * table + `secret.owned_by_connection_id` has completed. */ |
| | 38 | const connectionsReady = (sqlite: Database): boolean => { |
| | 39 | const secretColumns = sqlite |
| | 40 | .prepare("PRAGMA table_info('secret')") |
| | 41 | .all() as ReadonlyArray<{ readonly name: string }>; |
| | 42 | if (!secretColumns.some((c) => c.name === "owned_by_connection_id")) return false; |
| | 43 | const connectionTable = sqlite |
| | 44 | .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='connection'") |
| | 45 | .get(); |
| | 46 | return connectionTable !== null && connectionTable !== undefined; |
| | 47 | }; |
| | 48 | |
| | 49 | const tableExists = (sqlite: Database, name: string): boolean => { |
| | 50 | const row = sqlite |
| | 51 | .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") |
| | 52 | .get(name); |
| | 53 | return row !== null && row !== undefined; |
| | 54 | }; |
| | 55 | |
| | 56 | type SecretRow = { id: string; owned_by_connection_id: string | null }; |
| | 57 | |
| | 58 | /** Shared: re-parent the pointed-to secret ids to the new connection, |
| | 59 | * backfilling any missing routing rows. Returns `null` on success, an |
| | 60 | * error message string on skip. */ |
| | 61 | const rewireSecrets = ( |
| | 62 | sqlite: Database, |
| | 63 | scopeId: string, |
| | 64 | connectionId: string, |
| | 65 | secretIds: ReadonlyArray<string>, |
| | 66 | namesByIndex: ReadonlyArray<string>, |
| | 67 | ): string | null => { |
| | 68 | const selectSecret = sqlite.prepare( |
| | 69 | "SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? AND id = ?", |
| | 70 | ); |
| | 71 | const selectAnySecretProvider = sqlite.prepare( |
| | 72 | "SELECT provider FROM secret WHERE scope_id = ? LIMIT 1", |
| | 73 | ); |
| | 74 | const updateSecretOwner = sqlite.prepare( |
| | 75 | "UPDATE secret SET owned_by_connection_id = ? WHERE scope_id = ? AND id = ?", |
| | 76 | ); |
| | 77 | const insertSecret = sqlite.prepare( |
| | 78 | `INSERT INTO secret ( |
| | 79 | id, scope_id, provider, name, |
| | 80 | owned_by_connection_id, created_at |
| | 81 | ) VALUES (?, ?, ?, ?, ?, ?)`, |
| | 82 | ); |
| | 83 | |
| | 84 | const rows = secretIds.map( |
| | 85 | (sid) => selectSecret.get(scopeId, sid) as SecretRow | undefined, |
| | 86 | ); |
| | 87 | const alreadyOwned = rows |
| | 88 | .filter((r): r is SecretRow => !!r) |
| | 89 | .filter( |
| | 90 | (r) => |
| | 91 | r.owned_by_connection_id !== null && |
| | 92 | r.owned_by_connection_id !== connectionId, |
| | 93 | ); |
| | 94 | if (alreadyOwned.length > 0) return "secret(s) already owned"; |
| | 95 | |
| | 96 | // Early-onboarded rows never got a `secret` routing row — pre-refactor |
| | 97 | // `secretsGet` resolved them via provider enumeration. Pick the |
| | 98 | // provider already in use at this scope (or fall back to keychain) so |
| | 99 | // the new id-indexed fast path resolves. If we guess wrong the SDK's |
| | 100 | // enumerate-fallback still works. |
| | 101 | const missingCount = rows.filter((r) => r === undefined).length; |
| | 102 | let fallbackProvider: string | null = null; |
| | 103 | if (missingCount > 0) { |
| | 104 | const existing = selectAnySecretProvider.get(scopeId) as |
| | 105 | | { provider: string } |
| | 106 | | undefined; |
| | 107 | fallbackProvider = existing?.provider ?? "keychain"; |
| | 108 | } |
| | 109 | |
| | 110 | const now = Date.now(); |
| | 111 | for (let i = 0; i < secretIds.length; i++) { |
| | 112 | const sid = secretIds[i]!; |
| | 113 | if (rows[i] === undefined) { |
| | 114 | insertSecret.run(sid, scopeId, fallbackProvider!, namesByIndex[i]!, connectionId, now); |
| | 115 | } else { |
| | 116 | updateSecretOwner.run(connectionId, scopeId, sid); |
| | 117 | } |
| | 118 | } |
| | 119 | return null; |
| | 120 | }; |
| | 121 | |
| | 122 | const insertConnectionRow = ( |
| | 123 | sqlite: Database, |
| | 124 | params: { |
| | 125 | id: string; |
| | 126 | scopeId: string; |
| | 127 | provider: string; |
| | 128 | identityLabel: string; |
| | 129 | accessTokenSecretId: string; |
| | 130 | refreshTokenSecretId: string | null; |
| | 131 | expiresAt: number | null; |
| | 132 | scope: string | null; |
| | 133 | providerState: unknown; |
| | 134 | }, |
| | 135 | ): void => { |
| | 136 | const stmt = sqlite.prepare( |
| | 137 | `INSERT INTO connection ( |
| | 138 | id, scope_id, provider, kind, identity_label, |
| | 139 | access_token_secret_id, refresh_token_secret_id, |
| | 140 | expires_at, scope, provider_state, |
| | 141 | created_at, updated_at |
| | 142 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| | 143 | ); |
| | 144 | const now = Date.now(); |
| | 145 | stmt.run( |
| | 146 | params.id, |
| | 147 | params.scopeId, |
| | 148 | params.provider, |
| | 149 | "user", |
| | 150 | params.identityLabel, |
| | 151 | params.accessTokenSecretId, |
| | 152 | params.refreshTokenSecretId, |
| | 153 | params.expiresAt, |
| | 154 | params.scope, |
| | 155 | JSON.stringify(params.providerState), |
| | 156 | now, |
| | 157 | now, |
| | 158 | ); |
| | 159 | }; |
| | 160 | |
| | 161 | // --------------------------------------------------------------------------- |
| | 162 | // OpenAPI — legacy shape |
| | 163 | // --------------------------------------------------------------------------- |
| 24 | | 164 | |
| 25 | const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials"); | 165 | const OAuth2Flow = Schema.Literal("authorizationCode", "clientCredentials"); |
| 26 | | 166 | |
| 27 | class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth")({ | 167 | class LegacyOpenApiOAuth2 extends Schema.Class<LegacyOpenApiOAuth2>("LegacyOpenApiOAuth2")({ |
| 28 | kind: Schema.Literal("oauth2"), | 168 | kind: Schema.Literal("oauth2"), |
| 29 | securitySchemeName: Schema.String, | 169 | securitySchemeName: Schema.String, |
| 30 | flow: OAuth2Flow, | 170 | flow: OAuth2Flow, |
| @@ -39,12 +179,8 @@ class LegacyOAuth2Auth extends Schema.Class<LegacyOAuth2Auth>("LegacyOAuth2Auth" · 8 unmodified lines |
| 39 | scopes: Schema.Array(Schema.String), | 179 | scopes: Schema.Array(Schema.String), |
| 40 | }) {} | 180 | }) {} |
| 41 | | 181 | |
| 42 | const decodeCurrent = Schema.decodeUnknownOption(OAuth2Auth); | 182 | const decodeOpenApiCurrent = Schema.decodeUnknownOption(OAuth2Auth); |
| 43 | const decodeLegacy = Schema.decodeUnknownOption(LegacyOAuth2Auth); | 183 | const decodeOpenApiLegacy = Schema.decodeUnknownOption(LegacyOpenApiOAuth2); |
| 44 | | | |
| 45 | const isRecord = (v: unknown): v is Record<string, unknown> => | | |
| 46 | typeof v === "object" && v !== null && !Array.isArray(v); | | |
| 47 | const isString = (v: unknown): v is string => typeof v === "string"; | | |
| 48 | | 184 | |
| 49 | const extractAuthorizationUrl = async ( | 185 | const extractAuthorizationUrl = async ( |
| 50 | rawSpec: string, | 186 | rawSpec: string, |
| @@ -78,7 +214,7 @@ const extractAuthorizationUrl = async ( · 27 unmodified lines |
| 78 | : null; | 214 | : null; |
| 79 | }; | 215 | }; |
| 80 | | 216 | |
| 81 | type Row = { | 217 | type OpenApiRow = { |
| 82 | scope_id: string; | 218 | scope_id: string; |
| 83 | id: string; | 219 | id: string; |
| 84 | name: string; | 220 | name: string; |
| @@ -87,56 +223,15 @@ type Row = { · 2 unmodified lines |
| 87 | oauth2: string | null; | 223 | oauth2: string | null; |
| 88 | }; | 224 | }; |
| 89 | | 225 | |
| 90 | /** | 226 | const migrateOpenApi = async (sqlite: Database): Promise<void> => { |
| 91 | * Scan `openapi_source`, migrate any row still on the legacy OAuth2 shape | 227 | if (!tableExists(sqlite, "openapi_source")) return; |
| 92 | * to a fresh Connection row + pointer. Idempotent — rows already on the | | |
| 93 | * current shape are skipped. Logs one line per migrated row. | | |
| 94 | */ | | |
| 95 | export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise<void> => { | | |
| 96 | // The 0002_lively_sue_storm migration introduced the `connection` table | | |
| 97 | // and added `secret.owned_by_connection_id`. If those aren't present yet, | | |
| 98 | // the drizzle `migrate()` call upstream hasn't finished — bail. | | |
| 99 | const secretColumns = sqlite | | |
| 100 | .prepare("PRAGMA table_info('secret')") | | |
| 101 | .all() as ReadonlyArray<{ readonly name: string }>; | | |
| 102 | if (!secretColumns.some((c) => c.name === "owned_by_connection_id")) { | | |
| 103 | return; | | |
| 104 | } | | |
| 105 | const connectionTable = sqlite | | |
| 106 | .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='connection'") | | |
| 107 | .get(); | | |
| 108 | if (!connectionTable) return; | | |
| 109 | | | |
| 110 | const rows = sqlite | 228 | const rows = sqlite |
| 111 | .prepare( | 229 | .prepare( |
| 112 | "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source", | 230 | "SELECT scope_id, id, name, spec, invocation_config, oauth2 FROM openapi_source", |
| 113 | ) | 231 | ) |
| 114 | .all() as ReadonlyArray<Row>; | 232 | .all() as ReadonlyArray<OpenApiRow>; |
| 115 | if (rows.length === 0) return; | 233 | if (rows.length === 0) return; |
| 116 | | 234 | |
| 117 | const insertConnection = sqlite.prepare( | | |
| 118 | `INSERT INTO connection ( | | |
| 119 | id, scope_id, provider, kind, identity_label, | | |
| 120 | access_token_secret_id, refresh_token_secret_id, | | |
| 121 | expires_at, scope, provider_state, | | |
| 122 | created_at, updated_at | | |
| 123 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | | |
| 124 | ); | | |
| 125 | const updateSecretOwner = sqlite.prepare( | | |
| 126 | "UPDATE secret SET owned_by_connection_id = ? WHERE scope_id = ? AND id = ?", | | |
| 127 | ); | | |
| 128 | const selectSecret = sqlite.prepare( | | |
| 129 | "SELECT id, owned_by_connection_id FROM secret WHERE scope_id = ? AND id = ?", | | |
| 130 | ); | | |
| 131 | const selectAnySecretProvider = sqlite.prepare( | | |
| 132 | "SELECT provider FROM secret WHERE scope_id = ? LIMIT 1", | | |
| 133 | ); | | |
| 134 | const insertSecret = sqlite.prepare( | | |
| 135 | `INSERT INTO secret ( | | |
| 136 | id, scope_id, provider, name, | | |
| 137 | owned_by_connection_id, created_at | | |
| 138 | ) VALUES (?, ?, ?, ?, ?, ?)`, | | |
| 139 | ); | | |
| 140 | const updateSource = sqlite.prepare( | 235 | const updateSource = sqlite.prepare( |
| 141 | "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?", | 236 | "UPDATE openapi_source SET oauth2 = ?, invocation_config = ? WHERE scope_id = ? AND id = ?", |
| 142 | ); | 237 | ); |
| @@ -161,9 +256,9 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 18 unmodified lines |
| 161 | } | 256 | } |
| 162 | const primary = invocation.oauth2 ?? oauth2Col; | 257 | const primary = invocation.oauth2 ?? oauth2Col; |
| 163 | if (primary == null) continue; | 258 | if (primary == null) continue; |
| 164 | if (Option.isSome(decodeCurrent(primary))) continue; | 259 | if (Option.isSome(decodeOpenApiCurrent(primary))) continue; |
| 165 | | 260 | |
| 166 | const legacyOption = decodeLegacy(primary); | 261 | const legacyOption = decodeOpenApiLegacy(primary); |
| 167 | if (Option.isNone(legacyOption)) continue; | 262 | if (Option.isNone(legacyOption)) continue; |
| 168 | const legacy = legacyOption.value; | 263 | const legacy = legacyOption.value; |
| 169 | | 264 | |
| @@ -174,7 +269,7 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 4 unmodified lines |
| 174 | ); | 269 | ); |
| 175 | if (legacy.flow === "authorizationCode" && authorizationUrl === null) { | 270 | if (legacy.flow === "authorizationCode" && authorizationUrl === null) { |
| 176 | console.warn( | 271 | console.warn( |
| 177 | `[migrate-connections] skip ${row.scope_id}/${row.id}: authorizationCode flow but authorizationUrl unavailable`, | 272 | `[migrate-connections] skip openapi ${row.scope_id}/${row.id}: authorizationCode flow but authorizationUrl unavailable`, |
| 178 | ); | 273 | ); |
| 179 | continue; | 274 | continue; |
| 180 | } | 275 | } |
| @@ -200,95 +295,292 @@ export const migrateOpenApiOAuthConnections = async (sqlite: Database): Promise< · 19 unmodified lines |
| 200 | }; | 295 | }; |
| 201 | | 296 | |
| 202 | const secretIds = [legacy.accessTokenSecretId]; | 297 | const secretIds = [legacy.accessTokenSecretId]; |
| 203 | if (legacy.refreshTokenSecretId) secretIds.push(legacy.refreshTokenSecretId); | 298 | const secretNames = [`Connection ${connectionId} access token`]; |
| | 299 | if (legacy.refreshTokenSecretId) { |
| | 300 | secretIds.push(legacy.refreshTokenSecretId); |
| | 301 | secretNames.push(`Connection ${connectionId} refresh token`); |
| | 302 | } |
| 204 | | 303 | |
| 205 | const secretRows = secretIds.map( | 304 | const txn = sqlite.transaction(() => { |
| 206 | (sid) => | 305 | insertConnectionRow(sqlite, { |
| 207 | selectSecret.get(row.scope_id, sid) as | 306 | id: connectionId, |
| 208 | | { id: string; owned_by_connection_id: string | null } | 307 | scopeId: row.scope_id, |
| 209 | | undefined, | 308 | provider: "openapi:oauth2", |
| 210 | ); | 309 | identityLabel: row.name, |
| 211 | const alreadyOwned = secretRows | 310 | accessTokenSecretId: legacy.accessTokenSecretId, |
| 212 | .filter((s): s is { id: string; owned_by_connection_id: string | null } => !!s) | 311 | refreshTokenSecretId: legacy.refreshTokenSecretId, |
| 213 | .filter( | 312 | expiresAt: legacy.expiresAt, |
| 214 | (s) => | 313 | scope: legacy.scope, |
| 215 | s.owned_by_connection_id !== null && | 314 | providerState, |
| 216 | s.owned_by_connection_id !== connectionId, | 315 | }); |
| | 316 | const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); |
| | 317 | if (err) throw new Error(err); |
| | 318 | const nextInvocation = { ...invocation, oauth2: oauth2Pointer }; |
| | 319 | updateSource.run( |
| | 320 | JSON.stringify(oauth2Pointer), |
| | 321 | JSON.stringify(nextInvocation), |
| | 322 | row.scope_id, |
| | 323 | row.id, |
| | 324 | ); |
| | 325 | }); |
| | 326 | try { |
| | 327 | txn(); |
| | 328 | console.log( |
| | 329 | `[migrate-connections] openapi ${row.scope_id}/${row.id} -> ${connectionId}`, |
| 217 | ); | 330 | ); |
| 218 | if (alreadyOwned.length > 0) { | 331 | } catch (err) { |
| 219 | console.warn( | 332 | console.warn( |
| 220 | `[migrate-connections] skip ${row.scope_id}/${row.id}: secret(s) already owned`, | 333 | `[migrate-connections] fail openapi ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`, |
| 221 | ); | 334 | ); |
| | 335 | } |
| | 336 | } |
| | 337 | }; |
| | 338 | |
| | 339 | // --------------------------------------------------------------------------- |
| | 340 | // MCP — legacy shape |
| | 341 | // --------------------------------------------------------------------------- |
| | 342 | |
| | 343 | const LegacyMcpOAuth2 = Schema.Struct({ |
| | 344 | kind: Schema.Literal("oauth2"), |
| | 345 | accessTokenSecretId: Schema.String, |
| | 346 | refreshTokenSecretId: Schema.NullOr(Schema.String), |
| | 347 | tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), |
| | 348 | expiresAt: Schema.NullOr(Schema.Number), |
| | 349 | scope: Schema.NullOr(Schema.String), |
| | 350 | clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), { |
| | 351 | default: () => null, |
| | 352 | }), |
| | 353 | authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), { |
| | 354 | default: () => null, |
| | 355 | }), |
| | 356 | resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), { |
| | 357 | default: () => null, |
| | 358 | }), |
| | 359 | }); |
| | 360 | |
| | 361 | const decodeMcpCurrent = Schema.decodeUnknownOption(McpConnectionAuth); |
| | 362 | const decodeMcpLegacy = Schema.decodeUnknownOption(LegacyMcpOAuth2); |
| | 363 | |
| | 364 | type McpRow = { |
| | 365 | scope_id: string; |
| | 366 | id: string; |
| | 367 | name: string; |
| | 368 | config: string; |
| | 369 | }; |
| | 370 | |
| | 371 | const migrateMcp = (sqlite: Database): void => { |
| | 372 | if (!tableExists(sqlite, "mcp_source")) return; |
| | 373 | const rows = sqlite |
| | 374 | .prepare("SELECT scope_id, id, name, config FROM mcp_source") |
| | 375 | .all() as ReadonlyArray<McpRow>; |
| | 376 | if (rows.length === 0) return; |
| | 377 | |
| | 378 | const updateSource = sqlite.prepare( |
| | 379 | "UPDATE mcp_source SET config = ? WHERE scope_id = ? AND id = ?", |
| | 380 | ); |
| | 381 | |
| | 382 | for (const row of rows) { |
| | 383 | let config: Record<string, unknown> = {}; |
| | 384 | try { |
| | 385 | const parsed = JSON.parse(row.config) as unknown; |
| | 386 | if (!isRecord(parsed)) continue; |
| | 387 | config = parsed; |
| | 388 | } catch { |
| 222 | continue; | 389 | continue; |
| 223 | } | 390 | } |
| 224 | // Early-onboarded OpenAPI OAuth tokens never got a `secret` routing | 391 | if (config.transport !== "remote") continue; |
| 225 | // row — pre-refactor `secretsGet` resolved them via provider | 392 | const auth = config.auth; |
| 226 | // enumeration. Pick the provider already in use at this scope (or | 393 | if (!isRecord(auth) || auth.kind !== "oauth2") continue; |
| 227 | // fall back to keychain) so the new id-indexed fast path resolves; | 394 | |
| 228 | // if we guess wrong the SDK's enumerate-fallback still works. | 395 | if (Option.isSome(decodeMcpCurrent(auth))) continue; |
| 229 | const missingIndexes = secretIds | 396 | |
| 230 | .map((_, i) => i) | 397 | const legacyOption = decodeMcpLegacy(auth); |
| 231 | .filter((i) => secretRows[i] === undefined); | 398 | if (Option.isNone(legacyOption)) continue; |
| 232 | let fallbackProvider: string | null = null; | 399 | const legacy = legacyOption.value; |
| 233 | if (missingIndexes.length > 0) { | 400 | |
| 234 | const existing = selectAnySecretProvider.get(row.scope_id) as | 401 | const endpoint = typeof config.endpoint === "string" ? config.endpoint : null; |
| 235 | | { provider: string } | 402 | if (!endpoint) { |
| 236 | | undefined; | 403 | console.warn( |
| 237 | fallbackProvider = existing?.provider ?? "keychain"; | 404 | `[migrate-connections] skip mcp ${row.scope_id}/${row.id}: endpoint missing`, |
| | 405 | ); |
| | 406 | continue; |
| | 407 | } |
| | 408 | const connectionId = `mcp-oauth2-${row.id}`; |
| | 409 | const providerState = { |
| | 410 | endpoint, |
| | 411 | tokenType: legacy.tokenType, |
| | 412 | clientInformation: legacy.clientInformation, |
| | 413 | authorizationServerUrl: legacy.authorizationServerUrl, |
| | 414 | authorizationServerMetadata: null, |
| | 415 | resourceMetadataUrl: legacy.resourceMetadataUrl, |
| | 416 | resourceMetadata: null, |
| | 417 | }; |
| | 418 | const authPointer = { kind: "oauth2" as const, connectionId }; |
| | 419 | const nextConfig = { ...config, auth: authPointer }; |
| | 420 | |
| | 421 | const secretIds = [legacy.accessTokenSecretId]; |
| | 422 | const secretNames = [`Connection ${connectionId} access token`]; |
| | 423 | if (legacy.refreshTokenSecretId) { |
| | 424 | secretIds.push(legacy.refreshTokenSecretId); |
| | 425 | secretNames.push(`Connection ${connectionId} refresh token`); |
| 238 | } | 426 | } |
| 239 | | 427 | |
| 240 | const now = Date.now(); | | |
| 241 | const txn = sqlite.transaction(() => { | 428 | const txn = sqlite.transaction(() => { |
| 242 | insertConnection.run( | 429 | insertConnectionRow(sqlite, { |
| 243 | connectionId, | 430 | id: connectionId, |
| 244 | row.scope_id, | 431 | scopeId: row.scope_id, |
| 245 | "openapi:oauth2", | 432 | provider: "mcp:oauth2", |
| 246 | "user", | 433 | identityLabel: row.name, |
| 247 | row.name, | 434 | accessTokenSecretId: legacy.accessTokenSecretId, |
| 248 | legacy.accessTokenSecretId, | 435 | refreshTokenSecretId: legacy.refreshTokenSecretId, |
| 249 | legacy.refreshTokenSecretId, | 436 | expiresAt: legacy.expiresAt, |
| 250 | legacy.expiresAt, | 437 | scope: legacy.scope, |
| 251 | legacy.scope, | 438 | providerState, |
| 252 | JSON.stringify(providerState), | 439 | }); |
| 253 | now, | 440 | const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); |
| 254 | now, | 441 | if (err) throw new Error(err); |
| | 442 | updateSource.run(JSON.stringify(nextConfig), row.scope_id, row.id); |
| | 443 | }); |
| | 444 | try { |
| | 445 | txn(); |
| | 446 | console.log( |
| | 447 | `[migrate-connections] mcp ${row.scope_id}/${row.id} -> ${connectionId}`, |
| 255 | ); | 448 | ); |
| 256 | for (let i = 0; i < secretIds.length; i++) { | 449 | } catch (err) { |
| 257 | const sid = secretIds[i]!; | 450 | console.warn( |
| 258 | if (secretRows[i] === undefined) { | 451 | `[migrate-connections] fail mcp ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`, |
| 259 | const name = | | |
| 260 | sid === legacy.accessTokenSecretId | | |
| 261 | ? `Connection ${connectionId} access token` | | |
| 262 | : `Connection ${connectionId} refresh token`; | | |
| 263 | insertSecret.run( | | |
| 264 | sid, | | |
| 265 | row.scope_id, | | |
| 266 | fallbackProvider!, | | |
| 267 | name, | | |
| 268 | connectionId, | | |
| 269 | now, | | |
| 270 | ); | | |
| 271 | } else { | | |
| 272 | updateSecretOwner.run(connectionId, row.scope_id, sid); | | |
| 273 | } | | |
| 274 | } | | |
| 275 | const nextInvocation = { ...invocation, oauth2: oauth2Pointer }; | | |
| 276 | updateSource.run( | | |
| 277 | JSON.stringify(oauth2Pointer), | | |
| 278 | JSON.stringify(nextInvocation), | | |
| 279 | row.scope_id, | | |
| 280 | row.id, | | |
| 281 | ); | 452 | ); |
| | 453 | } |
| | 454 | } |
| | 455 | }; |
| | 456 | |
| | 457 | // --------------------------------------------------------------------------- |
| | 458 | // google-discovery — legacy shape |
| | 459 | // --------------------------------------------------------------------------- |
| | 460 | |
| | 461 | const LegacyGoogleDiscoveryOAuth2 = Schema.Struct({ |
| | 462 | kind: Schema.Literal("oauth2"), |
| | 463 | clientIdSecretId: Schema.String, |
| | 464 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| | 465 | accessTokenSecretId: Schema.String, |
| | 466 | refreshTokenSecretId: Schema.NullOr(Schema.String), |
| | 467 | tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), |
| | 468 | expiresAt: Schema.NullOr(Schema.Number), |
| | 469 | scope: Schema.NullOr(Schema.String), |
| | 470 | scopes: Schema.Array(Schema.String), |
| | 471 | }); |
| | 472 | |
| | 473 | const CurrentGoogleDiscoveryOAuth2 = Schema.Struct({ |
| | 474 | kind: Schema.Literal("oauth2"), |
| | 475 | connectionId: Schema.String, |
| | 476 | clientIdSecretId: Schema.String, |
| | 477 | clientSecretSecretId: Schema.NullOr(Schema.String), |
| | 478 | scopes: Schema.Array(Schema.String), |
| | 479 | }); |
| | 480 | |
| | 481 | const decodeGoogleCurrent = Schema.decodeUnknownOption(CurrentGoogleDiscoveryOAuth2); |
| | 482 | const decodeGoogleLegacy = Schema.decodeUnknownOption(LegacyGoogleDiscoveryOAuth2); |
| | 483 | |
| | 484 | type GoogleRow = { |
| | 485 | scope_id: string; |
| | 486 | id: string; |
| | 487 | name: string; |
| | 488 | config: string; |
| | 489 | }; |
| | 490 | |
| | 491 | const migrateGoogleDiscovery = (sqlite: Database): void => { |
| | 492 | if (!tableExists(sqlite, "google_discovery_source")) return; |
| | 493 | const rows = sqlite |
| | 494 | .prepare("SELECT scope_id, id, name, config FROM google_discovery_source") |
| | 495 | .all() as ReadonlyArray<GoogleRow>; |
| | 496 | if (rows.length === 0) return; |
| | 497 | |
| | 498 | const updateSource = sqlite.prepare( |
| | 499 | "UPDATE google_discovery_source SET config = ?, updated_at = ? WHERE scope_id = ? AND id = ?", |
| | 500 | ); |
| | 501 | |
| | 502 | for (const row of rows) { |
| | 503 | let config: Record<string, unknown> = {}; |
| | 504 | try { |
| | 505 | const parsed = JSON.parse(row.config) as unknown; |
| | 506 | if (!isRecord(parsed)) continue; |
| | 507 | config = parsed; |
| | 508 | } catch { |
| | 509 | continue; |
| | 510 | } |
| | 511 | const auth = config.auth; |
| | 512 | if (!isRecord(auth) || auth.kind !== "oauth2") continue; |
| | 513 | |
| | 514 | if (Option.isSome(decodeGoogleCurrent(auth))) continue; |
| | 515 | |
| | 516 | const legacyOption = decodeGoogleLegacy(auth); |
| | 517 | if (Option.isNone(legacyOption)) continue; |
| | 518 | const legacy = legacyOption.value; |
| | 519 | |
| | 520 | const connectionId = `google-discovery-oauth2-${randomUUID()}`; |
| | 521 | const providerState = { |
| | 522 | clientIdSecretId: legacy.clientIdSecretId, |
| | 523 | clientSecretSecretId: legacy.clientSecretSecretId, |
| | 524 | scopes: legacy.scopes, |
| | 525 | }; |
| | 526 | const authPointer = { |
| | 527 | kind: "oauth2" as const, |
| | 528 | connectionId, |
| | 529 | clientIdSecretId: legacy.clientIdSecretId, |
| | 530 | clientSecretSecretId: legacy.clientSecretSecretId, |
| | 531 | scopes: legacy.scopes, |
| | 532 | }; |
| | 533 | const nextConfig = { ...config, auth: authPointer }; |
| | 534 | |
| | 535 | const secretIds = [legacy.accessTokenSecretId]; |
| | 536 | const secretNames = [`Connection ${connectionId} access token`]; |
| | 537 | if (legacy.refreshTokenSecretId) { |
| | 538 | secretIds.push(legacy.refreshTokenSecretId); |
| | 539 | secretNames.push(`Connection ${connectionId} refresh token`); |
| | 540 | } |
| | 541 | |
| | 542 | const txn = sqlite.transaction(() => { |
| | 543 | insertConnectionRow(sqlite, { |
| | 544 | id: connectionId, |
| | 545 | scopeId: row.scope_id, |
| | 546 | provider: "google-discovery:oauth2", |
| | 547 | identityLabel: row.name, |
| | 548 | accessTokenSecretId: legacy.accessTokenSecretId, |
| | 549 | refreshTokenSecretId: legacy.refreshTokenSecretId, |
| | 550 | expiresAt: legacy.expiresAt, |
| | 551 | scope: legacy.scope, |
| | 552 | providerState, |
| | 553 | }); |
| | 554 | const err = rewireSecrets(sqlite, row.scope_id, connectionId, secretIds, secretNames); |
| | 555 | if (err) throw new Error(err); |
| | 556 | updateSource.run(JSON.stringify(nextConfig), Date.now(), row.scope_id, row.id); |
| 282 | }); | 557 | }); |
| 283 | try { | 558 | try { |
| 284 | txn(); | 559 | txn(); |
| 285 | console.log( | 560 | console.log( |
| 286 | `[migrate-connections] ${row.scope_id}/${row.id} -> ${connectionId}`, | 561 | `[migrate-connections] google-discovery ${row.scope_id}/${row.id} -> ${connectionId}`, |
| 287 | ); | 562 | ); |
| 288 | } catch (err) { | 563 | } catch (err) { |
| 289 | console.warn( | 564 | console.warn( |
| 290 | `[migrate-connections] fail ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`, | 565 | `[migrate-connections] fail google-discovery ${row.scope_id}/${row.id}: ${err instanceof Error ? err.message : String(err)}`, |
| 291 | ); | 566 | ); |
| 292 | } | 567 | } |
| 293 | } | 568 | } |
| 294 | }; | 569 | }; |
| | 570 | |
| | 571 | // --------------------------------------------------------------------------- |
| | 572 | // Umbrella |
| | 573 | // --------------------------------------------------------------------------- |
| | 574 | |
| | 575 | /** |
| | 576 | * Scan openapi_source, mcp_source, and google_discovery_source; migrate |
| | 577 | * any row still on its plugin's legacy inline-OAuth shape to a fresh |
| | 578 | * Connection row + pointer. Idempotent — rows already on the current |
| | 579 | * shape are skipped. Logs one line per migrated row. |
| | 580 | */ |
| | 581 | export const migrateLegacyConnections = async (sqlite: Database): Promise<void> => { |
| | 582 | if (!connectionsReady(sqlite)) return; |
| | 583 | await migrateOpenApi(sqlite); |
| | 584 | migrateMcp(sqlite); |
| | 585 | migrateGoogleDiscovery(sqlite); |
| | 586 | }; |